diff --git a/.github/workflows/windows-hardening.yml b/.github/workflows/windows-hardening.yml index 79bb4d86..f88ff90e 100644 --- a/.github/workflows/windows-hardening.yml +++ b/.github/workflows/windows-hardening.yml @@ -58,7 +58,7 @@ jobs: env: HARDENING_REQUIRED: ${{ github.event_name != 'pull_request' || needs.changes.result != 'success' || needs.changes.outputs.windows == 'true' }} runs-on: ${{ (github.event_name != 'pull_request' || needs.changes.result != 'success' || needs.changes.outputs.windows == 'true') && matrix.os || 'ubuntu-latest' }} - timeout-minutes: 15 + timeout-minutes: 40 steps: - name: No Windows-relevant changes if: env.HARDENING_REQUIRED != 'true' @@ -69,13 +69,100 @@ jobs: if: env.HARDENING_REQUIRED == 'true' shell: pwsh run: ./Tools/windows/validate.ps1 -Task hardening - - name: Verify runner contract - # Pull requests run this source contract in the windows-spikes aggregate - # once with pinned Swift. Keep one fallback for this workflow's independent - # scheduled and manual runs. + # Pull requests run the complete compiled host once in windows-spikes. + # Independent scheduled/manual runs retain one Windows 2022 fallback. + - name: Install pinned Swift for runner fallback + if: env.HARDENING_REQUIRED == 'true' && github.event_name != 'pull_request' && matrix.os == 'windows-2022' + uses: compnerd/gha-setup-swift@397094e75494a93fa8d81db0268dbc8f5d6cf7c6 # v0.4.1 + timeout-minutes: 10 + with: + swift-version: swift-6.3.3-release + swift-build: 6.3.3-RELEASE + cache: true + - name: Install pinned compiled validation SDK + if: env.HARDENING_REQUIRED == 'true' && github.event_name != 'pull_request' && matrix.os == 'windows-2022' + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 + timeout-minutes: 10 + with: + dotnet-version: 10.0.403 + - name: Build compiled validation runner + if: env.HARDENING_REQUIRED == 'true' && github.event_name != 'pull_request' && matrix.os == 'windows-2022' + shell: pwsh + working-directory: Tools/windows/ValidationRunner + timeout-minutes: 10 + run: | + $ErrorActionPreference = 'Stop' + $version = dotnet --version + if ($LASTEXITCODE -ne 0 -or $version -cne '10.0.403') { + throw 'Compiled validation requires SDK 10.0.403 selected by global.json.' + } + dotnet build -c Release + if ($LASTEXITCODE -ne 0) { throw "Compiled validation build failed: exit=$LASTEXITCODE" } + - name: Prepare owned Pester 5 prerequisite + if: env.HARDENING_REQUIRED == 'true' && github.event_name != 'pull_request' && matrix.os == 'windows-2022' + shell: pwsh + timeout-minutes: 5 + run: | + $ErrorActionPreference = 'Stop' + $pester5 = @(Get-Module -Name Pester | Where-Object { $_.Version.Major -eq 5 }) + if ($pester5.Count -eq 0) { + $pester5 = @(Get-Module -ListAvailable -Name Pester | Where-Object { $_.Version.Major -eq 5 }) + } + if ($pester5.Count -eq 0) { + $moduleRoot = Join-Path $PWD '.ci-modules' + $null = New-Item -ItemType Directory -Force -Path $moduleRoot + Save-Module -Name Pester -RequiredVersion 5.7.1 -Path $moduleRoot -ErrorAction Stop + $env:PSModulePath = $moduleRoot + [IO.Path]::PathSeparator + $env:PSModulePath + [IO.File]::AppendAllText($env:GITHUB_ENV, "PSModulePath=$env:PSModulePath`n", [Text.UTF8Encoding]::new($false)) + } + . .\Tools\windows\Import-PinnedPester.ps1 + $loaded = @(Get-Module -Name Pester | Where-Object { $_.Version.Major -eq 5 }) + if ($loaded.Count -ne 1) { throw 'Compiled validation requires exactly one loaded Pester 5 module.' } + - name: Verify complete compiled validation runner contract if: env.HARDENING_REQUIRED == 'true' && github.event_name != 'pull_request' && matrix.os == 'windows-2022' shell: pwsh - run: ./Tools/windows/Invoke-ValidationRunnerContract.ps1 + # Normal full run measured 16m14s; allow 25m execution headroom. + timeout-minutes: 25 + run: | + $ErrorActionPreference = 'Stop' + if (Test-Path -LiteralPath .ci-validation-runner\coverage.json) { + throw 'Compiled validation requires a fresh coverage report; prior evidence was retained.' + } + $output = [Collections.Generic.List[string]]::new() + $exitCode = $null + & { + .\Tools\windows\ValidationRunner\bin\Release\net10.0-windows\ValidationRunner.exe --repository-root $PWD --artifact-root .ci-validation-runner + $script:exitCode = $LASTEXITCODE + } 2>&1 | ForEach-Object { + $line = "$_" + $output.Add($line) + Write-Host $line + } + if ($exitCode -ne 0) { throw "Compiled validation failed: exit=$exitCode" } + $report = Get-Content -LiteralPath .ci-validation-runner\coverage.json -Raw | ConvertFrom-Json + if ($report.runner -cne 'CompiledValidationRunner' -or + $report.complete -isnot [bool] -or -not $report.complete -or + -not ($report.requiredCount -is [int] -or $report.requiredCount -is [long]) -or + -not ($report.passedCount -is [int] -or $report.passedCount -is [long]) -or + $report.requiredCount -le 0 -or $report.passedCount -ne $report.requiredCount -or + @($report.units).Count -ne $report.requiredCount -or @($report.errors).Count -ne 0) { + throw 'Compiled validation did not report complete positive full coverage.' + } + $passMarkers = @($output | Where-Object { $_ -ceq 'CompiledValidationRunner: PASS' }).Count + $executedMarkers = @($output | Where-Object { $_ -ceq 'VALIDATION_RUNNER_EXECUTED=1' }).Count + if ($passMarkers -ne 1 -or $executedMarkers -ne 1) { + throw 'Compiled validation must report exactly one full PASS and one positive execution marker.' + } + - name: Retain compiled validation fallback evidence + if: ${{ always() && env.HARDENING_REQUIRED == 'true' && github.event_name != 'pull_request' && matrix.os == 'windows-2022' }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + timeout-minutes: 5 + with: + name: compiled-validation-hardening-${{ matrix.os }}-${{ github.run_attempt }} + path: .ci-validation-runner + include-hidden-files: true + if-no-files-found: warn + retention-days: 7 - name: Resolve capture contract failure diagnostics root id: capture_temp if: failure() diff --git a/.github/workflows/windows-port-validation.yml b/.github/workflows/windows-port-validation.yml index 74983bc8..38bede48 100644 --- a/.github/workflows/windows-port-validation.yml +++ b/.github/workflows/windows-port-validation.yml @@ -21,7 +21,7 @@ env: # on every Windows-relevant pull request: windows-shell and packaging run in # windows-shell.yml (required check `windows-shell`), and hardening runs in # windows-hardening.yml (required `Deterministic hardening (...)` checks). The -# remaining tasks and the source-only runner contract are split across three +# remaining tasks and the compiled runner contract are split across three # parallel parts behind the required # `windows-spikes` aggregate job. jobs: @@ -59,8 +59,8 @@ jobs: shell: pwsh run: ./Tools/windows/validate.ps1 -Task privacy - # ValidationRunner.Tests.ps1 is source-only but deliberately uses the real - # pinned Swift formatter for its negative control. Run it once on Windows + # The compiled contract host uses the real pinned Swift formatter for its + # negative control. Run the full host once on Windows # 2022 in parallel instead of repeating it inside spikes-swift and both # deterministic hardening legs. Windows 2025 remains covered by hardening. validation-runner: @@ -68,7 +68,7 @@ jobs: needs: changes if: ${{ !cancelled() && (github.event_name != 'pull_request' || needs.changes.result != 'success' || needs.changes.outputs.windows == 'true') }} runs-on: windows-2022 - timeout-minutes: 20 + timeout-minutes: 40 steps: - name: Check out repository uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 @@ -82,10 +82,90 @@ jobs: swift-build: 6.3.3-RELEASE cache: true - - name: Verify validation runner contract + - name: Install pinned compiled validation SDK + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 + timeout-minutes: 10 + with: + dotnet-version: 10.0.403 + + - name: Build compiled validation runner shell: pwsh - timeout-minutes: 15 - run: ./Tools/windows/Invoke-ValidationRunnerContract.ps1 + working-directory: Tools/windows/ValidationRunner + timeout-minutes: 10 + run: | + $ErrorActionPreference = 'Stop' + $version = dotnet --version + if ($LASTEXITCODE -ne 0 -or $version -cne '10.0.403') { + throw 'Compiled validation requires SDK 10.0.403 selected by global.json.' + } + dotnet build -c Release + if ($LASTEXITCODE -ne 0) { throw "Compiled validation build failed: exit=$LASTEXITCODE" } + + - name: Prepare owned Pester 5 prerequisite + shell: pwsh + timeout-minutes: 5 + run: | + $ErrorActionPreference = 'Stop' + $pester5 = @(Get-Module -Name Pester | Where-Object { $_.Version.Major -eq 5 }) + if ($pester5.Count -eq 0) { + $pester5 = @(Get-Module -ListAvailable -Name Pester | Where-Object { $_.Version.Major -eq 5 }) + } + if ($pester5.Count -eq 0) { + $moduleRoot = Join-Path $PWD '.ci-modules' + $null = New-Item -ItemType Directory -Force -Path $moduleRoot + Save-Module -Name Pester -RequiredVersion 5.7.1 -Path $moduleRoot -ErrorAction Stop + $env:PSModulePath = $moduleRoot + [IO.Path]::PathSeparator + $env:PSModulePath + [IO.File]::AppendAllText($env:GITHUB_ENV, "PSModulePath=$env:PSModulePath`n", [Text.UTF8Encoding]::new($false)) + } + . .\Tools\windows\Import-PinnedPester.ps1 + $loaded = @(Get-Module -Name Pester | Where-Object { $_.Version.Major -eq 5 }) + if ($loaded.Count -ne 1) { throw 'Compiled validation requires exactly one loaded Pester 5 module.' } + + - name: Verify complete compiled validation runner contract + shell: pwsh + # Normal full run measured 16m14s; allow 25m execution headroom. + timeout-minutes: 25 + run: | + $ErrorActionPreference = 'Stop' + if (Test-Path -LiteralPath .ci-validation-runner\coverage.json) { + throw 'Compiled validation requires a fresh coverage report; prior evidence was retained.' + } + $output = [Collections.Generic.List[string]]::new() + $exitCode = $null + & { + .\Tools\windows\ValidationRunner\bin\Release\net10.0-windows\ValidationRunner.exe --repository-root $PWD --artifact-root .ci-validation-runner + $script:exitCode = $LASTEXITCODE + } 2>&1 | ForEach-Object { + $line = "$_" + $output.Add($line) + Write-Host $line + } + if ($exitCode -ne 0) { throw "Compiled validation failed: exit=$exitCode" } + $report = Get-Content -LiteralPath .ci-validation-runner\coverage.json -Raw | ConvertFrom-Json + if ($report.runner -cne 'CompiledValidationRunner' -or + $report.complete -isnot [bool] -or -not $report.complete -or + -not ($report.requiredCount -is [int] -or $report.requiredCount -is [long]) -or + -not ($report.passedCount -is [int] -or $report.passedCount -is [long]) -or + $report.requiredCount -le 0 -or $report.passedCount -ne $report.requiredCount -or + @($report.units).Count -ne $report.requiredCount -or @($report.errors).Count -ne 0) { + throw 'Compiled validation did not report complete positive full coverage.' + } + $passMarkers = @($output | Where-Object { $_ -ceq 'CompiledValidationRunner: PASS' }).Count + $executedMarkers = @($output | Where-Object { $_ -ceq 'VALIDATION_RUNNER_EXECUTED=1' }).Count + if ($passMarkers -ne 1 -or $executedMarkers -ne 1) { + throw 'Compiled validation must report exactly one full PASS and one positive execution marker.' + } + + - name: Retain compiled validation evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + timeout-minutes: 5 + with: + name: compiled-validation-runner-${{ github.run_attempt }} + path: .ci-validation-runner + include-hidden-files: true + if-no-files-found: warn + retention-days: 7 - name: Resolve capture contract failure diagnostics root id: capture_temp diff --git a/Tools/windows/Build-CompiledValidationRunner.ps1 b/Tools/windows/Build-CompiledValidationRunner.ps1 new file mode 100644 index 00000000..b6e9f1d0 --- /dev/null +++ b/Tools/windows/Build-CompiledValidationRunner.ps1 @@ -0,0 +1,60 @@ +[CmdletBinding()] +param( + [ValidateSet("Debug", "Release")][string] $Configuration = "Release" +) + +$ErrorActionPreference = "Stop" +. (Join-Path $PSScriptRoot "DotNetSdk.ps1") +$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\..")) +$projectRoot = Join-Path $PSScriptRoot "ValidationRunner" +$local = Join-Path $projectRoot ".local" +$names = @("DOTNET_CLI_HOME", "NUGET_PACKAGES", "TEMP", "TMP", "DOTNET_CLI_TELEMETRY_OPTOUT", + "DOTNET_GENERATE_ASPNET_CERTIFICATE", "DOTNET_SKIP_FIRST_TIME_EXPERIENCE", "DOTNET_ROOT", + "DOTNET_MULTILEVEL_LOOKUP", "DOTNET_ROLL_FORWARD", "DOTNET_NOLOGO") +$before = @{} +$environment = [Environment]::GetEnvironmentVariables("Process") +foreach ($name in $names) { + $before[$name] = @{ present = $environment.Contains($name); value = $environment[$name] } +} +try { + $env:DOTNET_CLI_HOME = Join-Path $local "dotnet" + $env:NUGET_PACKAGES = Join-Path $local "nuget" + $env:TEMP = Join-Path $local "temp" + $env:TMP = $env:TEMP + $env:DOTNET_CLI_TELEMETRY_OPTOUT = "1" + $env:DOTNET_GENERATE_ASPNET_CERTIFICATE = "false" + $env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE = "1" + $env:DOTNET_NOLOGO = "1" + [void][IO.Directory]::CreateDirectory($env:TEMP) + # A loaded bootstrap environment supplies GRAPHCODE_DOTNET10403. The resolver + # also admits an installed exact SDK, but never downloads during a build. + $dotnet = Resolve-GraphCodeDotNetSdk -RepositoryRoot $repositoryRoot ` + -ToolRoot (Join-Path $repositoryRoot ".graphcode-tools") + $expectedVersion = Get-GraphCodeDotNetPin $repositoryRoot + $env:DOTNET_ROOT = [IO.Path]::GetDirectoryName($dotnet) + $env:DOTNET_MULTILEVEL_LOOKUP = "0" + $env:DOTNET_ROLL_FORWARD = "LatestPatch" + Push-Location $projectRoot + try { + $version = & $dotnet --version + if ($LASTEXITCODE -ne 0 -or $version -cne $expectedVersion) { + throw "Compiled validation requires the pinned .NET SDK $expectedVersion; rerun Tools\windows\bootstrap.ps1" + } + & $dotnet build ValidationRunner.csproj --configuration $Configuration --nologo --verbosity quiet | + ForEach-Object { Write-Host $_ } + if ($LASTEXITCODE -ne 0) { throw "Compiled validation build failed: exit=$LASTEXITCODE" } + } finally { Pop-Location } + $assembly = Join-Path $projectRoot "bin\$Configuration\net10.0-windows\ValidationRunner.dll" + if (-not (Test-Path -LiteralPath $assembly -PathType Leaf)) { throw "Compiled validation build produced no assembly" } + Write-Output $assembly +} finally { + foreach ($name in $names) { + if ($before[$name].present) { + [Environment]::SetEnvironmentVariable($name, $before[$name].value, "Process") + } else { + if ([Environment]::GetEnvironmentVariables("Process").Contains($name)) { + Remove-Item -LiteralPath "Env:\$name" -ErrorAction Stop + } + } + } +} diff --git a/Tools/windows/DotNetSdk.ps1 b/Tools/windows/DotNetSdk.ps1 new file mode 100644 index 00000000..7761587b --- /dev/null +++ b/Tools/windows/DotNetSdk.ps1 @@ -0,0 +1,229 @@ +$ErrorActionPreference = 'Stop' + +function Get-GraphCodeDotNetPin([string] $RepositoryRoot) { + $path = Join-Path $RepositoryRoot 'Tools\windows\ValidationRunner\global.json' + $pin = [IO.File]::ReadAllText($path) | ConvertFrom-Json + if ($pin.sdk.version -isnot [string] -or $pin.sdk.version -notmatch '^10\.0\.\d+$' -or + $pin.sdk.rollForward -isnot [string] -or $pin.sdk.rollForward -cne 'disable' -or $pin.sdk.allowPrerelease -isnot [bool] -or + $pin.sdk.allowPrerelease -ne $false) { + throw "Invalid release SDK pin in $path; an exact 10.0 SDK with rollForward=disable is required" + } + return [string]$pin.sdk.version +} + +function Invoke-GraphCodeDotNetCommand( + [string] $Executable, [string] $WorkingDirectory, [string[]] $Arguments +) { + $PSNativeCommandUseErrorActionPreference = $false + Push-Location -LiteralPath $WorkingDirectory + try { + $output = @(& $Executable @Arguments 2>&1 | ForEach-Object { [string]$_ }) + return [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = $output } + } finally { Pop-Location } +} + +function Get-GraphCodeDotNetSdkProbe( + [string] $Executable, [string] $RepositoryRoot, [string] $ToolRoot, [string] $Version +) { + $invalid = { param($reason) [pscustomobject]@{ Valid = $false; Reason = $reason; Executable = $Executable } } + if (-not [IO.Path]::IsPathFullyQualified($Executable) -or + -not [IO.File]::Exists($Executable) -or [IO.Path]::GetExtension($Executable) -ine '.exe') { + return & $invalid "SDK host is not an existing absolute executable: $Executable" + } + $root = [IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($Executable)) + $sdkRoot = Join-Path $root "sdk\$Version" + $propsPath = Join-Path $sdkRoot 'Microsoft.NETCoreSdk.BundledVersions.props' + if (-not [IO.File]::Exists((Join-Path $sdkRoot 'MSBuild.dll')) -or -not [IO.File]::Exists($propsPath)) { + return & $invalid "SDK $Version inputs are missing below $root" + } + [xml]$props = [IO.File]::ReadAllText($propsPath) + $sdkNodes = @($props.SelectNodes("//*[local-name()='NETCoreSdkVersion']")) + if ($sdkNodes.Count -ne 1 -or $sdkNodes[0].InnerText -cne $Version) { + return & $invalid "SDK metadata below $root does not declare $Version" + } + foreach ($framework in @('Microsoft.NETCore.App', 'Microsoft.WindowsDesktop.App')) { + $nodes = @($props.SelectNodes("//*[local-name()='KnownFrameworkReference' and @Include='$framework' and @TargetFramework='net10.0']")) + if ($nodes.Count -ne 1 -or $nodes[0].TargetingPackVersion -notmatch '^10\.0\.\d+$' -or + $nodes[0].TargetingPackName -cne "$framework.Ref") { + return & $invalid "SDK metadata is missing the net10.0 $framework targeting-pack declaration" + } + $referenceRoot = Join-Path $root ("packs\{0}\{1}\ref\net10.0" -f $nodes[0].TargetingPackName, $nodes[0].TargetingPackVersion) + $files = if ($framework -ceq 'Microsoft.NETCore.App') { @('System.Runtime.dll') } + else { @('UIAutomationClient.dll', 'UIAutomationTypes.dll', 'WindowsBase.dll') } + foreach ($file in $files) { + if (-not [IO.File]::Exists((Join-Path $referenceRoot $file))) { + return & $invalid "Missing $framework reference input: $(Join-Path $referenceRoot $file)" + } + } + } + + $stateRoot = Join-Path $ToolRoot 'dotnet-state' + $values = @{ + DOTNET_ROOT = $root; DOTNET_MULTILEVEL_LOOKUP = '0'; DOTNET_ROLL_FORWARD = 'LatestPatch' + DOTNET_CLI_HOME = (Join-Path $stateRoot 'home'); NUGET_PACKAGES = (Join-Path $stateRoot 'nuget') + TEMP = (Join-Path $stateRoot 'temp'); TMP = (Join-Path $stateRoot 'temp') + DOTNET_CLI_TELEMETRY_OPTOUT = '1'; DOTNET_GENERATE_ASPNET_CERTIFICATE = 'false' + DOTNET_SKIP_FIRST_TIME_EXPERIENCE = '1'; DOTNET_NOLOGO = '1' + } + $before = [Environment]::GetEnvironmentVariables('Process') + try { + $null = [IO.Directory]::CreateDirectory($values.TEMP) + foreach ($name in $values.Keys) { [Environment]::SetEnvironmentVariable($name, $values[$name], 'Process') } + $workingDirectory = Join-Path $RepositoryRoot 'Tools\windows\ValidationRunner' + $versionProbe = Invoke-GraphCodeDotNetCommand $Executable $workingDirectory @('--version') + if ($versionProbe.ExitCode -ne 0 -or ($versionProbe.Output -join "`n").Trim() -cne $Version) { + return & $invalid "SDK pin resolution from $workingDirectory did not select $Version`: $($versionProbe.Output -join '; ')" + } + $runtimes = Invoke-GraphCodeDotNetCommand $Executable $workingDirectory @('--list-runtimes') + if ($runtimes.ExitCode -ne 0) { return & $invalid "Listing runtimes failed for $Executable" } + foreach ($framework in @('Microsoft.NETCore.App', 'Microsoft.WindowsDesktop.App')) { + $runtimeRoot = Join-Path $root "shared\$framework" + $present = $false + foreach ($line in $runtimes.Output) { + if ($line -match ('^' + [regex]::Escape($framework) + ' (10\.0\.\d+) \[(.+)\]$')) { + $runtimeVersion = $Matches[1] + if ([IO.Path]::GetFullPath($Matches[2]) -ieq [IO.Path]::GetFullPath($runtimeRoot)) { + $runtimeFile = if ($framework -ceq 'Microsoft.NETCore.App') { 'coreclr.dll' } else { 'PresentationCore.dll' } + if ([IO.File]::Exists((Join-Path $runtimeRoot "$runtimeVersion\$runtimeFile"))) { $present = $true } + } + } + } + if (-not $present) { return & $invalid "Missing usable $framework 10.0 runtime below $root; rerun Tools\windows\bootstrap.ps1" } + } + return [pscustomobject]@{ Valid = $true; Reason = ''; Executable = [IO.Path]::GetFullPath($Executable) } + } finally { + foreach ($name in $values.Keys) { + if ($before.Contains($name)) { [Environment]::SetEnvironmentVariable($name, $before[$name], 'Process') } + elseif ([Environment]::GetEnvironmentVariables('Process').Contains($name)) { + Remove-Item -LiteralPath "Env:\$name" -ErrorAction Stop + } + } + } +} + +function Get-GraphCodeDotNetCandidates([string] $ToolRoot, [string] $Version) { + $candidates = [Collections.Generic.List[string]]::new() + $candidates.Add((Join-Path $ToolRoot "dotnet\$Version\dotnet.exe")) + foreach ($command in @(Get-Command dotnet -CommandType Application -All -ErrorAction SilentlyContinue)) { + if ($command.Source) { $candidates.Add($command.Source) } + } + foreach ($root in @($env:DOTNET_ROOT, $(if ($env:ProgramFiles) { Join-Path $env:ProgramFiles 'dotnet' }))) { + if ($root) { $candidates.Add((Join-Path $root 'dotnet.exe')) } + } + return @($candidates | Select-Object -Unique) +} + +function Get-GraphCodeDotNetAsset([string] $Version) { + $rid = switch ([Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()) { + X64 { 'win-x64' }; Arm64 { 'win-arm64' } + default { throw 'SDK bootstrap requires a 64-bit Windows host (x64 or Arm64)' } + } + $metadata = Invoke-RestMethod -Uri 'https://dotnetcli.blob.core.windows.net/dotnet/release-metadata/10.0/releases.json' ` + -TimeoutSec 60 -MaximumRedirection 0 -ErrorAction Stop + $assets = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal) + foreach ($release in $metadata.releases) { + $sdks = @($release.sdks) + @($release.sdk) + foreach ($sdk in $sdks) { + if ($null -eq $sdk -or $sdk.version -cne $Version) { continue } + foreach ($file in $sdk.files) { + if ($file.rid -cne $rid -or $file.name -cne "dotnet-sdk-$rid.zip") { continue } + $uri = [uri]$file.url + if (-not $uri.IsAbsoluteUri -or $uri.Scheme -cne 'https' -or $uri.Port -ne 443 -or + $uri.UserInfo -or $uri.Query -or $uri.Fragment -or + $uri.Host -cnotin @('builds.dotnet.microsoft.com', 'dotnetcli.blob.core.windows.net', + 'dotnetcli.azureedge.net', 'download.visualstudio.microsoft.com', 'download.microsoft.com') -or + -not $uri.AbsolutePath.EndsWith('.zip', [StringComparison]::OrdinalIgnoreCase)) { + throw "SDK metadata contains a non-official ZIP URL for $Version" + } + if ($file.hash -notmatch '^[0-9A-Fa-f]{128}$') { throw "SDK metadata has no valid SHA-512 checksum for $Version" } + $key = $uri.AbsoluteUri + ':' + $file.hash.ToUpperInvariant() + $assets[$key] = [pscustomobject]@{ Uri = $uri; Sha512 = $file.hash.ToUpperInvariant() } + } + } + } + if ($assets.Count -ne 1) { throw "Official Microsoft metadata must provide exactly one SDK $Version $rid ZIP and SHA-512; found $($assets.Count)" } + return @($assets.Values)[0] +} + +function Assert-GraphCodeDotNetArchive([string] $Archive, [string] $Destination) { + $prefix = [IO.Path]::GetFullPath($Destination).TrimEnd('\') + '\' + $zip = [IO.Compression.ZipFile]::OpenRead($Archive) + try { + if ($zip.Entries.Count -eq 0) { throw 'SDK ZIP is empty' } + foreach ($entry in $zip.Entries) { + $name = $entry.FullName.Replace('/', '\') + if ([IO.Path]::IsPathRooted($name) -or $name.Contains(':') -or + -not [IO.Path]::GetFullPath((Join-Path $Destination $name)).StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase) -or + (($entry.ExternalAttributes -shr 16) -band 0xF000) -eq 0xA000) { + throw "SDK ZIP contains an unsafe archive entry: $($entry.FullName)" + } + } + } finally { $zip.Dispose() } +} + +function Install-GraphCodeDotNetSdk([string] $RepositoryRoot, [string] $ToolRoot, [string] $Version) { + $sdkParent = Join-Path $ToolRoot 'dotnet' + $destination = Join-Path $sdkParent $Version + if (Test-Path -LiteralPath $destination) { + throw "Private SDK destination already exists but was not verified: $destination; preserve it and select a fresh -ToolRoot" + } + $null = [IO.Directory]::CreateDirectory($sdkParent) + $scratch = Join-Path $sdkParent ('.install-' + [guid]::NewGuid().ToString('N')) + $null = [IO.Directory]::CreateDirectory($scratch) + $primary = $null + try { + $asset = Get-GraphCodeDotNetAsset $Version + $archive = Join-Path $scratch 'sdk.zip' + Invoke-WebRequest -Uri $asset.Uri -OutFile $archive -TimeoutSec 900 -MaximumRedirection 0 -ErrorAction Stop + if ((Get-FileHash -LiteralPath $archive -Algorithm SHA512).Hash -cne $asset.Sha512) { + throw "SDK $Version SHA-512 checksum mismatch; archive was not extracted" + } + $payload = Join-Path $scratch 'payload' + Assert-GraphCodeDotNetArchive $archive $payload + Expand-Archive -LiteralPath $archive -DestinationPath $payload -ErrorAction Stop + $probe = Get-GraphCodeDotNetSdkProbe (Join-Path $payload 'dotnet.exe') $RepositoryRoot $ToolRoot $Version + if (-not $probe.Valid) { throw "Downloaded SDK failed verification: $($probe.Reason)" } + [IO.Directory]::Move($payload, $destination) + return Join-Path $destination 'dotnet.exe' + } catch { + $primary = $_.Exception + throw + } finally { + try { Remove-Item -LiteralPath $scratch -Recurse -Force -ErrorAction Stop } + catch { + if ($primary) { + $primary.Data['GraphCodeDotNetCleanupFailure'] = $_.Exception.ToString() + Write-Warning "SDK scratch cleanup failed at $scratch; the primary failure is preserved" + } else { throw } + } + } +} + +function Resolve-GraphCodeDotNetSdk { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ToolRoot, + [switch] $InstallIfMissing + ) + $RepositoryRoot = [IO.Path]::GetFullPath($RepositoryRoot) + $ToolRoot = [IO.Path]::GetFullPath($ToolRoot) + $version = Get-GraphCodeDotNetPin $RepositoryRoot + $environment = [Environment]::GetEnvironmentVariables('Process') + if ($environment.Contains('GRAPHCODE_DOTNET10403')) { + $selected = [string]$environment['GRAPHCODE_DOTNET10403'] + if ([string]::IsNullOrWhiteSpace($selected)) { throw 'GRAPHCODE_DOTNET10403 is explicitly empty; reload the bootstrap environment' } + $probe = Get-GraphCodeDotNetSdkProbe $selected $RepositoryRoot $ToolRoot $version + if (-not $probe.Valid) { throw "GRAPHCODE_DOTNET10403 is invalid: $($probe.Reason)" } + return $probe.Executable + } + $rejected = [Collections.Generic.List[string]]::new() + foreach ($candidate in @(Get-GraphCodeDotNetCandidates $ToolRoot $version)) { + $probe = Get-GraphCodeDotNetSdkProbe $candidate $RepositoryRoot $ToolRoot $version + if ($probe.Valid) { return $probe.Executable } + $rejected.Add($probe.Reason) + Write-Verbose $probe.Reason + } + if ($InstallIfMissing) { return Install-GraphCodeDotNetSdk $RepositoryRoot $ToolRoot $version } + throw "Verified .NET SDK $version with .NETCore/WindowsDesktop 10.0 runtimes and reference inputs is required. Run Tools\windows\bootstrap.ps1. $($rejected -join '; ')" +} diff --git a/Tools/windows/Import-CompiledValidationAssembly.ps1 b/Tools/windows/Import-CompiledValidationAssembly.ps1 new file mode 100644 index 00000000..6fb8d6fe --- /dev/null +++ b/Tools/windows/Import-CompiledValidationAssembly.ps1 @@ -0,0 +1,137 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $AssemblyPath, + [Parameter(Mandatory)][string] $RequiredType +) + +$ErrorActionPreference = "Stop" +if ($RequiredType -cne "GraphCode.ValidationFixtureDirectoryNative" -and [Environment]::Version.Major -lt 8) { + throw "Compiled Windows bindings require PowerShell on .NET 8 or newer (PowerShell 7.4+); observed $([Environment]::Version)" +} +if (-not [IO.Path]::IsPathFullyQualified($AssemblyPath)) { + throw "Compiled validation assembly must be an explicit absolute path" +} +$projectRoot = Join-Path $PSScriptRoot "ValidationRunner" +$fileName = if ($RequiredType -ceq "GraphCode.ValidationFixtureDirectoryNative") { + "ValidationFixtureBindings.dll" +} elseif ($RequiredType -ceq "GraphCode.Validation.Ownership.OwnershipJobFixture" -or + $RequiredType.StartsWith("GraphCode.Validation.TestFixtures.", [StringComparison]::Ordinal)) { + "ValidationTestFixtures.dll" +} else { "WindowsValidationBindings.dll" } +if ([IO.Path]::GetFileName($AssemblyPath) -ceq "ValidationRunner.dll") { + $AssemblyPath = Join-Path ([IO.Path]::GetDirectoryName($AssemblyPath)) $fileName +} +$selected = (Resolve-Path -LiteralPath $AssemblyPath -ErrorAction Stop).Path +$expected = @("Debug", "Release") | ForEach-Object { + [IO.Path]::GetFullPath((Join-Path $projectRoot "bin\$_\net10.0-windows\$fileName")) +} +if ($expected -cnotcontains $selected) { + throw "Compiled validation assembly is not a build from this worktree: $selected" +} +$alreadyLoaded = $RequiredType -as [type] +if ($null -ne $alreadyLoaded -and $alreadyLoaded.Assembly.Location -cne $selected) { + throw "Required validation type is already loaded from a different assembly: $RequiredType" +} +$assembly = [Reflection.Assembly]::LoadFrom($selected) +$type = $assembly.GetType($RequiredType, $false) +if ($null -eq $type -or $type.Assembly.Location -cne $selected) { + throw "Expected file-backed validation type was not loaded: $RequiredType" +} +$sourceRoots = @($assembly.GetCustomAttributesData() | Where-Object { + $_.AttributeType.FullName -ceq "System.Reflection.AssemblyMetadataAttribute" -and + $_.ConstructorArguments.Count -eq 2 -and + $_.ConstructorArguments[0].Value -ceq "GraphCode.Validation.SourceRoot" + }) +if ($sourceRoots.Count -ne 1 -or $sourceRoots[0].ConstructorArguments[1].Value -cne $projectRoot) { + throw "Compiled validation assembly has no matching worktree build provenance" +} +$inputs = [Collections.Generic.Dictionary[string,string]]::new([StringComparer]::OrdinalIgnoreCase) +foreach ($attribute in $assembly.GetCustomAttributesData()) { + if ($attribute.AttributeType.FullName -cne "System.Reflection.AssemblyMetadataAttribute" -or + $attribute.ConstructorArguments.Count -ne 2 -or + $attribute.ConstructorArguments[0].Value -cne "GraphCode.Validation.Input") { continue } + $value = [string]$attribute.ConstructorArguments[1].Value + $parts = $value.Split('|') + if ($parts.Count -ne 2 -or -not [IO.Path]::IsPathFullyQualified($parts[0]) -or + $parts[1] -cnotmatch '^[0-9A-F]{64}$' -or -not $inputs.TryAdd($parts[0], $parts[1])) { + throw "Compiled validation assembly has invalid or duplicate input provenance" + } +} +$requiredInputs = [Collections.Generic.List[string]]::new() +$requiredInputs.Add((Join-Path $projectRoot "global.json")) +$requiredInputs.Add((Join-Path $projectRoot "BuildProvenance.targets")) +switch ($fileName) { + "ValidationFixtureBindings.dll" { + $requiredInputs.Add((Join-Path $projectRoot "Bindings\ValidationFixtureBindings.csproj")) + $requiredInputs.Add((Join-Path $PSScriptRoot "ValidationFixtureDirectoryNative.cs")) + } + "ValidationTestFixtures.dll" { + $requiredInputs.Add((Join-Path $projectRoot "Bindings\Fixtures\ValidationTestFixtures.csproj")) + $requiredInputs.Add((Join-Path $projectRoot "Ownership\OwnershipJobFixture.cs")) + foreach ($file in (Get-ChildItem -LiteralPath (Join-Path $projectRoot "Host\Fixtures") -Filter "*.cs" -File)) { + $requiredInputs.Add($file.FullName) + } + } + "WindowsValidationBindings.dll" { + $requiredInputs.Add((Join-Path $projectRoot "Bindings\Windows\WindowsValidationBindings.csproj")) + $requiredInputs.Add((Join-Path $PSScriptRoot "UiaWindowActivation.cs")) + $requiredInputs.Add((Join-Path $PSScriptRoot "ShellOwnedProcess.cs")) + foreach ($directory in @("Ui\Production", "Host\Production", "Ownership")) { + foreach ($file in (Get-ChildItem -LiteralPath (Join-Path $projectRoot $directory) -Filter "*.cs" -File)) { + if ($directory -ceq "Ownership" -and + ($file.Name -like "*Fixture.cs" -or $file.Name -ceq "SuiteRegistration.cs")) { continue } + $requiredInputs.Add($file.FullName) + } + } + } +} +foreach ($inputPath in $requiredInputs) { + if (-not $inputs.ContainsKey([IO.Path]::GetFullPath($inputPath))) { + throw "Compiled validation input manifest is incomplete; rebuild with Build-CompiledValidationRunner.ps1" + } +} +if ($inputs.Count -le $requiredInputs.Count) { + throw "Compiled validation input manifest omits compiler reference inputs" +} +foreach ($inputPath in $inputs.Keys) { + if (-not (Test-Path -LiteralPath $inputPath -PathType Leaf) -or + (Get-FileHash -LiteralPath $inputPath -Algorithm SHA256).Hash -cne $inputs[$inputPath]) { + throw "Compiled validation inputs changed after build; rebuild with Build-CompiledValidationRunner.ps1" + } +} +$file = [IO.File]::Open($selected, [IO.FileMode]::Open, [IO.FileAccess]::Read, + [IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete) +try { + $pe = [Reflection.PortableExecutable.PEReader]::new($file) + try { + $metadata = [Reflection.Metadata.PEReaderExtensions]::GetMetadataReader($pe) + $fileMvid = $metadata.GetGuid($metadata.GetModuleDefinition().Mvid) + if ($fileMvid -ne $assembly.ManifestModule.ModuleVersionId) { + throw "An older validation assembly is already loaded at the current path; start a fresh PowerShell process" + } + } finally { $pe.Dispose() } +} finally { $file.Dispose() } +if ($RequiredType -cin @("GraphCodeUiaGateState", "GraphCodeUiaAttribution")) { + $uiFrameworkAssemblies = @{} + foreach ($runtimeName in @("WindowsBase", "UIAutomationTypes", "UIAutomationClient")) { + $runtimePath = Join-Path $PSHOME "$runtimeName.dll" + $runtimeIdentity = [Reflection.AssemblyName]::GetAssemblyName($runtimePath) + if ($runtimeIdentity.Version.Major -ne [Environment]::Version.Major) { + throw "UIA runtime dependency $runtimePath does not match PowerShell CLR $([Environment]::Version); reference-pack assemblies are not supported" + } + $runtimeAssembly = [Reflection.Assembly]::LoadFrom($runtimePath) + if ($runtimeAssembly.GetName().Version.Major -ne [Environment]::Version.Major -or + @($runtimeAssembly.GetCustomAttributesData() | Where-Object { + $_.AttributeType.FullName -ceq "System.Runtime.CompilerServices.ReferenceAssemblyAttribute" + }).Count -ne 0) { + throw "UIA dependency is not a compatible runtime assembly: $runtimePath" + } + $uiFrameworkAssemblies[$runtimeName] = $runtimeAssembly + } + if ([System.Windows.Rect].Assembly -ne $uiFrameworkAssemblies.WindowsBase -or + [System.Windows.Automation.ControlType].Assembly -ne $uiFrameworkAssemblies.UIAutomationTypes -or + [System.Windows.Automation.InvokePattern].Assembly -ne $uiFrameworkAssemblies.UIAutomationClient) { + throw "UIA types do not belong to the explicitly selected PowerShell runtime assemblies" + } +} +Write-Output $assembly diff --git a/Tools/windows/Invoke-ValidationRunnerContract.ps1 b/Tools/windows/Invoke-ValidationRunnerContract.ps1 index 38e68be2..eadbf6a6 100644 --- a/Tools/windows/Invoke-ValidationRunnerContract.ps1 +++ b/Tools/windows/Invoke-ValidationRunnerContract.ps1 @@ -1,21 +1,68 @@ [CmdletBinding()] param() +function Invoke-WithValidationRunnerRuntime([scriptblock] $Action) { + $selected = [Environment]::GetEnvironmentVariable("GRAPHCODE_DOTNET10403", "Process") + $prior = @{} + if (-not [string]::IsNullOrWhiteSpace($selected)) { + if (-not [IO.Path]::IsPathFullyQualified($selected) -or + -not (Test-Path -LiteralPath $selected -PathType Leaf)) { + throw "GRAPHCODE_DOTNET10403 must identify the selected private dotnet executable" + } + $runtimeRoot = [IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($selected)) + $environment = [Environment]::GetEnvironmentVariables("Process") + foreach ($name in @("DOTNET_ROOT", "DOTNET_ROOT_X64")) { + $prior[$name] = @{ present = $environment.Contains($name); value = $environment[$name] } + } + } + try { + foreach ($name in $prior.Keys) { + [Environment]::SetEnvironmentVariable($name, $runtimeRoot, "Process") + } + & $Action + } finally { + foreach ($name in $prior.Keys) { + $value = if ($prior[$name].present) { $prior[$name].value } else { $null } + [Environment]::SetEnvironmentVariable($name, $value, "Process") + } + } +} + $ErrorActionPreference = "Stop" -$runner = Join-Path $PSScriptRoot "Tests\ValidationRunner.Tests.ps1" -$pwsh = (Get-Process -Id $PID).Path +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +$assembly = & (Join-Path $PSScriptRoot "Build-CompiledValidationRunner.ps1") -Configuration Release +$runner = Join-Path (Split-Path $assembly -Parent) "ValidationRunner.exe" +if (-not (Test-Path -LiteralPath $runner -PathType Leaf)) { + throw "Compiled validation build produced no executable" +} +$artifactRoot = Join-Path $repoRoot (".build\validation-runner-contract-" + [guid]::NewGuid().ToString("N")) +$output = [Collections.Generic.List[string]]::new() $passMarkers = 0 -& $pwsh -NoProfile -File $runner 2>&1 | ForEach-Object { - $line = "$_" - if ($line -ceq "ValidationRunner.Tests.ps1: PASS") { $script:passMarkers++ } - Write-Host $line +$executedMarkers = 0 +$exitCode = $null +Invoke-WithValidationRunnerRuntime { + & $runner --repository-root $repoRoot --artifact-root $artifactRoot 2>&1 | ForEach-Object { + $line = "$_" + $output.Add($line) + Write-Host $line + } + $script:exitCode = $LASTEXITCODE } -$exitCode = $LASTEXITCODE if ($exitCode -ne 0) { - throw "Validation runner contract failed with exit code $exitCode" + throw "Compiled validation failed with exit code $exitCode; evidence retained at $artifactRoot" +} +$report = Get-Content -LiteralPath (Join-Path $artifactRoot "coverage.json") -Raw | ConvertFrom-Json +if ($report.runner -cne "CompiledValidationRunner" -or + $report.complete -isnot [bool] -or -not $report.complete -or + -not ($report.requiredCount -is [int] -or $report.requiredCount -is [long]) -or + -not ($report.passedCount -is [int] -or $report.passedCount -is [long]) -or + $report.requiredCount -le 0 -or $report.passedCount -ne $report.requiredCount -or + @($report.units).Count -ne $report.requiredCount -or @($report.errors).Count -ne 0) { + throw "Compiled validation did not report complete positive full coverage; evidence retained at $artifactRoot" } -if ($passMarkers -ne 1) { - throw "Validation runner contract did not report exactly one PASS marker" +$passMarkers = @($output | Where-Object { $_ -ceq "CompiledValidationRunner: PASS" }).Count +$executedMarkers = @($output | Where-Object { $_ -ceq "VALIDATION_RUNNER_EXECUTED=1" }).Count +if ($passMarkers -ne 1 -or $executedMarkers -ne 1) { + throw "Compiled validation must report exactly one full PASS and one positive execution marker" } -Write-Host "VALIDATION_RUNNER_EXECUTED=1" exit 0 diff --git a/Tools/windows/OwnedLiveProcess.ps1 b/Tools/windows/OwnedLiveProcess.ps1 new file mode 100644 index 00000000..d716322a --- /dev/null +++ b/Tools/windows/OwnedLiveProcess.ps1 @@ -0,0 +1,247 @@ +function Stop-OwnedLiveProcess { + [CmdletBinding()] + param( + [Parameter(Mandatory)][Diagnostics.Process] $Process, + [Parameter(Mandatory)][string] $Name, + [ValidateRange(1, 60000)][int] $TimeoutMilliseconds = 10000 + ) + + if ($Process.HasExited) { return } + try { + $Process.Kill() + } catch { + $killError = $_.Exception + try { + $exited = $Process.HasExited + } catch { + throw [AggregateException]::new( + "Could not stop or verify the owned $Name process.", + [Exception[]]@($killError, $_.Exception)) + } + if (-not $exited) { throw $killError } + } + if (-not $Process.WaitForExit($TimeoutMilliseconds)) { + throw [TimeoutException]::new( + "The owned $Name process did not exit within $TimeoutMilliseconds ms.") + } +} + +function Write-OwnedLiveText { + [CmdletBinding()] + param( + [Parameter(Mandatory)][IO.TextWriter] $Writer, + [AllowEmptyString()][string] $Text, + [Parameter(Mandatory)][ref] $Pending, + [switch] $Final + ) + + $textToWrite = $Pending.Value + $Text + $Pending.Value = "" + if (-not $Final -and $textToWrite.Length -gt 0 -and + [char]::IsHighSurrogate($textToWrite[$textToWrite.Length - 1])) { + $Pending.Value = $textToWrite.Substring($textToWrite.Length - 1) + $textToWrite = $textToWrite.Substring(0, $textToWrite.Length - 1) + } + if ($textToWrite.Length -gt 0) { + $Writer.Write($textToWrite) + $Writer.Flush() + } +} + +function Write-OwnedLiveOutputChunk { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Scope, + [Parameter(Mandatory)][Diagnostics.Process] $Process, + [Parameter(Mandatory)][hashtable] $Pending + ) + + $maximumCharacters = 65536 + $chunk = Receive-ShellOwnedOutput $Scope $Process -MaximumCharacters $maximumCharacters + if ($null -eq $chunk -or $chunk.Output -isnot [string] -or + $chunk.Error -isnot [string] -or + $chunk.Output.Length -gt $maximumCharacters -or + $chunk.Error.Length -gt $maximumCharacters) { + throw "Owned incremental output returned an invalid or oversized chunk." + } + $outputPending = $Pending.Output + $errorPending = $Pending.Error + Write-OwnedLiveText -Writer ([Console]::Out) -Text $chunk.Output -Pending ([ref]$outputPending) + Write-OwnedLiveText -Writer ([Console]::Error) -Text $chunk.Error -Pending ([ref]$errorPending) + $Pending.Output = $outputPending + $Pending.Error = $errorPending + return ($chunk.Output.Length -gt 0 -or $chunk.Error.Length -gt 0) +} + +function Invoke-OwnedLiveCommand { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Scope, + [Parameter(Mandatory)][Diagnostics.ProcessStartInfo] $StartInfo, + [switch] $StreamOutput + ) + + $StartInfo.RedirectStandardOutput = $true + $StartInfo.RedirectStandardError = $true + $process = Start-ShellOwnedProcess $Scope $StartInfo + if ($StreamOutput) { + $pending = @{ Output = ""; Error = "" } + do { + $received = Write-OwnedLiveOutputChunk -Scope $Scope -Process $process -Pending $pending + $wait = if ($received) { 0 } else { 100 } + $exited = $process.WaitForExit($wait) + } while (-not $exited) + } else { + [void]$process.WaitForExit() + } + Complete-ShellOwnedProcess $Scope $process + if ($StreamOutput) { + do { + $received = Write-OwnedLiveOutputChunk -Scope $Scope -Process $process -Pending $pending + } while ($received) + $outputPending = $pending.Output + $errorPending = $pending.Error + Write-OwnedLiveText -Writer ([Console]::Out) -Text "" -Pending ([ref]$outputPending) -Final + Write-OwnedLiveText -Writer ([Console]::Error) -Text "" -Pending ([ref]$errorPending) -Final + } + return [pscustomobject]@{ + ExitCode = $process.ExitCode + Output = Read-ShellOwnedOutput $Scope $process + Error = Read-ShellOwnedOutput $Scope $process -StandardError + } +} + +function Get-OwnedLiveProcessRetainedRoots { + [CmdletBinding()] + param( + [Exception[]] $Errors = @(), + [string[]] $RetainedRoots = @() + ) + + $observed = [Collections.Generic.List[string]]::new() + $pathsSeen = [Collections.Generic.HashSet[string]]::new( + [StringComparer]::OrdinalIgnoreCase) + $pending = [Collections.Generic.Stack[Exception]]::new() + $errorsSeen = [Collections.Generic.HashSet[Exception]]::new() + foreach ($root in $RetainedRoots) { + if ($root -and [IO.Directory]::Exists($root) -and $pathsSeen.Add($root)) { + $observed.Add($root) + } + } + foreach ($error in $Errors) { + if ($error) { $pending.Push($error) } + } + while ($pending.Count -gt 0) { + $current = $pending.Pop() + if (-not $errorsSeen.Add($current)) { continue } + if ($current.Data.Contains("GraphCodeValidationRetainedRoots")) { + foreach ($root in @($current.Data["GraphCodeValidationRetainedRoots"])) { + if ($root -is [string] -and $root -and [IO.Directory]::Exists($root) -and + $pathsSeen.Add($root)) { + $observed.Add($root) + } + } + } + if ($current -is [AggregateException]) { + foreach ($inner in $current.InnerExceptions) { $pending.Push($inner) } + } elseif ($current.InnerException) { + $pending.Push($current.InnerException) + } + } + return $observed.ToArray() +} + +function New-OwnedLiveProcessCleanupError { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string] $Message, + [Parameter(Mandatory)][Exception[]] $Errors, + [string[]] $RetainedRoots = @() + ) + + $allErrors = [Collections.Generic.List[Exception]]::new() + foreach ($error in $Errors) { $allErrors.Add($error) } + $environment = [Environment]::GetEnvironmentVariables( + [EnvironmentVariableTarget]::Process) + try { + if (-not $environment.Contains("GRAPHCODE_VALIDATION_ROOT")) { + throw "GRAPHCODE_VALIDATION_ROOT is absent; a retention receipt cannot be written." + } + $root = [string]$environment["GRAPHCODE_VALIDATION_ROOT"] + if ([string]::IsNullOrWhiteSpace($root) -or + -not [IO.Path]::IsPathFullyQualified($root)) { + throw "GRAPHCODE_VALIDATION_ROOT is not a fully qualified path." + } + $directory = [IO.DirectoryInfo]::new($root) + while ($directory) { + if (-not $directory.Exists -or + ($directory.Attributes -band [IO.FileAttributes]::ReparsePoint)) { + throw "Validation root or an ancestor is missing or is a reparse point: $($directory.FullName)" + } + $directory = $directory.Parent + } + $receiptPath = Join-Path $root "ownership-unproven.json" + $receiptExists = $true + try { + [void][IO.File]::GetAttributes($receiptPath) + } catch [IO.FileNotFoundException] { + $receiptExists = $false + } + if ($receiptExists) { throw "Ownership receipt already exists: $receiptPath" } + $receipt = [ordered]@{ + schemaVersion = 1 + validationRoot = $root + reason = "ownership_unproven" + } | ConvertTo-Json -Compress + $bytes = [Text.UTF8Encoding]::new($false).GetBytes($receipt) + if ($bytes.Length -gt 4096) { throw "Ownership receipt exceeds 4096 bytes." } + $stream = [IO.File]::Open( + $receiptPath, [IO.FileMode]::CreateNew, + [IO.FileAccess]::Write, [IO.FileShare]::Read) + try { + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + } finally { + $stream.Dispose() + } + } catch { + $allErrors.Add($_.Exception) + try { + [Console]::Out.WriteLine( + "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven") + [Console]::Out.Flush() + } catch { + $allErrors.Add($_.Exception) + } + } + + $result = [AggregateException]::new($Message, [Exception[]]$allErrors.ToArray()) + $result.Data["GraphCodeValidationRootRetentionRequired"] = $true + $result.Data["GraphCodeValidationRetainedRoots"] = [string[]]@( + Get-OwnedLiveProcessRetainedRoots -Errors $Errors -RetainedRoots $RetainedRoots) + return ,$result +} + +function Test-OwnedLiveProcessRetentionRequired { + [CmdletBinding()] + param([Parameter(Mandatory)][Exception] $Exception) + + $pending = [Collections.Generic.Stack[Exception]]::new() + $seen = [Collections.Generic.HashSet[Exception]]::new() + $pending.Push($Exception) + while ($pending.Count -gt 0) { + $current = $pending.Pop() + if (-not $seen.Add($current)) { continue } + if ($current.Data.Contains("GraphCodeValidationRootRetentionRequired") -and + $current.Data["GraphCodeValidationRootRetentionRequired"] -is [bool] -and + $current.Data["GraphCodeValidationRootRetentionRequired"]) { + return $true + } + if ($current -is [AggregateException]) { + foreach ($inner in $current.InnerExceptions) { $pending.Push($inner) } + } elseif ($current.InnerException) { + $pending.Push($current.InnerException) + } + } + return $false +} diff --git a/Tools/windows/ShellOwnedProcess.cs b/Tools/windows/ShellOwnedProcess.cs new file mode 100644 index 00000000..710edf87 --- /dev/null +++ b/Tools/windows/ShellOwnedProcess.cs @@ -0,0 +1,285 @@ +#nullable disable +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading.Tasks; + +public sealed class ShellOwnedLaunch { + public static string SelectWorkingDirectory(ProcessStartInfo info) { + return String.IsNullOrEmpty(info.WorkingDirectory) ? null : info.WorkingDirectory; + } + [StructLayout(LayoutKind.Sequential)] + struct Security { public int Length; public IntPtr Descriptor; public int Inherit; } + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + struct Startup { + public int Size; + public string Reserved, Desktop, Title; + public int X, Y, Width, Height, XChars, YChars, Fill, Flags; + public short Show, ReservedSize; + public IntPtr ReservedBytes, Input, Output, Error, Attributes; + } + [StructLayout(LayoutKind.Sequential)] + struct ProcessInfo { public IntPtr Process, Thread; public int Pid, Tid; } + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool CreatePipe(out IntPtr read, out IntPtr write, ref Security security, int size); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool SetHandleInformation(IntPtr handle, uint mask, uint flags); + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + static extern IntPtr CreateFileW(string name, uint access, uint share, ref Security security, + uint disposition, uint flags, IntPtr template); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool InitializeProcThreadAttributeList(IntPtr list, int count, int flags, ref IntPtr size); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool UpdateProcThreadAttribute(IntPtr list, uint flags, IntPtr attribute, + IntPtr value, IntPtr size, IntPtr previous, IntPtr returned); + [DllImport("kernel32.dll")] + static extern void DeleteProcThreadAttributeList(IntPtr list); + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + static extern bool CreateProcessW(string file, StringBuilder command, IntPtr processSecurity, + IntPtr threadSecurity, bool inherit, uint flags, IntPtr environment, string directory, + ref Startup startup, out ProcessInfo process); + [DllImport("kernel32.dll", SetLastError = true)] + static extern uint ResumeThread(IntPtr thread); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool TerminateProcess(IntPtr process, uint code); + [DllImport("kernel32.dll", SetLastError = true)] + static extern uint WaitForSingleObject(IntPtr handle, uint timeout); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool GetProcessTimes(IntPtr process, out long created, out long exited, + out long kernel, out long user); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool CloseHandle(IntPtr handle); + + public Process Process { get; private set; } + public IntPtr Handle { get; private set; } + public Task Output { get; private set; } + public Task Error { get; private set; } + IntPtr thread; + StreamReader outputReader, errorReader; + OutputCapture outputCapture, errorCapture; + + sealed class OutputCapture { + readonly TextReader reader; + readonly TextWriter forward; + readonly object gate = new object(); + StringBuilder captured = new StringBuilder(); + int forwarded; + public Task Completion { get; private set; } + public OutputCapture(TextReader reader) : this(reader, null) {} + public OutputCapture(TextReader reader, TextWriter forward) { + this.reader = reader; + this.forward = forward; + Completion = Pump(); + } + async Task Pump() { + var buffer = new char[4096]; + char? pendingHigh = null; + int count; + while ((count = await reader.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false)) != 0) { + lock (gate) captured.Append(buffer, 0, count); + if (forward != null) { + char[] chunk = buffer; + int length = count; + if (pendingHigh.HasValue) { + chunk = new char[count + 1]; + chunk[0] = pendingHigh.Value; + Array.Copy(buffer, 0, chunk, 1, count); + length++; + pendingHigh = null; + } + if (Char.IsHighSurrogate(chunk[length - 1])) { + pendingHigh = chunk[length - 1]; + length--; + } + if (length > 0) { + forward.Write(chunk, 0, length); + forward.Flush(); + } + } + } + if (forward != null && pendingHigh.HasValue) { + forward.Write(new[] { pendingHigh.Value }, 0, 1); + forward.Flush(); + } + lock (gate) return captured.ToString(); + } + public string Take(int maximumCharacters) { + if (maximumCharacters < 2 || maximumCharacters > 65536) + throw new ArgumentOutOfRangeException(nameof(maximumCharacters)); + if (Completion.IsFaulted || Completion.IsCanceled) Completion.GetAwaiter().GetResult(); + lock (gate) { + if (captured == null) throw new ObjectDisposedException(nameof(OutputCapture)); + int count = Math.Min(maximumCharacters, captured.Length - forwarded); + if (count > 0 && Char.IsHighSurrogate(captured[forwarded + count - 1])) count--; + string result = captured.ToString(forwarded, count); + forwarded += count; + return result; + } + } + public void Release() { lock (gate) captured = null; } + } + + static TextWriter ForwardWriter(bool redirected, TextWriter writer) { + if (!redirected && writer == null) throw new ArgumentNullException(nameof(writer)); + return redirected ? null : writer; + } + + static string Quote(string value) { + var result = new StringBuilder("\""); + int slashes = 0; + foreach (char c in value) { + if (c == '\\') { slashes++; continue; } + result.Append('\\', c == '"' ? slashes * 2 + 1 : slashes); + result.Append(c); + slashes = 0; + } + return result.Append('\\', slashes * 2).Append('"').ToString(); + } + + public static ShellOwnedLaunch Suspended(ProcessStartInfo info) { + if (info.UseShellExecute || !String.IsNullOrEmpty(info.Arguments)) + throw new InvalidOperationException("Owned launch requires UseShellExecute=false and ArgumentList."); + var security = new Security { Length = Marshal.SizeOf(), Inherit = 1 }; + IntPtr outRead = IntPtr.Zero, outWrite = IntPtr.Zero, errRead = IntPtr.Zero, errWrite = IntPtr.Zero; + IntPtr input = IntPtr.Zero, attributes = IntPtr.Zero, handles = IntPtr.Zero, environment = IntPtr.Zero; + bool initialized = false; + var run = new ShellOwnedLaunch(); + try { + if (!CreatePipe(out outRead, out outWrite, ref security, 0) || + !CreatePipe(out errRead, out errWrite, ref security, 0) || + !SetHandleInformation(outRead, 1, 0) || !SetHandleInformation(errRead, 1, 0)) + throw new Win32Exception(); + input = CreateFileW("NUL", 0x80000000, 3, ref security, 3, 0, IntPtr.Zero); + if (input == new IntPtr(-1)) throw new Win32Exception(); + IntPtr size = IntPtr.Zero; + InitializeProcThreadAttributeList(IntPtr.Zero, 1, 0, ref size); + attributes = Marshal.AllocHGlobal(size); + if (!InitializeProcThreadAttributeList(attributes, 1, 0, ref size)) throw new Win32Exception(); + initialized = true; + handles = Marshal.AllocHGlobal(IntPtr.Size * 3); + Marshal.WriteIntPtr(handles, 0, input); + Marshal.WriteIntPtr(handles, IntPtr.Size, outWrite); + Marshal.WriteIntPtr(handles, IntPtr.Size * 2, errWrite); + if (!UpdateProcThreadAttribute(attributes, 0, new IntPtr(0x20002), handles, + new IntPtr(IntPtr.Size * 3), IntPtr.Zero, IntPtr.Zero)) throw new Win32Exception(); + var block = new StringBuilder(); + foreach (var pair in info.Environment.OrderBy(p => p.Key, StringComparer.OrdinalIgnoreCase)) + block.Append(pair.Key).Append('=').Append(pair.Value).Append('\0'); + block.Append('\0'); + environment = Marshal.StringToHGlobalUni(block.ToString()); + var command = new StringBuilder(Quote(info.FileName)); + foreach (string argument in info.ArgumentList) command.Append(' ').Append(Quote(argument)); + var startup = new Startup { + Size = Marshal.SizeOf(), Flags = 0x101, Input = input, + Show = (short)(info.WindowStyle == ProcessWindowStyle.Hidden ? 0 : + info.WindowStyle == ProcessWindowStyle.Minimized ? 2 : + info.WindowStyle == ProcessWindowStyle.Maximized ? 3 : 1), + Output = outWrite, Error = errWrite, Attributes = attributes + }; + ProcessInfo created; + uint flags = 0x4 | 0x400 | 0x80000; // suspended, Unicode environment, explicit handle list + if (info.CreateNoWindow) flags |= 0x08000000; + if (!CreateProcessW(info.FileName, command, IntPtr.Zero, IntPtr.Zero, true, flags, + environment, SelectWorkingDirectory(info), ref startup, out created)) throw new Win32Exception(); + run.Handle = created.Process; + run.thread = created.Thread; + run.Process = Process.GetProcessById(created.Pid); + // Retain both handles before releasing the suspended target. + var retained = run.Process.Handle; + long nativeCreated, managedCreated, ignoredExit, ignoredKernel, ignoredUser; + if (!GetProcessTimes(run.Handle, out nativeCreated, out ignoredExit, out ignoredKernel, out ignoredUser) || + !GetProcessTimes(retained, out managedCreated, out ignoredExit, out ignoredKernel, out ignoredUser)) + throw new Win32Exception(); + if (nativeCreated != managedCreated) + throw new InvalidOperationException("Managed process generation differs from the held launch."); + run.outputReader = new StreamReader(new FileStream( + new Microsoft.Win32.SafeHandles.SafeFileHandle(outRead, true), FileAccess.Read)); + outRead = IntPtr.Zero; + run.errorReader = new StreamReader(new FileStream( + new Microsoft.Win32.SafeHandles.SafeFileHandle(errRead, true), FileAccess.Read)); + errRead = IntPtr.Zero; + run.outputCapture = new OutputCapture(run.outputReader, ForwardWriter(info.RedirectStandardOutput, Console.Out)); + run.errorCapture = new OutputCapture(run.errorReader, ForwardWriter(info.RedirectStandardError, Console.Error)); + run.Output = run.outputCapture.Completion; + run.Error = run.errorCapture.Completion; + return run; + } catch (Exception primary) { + if (run.Handle != IntPtr.Zero) { + try { run.Stop(); run.Close(); } + catch (Exception cleanup) { + var error = new InvalidOperationException("Owned launch failed: " + primary.Message + + "; OWNED_CLEANUP_UNPROVEN: " + cleanup.Message, primary); + error.Data["GraphCodeValidationRootRetentionRequired"] = true; + throw error; + } + } + throw; + } finally { + foreach (IntPtr handle in new[] { outRead, outWrite, errRead, errWrite, input }) + if (handle != IntPtr.Zero && handle != new IntPtr(-1)) CloseHandle(handle); + if (initialized) DeleteProcThreadAttributeList(attributes); + foreach (IntPtr allocation in new[] { attributes, handles, environment }) + if (allocation != IntPtr.Zero) Marshal.FreeHGlobal(allocation); + } + } + + public void Resume() { + if (thread == IntPtr.Zero) throw new InvalidOperationException("Owned thread handle is unavailable."); + uint previous = ResumeThread(thread); + if (previous == UInt32.MaxValue) throw new Win32Exception(); + if (previous != 1) { + var error = new InvalidOperationException("Owned launch suspension count changed before release."); + error.Data["GraphCodeValidationRootRetentionRequired"] = true; + throw error; + } + if (!CloseHandle(thread)) throw new Win32Exception(); + thread = IntPtr.Zero; + } + public void Stop() { + uint state = WaitForSingleObject(Handle, 0); + if (state == 0) return; + if (state != 258) throw new Win32Exception(); + if (!TerminateProcess(Handle, 124) && WaitForSingleObject(Handle, 0) != 0) throw new Win32Exception(); + if (WaitForSingleObject(Handle, 5000) != 0) throw new InvalidOperationException("Owned process did not exit."); + } + public void RequireExited() { + RequireExited(0); + } + public void RequireExited(int timeoutMilliseconds) { + if (timeoutMilliseconds < 0) throw new ArgumentOutOfRangeException(nameof(timeoutMilliseconds)); + if (Handle == IntPtr.Zero) + throw new InvalidOperationException("Original owned root is still live or its exit is unproved."); + uint state = WaitForSingleObject(Handle, (uint)timeoutMilliseconds); + if (state == 0) return; + if (state == 258) + throw new InvalidOperationException("Original owned root is still live or its exit is unproved."); + throw new Win32Exception(); + } + public string TakeOutput(int maximumCharacters) { + if (outputCapture == null) throw new InvalidOperationException("Owned stdout capture is unavailable."); + return outputCapture.Take(maximumCharacters); + } + public string TakeError(int maximumCharacters) { + if (errorCapture == null) throw new InvalidOperationException("Owned stderr capture is unavailable."); + return errorCapture.Take(maximumCharacters); + } + public void Drain() { + foreach (var task in new[] { Output, Error }) + if (task != null && !task.Wait(5000)) throw new InvalidOperationException("Owned output EOF is unproved."); + } + public void Close() { + if (Handle != IntPtr.Zero) RequireExited(); + Drain(); + if (thread != IntPtr.Zero) { if (!CloseHandle(thread)) throw new Win32Exception(); thread = IntPtr.Zero; } + if (Handle != IntPtr.Zero) { if (!CloseHandle(Handle)) throw new Win32Exception(); Handle = IntPtr.Zero; } + if (outputReader != null) outputReader.Dispose(); + if (errorReader != null) errorReader.Dispose(); + if (outputCapture != null) outputCapture.Release(); + if (errorCapture != null) errorCapture.Release(); + if (Process != null) Process.Dispose(); + } +} diff --git a/Tools/windows/ShellProcessOwnership.psm1 b/Tools/windows/ShellProcessOwnership.psm1 new file mode 100644 index 00000000..6417a547 --- /dev/null +++ b/Tools/windows/ShellProcessOwnership.psm1 @@ -0,0 +1,277 @@ +Set-StrictMode -Version Latest +$script:CompiledOwnershipAssemblyPath = $null +# Targets start suspended, join a private non-breakaway job, then resume. CIM +# observations are never admitted into this scope; disposed managed objects do +# not release the native launch handles retained until verified job teardown. +# Complete-ShellOwnedProcess must follow root exit and survivor sampling. It +# empties only that launch's private job before requiring both pipe EOFs; +# output reads refuse unfinished launches, and scope cleanup handles failures. +# Receive-ShellOwnedOutput forwards buffered characters without implying EOF. +# It has independent per-stream cursors; Read still returns the full capture. +# One incremental caller may take 2..65536 UTF-16 units per stream per call. +# Empty batches are not EOF; batch bounds do not cap full-capture storage. +# False redirection flags tee that stream live while retaining capture; true +# flags stay quiet. Child stdio still uses pipes/NUL, not console equivalence. +# Failed diagnostic transports remain attached to the original tagged exception; +# a failed sentinel does not prevent the independent receipt attempt. +# ShellProcessOwnership.Tests.ps1 also exercises controlled native suspended +# launches and job/root/reader teardown; it is not a product walkthrough. + +function Initialize-ShellProcessOwnership { + $assemblyPath = $env:GRAPHCODE_VALIDATION_ASSEMBLY + if (-not $assemblyPath) { + if (-not $script:CompiledOwnershipAssemblyPath) { + $script:CompiledOwnershipAssemblyPath = & (Join-Path $PSScriptRoot "Build-CompiledValidationRunner.ps1") + } + $assemblyPath = $script:CompiledOwnershipAssemblyPath + } + foreach ($requiredType in @("ShellOwnedLaunch", "GraphCode.Validation.Ownership.StandaloneProcessJob")) { + & (Join-Path $PSScriptRoot "Import-CompiledValidationAssembly.ps1") ` + -AssemblyPath $assemblyPath -RequiredType $requiredType | Out-Null + } +} + +function Invoke-ShellJob([string] $Operation, [IntPtr] $Job = [IntPtr]::Zero, + [IntPtr] $Handle = [IntPtr]::Zero, [ValidateRange(0, 5000)][int] $TimeoutMilliseconds = 5000) { + switch ($Operation) { + "Create" { [GraphCode.Validation.Ownership.StandaloneProcessJob]::Create() } + "Assign" { [GraphCode.Validation.Ownership.StandaloneProcessJob]::Assign($Job, $Handle) } + "Contains" { [GraphCode.Validation.Ownership.StandaloneProcessJob]::Contains($Job, $Handle) } + "Terminate" { [GraphCode.Validation.Ownership.StandaloneProcessJob]::Terminate($Job) } + "Wait" { [GraphCode.Validation.Ownership.StandaloneProcessJob]::WaitForEmpty($Job, $TimeoutMilliseconds) } + "Active" { [GraphCode.Validation.Ownership.StandaloneProcessJob]::Active($Job) } + "Close" { [GraphCode.Validation.Ownership.StandaloneProcessJob]::Close($Job) } + default { throw "Unknown owned-job operation: $Operation" } + } +} + +function New-ShellSuspendedLaunch([Diagnostics.ProcessStartInfo] $StartInfo) { + [ShellOwnedLaunch]::Suspended($StartInfo) +} + +function Test-ShellOwnershipUnproven($Failure) { + $exception = if ($Failure -is [Management.Automation.ErrorRecord]) { $Failure.Exception } else { $Failure } + while ($exception) { + if ($exception.Data["GraphCodeValidationRootRetentionRequired"] -is [bool] -and + $exception.Data["GraphCodeValidationRootRetentionRequired"]) { return $true } + $exception = $exception.InnerException + } + return $false +} + +function Write-ShellOwnershipDiagnostic([Exception] $Exception, [string] $Message) { + try { + Write-Host $Message -ErrorAction Stop + } catch { + $failure = $_.Exception + $previous = @($Exception.Data["GraphCodeValidationRetentionDiagnosticFailures"] | + Where-Object { $null -ne $_ }) + $Exception.Data["GraphCodeValidationRetentionDiagnosticFailures"] = $previous + $failure + } +} + +function Set-ShellOwnershipUnproven([Exception] $Exception, [string[]] $RetainedRoots = @()) { + $Exception.Data["GraphCodeValidationRootRetentionRequired"] = $true + $roots = @($RetainedRoots | Where-Object { $_ -and (Test-Path -LiteralPath $_ -PathType Container) }) + $Exception.Data["GraphCodeValidationRetainedRoots"] = $roots + Write-ShellOwnershipDiagnostic $Exception "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven" + if ([Environment]::GetEnvironmentVariables().Contains("GRAPHCODE_VALIDATION_ROOT")) { + $stream = $null + try { + $configured = [Environment]::GetEnvironmentVariable("GRAPHCODE_VALIDATION_ROOT") + if (-not $configured -or -not [IO.Path]::IsPathFullyQualified($configured)) { + throw "Validation retention root is not an absolute directory" + } + $root = [IO.Path]::GetFullPath($configured) + $directory = Get-Item -LiteralPath $root -Force -ErrorAction Stop + if (-not $directory.PSIsContainer) { throw "Validation retention root is not a directory" } + while ($directory) { + if ($directory.Attributes -band [IO.FileAttributes]::ReparsePoint) { + throw "Validation retention root has a reparse ancestor" + } + $directory = $directory.Parent + } + $payload = [ordered]@{ schemaVersion = 1; validationRoot = $root; reason = "ownership_unproven" } + $bytes = [Text.UTF8Encoding]::new($false).GetBytes(($payload | ConvertTo-Json -Compress)) + if ($bytes.Length -gt 4096) { throw "Validation retention receipt exceeds 4096 bytes" } + $stream = [IO.File]::Open((Join-Path $root "ownership-unproven.json"), + [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::Read) + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + } catch { + $Exception.Data["GraphCodeValidationRetentionReceiptFailure"] = $_.Exception.Message + Write-ShellOwnershipDiagnostic $Exception "SHELL_RETENTION_RECEIPT_UNPROVEN=$($_.Exception.Message)" + } finally { + if ($stream) { $stream.Dispose() } + } + } + return $Exception +} + +function New-ShellProcessScope { + Initialize-ShellProcessOwnership + [pscustomobject]@{ + Job = Invoke-ShellJob "Create" + Runs = [Collections.Generic.List[object]]::new() + Members = [Collections.Generic.List[Diagnostics.Process]]::new() + Closed = $false + } +} + +function Start-ShellOwnedProcess($Scope, [Diagnostics.ProcessStartInfo] $StartInfo) { + if ($Scope.Closed) { throw "Owned shell scope is closed" } + try { $run = New-ShellSuspendedLaunch $StartInfo } catch { + if (Test-ShellOwnershipUnproven $_) { + throw (Set-ShellOwnershipUnproven $_.Exception) + } + throw + } + $run | Add-Member -NotePropertyMembers @{ + LaunchJob = [IntPtr]::Zero; ScopeAssigned = $false; LaunchAssigned = $false + Finalized = $false; LaunchJobClosed = $false + } -Force + $Scope.Runs.Add($run) + try { + Invoke-ShellJob "Assign" $Scope.Job $run.Handle + $run.ScopeAssigned = $true + $run.LaunchJob = Invoke-ShellJob "Create" + Invoke-ShellJob "Assign" $run.LaunchJob $run.Handle + $run.LaunchAssigned = $true + $run.Resume() + return $run.Process + } catch { + # This handle came from CreateProcess; no target code ran before assignment. + $primary = $_ + try { $run.Stop() } catch { + throw (Set-ShellOwnershipUnproven ([InvalidOperationException]::new( + "Owned launch failed: $($primary.Exception.Message); OWNED_CLEANUP_UNPROVEN: $($_.Exception.Message)", $primary.Exception))) + } + if (Test-ShellOwnershipUnproven $primary) { + throw (Set-ShellOwnershipUnproven $primary.Exception) + } + throw $primary + } +} + +function Get-ShellOwnedMember($Scope, [int] $ProcessId) { + if ($Scope.Closed) { throw "UNATTESTED_PROCESS: owned shell scope is closed" } + $process = $null + try { + $process = [Diagnostics.Process]::GetProcessById($ProcessId) + if ($Scope.Closed -or -not (Invoke-ShellJob "Contains" $Scope.Job $process.Handle)) { + throw "UNATTESTED_PROCESS: pid=$ProcessId is not a retained job member" + } + $Scope.Members.Add($process) + return $process + } catch { + if ($process) { $process.Dispose() } + throw (Set-ShellOwnershipUnproven $_.Exception) + } +} + +function Stop-ShellOwnedProcess($Scope, [Diagnostics.Process] $Process) { + $runs = @($Scope.Runs | Where-Object { [object]::ReferenceEquals($_.Process, $Process) }) + if ($Scope.Closed -or $runs.Count -ne 1) { + throw (Set-ShellOwnershipUnproven ([InvalidOperationException]::new( + "UNATTESTED_PROCESS: termination requires the original launch record"))) + } + if (-not (Invoke-ShellJob "Contains" $Scope.Job $runs[0].Handle)) { + throw (Set-ShellOwnershipUnproven ([InvalidOperationException]::new( + "UNATTESTED_PROCESS: retained launch is not in its owned job"))) + } + $runs[0].Stop() +} + +function Read-ShellOwnedOutput($Scope, [Diagnostics.Process] $Process, [switch] $StandardError) { + $runs = @($Scope.Runs | Where-Object { [object]::ReferenceEquals($_.Process, $Process) }) + if ($runs.Count -ne 1) { throw "Owned output requires the original launch record" } + if (-not $runs[0].Finalized) { throw "Owned output requires explicit launch finalization after survivor sampling" } + $task = if ($StandardError) { $runs[0].Error } else { $runs[0].Output } + if (-not $task.Wait(5000)) { throw "Owned output EOF is unproved" } + $task.GetAwaiter().GetResult() +} + +function Receive-ShellOwnedOutput($Scope, [Diagnostics.Process] $Process, + [ValidateRange(2, 65536)][int] $MaximumCharacters = 65536) { + $runs = @($Scope.Runs | Where-Object { [object]::ReferenceEquals($_.Process, $Process) }) + if ($Scope.Closed -or $runs.Count -ne 1) { + throw (Set-ShellOwnershipUnproven ([InvalidOperationException]::new( + "Owned incremental output requires an open scope and the original launch record"))) + } + try { + [pscustomobject]@{ + Output = $runs[0].TakeOutput($MaximumCharacters) + Error = $runs[0].TakeError($MaximumCharacters) + } + } catch { + throw (Set-ShellOwnershipUnproven $_.Exception) + } +} + +function Complete-ShellLaunch($Run) { + if ($Run.Finalized) { return } + try { + $Run.RequireExited() + if ($Run.LaunchJob -ne [IntPtr]::Zero -and -not $Run.LaunchJobClosed) { + Invoke-ShellJob "Terminate" $Run.LaunchJob + Invoke-ShellJob "Wait" $Run.LaunchJob + if ((Invoke-ShellJob "Active" $Run.LaunchJob) -ne 0) { + throw "OWNED_CLEANUP_UNPROVEN: private launch job not empty" + } + } + $Run.Drain() + if ($Run.LaunchJob -ne [IntPtr]::Zero -and -not $Run.LaunchJobClosed) { + Invoke-ShellJob "Close" $Run.LaunchJob + $Run.LaunchJobClosed = $true + } + $Run.Finalized = $true + } catch { + throw (Set-ShellOwnershipUnproven $_.Exception) + } +} + +function Complete-ShellOwnedProcess($Scope, [Diagnostics.Process] $Process) { + $runs = @($Scope.Runs | Where-Object { [object]::ReferenceEquals($_.Process, $Process) }) + if ($runs.Count -ne 1) { + throw (Set-ShellOwnershipUnproven ([InvalidOperationException]::new( + "UNATTESTED_PROCESS: finalization requires the original launch record"))) + } + if ($runs[0].Finalized) { return } + if ($Scope.Closed -or -not $runs[0].LaunchAssigned) { + throw (Set-ShellOwnershipUnproven ([InvalidOperationException]::new( + "Owned launch finalization requires an open scope and its assigned private job"))) + } + Complete-ShellLaunch $runs[0] +} + +function Close-ShellProcessScope($Scope) { + if ($Scope.Closed) { return } + $exitClock = [Diagnostics.Stopwatch]::StartNew() + try { + Invoke-ShellJob "Terminate" $Scope.Job + Invoke-ShellJob "Wait" $Scope.Job -TimeoutMilliseconds ([Math]::Max(0, 5000 - [int]$exitClock.ElapsedMilliseconds)) + if ((Invoke-ShellJob "Active" $Scope.Job) -ne 0) { throw "OWNED_CLEANUP_UNPROVEN: job not empty" } + # Job accounting can reach zero before the original process objects signal. + # Prove every held root within the same termination deadline before closing any. + foreach ($run in $Scope.Runs) { + if ($run.Finalized) { continue } + if (-not $run.ScopeAssigned) { $run.Stop() } + $run.RequireExited([Math]::Max(0, 5000 - [int]$exitClock.ElapsedMilliseconds)) + } + foreach ($run in $Scope.Runs) { + Complete-ShellLaunch $run + $run.Close() + } + foreach ($member in $Scope.Members) { $member.Dispose() } + Invoke-ShellJob "Close" $Scope.Job + $Scope.Closed = $true + Write-Host "SHELL_OWNED_JOB_CLEANUP=verified active=0 launches=$($Scope.Runs.Count)" + } catch { + throw (Set-ShellOwnershipUnproven $_.Exception) + } +} + +Export-ModuleMember -Function New-ShellProcessScope, Start-ShellOwnedProcess, + Get-ShellOwnedMember, Stop-ShellOwnedProcess, Read-ShellOwnedOutput, Close-ShellProcessScope, + Complete-ShellOwnedProcess, Receive-ShellOwnedOutput, + Test-ShellOwnershipUnproven, Set-ShellOwnershipUnproven diff --git a/Tools/windows/Tests/CompiledValidationEnvironment.Tests.ps1 b/Tools/windows/Tests/CompiledValidationEnvironment.Tests.ps1 new file mode 100644 index 00000000..2c4d6a5d --- /dev/null +++ b/Tools/windows/Tests/CompiledValidationEnvironment.Tests.ps1 @@ -0,0 +1,191 @@ +[CmdletBinding()] +param([string] $Worker, [string] $WorkspaceRoot, [string] $ProducerPath) + +$ErrorActionPreference = 'Stop' +function Import-CompiledEnvironmentProducer([string] $Path) { + $tokens = $null + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$errors) + if ($errors.Count) { throw 'Compiled environment producer does not parse' } + $functions = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq 'Invoke-WithCompiledValidationAssembly' + }, $true)) + if ($functions.Count -ne 1) { throw 'Expected exactly one compiled environment boundary' } + [scriptblock]::Create($functions[0].Extent.Text) +} +if ($Worker -eq 'empty') { + . (Import-CompiledEnvironmentProducer $ProducerPath) + $before = [Environment]::GetEnvironmentVariables('Process') + if (-not $before.Contains('GRAPHCODE_VALIDATION_ASSEMBLY') -or + [Environment]::GetEnvironmentVariable('GRAPHCODE_VALIDATION_ASSEMBLY') -ne '') { + throw 'Child did not receive an explicitly empty environment entry' + } + $state = @{ ran = $false } + $failure = $null + try { + Invoke-WithCompiledValidationAssembly -WorkspaceRoot $WorkspaceRoot -Action { $state.ran = $true } + } catch { $failure = $_ } + if ($null -eq $failure -or $failure.Exception.Message -notmatch 'explicitly empty' -or $state.ran -or + -not [Environment]::GetEnvironmentVariables('Process').Contains('GRAPHCODE_VALIDATION_ASSEMBLY') -or + [Environment]::GetEnvironmentVariable('GRAPHCODE_VALIDATION_ASSEMBLY') -ne '') { + throw 'Explicit empty entry was replaced, removed, or admitted' + } + Write-Output 'COMPILED_ENV_EMPTY_PRESERVED=1' + exit 0 +} +. (Join-Path $PSScriptRoot '..\Import-PinnedPester.ps1') + +Describe 'Scoped compiled assembly publication at the actual Windows caller boundary' { + BeforeAll { + $repository = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path + $producer = Join-Path $repository 'Tools\windows\validate.ps1' + $testScript = Join-Path $PSScriptRoot 'CompiledValidationEnvironment.Tests.ps1' + $testAst = [Management.Automation.Language.Parser]::ParseFile($testScript, [ref]$null, [ref]$null) + $loader = $testAst.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq 'Import-CompiledEnvironmentProducer' + }, $true) + . ([scriptblock]::Create($loader.Extent.Text)) + . (Import-CompiledEnvironmentProducer $producer) + } + BeforeEach { + $present = [Environment]::GetEnvironmentVariables('Process').Contains('GRAPHCODE_VALIDATION_ASSEMBLY') + $saved = [Environment]::GetEnvironmentVariable('GRAPHCODE_VALIDATION_ASSEMBLY') + if ($present) { Remove-Item -LiteralPath 'Env:\GRAPHCODE_VALIDATION_ASSEMBLY' -ErrorAction Stop } + $root = Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) + $scripts = Join-Path $root 'Tools\windows' + $null = New-Item -ItemType Directory -Force -Path $scripts + $assembly = Join-Path $root 'ValidationRunner.dll' + [IO.File]::WriteAllBytes($assembly, [byte[]]@()) + $events = Join-Path $root 'events.txt' + $settings = Join-Path $root 'settings.json' + @{ assembly = $assembly; events = $events; mode = 'normal' } | ConvertTo-Json | + Set-Content -LiteralPath $settings + @' +$s = Get-Content (Join-Path $PSScriptRoot '..\..\settings.json') -Raw | ConvertFrom-Json +Add-Content -LiteralPath $s.events -Value 'build' +if ($s.mode -eq 'build-fail') { throw [IO.IOException]::new('controlled compiler failure') } +$s.assembly +'@ | Set-Content -LiteralPath (Join-Path $scripts 'Build-CompiledValidationRunner.ps1') + @' +param([string] $AssemblyPath, [string] $RequiredType) +$s = Get-Content (Join-Path $PSScriptRoot '..\..\settings.json') -Raw | ConvertFrom-Json +Add-Content -LiteralPath $s.events -Value 'load' +if ($s.mode -eq 'load-fail') { throw [IO.IOException]::new('controlled provenance failure') } +if ($AssemblyPath -cne $s.assembly -or $RequiredType -cne 'ShellOwnedLaunch') { + throw 'Unexpected current assembly loader arguments' +} +'@ | Set-Content -LiteralPath (Join-Path $scripts 'Import-CompiledValidationAssembly.ps1') + } + AfterEach { + if ($present) { [Environment]::SetEnvironmentVariable('GRAPHCODE_VALIDATION_ASSEMBLY', $saved, 'Process') } + elseif ([Environment]::GetEnvironmentVariables('Process').Contains('GRAPHCODE_VALIDATION_ASSEMBLY')) { + Remove-Item -LiteralPath 'Env:\GRAPHCODE_VALIDATION_ASSEMBLY' -ErrorAction Stop + } + } + It 'builds and validates once before publishing a missing entry, then removes only its publication' { + Invoke-WithCompiledValidationAssembly -WorkspaceRoot $root -Action { + $env:GRAPHCODE_VALIDATION_ASSEMBLY | Should -BeExactly $assembly + $start = [Diagnostics.ProcessStartInfo]::new((Join-Path $PSHOME 'pwsh.exe')) + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + foreach ($argument in @('-NoProfile', '-Command', + '[Environment]::GetEnvironmentVariable("GRAPHCODE_VALIDATION_ASSEMBLY")')) { + $start.ArgumentList.Add($argument) + } + $child = [Diagnostics.Process]::Start($start) + try { + $child.WaitForExit(15000) | Should -BeTrue + $child.ExitCode | Should -Be 0 -Because ($child.StandardError.ReadToEnd()) + $child.StandardOutput.ReadToEnd().Trim() | Should -BeExactly $assembly + } finally { $child.Dispose() } + Add-Content -LiteralPath $events -Value 'action' + } + (Get-Content $events) -join ',' | Should -BeExactly 'build,load,action' + [Environment]::GetEnvironmentVariables('Process').Contains('GRAPHCODE_VALIDATION_ASSEMBLY') | Should -BeFalse + } + It 'validates but does not replace or rebuild a supplied assembly entry' { + [Environment]::SetEnvironmentVariable('GRAPHCODE_VALIDATION_ASSEMBLY', $assembly, 'Process') + Invoke-WithCompiledValidationAssembly -WorkspaceRoot $root -Action { + $env:GRAPHCODE_VALIDATION_ASSEMBLY | Should -BeExactly $assembly + } + (Get-Content $events) -join ',' | Should -BeExactly 'load' + $env:GRAPHCODE_VALIDATION_ASSEMBLY | Should -BeExactly $assembly + } + It 'preserves and refuses an explicitly empty native child entry without compiling or running the action' { + $start = [Diagnostics.ProcessStartInfo]::new((Join-Path $PSHOME 'pwsh.exe')) + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + $start.Environment['GRAPHCODE_VALIDATION_ASSEMBLY'] = '' + foreach ($argument in @('-NoProfile', '-File', $testScript, '-Worker', 'empty', + '-WorkspaceRoot', $root, '-ProducerPath', $producer)) { $start.ArgumentList.Add($argument) } + $child = [Diagnostics.Process]::Start($start) + try { + $child.WaitForExit(15000) | Should -BeTrue + $stdout = $child.StandardOutput.ReadToEnd() + $stderr = $child.StandardError.ReadToEnd() + $child.ExitCode | Should -Be 0 -Because $stderr + $stdout.Trim() | Should -BeExactly 'COMPILED_ENV_EMPTY_PRESERVED=1' + Test-Path -LiteralPath $events | Should -BeFalse + } finally { $child.Dispose() } + } + It 'keeps compiler failure primary and does not publish or invoke the action' { + @{ assembly = $assembly; events = $events; mode = 'build-fail' } | ConvertTo-Json | Set-Content $settings + { Invoke-WithCompiledValidationAssembly -WorkspaceRoot $root -Action { throw 'unexpected action' } } | + Should -Throw '*controlled compiler failure*' + (Get-Content $events) -join ',' | Should -BeExactly 'build' + [Environment]::GetEnvironmentVariables('Process').Contains('GRAPHCODE_VALIDATION_ASSEMBLY') | Should -BeFalse + } + It 'refuses failed current-source provenance before publishing or running the action' { + @{ assembly = $assembly; events = $events; mode = 'load-fail' } | ConvertTo-Json | Set-Content $settings + { Invoke-WithCompiledValidationAssembly -WorkspaceRoot $root -Action { throw 'unexpected action' } } | + Should -Throw '*controlled provenance failure*' + (Get-Content $events) -join ',' | Should -BeExactly 'build,load' + [Environment]::GetEnvironmentVariables('Process').Contains('GRAPHCODE_VALIDATION_ASSEMBLY') | Should -BeFalse + } + It 'preserves action exception identity while restoring the originally missing entry' { + $expected = [IO.IOException]::new('controlled action failure') + $failure = $null + try { + Invoke-WithCompiledValidationAssembly -WorkspaceRoot $root -Action { + Remove-Item -LiteralPath 'Env:\GRAPHCODE_VALIDATION_ASSEMBLY' -ErrorAction Stop + throw $expected + } + } + catch { $failure = $_ } + [object]::ReferenceEquals($failure.Exception, $expected) | Should -BeTrue + [Environment]::GetEnvironmentVariables('Process').Contains('GRAPHCODE_VALIDATION_ASSEMBLY') | Should -BeFalse + } + It 'wraps the actual Windows task exactly once inside profile isolation and only outside dry run' { + $tokens = $null + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile($producer, [ref]$tokens, [ref]$errors) + $calls = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -ceq 'Invoke-WithCompiledValidationAssembly' + }, $true)) + $calls.Count | Should -Be 1 + $blocks = @($calls[0].CommandElements | Where-Object { + $_ -is [Management.Automation.Language.ScriptBlockExpressionAst] + }) + $blocks.Count | Should -Be 1 + $blocks[0].Extent.Text | Should -Match 'Invoke-Task "windows-shell"' + $isolation = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -ceq 'Invoke-WindowsShellValidationIsolation' -and + $node.Extent.StartOffset -le $calls[0].Extent.StartOffset -and + $node.Extent.EndOffset -ge $calls[0].Extent.EndOffset + }, $true)) + $isolation.Count | Should -Be 1 + ([IO.File]::ReadAllText($producer)) | Should -Match '\$name -eq "windows-shell" -and -not \$DryRun' + } +} diff --git a/Tools/windows/Tests/DaemonHandoff.Live.Tests.ps1 b/Tools/windows/Tests/DaemonHandoff.Live.Tests.ps1 index c2d501e4..c4687c23 100644 --- a/Tools/windows/Tests/DaemonHandoff.Live.Tests.ps1 +++ b/Tools/windows/Tests/DaemonHandoff.Live.Tests.ps1 @@ -5,6 +5,8 @@ param( ) $ErrorActionPreference = "Stop" +Import-Module (Join-Path $PSScriptRoot "..\ShellProcessOwnership.psm1") +$processScope = $null $script:WM_QUIT = 0x0012 $daemonExecutable = Join-Path (Split-Path -Parent $Executable) "graphcoded.exe" $cliExecutable = Join-Path (Split-Path -Parent $Executable) "graphcode.exe" @@ -57,13 +59,7 @@ function Start-HandoffShell( $startInfo.Environment["GRAPHCODE_DAEMON_HANDOFF_TEST_STATE"] = $daemonStatePath $startInfo.Environment["GRAPHCODE_DAEMON_SUPERVISOR_TEST_HOOK"] = "1" $startInfo.Environment["GRAPHCODE_DAEMON_HANDOFF_TEST_USER"] = $userName - $process = [Diagnostics.Process]::new() - $process.StartInfo = $startInfo - if (-not $process.Start()) { - throw "could not start shell $userName" - } - [void] $process.Handle - return $process + Start-ShellOwnedProcess $processScope $startInfo } function Get-DaemonChildren([int[]] $parentIds) { @@ -143,16 +139,20 @@ function Assert-EndpointReachable { $startInfo.Environment["GRAPHCODE_SUPPORT_DIR"] = $supportDirectory $startInfo.Environment["GRAPHCODE_DAEMON_PIPE"] = $daemonPipe [void] $startInfo.ArgumentList.Add("projects") - $process = [Diagnostics.Process]::new() - $process.StartInfo = $startInfo - if (-not $process.Start() -or -not $process.WaitForExit(7000)) { - throw "daemon endpoint was not reachable through graphcode.exe" - } - $stderr = $process.StandardError.ReadToEnd() - if ($process.ExitCode -ne 0) { - throw "daemon endpoint rejected graphcode.exe: $stderr" + $process = Start-ShellOwnedProcess $processScope $startInfo + try { + if (-not $process.WaitForExit(7000)) { + throw "daemon endpoint was not reachable through graphcode.exe" + } + Complete-ShellOwnedProcess $processScope $process + $stderr = Read-ShellOwnedOutput $processScope $process -StandardError + if ($process.ExitCode -ne 0) { + throw "daemon endpoint rejected graphcode.exe: $stderr" + } + } finally { + if (-not $process.HasExited) { Stop-ShellOwnedProcess $processScope $process } + $process.Dispose() } - $process.Dispose() } function Get-HandoffElapsed([Diagnostics.Stopwatch] $clock) { @@ -226,8 +226,13 @@ $shellA = $null $shellB = $null $daemonProcess = $null $handoffClock = [Diagnostics.Stopwatch]::StartNew() +$primaryFailure = $null +$supportCreated = $false try { - New-Item -ItemType Directory -Force -Path $supportDirectory | Out-Null + if (Test-Path -LiteralPath $supportDirectory) { throw "Handoff support directory already exists: $supportDirectory" } + New-Item -ItemType Directory -Path $supportDirectory | Out-Null + $supportCreated = $true + $processScope = New-ShellProcessScope $daemonStateA = Join-Path $supportDirectory "daemon-a.state" $daemonStateB = Join-Path $supportDirectory "daemon-b.state" $shellA = Start-HandoffShell ` @@ -244,7 +249,8 @@ try { ) $children = @(Get-DaemonChildren $parents) if ($children.Count -eq 1) { - $candidate = Get-Process -Id $children[0].ProcessId -ErrorAction SilentlyContinue + # CIM supplies a candidate only; membership of the retained job is authority. + $candidate = Get-ShellOwnedMember $processScope $children[0].ProcessId if ($candidate) { $daemonProcess = $candidate $selectedChild = $children[0] @@ -301,28 +307,45 @@ try { if ($daemonProcess.HasExited) { throw "non-owning shell exit stopped the externally owned daemon" } + Complete-ShellOwnedProcess $processScope $contender Stop-ShellNormally $owner "owning" if (-not $daemonProcess.WaitForExit(7000)) { throw "owning shell exit did not stop its graphcoded child" } - Write-Output "Concurrent two-shell daemon handoff: PASS" + Complete-ShellOwnedProcess $processScope $owner +} catch { + $primaryFailure = $_ + throw } finally { - foreach ($process in @($shellA, $shellB)) { - if ($process -and -not $process.HasExited) { - Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue + try { + if ($processScope) { Close-ShellProcessScope $processScope } + $expectedDaemonPath = [IO.Path]::GetFullPath($daemonExecutable) + $survivors = @(Get-CimInstance Win32_Process -ErrorAction Stop | + Where-Object { + $_.Name -ieq "graphcoded.exe" -and $_.ExecutablePath -and + [IO.Path]::GetFullPath($_.ExecutablePath) -ieq $expectedDaemonPath + }) + if ($survivors.Count) { + throw "UNATTESTED_SURVIVORS: handoff daemon image matches after owned-job cleanup; no termination attempted: $($survivors.ProcessId -join ',')" } - if ($process) { $process.Dispose() } - } - if ($daemonProcess -and -not $daemonProcess.HasExited) { - Stop-Process -Id $daemonProcess.Id -Force -ErrorAction SilentlyContinue + if ($primaryFailure -and (Test-ShellOwnershipUnproven $primaryFailure)) { + throw "Earlier ownership refusal requires retained handoff support files" + } + if ($supportCreated) { + Remove-Item -LiteralPath $supportDirectory -Recurse -Force -ErrorAction SilentlyContinue + } + } catch { + Write-Host "HANDOFF_CLEANUP_UNPROVEN=$($_.Exception.Message); retained=$supportDirectory" + $cleanupFailure = Set-ShellOwnershipUnproven $_.Exception @($supportDirectory) + if ($primaryFailure) { + $failure = [InvalidOperationException]::new( + "Handoff failed: $($primaryFailure.Exception.Message); cleanup unproved: $($cleanupFailure.Message)", $primaryFailure.Exception) + $failure.Data["GraphCodeValidationRootRetentionRequired"] = $true + $failure.Data["GraphCodeValidationRetainedRoots"] = $cleanupFailure.Data["GraphCodeValidationRetainedRoots"] + throw $failure + } + throw $cleanupFailure } - $expectedDaemonPath = [IO.Path]::GetFullPath($daemonExecutable) - Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | - Where-Object { - $_.Name -ieq "graphcoded.exe" -and $_.ExecutablePath -and - [IO.Path]::GetFullPath($_.ExecutablePath) -ieq $expectedDaemonPath - } | - ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } - Remove-Item -LiteralPath $supportDirectory -Recurse -Force -ErrorAction SilentlyContinue } +Write-Output "Concurrent two-shell daemon handoff: PASS" diff --git a/Tools/windows/Tests/DaemonRoundTrip.Live.Tests.ps1 b/Tools/windows/Tests/DaemonRoundTrip.Live.Tests.ps1 index ef70275a..4359cb42 100644 --- a/Tools/windows/Tests/DaemonRoundTrip.Live.Tests.ps1 +++ b/Tools/windows/Tests/DaemonRoundTrip.Live.Tests.ps1 @@ -11,6 +11,9 @@ param( ) $ErrorActionPreference = "Stop" +. (Join-Path $PSScriptRoot "..\OwnedLiveProcess.ps1") +. (Join-Path $PSScriptRoot "ValidationFixtureDirectory.ps1") +Import-Module (Join-Path $PSScriptRoot "..\ShellProcessOwnership.psm1") -Force $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path $shellRoot = Join-Path $repoRoot "graphcode-windows" $DaemonExecutable = (Resolve-Path -LiteralPath $DaemonExecutable).Path @@ -52,6 +55,7 @@ $defaultSupportDirectory = Join-Path ([Environment]::GetFolderPath("UserProfile" $successful = $false $shutdownEvent = $null $daemonProcess = $null +$processScope = $null $runLog = [Collections.Generic.List[string]]::new() function Get-DefaultSupportSignature { @@ -107,40 +111,66 @@ function Start-OwnedDaemon { $startInfo.Environment["GRAPHCODE_DAEMON_PIPE"] = $daemonPipe $startInfo.Environment["GRAPHCODE_DAEMON_SHUTDOWN_EVENT"] = $shutdownEventName - $process = [Diagnostics.Process]::new() - $process.StartInfo = $startInfo - if (-not $process.Start()) { - throw "could not start graphcoded.exe" - } - [void] $process.Handle - $stdout = $process.StandardOutput.ReadToEndAsync() - $stderr = $process.StandardError.ReadToEndAsync() - $deadline = [DateTime]::UtcNow.AddSeconds(15) - while ([DateTime]::UtcNow -lt $deadline) { - $process.Refresh() - if ($process.HasExited) { - throw "graphcoded.exe exited during startup: $($stderr.Result)" + $process = $null + $started = $false + $handleHeld = $false + try { + $process = Start-ShellOwnedProcess $processScope $startInfo + $started = $true + [void] $process.Handle + $handleHeld = $true + $deadline = [DateTime]::UtcNow.AddSeconds(15) + while ([DateTime]::UtcNow -lt $deadline) { + $process.Refresh() + if ($process.HasExited) { + Complete-ShellOwnedProcess $processScope $process + $stderr = Read-ShellOwnedOutput $processScope $process -StandardError + throw "graphcoded.exe exited during startup: $stderr" + } + if (@(Get-OwnedDaemon $process.Id).Count -eq 1) { + # A PID exists before the lifetime mutex and listener are initialized. This is + # especially observable on restart, when the Zig test executable is already built. + $readyPipe = [IO.Pipes.NamedPipeClientStream]::new(".", $daemonPipe.Substring(9), + [IO.Pipes.PipeDirection]::InOut) + try { + $readyPipe.Connect(15000) + } catch { + throw [InvalidOperationException]::new( + "Owned daemon did not publish its listener before the round-trip test.", $_.Exception) + } finally { + $readyPipe.Dispose() + } + return $process + } + Start-Sleep -Milliseconds 100 } - if (@(Get-OwnedDaemon $process.Id).Count -eq 1) { - $script:daemonStdout = $stdout - $script:daemonStderr = $stderr - # A PID exists before the lifetime mutex and listener are initialized. This is - # especially observable on restart, when the Zig test executable is already built. - $readyPipe = [IO.Pipes.NamedPipeClientStream]::new(".", $daemonPipe.Substring(9), - [IO.Pipes.PipeDirection]::InOut) - try { - $readyPipe.Connect(15000) - } catch { - if (-not $process.HasExited) { $process.Kill(); [void]$process.WaitForExit(5000) } - throw "Owned daemon did not publish its listener before the round-trip test: $($_.Exception.Message)" - } finally { - $readyPipe.Dispose() + throw "graphcoded.exe did not appear as a child at its exact executable path" + } catch { + $startupError = $_ + $cleanupErrors = [Collections.Generic.List[Exception]]::new() + try { + if ($started) { + if (-not $handleHeld) { + throw "The started daemon's original process handle was not acquired; no termination attempted." + } + Stop-OwnedLiveProcess -Process $process -Name "round-trip daemon startup" ` + -TimeoutMilliseconds 5000 + } + } catch { + $cleanupErrors.Add($_.Exception) + } finally { + if ($process) { + try { $process.Dispose() } catch { $cleanupErrors.Add($_.Exception) } } - return $process } - Start-Sleep -Milliseconds 100 + if ($cleanupErrors.Count -gt 0) { + $cleanupErrors.Insert(0, $startupError.Exception) + throw (New-OwnedLiveProcessCleanupError ` + -Message "Round-trip daemon startup cleanup could not be verified; fixture root: $runRoot" ` + -Errors ([Exception[]]$cleanupErrors.ToArray()) -RetainedRoots @($runRoot)) + } + throw $startupError } - throw "graphcoded.exe did not appear as a child at its exact executable path" } function Stop-OwnedDaemon([Diagnostics.Process] $process) { @@ -156,9 +186,12 @@ function Stop-OwnedDaemon([Diagnostics.Process] $process) { if (-not $process.HasExited) { throw "graphcoded.exe is still running after shutdown" } + Complete-ShellOwnedProcess $processScope $process $script:runLog.Add("daemon-exit pid=$($process.Id) code=$($process.ExitCode)") - $script:runLog.Add("daemon-stdout=$($script:daemonStdout.Result.Trim())") - $script:runLog.Add("daemon-stderr=$($script:daemonStderr.Result.Trim())") + $stdout = Read-ShellOwnedOutput $processScope $process + $stderr = Read-ShellOwnedOutput $processScope $process -StandardError + $script:runLog.Add("daemon-stdout=$($stdout.Trim())") + $script:runLog.Add("daemon-stderr=$($stderr.Trim())") } function Invoke-ZigTest([string] $label, [string] $filter) { @@ -167,19 +200,21 @@ function Invoke-ZigTest([string] $label, [string] $filter) { $env:GRAPHCODE_LOCAL_TEST_SUPPORT = $supportDirectory $env:ZIG_LOCAL_CACHE_DIR = $zigLocalCache $env:ZIG_GLOBAL_CACHE_DIR = $zigGlobalCache - $output = @() - $exitCode = 0 - Push-Location $shellRoot - try { - $output = @(& $ZigExecutable test src\DaemonRoundTripTests.zig ` - -target x86_64-windows-msvc -lc -ladvapi32 "-I$WinghosttyInclude" ` - --cache-dir $zigLocalCache --global-cache-dir $zigGlobalCache ` - --test-filter $filter 2>&1) - $exitCode = $LASTEXITCODE - } finally { - Pop-Location - } - $text = $output -join "`n" + $testInfo = [Diagnostics.ProcessStartInfo]::new() + $testInfo.FileName = $ZigExecutable + $testInfo.WorkingDirectory = $shellRoot + $testInfo.UseShellExecute = $false + foreach ($argument in @( + "test", "src\DaemonRoundTripTests.zig", + "-target", "x86_64-windows-msvc", "-lc", "-ladvapi32", "-I$WinghosttyInclude", + "--cache-dir", $zigLocalCache, "--global-cache-dir", $zigGlobalCache, + "--test-filter", $filter)) { + [void]$testInfo.ArgumentList.Add($argument) + } + $result = Invoke-OwnedLiveCommand -Scope $processScope -StartInfo $testInfo + $exitCode = $result.ExitCode + $global:LASTEXITCODE = $exitCode + $text = @($result.Output, $result.Error) -join "`n" [Console]::WriteLine($text) $logPath = Join-Path $evidenceDirectory "daemon-roundtrip-$nonce-$label.log" [IO.File]::WriteAllText($logPath, "$text`n", [Text.UTF8Encoding]::new($false)) @@ -259,8 +294,25 @@ function Assert-MutatedGraph([object] $graph, [int] $fireCount) { } $defaultSignatureBefore = Get-DefaultSupportSignature +if (Test-Path -LiteralPath $runRoot) { + throw (New-OwnedLiveProcessCleanupError ` + -Message "Round-trip scratch already exists; ownership is unproven: $runRoot" ` + -Errors @([InvalidOperationException]::new("Existing scratch was not reused.")) ` + -RetainedRoots @($runRoot)) +} +try { + $null = New-ValidationFixtureDirectory -Path $runRoot +} catch { + $creationError = $_.Exception + if (Test-Path -LiteralPath $runRoot) { + throw (New-OwnedLiveProcessCleanupError ` + -Message "Round-trip scratch appeared during admission; ownership is unproven: $runRoot" ` + -Errors @($creationError) -RetainedRoots @($runRoot)) + } + throw +} New-Item -ItemType Directory -Force -Path @( - $runRoot, $supportDirectory, $sessionDirectory, $projectPath, $spawnProjectPath, + $supportDirectory, $sessionDirectory, $projectPath, $spawnProjectPath, $evidenceDirectory, $tempDirectory, $zigLocalCache, $zigGlobalCache ) | Out-Null [IO.File]::WriteAllText($sessionMarkerPath, $sessionMarker, [Text.UTF8Encoding]::new($false)) @@ -277,7 +329,9 @@ $shutdownEvent = [Threading.EventWaitHandle]::new( $shutdownEventName ) +$roundtripError = $null try { + $processScope = New-ShellProcessScope $homePrefix = $defaultSupportDirectory.TrimEnd('\') + '\' if ($runRoot.StartsWith($homePrefix, [StringComparison]::OrdinalIgnoreCase)) { throw "disposable daemon state unexpectedly resolves under the default support directory" @@ -354,36 +408,129 @@ try { throw "the user's default ~/.graphcode tree changed during the isolated daemon test" } $runLog.Add("default-support=unchanged") - $successful = $true $resultPath = Join-Path $evidenceDirectory "daemon-roundtrip-$nonce-summary.txt" [IO.File]::WriteAllLines($resultPath, $runLog, [Text.UTF8Encoding]::new($false)) - Write-Output "REAL_DAEMON_ROUNDTRIP: PASS" - Write-Output "Accepted mutations: create node, update and rename node, create edge, edit edge, promote sketch" - Write-Output "Restart readback: node identities/configuration and edge id/endpoints/fireCount/transform/spawn/cycle guard" - Write-Output "Mutex: present in each live process and absent after each clean shutdown, using production-derived name" - Write-Output "State: disposable support directory; default ~/.graphcode tree unchanged" - Write-Output "Evidence: $resultPath" + $successful = $true +} catch { + $roundtripError = $_.Exception + throw } finally { - if ($daemonProcess -and -not $daemonProcess.HasExited) { + $shutdownError = $null + $cleanupError = $null + $retentionError = $null + if ($daemonProcess) { try { Stop-OwnedDaemon $daemonProcess } catch { - Stop-Process -Id $daemonProcess.Id -Force -ErrorAction SilentlyContinue + $shutdownError = $_.Exception + try { + Stop-OwnedLiveProcess -Process $daemonProcess -Name "round-trip daemon" + } catch { + $cleanupError = $_.Exception + $errors = @() + if ($roundtripError) { $errors += $roundtripError } + $errors += $shutdownError + $errors += $cleanupError + $retentionError = New-OwnedLiveProcessCleanupError ` + -Message "Round-trip daemon cleanup could not be verified; fixture root: $runRoot" ` + -Errors $errors -RetainedRoots @($runRoot) + } + } + try { $daemonProcess.Dispose() } catch { + $disposeError = $_.Exception + $errors = @() + if ($roundtripError) { $errors += $roundtripError } + if ($shutdownError) { $errors += $shutdownError } + if ($cleanupError) { $errors += $cleanupError } + $errors += $disposeError + $retentionError = New-OwnedLiveProcessCleanupError ` + -Message "Round-trip daemon handle cleanup could not be verified; fixture root: $runRoot" ` + -Errors $errors -RetainedRoots @($runRoot) } } - if ($daemonProcess) { $daemonProcess.Dispose() } - if ($shutdownEvent) { $shutdownEvent.Dispose() } + if ($processScope) { + try { Close-ShellProcessScope $processScope } catch { + $jobError = $_.Exception + $errors = @() + if ($retentionError) { + $errors += $retentionError + } elseif ($roundtripError) { + $errors += $roundtripError + } + if ($shutdownError -and -not $retentionError) { $errors += $shutdownError } + $errors += $jobError + $retentionError = New-OwnedLiveProcessCleanupError ` + -Message "Round-trip job cleanup could not be verified; fixture root: $runRoot" ` + -Errors $errors -RetainedRoots @($runRoot) + } + } + $auxiliaryErrors = [Collections.Generic.List[Exception]]::new() + if ($shutdownEvent) { + try { $shutdownEvent.Dispose() } catch { $auxiliaryErrors.Add($_.Exception) } + } foreach ($name in @( "GRAPHCODE_DAEMON_ROUNDTRIP_PHASE", "GRAPHCODE_DAEMON_ROUNDTRIP_PROJECT", "GRAPHCODE_DAEMON_ROUNDTRIP_SPAWN_PROJECT", "GRAPHCODE_DAEMON_ROUNDTRIP_EDGE_ID", "GRAPHCODE_LOCAL_TEST_SUPPORT")) { - Remove-Item -LiteralPath "env:$name" -ErrorAction SilentlyContinue + $environment = [Environment]::GetEnvironmentVariables( + [EnvironmentVariableTarget]::Process) + if ($environment.Contains($name)) { + try { Remove-Item -LiteralPath "env:$name" -ErrorAction Stop } catch { + $auxiliaryErrors.Add($_.Exception) + } + } + } + $artifactError = $null + try { + if ($successful -and -not $roundtripError -and -not $shutdownError -and -not $retentionError -and + $auxiliaryErrors.Count -eq 0) { + Remove-Item -LiteralPath $runRoot -Recurse -Force + } else { + $failurePath = Join-Path $evidenceDirectory "daemon-roundtrip-$nonce-failure.txt" + [IO.File]::WriteAllLines($failurePath, $runLog, [Text.UTF8Encoding]::new($false)) + Write-Output "Failure artifacts written at $runRoot and $failurePath; enclosing isolation may remove them" + } + } catch { + $artifactError = $_.Exception } - if ($successful) { - Remove-Item -LiteralPath $runRoot -Recurse -Force - } else { - $failurePath = Join-Path $evidenceDirectory "daemon-roundtrip-$nonce-failure.txt" - [IO.File]::WriteAllLines($failurePath, $runLog, [Text.UTF8Encoding]::new($false)) - Write-Output "Failure artifacts retained at $runRoot and $failurePath" + if ($artifactError -or $auxiliaryErrors.Count -gt 0) { + $errors = @() + if ($retentionError) { + $errors += $retentionError + } elseif ($roundtripError) { + $errors += $roundtripError + } + if ($shutdownError -and -not $retentionError) { $errors += $shutdownError } + foreach ($error in $auxiliaryErrors) { $errors += $error } + if ($artifactError) { $errors += $artifactError } + $combined = [AggregateException]::new( + "Round-trip failure reporting or fixture cleanup failed.", [Exception[]]$errors) + if (Test-OwnedLiveProcessRetentionRequired $combined) { + $combined.Data["GraphCodeValidationRootRetentionRequired"] = $true + $combined.Data["GraphCodeValidationRetainedRoots"] = [string[]]@( + Get-OwnedLiveProcessRetainedRoots -Errors $errors -RetainedRoots @($runRoot)) + } + throw $combined + } + if ($retentionError) { throw $retentionError } + if ($shutdownError) { + $errors = @() + if ($roundtripError) { $errors += $roundtripError } + $errors += $shutdownError + $combined = [AggregateException]::new( + "Round-trip daemon required forced cleanup after shutdown failed.", + [Exception[]]$errors) + if (Test-OwnedLiveProcessRetentionRequired $combined) { + $combined.Data["GraphCodeValidationRootRetentionRequired"] = $true + $combined.Data["GraphCodeValidationRetainedRoots"] = [string[]]@( + Get-OwnedLiveProcessRetainedRoots -Errors $errors -RetainedRoots @($runRoot)) + } + throw $combined } } +Write-Output "REAL_DAEMON_ROUNDTRIP: PASS" +Write-Output "Accepted mutations: create node, update and rename node, create edge, edit edge, promote sketch" +Write-Output "Restart readback: node identities/configuration and edge id/endpoints/fireCount/transform/spawn/cycle guard" +Write-Output "Mutex: present in each live process and absent after each clean shutdown, using production-derived name" +Write-Output "State: disposable support directory; default ~/.graphcode tree unchanged" +Write-Output "Evidence: $resultPath" diff --git a/Tools/windows/Tests/DotNetSdk.Tests.ps1 b/Tools/windows/Tests/DotNetSdk.Tests.ps1 new file mode 100644 index 00000000..2c6dca23 --- /dev/null +++ b/Tools/windows/Tests/DotNetSdk.Tests.ps1 @@ -0,0 +1,369 @@ +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot '..\Import-PinnedPester.ps1') + +BeforeAll { + $script:repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..\..')) + . (Join-Path $repositoryRoot 'Tools\windows\DotNetSdk.ps1') + function New-DotNetTestFixture { + $script:caseRoot = Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) + $script:fixtureRepo = Join-Path $caseRoot 'repo' + $script:toolRoot = Join-Path $caseRoot 'tools' + $script:sdkRoot = Join-Path $caseRoot 'installed' + $script:exe = Join-Path $sdkRoot 'dotnet.exe' + $script:propsPath = Join-Path $sdkRoot 'sdk\10.0.403\Microsoft.NETCoreSdk.BundledVersions.props' + $files = @('dotnet.exe', 'sdk\10.0.403\MSBuild.dll', + 'packs\Microsoft.NETCore.App.Ref\10.0.14\ref\net10.0\System.Runtime.dll', + 'packs\Microsoft.WindowsDesktop.App.Ref\10.0.14\ref\net10.0\UIAutomationClient.dll', + 'packs\Microsoft.WindowsDesktop.App.Ref\10.0.14\ref\net10.0\UIAutomationTypes.dll', + 'packs\Microsoft.WindowsDesktop.App.Ref\10.0.14\ref\net10.0\WindowsBase.dll', + 'shared\Microsoft.NETCore.App\10.0.14\coreclr.dll', + 'shared\Microsoft.WindowsDesktop.App\10.0.14\PresentationCore.dll') + foreach ($file in $files) { + $path = Join-Path $sdkRoot $file + $null = [IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($path)) + [IO.File]::WriteAllText($path, 'deliberate test input; never executed') + } + [IO.File]::WriteAllText($propsPath, @' +10.0.403 + + + +'@) + $runner = Join-Path $fixtureRepo 'Tools\windows\ValidationRunner' + $null = [IO.Directory]::CreateDirectory($runner) + [IO.File]::WriteAllText((Join-Path $runner 'global.json'), + '{"sdk":{"version":"10.0.403","rollForward":"disable","allowPrerelease":false}}') + $script:runtimeOutput = @("Microsoft.NETCore.App 10.0.14 [$sdkRoot\shared\Microsoft.NETCore.App]", + "Microsoft.WindowsDesktop.App 10.0.14 [$sdkRoot\shared\Microsoft.WindowsDesktop.App]") + } +} + +Describe 'Pinned .NET prerequisite production wiring' { + It 'resolves the SDK during the existing bootstrap and exports its executable' { + $text = [IO.File]::ReadAllText((Join-Path $repositoryRoot 'Tools\windows\bootstrap.ps1')) + $text | Should -Match 'Resolve-GraphCodeDotNetSdk' + $text | Should -Match 'GRAPHCODE_DOTNET10403' + } + It 'uses the shared SDK resolver in the actual compiled build wrapper' { + $text = [IO.File]::ReadAllText((Join-Path $repositoryRoot 'Tools\windows\Build-CompiledValidationRunner.ps1')) + $text | Should -Match 'Resolve-GraphCodeDotNetSdk' + $text | Should -Match 'GRAPHCODE_DOTNET10403' + $text | Should -Match 'DOTNET_ROOT' + } + It 'documents the pinned SDK in the Windows contributor prerequisites' { + $text = [IO.File]::ReadAllText((Join-Path $repositoryRoot 'graphcode-windows\README.md')) + $start = $text.IndexOf('## Windows contributor quick start', [StringComparison]::Ordinal) + $text.Substring($start, [Math]::Min(6000, $text.Length - $start)) | Should -Match '\.NET SDK.*10\.0\.403' + } +} + +Describe 'Exact SDK discovery and prerequisite probes' { + BeforeEach { + New-DotNetTestFixture + $script:savedEnvironment = [Environment]::GetEnvironmentVariables('Process') + if ([Environment]::GetEnvironmentVariables('Process').Contains('GRAPHCODE_DOTNET10403')) { + Remove-Item -LiteralPath Env:\GRAPHCODE_DOTNET10403 + } + Mock Get-GraphCodeDotNetCandidates { @($exe) } + Mock Install-GraphCodeDotNetSdk { throw 'Unexpected installation' } + Mock Invoke-RestMethod { throw 'Unexpected network' } + Mock Invoke-GraphCodeDotNetCommand { + if ($Arguments[0] -ceq '--version') { return [pscustomobject]@{ ExitCode=0; Output=@('10.0.403') } } + [pscustomobject]@{ ExitCode=0; Output=$runtimeOutput } + } + } + AfterEach { + foreach ($name in @('GRAPHCODE_DOTNET10403','DOTNET_ROOT','DOTNET_CLI_HOME','DOTNET_ROLL_FORWARD','TEMP','TMP', + 'GRAPHCODE_ZIG0152','GRAPHCODE_ZIG0160','GRAPHCODE_WINGHOSTTY_ROOT','GRAPHCODE_ZMX_ROOT','GITHUB_ENV')) { + if ($savedEnvironment.Contains($name)) { + [Environment]::SetEnvironmentVariable($name, $savedEnvironment[$name], 'Process') + } elseif ([Environment]::GetEnvironmentVariables('Process').Contains($name)) { + Remove-Item -LiteralPath "Env:\$name" + } + } + } + It 'reuses a verified installed exact SDK without installing or fetching metadata' { + Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot -InstallIfMissing | Should -BeExactly $exe + Should -Invoke Install-GraphCodeDotNetSdk -Times 0 + Should -Invoke Invoke-RestMethod -Times 0 + } + It 'prefers the explicitly exported full executable and bypasses discovery' { + $env:GRAPHCODE_DOTNET10403 = $exe + Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot | Should -BeExactly $exe + Should -Invoke Get-GraphCodeDotNetCandidates -Times 0 + } + It 'rejects an explicitly empty override instead of treating it as absent' { + [Environment]::SetEnvironmentVariable('GRAPHCODE_DOTNET10403', '', 'Process') + { Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot } | Should -Throw '*explicitly empty*' + } + It 'rejects an invalid explicit override without falling back to another executable' { + $env:GRAPHCODE_DOTNET10403 = Join-Path $TestDrive 'missing.exe' + { Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot -InstallIfMissing } | Should -Throw '*GRAPHCODE_DOTNET10403 is invalid*' + Should -Invoke Get-GraphCodeDotNetCandidates -Times 0 + Should -Invoke Install-GraphCodeDotNetSdk -Times 0 + } + It 'resolves global.json from the actual validator directory, never a project argument' { + Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot | Should -BeExactly $exe + Should -Invoke Invoke-GraphCodeDotNetCommand -Times 1 -ParameterFilter { + $WorkingDirectory -ceq (Join-Path $fixtureRepo 'Tools\windows\ValidationRunner') -and + $Arguments.Count -eq 1 -and $Arguments[0] -ceq '--version' + } + } + It 'does not accept an SDK version fallback even when the host exits successfully' { + Mock Invoke-GraphCodeDotNetCommand { [pscustomobject]@{ ExitCode=0; Output=@('10.0.404') } } + { Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot } | Should -Throw '*did not select 10.0.403*' + Should -Invoke Install-GraphCodeDotNetSdk -Times 0 + } + It 'rejects a failing exact SDK command and preserves actionable diagnostics' { + Mock Invoke-GraphCodeDotNetCommand { [pscustomobject]@{ ExitCode=145; Output=@('SDK selection failed') } } + { Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot } | Should -Throw '*SDK selection failed*' + } + It 'rejects missing WindowsDesktop runtime rather than admitting compile-only evidence' { + $script:runtimeOutput = @($runtimeOutput[0]) + { Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot } | Should -Throw '*Microsoft.WindowsDesktop.App 10.0 runtime*' + } + It 'rejects empty runtime enumeration instead of passing vacuously' { + $script:runtimeOutput = @() + { Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot } | Should -Throw '*Missing usable Microsoft.NETCore.App*' + } + It 'rejects a runtime reported from another installation root' { + $script:runtimeOutput = @("Microsoft.NETCore.App 10.0.14 [C:\foreign\shared\Microsoft.NETCore.App]", + $runtimeOutput[1]) + { Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot } | Should -Throw '*Missing usable Microsoft.NETCore.App*' + } + It 'requires the exact SDK-declared reference-pack version' { + Remove-Item -LiteralPath (Join-Path $sdkRoot 'packs\Microsoft.WindowsDesktop.App.Ref\10.0.14\ref\net10.0\UIAutomationTypes.dll') + { Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot } | Should -Throw '*Missing Microsoft.WindowsDesktop.App reference input*' + } + It 'restores environment membership and empty values after successful probes' { + $names=@('DOTNET_ROOT','DOTNET_CLI_HOME','TEMP','TMP','DOTNET_ROLL_FORWARD') + $before=[Environment]::GetEnvironmentVariables('Process') + Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot | Should -BeExactly $exe + $after=[Environment]::GetEnvironmentVariables('Process') + foreach($name in $names) { + $after.Contains($name) | Should -Be $before.Contains($name) + if($before.Contains($name)) { $after[$name] | Should -BeExactly $before[$name] } + } + } + It 'restores process environment when a native probe throws, without alternate downloads' { + $before=[Environment]::GetEnvironmentVariables('Process') + Mock Invoke-GraphCodeDotNetCommand { throw [IO.IOException]::new('controlled probe error') } + { Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot -InstallIfMissing } | Should -Throw '*controlled probe error*' + $after=[Environment]::GetEnvironmentVariables('Process') + foreach($name in @('DOTNET_ROOT','TEMP','TMP','DOTNET_CLI_HOME')) { + $after.Contains($name) | Should -Be $before.Contains($name) + if($before.Contains($name)) { $after[$name] | Should -BeExactly $before[$name] } + } + Should -Invoke Install-GraphCodeDotNetSdk -Times 0 + } + It 'fails actionably when no exact SDK exists and installation was not requested' { + Mock Get-GraphCodeDotNetCandidates { @() } + { Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot } | Should -Throw '*Run Tools\windows\bootstrap.ps1*' + Should -Invoke Install-GraphCodeDotNetSdk -Times 0 + } + It 'routes missing prerequisites through the repository private installer only when requested' { + Mock Get-GraphCodeDotNetCandidates { @() } + Mock Install-GraphCodeDotNetSdk { Join-Path $ToolRoot "dotnet\$Version\dotnet.exe" } + Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot -InstallIfMissing | + Should -BeExactly (Join-Path $toolRoot 'dotnet\10.0.403\dotnet.exe') + Should -Invoke Install-GraphCodeDotNetSdk -Times 1 -ParameterFilter { $Version -ceq '10.0.403' } + } + It 'keeps the pin as the single source of truth and rejects roll-forward' { + $path=Join-Path $fixtureRepo 'Tools\windows\ValidationRunner\global.json' + [IO.File]::WriteAllText($path,'{"sdk":{"version":"10.0.403","rollForward":"latestPatch","allowPrerelease":false}}') + { Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot } | Should -Throw '*Invalid release SDK pin*' + } + It 'restores an absent environment variable as absent rather than present-empty' { + if ([Environment]::GetEnvironmentVariables('Process').Contains('DOTNET_ROOT')) { Remove-Item Env:\DOTNET_ROOT } + Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot | Should -BeExactly $exe + [Environment]::GetEnvironmentVariables('Process').Contains('DOTNET_ROOT') | Should -BeFalse + } + It 'preserves a genuinely empty environment value across the probe' { + [Environment]::SetEnvironmentVariable('DOTNET_ROLL_FORWARD', '', 'Process') + Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot | Should -BeExactly $exe + $after=[Environment]::GetEnvironmentVariables('Process') + $after.Contains('DOTNET_ROLL_FORWARD') | Should -BeTrue + $after['DOTNET_ROLL_FORWARD'] | Should -BeExactly '' + } + It 'rejects a string-valued prerelease flag instead of coercing it' { + [IO.File]::WriteAllText((Join-Path $fixtureRepo 'Tools\windows\ValidationRunner\global.json'), + '{"sdk":{"version":"10.0.403","rollForward":"disable","allowPrerelease":"false"}}') + { Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot } | Should -Throw '*Invalid release SDK pin*' + } + It 'reads the version from global.json rather than returning a baked-in SDK version' { + [IO.File]::WriteAllText((Join-Path $fixtureRepo 'Tools\windows\ValidationRunner\global.json'), + '{"sdk":{"version":"10.0.404","rollForward":"disable","allowPrerelease":false}}') + Get-GraphCodeDotNetPin $fixtureRepo | Should -BeExactly '10.0.404' + { Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot } | Should -Throw '*SDK 10.0.404 inputs are missing*' + } + It 'reuses a private exact SDK without installing or consulting remote metadata' { + $private=Join-Path $toolRoot 'dotnet\10.0.403' + $null=[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($private)) + [IO.Directory]::Move($sdkRoot,$private) + $script:sdkRoot=$private + $script:exe=Join-Path $private 'dotnet.exe' + $script:runtimeOutput=@("Microsoft.NETCore.App 10.0.14 [$private\shared\Microsoft.NETCore.App]", + "Microsoft.WindowsDesktop.App 10.0.14 [$private\shared\Microsoft.WindowsDesktop.App]") + $env:GRAPHCODE_DOTNET10403=$exe + Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot -InstallIfMissing | Should -BeExactly $exe + Should -Invoke Install-GraphCodeDotNetSdk -Times 0 + Should -Invoke Invoke-RestMethod -Times 0 + } + It 'runs the actual bootstrap SDK assignment and environment producers without provider installation' { + $bootstrapAst=[Management.Automation.Language.Parser]::ParseFile( + (Join-Path $repositoryRoot 'Tools\windows\bootstrap.ps1'),[ref]$null,[ref]$null) + $sdkAssignment=@($bootstrapAst.FindAll({ + param($node) + $node -is [Management.Automation.Language.AssignmentStatementAst] -and + $node.Left.Extent.Text -ceq '$dotnet' + },$true)) + $valuesAssignment=@($bootstrapAst.FindAll({ + param($node) + $node -is [Management.Automation.Language.AssignmentStatementAst] -and + $node.Left.Extent.Text -ceq '$values' + },$true)) + $exportLoop=@($bootstrapAst.FindAll({ + param($node) + $node -is [Management.Automation.Language.ForEachStatementAst] -and + $node.Variable.VariablePath.UserPath -ceq 'entry' -and + $node.Condition.Extent.Text -ceq '$values.GetEnumerator()' + },$true)) + $environmentWriter=@($bootstrapAst.FindAll({ + param($node) + $node -is [Management.Automation.Language.PipelineAst] -and + $node.Extent.Text.Contains('Set-Content -LiteralPath $environmentScript') + },$true)) + $sdkAssignment.Count | Should -Be 1 + $valuesAssignment.Count | Should -Be 1 + $exportLoop.Count | Should -Be 1 + $environmentWriter.Count | Should -Be 1 + $setters=@($exportLoop[0].FindAll({ + param($node) + $node -is [Management.Automation.Language.InvokeMemberExpressionAst] -and + $node.Member.Value -ceq 'SetEnvironmentVariable' + },$true)) + $setters.Count | Should -Be 1 + $setters[0].Arguments.Count | Should -Be 2 + $repoRoot=$fixtureRepo + $ToolRoot=$script:toolRoot + $zig0152='fixture-zig0152';$zig0160='fixture-zig0160';$winghosttyRoot='fixture-winghostty';$zmxRoot='fixture-zmx' + $environmentScript=Join-Path $caseRoot 'environment.ps1' + $env:GITHUB_ENV=Join-Path $caseRoot 'github.env' + . ([scriptblock]::Create($sdkAssignment[0].Extent.Text)) + . ([scriptblock]::Create($valuesAssignment[0].Extent.Text)) + . ([scriptblock]::Create($exportLoop[0].Extent.Text)) + . ([scriptblock]::Create($environmentWriter[0].Extent.Text)) + $env:GRAPHCODE_DOTNET10403 | Should -BeExactly $exe + $env:DOTNET_ROOT | Should -BeExactly $sdkRoot + $text=[IO.File]::ReadAllText($environmentScript) + $text | Should -Match ('\$env:GRAPHCODE_DOTNET10403 = ' + [regex]::Escape("'$exe'")) + $text | Should -Match ('\$env:DOTNET_ROOT = ' + [regex]::Escape("'$sdkRoot'")) + [IO.File]::ReadAllText($env:GITHUB_ENV) | Should -Match ([regex]::Escape("GRAPHCODE_DOTNET10403=$exe")) + Should -Invoke Install-GraphCodeDotNetSdk -Times 0 + Should -Invoke Invoke-RestMethod -Times 0 + } +} + +Describe 'Official private SDK acquisition' { + BeforeEach { + New-DotNetTestFixture + $rid = if([Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() -ceq 'Arm64') {'win-arm64'} else {'win-x64'} + $script:metadataSdk=[pscustomobject]@{version='10.0.403';files=@([pscustomobject]@{ + name="dotnet-sdk-$rid.zip";rid=$rid;url="https://builds.dotnet.microsoft.com/dotnet/Sdk/10.0.403/dotnet-sdk-10.0.403-$rid.zip";hash=('A'*128)})} + Mock Invoke-RestMethod { [pscustomobject]@{releases=@([pscustomobject]@{sdks=@($metadataSdk);sdk=$metadataSdk})} } + } + It 'selects the exact official version and deduplicates identical sdk/sdks metadata' { + $asset=Get-GraphCodeDotNetAsset '10.0.403' + $asset.Sha512 | Should -BeExactly ('A'*128) + $asset.Uri.Host | Should -BeExactly 'builds.dotnet.microsoft.com' + Should -Invoke Invoke-RestMethod -Times 1 -ParameterFilter { $TimeoutSec -eq 60 -and $MaximumRedirection -eq 0 } + } + It 'does not substitute a newer release when the requested SDK is absent' { + $metadataSdk.version='10.0.404' + { Get-GraphCodeDotNetAsset '10.0.403' } | Should -Throw '*exactly one SDK 10.0.403*found 0*' + } + It 'rejects missing or malformed checksums' { + $metadataSdk.files[0].hash='not-a-sha512' + { Get-GraphCodeDotNetAsset '10.0.403' } | Should -Throw '*valid SHA-512*' + } + It 'rejects non-Microsoft metadata URLs before downloading' { + $metadataSdk.files[0].url='https://example.invalid/sdk.zip' + { Get-GraphCodeDotNetAsset '10.0.403' } | Should -Throw '*non-official ZIP URL*' + } + It 'rejects conflicting exact-version assets rather than picking the first' { + $other=[pscustomobject]@{name=$metadataSdk.files[0].name;rid=$metadataSdk.files[0].rid;url='https://builds.dotnet.microsoft.com/other.zip';hash=('B'*128)} + $metadataSdk.files += $other + { Get-GraphCodeDotNetAsset '10.0.403' } | Should -Throw '*found 2*' + } +} + +Describe 'Private SDK integrity and atomic publication' { + BeforeEach { + New-DotNetTestFixture + $script:archiveSource=Join-Path $caseRoot 'source.zip' + $zip=[IO.Compression.ZipFile]::Open($archiveSource,[IO.Compression.ZipArchiveMode]::Create) + try { $entry=$zip.CreateEntry('dotnet.exe');$writer=[IO.StreamWriter]::new($entry.Open());try{$writer.Write('fixture only')}finally{$writer.Dispose()} } finally {$zip.Dispose()} + $script:archiveHash=(Get-FileHash -LiteralPath $archiveSource -Algorithm SHA512).Hash + Mock Get-GraphCodeDotNetAsset { [pscustomobject]@{Uri=[uri]'https://builds.dotnet.microsoft.com/sdk.zip';Sha512=$archiveHash} } + Mock Invoke-WebRequest { Copy-Item -LiteralPath $archiveSource -Destination $OutFile } + Mock Expand-Archive { + $null=[IO.Directory]::CreateDirectory($DestinationPath) + [IO.File]::WriteAllText((Join-Path $DestinationPath 'dotnet.exe'),'fixture only; not executed') + } + Mock Get-GraphCodeDotNetSdkProbe { [pscustomobject]@{Valid=$true;Reason='';Executable=$Executable} } + } + It 'verifies checksum before extraction and publishes only a verified staged SDK' { + $result=Install-GraphCodeDotNetSdk $fixtureRepo $toolRoot '10.0.403' + $result | Should -BeExactly (Join-Path $toolRoot 'dotnet\10.0.403\dotnet.exe') + Test-Path -LiteralPath $result -PathType Leaf | Should -BeTrue + Should -Invoke Expand-Archive -Times 1 + Should -Invoke Get-GraphCodeDotNetSdkProbe -Times 1 -ParameterFilter { $Version -ceq '10.0.403' -and $Executable -like '*payload\dotnet.exe' } + @(Get-ChildItem (Join-Path $toolRoot 'dotnet') -Directory | Where-Object Name -Like '.install-*').Count | Should -Be 0 + } + It 'never extracts a checksum-mismatched archive' { + $script:archiveHash='0'*128 + { Install-GraphCodeDotNetSdk $fixtureRepo $toolRoot '10.0.403' } | Should -Throw '*SHA-512 checksum mismatch*' + Should -Invoke Expand-Archive -Times 0 + Test-Path (Join-Path $toolRoot 'dotnet\10.0.403') | Should -BeFalse + } + It 'surfaces download failure and leaves no published SDK' { + Mock Invoke-WebRequest { throw [IO.IOException]::new('controlled download failure') } + { Install-GraphCodeDotNetSdk $fixtureRepo $toolRoot '10.0.403' } | Should -Throw '*controlled download failure*' + Should -Invoke Expand-Archive -Times 0 + } + It 'rejects a downloaded payload with missing runtime/reference inputs before publication' { + Mock Get-GraphCodeDotNetSdkProbe { [pscustomobject]@{Valid=$false;Reason='missing WindowsDesktop runtime';Executable=$Executable} } + { Install-GraphCodeDotNetSdk $fixtureRepo $toolRoot '10.0.403' } | Should -Throw '*Downloaded SDK failed verification*' + Test-Path (Join-Path $toolRoot 'dotnet\10.0.403') | Should -BeFalse + } + It 'preserves an existing private directory instead of overwriting unknown contents' { + $directory=Join-Path $toolRoot 'dotnet\10.0.403';$null=[IO.Directory]::CreateDirectory($directory) + [IO.File]::WriteAllText((Join-Path $directory 'preserve.txt'),'owned elsewhere') + { Install-GraphCodeDotNetSdk $fixtureRepo $toolRoot '10.0.403' } | Should -Throw '*already exists but was not verified*' + [IO.File]::ReadAllText((Join-Path $directory 'preserve.txt')) | Should -BeExactly 'owned elsewhere' + Should -Invoke Invoke-WebRequest -Times 0 + } + It 'rejects traversal entries before extraction even if the archive hash matches' { + Remove-Item -LiteralPath $archiveSource + $zip=[IO.Compression.ZipFile]::Open($archiveSource,[IO.Compression.ZipArchiveMode]::Create) + try {$null=$zip.CreateEntry('../outside.exe')} finally {$zip.Dispose()} + $script:archiveHash=(Get-FileHash -LiteralPath $archiveSource -Algorithm SHA512).Hash + { Install-GraphCodeDotNetSdk $fixtureRepo $toolRoot '10.0.403' } | Should -Throw '*unsafe archive entry*' + Should -Invoke Expand-Archive -Times 0 + } +} + +Describe 'Installed exact SDK live prerequisite probe' { + It 'verifies this machine without installing dependencies or changing process environment' { + $before=[Environment]::GetEnvironmentVariables('Process') + $exe=(Get-Command dotnet -CommandType Application -ErrorAction Stop).Source + $result=Get-GraphCodeDotNetSdkProbe $exe $repositoryRoot (Join-Path $TestDrive 'actual-probe') (Get-GraphCodeDotNetPin $repositoryRoot) + $result.Valid | Should -BeTrue -Because $result.Reason + $result.Executable | Should -BeExactly $exe + $after=[Environment]::GetEnvironmentVariables('Process') + foreach($name in @('DOTNET_ROOT','DOTNET_CLI_HOME','TEMP','TMP','DOTNET_ROLL_FORWARD')) { + $after.Contains($name) | Should -Be $before.Contains($name) + if($before.Contains($name)){$after[$name]|Should -BeExactly $before[$name]} + } + } +} diff --git a/Tools/windows/Tests/GdiplusStartup.Live.Tests.ps1 b/Tools/windows/Tests/GdiplusStartup.Live.Tests.ps1 index baad5c88..560b3aa8 100644 --- a/Tools/windows/Tests/GdiplusStartup.Live.Tests.ps1 +++ b/Tools/windows/Tests/GdiplusStartup.Live.Tests.ps1 @@ -1,128 +1,195 @@ -[CmdletBinding()] -param( - [Parameter(Mandatory)][string] $ZigExecutable, - [Parameter(Mandatory)][string] $WinghosttyRoot, - [string] $ScratchRoot = (Join-Path $env:TEMP "graphcode-gdiplus-startup") -) - -$ErrorActionPreference = "Stop" - -if (-not ("GraphCodeGdiplusStartupNative" -as [type])) { - Add-Type @' -using System; -using System.Runtime.InteropServices; -using System.Text; - -public static class GraphCodeGdiplusStartupNative { - public delegate bool EnumWindowsProc(IntPtr window, IntPtr parameter); - [DllImport("user32.dll")] static extern bool EnumWindows(EnumWindowsProc callback, IntPtr parameter); - [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId); - [DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern int GetClassName(IntPtr window, StringBuilder value, int capacity); - [DllImport("user32.dll")] static extern bool IsWindowVisible(IntPtr window); - - public static IntPtr Find(uint processId, string className) { - IntPtr result = IntPtr.Zero; - EnumWindows(delegate(IntPtr window, IntPtr parameter) { - uint owner; - GetWindowThreadProcessId(window, out owner); - if (owner != processId || !IsWindowVisible(window)) return true; - var actual = new StringBuilder(128); - GetClassName(window, actual, actual.Capacity); - if (!String.Equals(actual.ToString(), className, StringComparison.Ordinal)) return true; - result = window; - return false; - }, IntPtr.Zero); - return result; - } -} -'@ -} - -$shellRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..\..\graphcode-windows") -$scratch = [IO.Path]::GetFullPath($ScratchRoot) -$bin = Join-Path $scratch "bin" -$profile = Join-Path $scratch "profile" -$temp = Join-Path $scratch "temp" -$executable = Join-Path $bin "gdiplus-startup-live.exe" -if (Test-Path -LiteralPath $scratch) { - Remove-Item -LiteralPath $scratch -Recurse -Force -} -New-Item -ItemType Directory -Force -Path ` - $bin,$profile,(Join-Path $profile "AppData\Local"),(Join-Path $profile "AppData\Roaming"),$temp | - Out-Null - -$include = Join-Path $WinghosttyRoot "include" -Push-Location $shellRoot -try { - & $ZigExecutable build-exe src\GdiplusStartupLiveRunner.zig ` - -target x86_64-windows-msvc -OReleaseSafe -lc ` - -luser32 -lgdi32 -lgdiplus "-I$include" "-femit-bin=$executable" - if ($LASTEXITCODE -ne 0) { - throw "GDI+ startup live runner build failed with exit code $LASTEXITCODE" - } -} finally { - Pop-Location -} - -$system32 = Join-Path $env:SystemRoot "System32" -$powerShell = Join-Path $system32 "WindowsPowerShell\v1.0" -$start = [Diagnostics.ProcessStartInfo]::new() -$start.FileName = $executable -$start.WorkingDirectory = $bin -$start.UseShellExecute = $false -$start.Environment.Clear() -$values = [ordered]@{ - SystemRoot = $env:SystemRoot - windir = $env:WINDIR - USERPROFILE = $profile - LOCALAPPDATA = Join-Path $profile "AppData\Local" - APPDATA = Join-Path $profile "AppData\Roaming" - TEMP = $temp - TMP = $temp - ProgramData = $env:ProgramData - HOMEDRIVE = [IO.Path]::GetPathRoot($profile).TrimEnd("\") - HOMEPATH = $profile.Substring([IO.Path]::GetPathRoot($profile).Length - 1) - PATH = "$system32;$powerShell;$bin" -} -foreach ($name in $values.Keys) { $start.Environment[$name] = [string]$values[$name] } - -$process = [Diagnostics.Process]::new() -$process.StartInfo = $start -$observations = 0 -$window = [IntPtr]::Zero -try { - if (-not $process.Start()) { throw "GDI+ startup live runner did not start" } - [void]$process.Handle - $deadline = [DateTime]::UtcNow.AddSeconds(5) - do { - $observations++ - $process.Refresh() - if ($process.HasExited) { - throw ("GDI+ startup live runner exited before creating a native window: " + - "exitCode=$($process.ExitCode); observations=$observations") - } - $window = [GraphCodeGdiplusStartupNative]::Find( - [uint32]$process.Id, - "GraphCodeGdiplusStartupTest" - ) - if ($window -ne [IntPtr]::Zero) { break } - Start-Sleep -Milliseconds 50 - } while ([DateTime]::UtcNow -lt $deadline) - - if ($observations -le 0) { throw "GDI+ startup live runner made zero observations" } - if ($window -eq [IntPtr]::Zero) { - throw ("GDI+ startup live runner remained alive but no native window appeared: " + - "observations=$observations; environmentKeys=$( - @($start.Environment.Keys | Sort-Object) -join ',')") - } - Write-Output ("GDIPLUS_STARTUP_LIVE: PASS; executed=1; observations=$observations; " + - "window=$($window.ToInt64()); environmentKeys=$( - @($start.Environment.Keys | Sort-Object) -join ',')") -} finally { - if ($process -and -not $process.HasExited) { - Stop-Process -Id $process.Id -Force - [void]$process.WaitForExit(10000) - } - if ($process) { $process.Dispose() } - Remove-Item -LiteralPath $scratch -Recurse -Force -ErrorAction SilentlyContinue -} +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $ZigExecutable, + [Parameter(Mandatory)][string] $WinghosttyRoot, + [string] $ScratchRoot = (Join-Path $env:TEMP "graphcode-gdiplus-startup") +) + +$ErrorActionPreference = "Stop" +. (Join-Path $PSScriptRoot "..\OwnedLiveProcess.ps1") +. (Join-Path $PSScriptRoot "ValidationFixtureDirectory.ps1") +Import-Module (Join-Path $PSScriptRoot "..\ShellProcessOwnership.psm1") -Force + +if (-not ("GraphCodeGdiplusStartupNative" -as [type])) { + Add-Type @' +using System; +using System.Runtime.InteropServices; +using System.Text; + +public static class GraphCodeGdiplusStartupNative { + public delegate bool EnumWindowsProc(IntPtr window, IntPtr parameter); + [DllImport("user32.dll")] static extern bool EnumWindows(EnumWindowsProc callback, IntPtr parameter); + [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId); + [DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern int GetClassName(IntPtr window, StringBuilder value, int capacity); + [DllImport("user32.dll")] static extern bool IsWindowVisible(IntPtr window); + + public static IntPtr Find(uint processId, string className) { + IntPtr result = IntPtr.Zero; + EnumWindows(delegate(IntPtr window, IntPtr parameter) { + uint owner; + GetWindowThreadProcessId(window, out owner); + if (owner != processId || !IsWindowVisible(window)) return true; + var actual = new StringBuilder(128); + GetClassName(window, actual, actual.Capacity); + if (!String.Equals(actual.ToString(), className, StringComparison.Ordinal)) return true; + result = window; + return false; + }, IntPtr.Zero); + return result; + } +} +'@ +} + +$shellRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..\..\graphcode-windows") +$scratch = [IO.Path]::GetFullPath($ScratchRoot) +$bin = Join-Path $scratch "bin" +$profile = Join-Path $scratch "profile" +$temp = Join-Path $scratch "temp" +$executable = Join-Path $bin "gdiplus-startup-live.exe" +if (Test-Path -LiteralPath $scratch) { + throw (New-OwnedLiveProcessCleanupError ` + -Message "GDI+ scratch already exists; ownership is unproven: $scratch" ` + -Errors @([InvalidOperationException]::new("Existing scratch was not removed.")) ` + -RetainedRoots @($scratch)) +} +try { + $scratchParent = [IO.Path]::GetDirectoryName($scratch) + if (-not [IO.Directory]::Exists($scratchParent)) { + [void][IO.Directory]::CreateDirectory($scratchParent) + } + $null = New-ValidationFixtureDirectory -Path $scratch +} catch { + $creationError = $_.Exception + if (Test-Path -LiteralPath $scratch) { + throw (New-OwnedLiveProcessCleanupError ` + -Message "GDI+ scratch appeared during admission; ownership is unproven: $scratch" ` + -Errors @($creationError) -RetainedRoots @($scratch)) + } + throw +} +New-Item -ItemType Directory -Force -Path ` + $bin,$profile,(Join-Path $profile "AppData\Local"),(Join-Path $profile "AppData\Roaming"),$temp | + Out-Null + +$system32 = Join-Path $env:SystemRoot "System32" +$powerShell = Join-Path $system32 "WindowsPowerShell\v1.0" +$start = [Diagnostics.ProcessStartInfo]::new() +$start.FileName = $executable +$start.WorkingDirectory = $bin +$start.UseShellExecute = $false +$start.Environment.Clear() +$values = [ordered]@{ + SystemRoot = $env:SystemRoot + windir = $env:WINDIR + USERPROFILE = $profile + LOCALAPPDATA = Join-Path $profile "AppData\Local" + APPDATA = Join-Path $profile "AppData\Roaming" + TEMP = $temp + TMP = $temp + ProgramData = $env:ProgramData + HOMEDRIVE = [IO.Path]::GetPathRoot($profile).TrimEnd("\") + HOMEPATH = $profile.Substring([IO.Path]::GetPathRoot($profile).Length - 1) + PATH = "$system32;$powerShell;$bin" +} +foreach ($name in $values.Keys) { $start.Environment[$name] = [string]$values[$name] } + +$process = $null +$processScope = $null +$observations = 0 +$window = [IntPtr]::Zero +$started = $false +$handleHeld = $false +$caseError = $null +try { + $processScope = New-ShellProcessScope + $buildInfo = [Diagnostics.ProcessStartInfo]::new() + $buildInfo.FileName = $ZigExecutable + $buildInfo.WorkingDirectory = $shellRoot.Path + $buildInfo.UseShellExecute = $false + $include = Join-Path $WinghosttyRoot "include" + foreach ($argument in @( + "build-exe", "src\GdiplusStartupLiveRunner.zig", + "-target", "x86_64-windows-msvc", "-OReleaseSafe", "-lc", + "-luser32", "-lgdi32", "-lgdiplus", "-I$include", "-femit-bin=$executable", + "--cache-dir", (Join-Path $scratch "zig-cache"), + "--global-cache-dir", (Join-Path $scratch "zig-global-cache"))) { + [void]$buildInfo.ArgumentList.Add($argument) + } + $build = Invoke-OwnedLiveCommand -Scope $processScope -StartInfo $buildInfo -StreamOutput + if ($build.ExitCode -ne 0) { + throw "GDI+ startup live runner build failed with exit code $($build.ExitCode)" + } + + $process = Start-ShellOwnedProcess $processScope $start + $started = $true + [void]$process.Handle + $handleHeld = $true + $deadline = [DateTime]::UtcNow.AddSeconds(5) + do { + $observations++ + $process.Refresh() + if ($process.HasExited) { + throw ("GDI+ startup live runner exited before creating a native window: " + + "exitCode=$($process.ExitCode); observations=$observations") + } + $window = [GraphCodeGdiplusStartupNative]::Find( + [uint32]$process.Id, + "GraphCodeGdiplusStartupTest" + ) + if ($window -ne [IntPtr]::Zero) { break } + Start-Sleep -Milliseconds 50 + } while ([DateTime]::UtcNow -lt $deadline) + + if ($observations -le 0) { throw "GDI+ startup live runner made zero observations" } + if ($window -eq [IntPtr]::Zero) { + throw ("GDI+ startup live runner remained alive but no native window appeared: " + + "observations=$observations; environmentKeys=$( + @($start.Environment.Keys | Sort-Object) -join ',')") + } +} catch { + $caseError = $_.Exception + throw +} finally { + $cleanupErrors = [Collections.Generic.List[Exception]]::new() + try { + if ($started) { + if (-not $handleHeld) { + throw "The started GDI+ runner's original process handle was not acquired; no termination attempted." + } + Stop-OwnedLiveProcess -Process $process -Name "GDI+ startup runner" + } + } catch { + $cleanupErrors.Add($_.Exception) + } finally { + if ($process) { + try { $process.Dispose() } catch { $cleanupErrors.Add($_.Exception) } + } + } + if ($processScope) { + try { Close-ShellProcessScope $processScope } catch { $cleanupErrors.Add($_.Exception) } + } + if ($cleanupErrors.Count -gt 0) { + if ($caseError) { $cleanupErrors.Insert(0, $caseError) } + throw (New-OwnedLiveProcessCleanupError ` + -Message "GDI+ runner cleanup could not be verified; fixture root: $scratch" ` + -Errors ([Exception[]]$cleanupErrors.ToArray()) -RetainedRoots @($scratch)) + } + if ($caseError -and (Test-OwnedLiveProcessRetentionRequired $caseError)) { + Write-Output "GDIPLUS_FIXTURE_RETENTION_REQUESTED=$scratch" + } else { + try { + Remove-Item -LiteralPath $scratch -Recurse -Force -ErrorAction Stop + } catch { + if ($caseError) { + throw [AggregateException]::new( + "GDI+ fixture cleanup failed.", [Exception[]]@($caseError, $_.Exception)) + } + throw + } + } +} +$global:LASTEXITCODE = 0 +Write-Output ("GDIPLUS_STARTUP_LIVE: PASS; executed=1; observations=$observations; " + + "window=$($window.ToInt64()); environmentKeys=$( + @($start.Environment.Keys | Sort-Object) -join ',')") diff --git a/Tools/windows/Tests/OwnedLiveAdapter.Tests.ps1 b/Tools/windows/Tests/OwnedLiveAdapter.Tests.ps1 new file mode 100644 index 00000000..bec6a6ab --- /dev/null +++ b/Tools/windows/Tests/OwnedLiveAdapter.Tests.ps1 @@ -0,0 +1,465 @@ +BeforeDiscovery { + Import-Module (Join-Path $PSScriptRoot "..\ShellProcessOwnership.psm1") -Force +} + +BeforeAll { + $hadValidationRoot = [Environment]::GetEnvironmentVariables("Process").Contains("GRAPHCODE_VALIDATION_ROOT") + $savedValidationRoot = [Environment]::GetEnvironmentVariable("GRAPHCODE_VALIDATION_ROOT", "Process") + Remove-Item Env:\GRAPHCODE_VALIDATION_ROOT -ErrorAction SilentlyContinue +} + +AfterAll { + if ($hadValidationRoot) { + [Environment]::SetEnvironmentVariable("GRAPHCODE_VALIDATION_ROOT", $savedValidationRoot, "Process") + } else { + Remove-Item Env:\GRAPHCODE_VALIDATION_ROOT -ErrorAction SilentlyContinue + } +} + +Describe "Owned live callers and shell adapter with mocked native operations" { + InModuleScope ShellProcessOwnership { + BeforeAll { + $moduleRoot = Split-Path -Parent (Get-Module ShellProcessOwnership).Path + . (Join-Path $moduleRoot "OwnedLiveProcess.ps1") + foreach ($item in @( + @{ File = "ScrubbedShellStartup.Live.Tests.ps1"; Name = "Invoke-ScrubbedCli" }, + @{ File = "DaemonHandoff.Live.Tests.ps1"; Name = "Assert-EndpointReachable" }, + @{ File = "DaemonRoundTrip.Live.Tests.ps1"; Name = "Start-OwnedDaemon" }, + @{ File = "DaemonRoundTrip.Live.Tests.ps1"; Name = "Stop-OwnedDaemon" })) { + $errors = $null + $source = Join-Path $moduleRoot "Tests\$($item.File)" + $ast = [Management.Automation.Language.Parser]::ParseFile($source, [ref]$null, [ref]$errors) + if ($errors.Count -ne 0) { throw "$source does not parse" } + $matches = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq $item.Name + }, $true)) + if ($matches.Count -ne 1) { throw "Expected one function named $($item.Name)." } + . ([scriptblock]::Create($matches[0].Extent.Text)) + } + + function New-StartInfo([string] $file, [string] $root) { + $info = [Diagnostics.ProcessStartInfo]::new() + $info.FileName = $file + $info.UseShellExecute = $false + return $info + } + + function New-ControlledLaunch([string] $name, [bool] $exited = $false) { + $process = [Diagnostics.Process]::new() + $process | Add-Member NoteProperty MockName $name + $process | Add-Member NoteProperty HasExited $exited -Force + $process | Add-Member NoteProperty ExitCode 7 -Force + $process | Add-Member NoteProperty Id 61001 -Force + $process | Add-Member NoteProperty Handle ([IntPtr]91) -Force + $process | Add-Member ScriptMethod WaitForExit { + param($milliseconds = $null) + $script:events.Add("wait-root:$($this.MockName)") + $this.HasExited = $true + if ($null -ne $milliseconds) { return $true } + } -Force + $process | Add-Member ScriptMethod Refresh { } -Force + $process | Add-Member ScriptMethod Dispose { + $script:events.Add("dispose:$($this.MockName)") + } -Force + $run = [pscustomobject]@{ + Process = $process + Handle = [IntPtr]91 + Output = [Threading.Tasks.Task]::FromResult[string]("stdout-$name") + Error = [Threading.Tasks.Task]::FromResult[string]("stderr-$name") + PipeError = $false + OutputChunks = [Collections.Generic.Queue[string]]::new() + ErrorChunks = [Collections.Generic.Queue[string]]::new() + } + $run | Add-Member ScriptMethod Resume { $script:events.Add("resume:$($this.Process.MockName)") } + $run | Add-Member ScriptMethod Stop { + $script:events.Add("stop:$($this.Process.MockName)") + $this.Process.HasExited = $true + } + $run | Add-Member ScriptMethod RequireExited { + $script:events.Add("require-exited:$($this.Process.MockName)") + if (-not $this.Process.HasExited) { throw "mock root is still live" } + } + $run | Add-Member ScriptMethod Drain { + $script:events.Add("drain:$($this.Process.MockName)") + if ($this.PipeError) { throw "mock inherited pipe EOF unproved" } + } + $run | Add-Member ScriptMethod TakeOutput { + param([int] $maximumCharacters) + $script:events.Add("take-output:$($this.Process.MockName)") + if ($maximumCharacters -ne 65536) { throw "unexpected output bound" } + if ($this.OutputChunks.Count -gt 0) { return $this.OutputChunks.Dequeue() } + return "" + } + $run | Add-Member ScriptMethod TakeError { + param([int] $maximumCharacters) + $script:events.Add("take-error:$($this.Process.MockName)") + if ($maximumCharacters -ne 65536) { throw "unexpected error bound" } + if ($this.ErrorChunks.Count -gt 0) { return $this.ErrorChunks.Dequeue() } + return "" + } + $run | Add-Member ScriptMethod Close { $script:events.Add("close-run:$($this.Process.MockName)") } + $script:queue.Enqueue($run) + return $run + } + } + + BeforeEach { + $script:events = [Collections.Generic.List[string]]::new() + $script:queue = [Collections.Generic.Queue[object]]::new() + $script:starts = [Collections.Generic.List[Diagnostics.ProcessStartInfo]]::new() + $script:nextJob = 100 + $script:scope = $null + Mock Initialize-ShellProcessOwnership {} + Mock New-ShellSuspendedLaunch { + param($StartInfo) + $script:events.Add("suspended") + $script:starts.Add($StartInfo) + return $script:queue.Dequeue() + } + Mock Invoke-ShellJob { + param($Operation, $Job, $Handle) + $script:events.Add("${Operation}:$Job") + switch ($Operation) { + "Create" { $script:nextJob += 100; return [IntPtr]$script:nextJob } + "Active" { return 0 } + "Contains" { return $true } + "Terminate" { + if ($null -ne $script:scope -and $Job -eq $script:scope.Job) { + foreach ($run in $script:scope.Runs) { $run.Process.HasExited = $true } + } + } + } + } + Mock Write-Host {} + $script:scope = New-ShellProcessScope + $script:events.Clear() + $script:Cli = "C:\mock\graphcode.exe" + $script:runLog = [Collections.Generic.List[string]]::new() + $script:processScope = $script:scope + } + + It "runs the command helper through assignment, completion and both output reads" { + $run = New-ControlledLaunch "command" + $info = [Diagnostics.ProcessStartInfo]::new() + $info.FileName = "C:\mock\zig.exe" + $info.UseShellExecute = $false + [void]$info.ArgumentList.Add("test") + + $result = Invoke-OwnedLiveCommand -Scope $script:scope -StartInfo $info + + $result.ExitCode | Should -Be 7 + $result.Output | Should -BeExactly "stdout-command" + $result.Error | Should -BeExactly "stderr-command" + $info.RedirectStandardOutput | Should -BeTrue + $info.RedirectStandardError | Should -BeTrue + $script:starts.Count | Should -Be 1 + [object]::ReferenceEquals($script:starts[0], $info) | Should -BeTrue + $run.Finalized | Should -BeTrue + ($script:events -join ",") | Should -BeExactly ( + "suspended,Assign:200,Create:,Assign:300,resume:command,wait-root:command," + + "require-exited:command,Terminate:300,Wait:300,Active:300,drain:command,Close:300") + Close-ShellProcessScope $script:scope + $script:scope.Closed | Should -BeTrue + } + + It "finalizes the handoff endpoint probe before reading or disposing it" { + $run = New-ControlledLaunch "endpoint" + $run.Process.ExitCode = 0 + $script:cliExecutable = "C:\mock\graphcode.exe" + $script:Executable = "C:\mock\graphcode-windows.exe" + $script:supportDirectory = "C:\mock\support" + $script:daemonPipe = "\\.\pipe\controlled-endpoint" + Assert-EndpointReachable + $run.Finalized | Should -BeTrue + ($script:events -join ",") | Should -BeExactly ( + "suspended,Assign:200,Create:,Assign:300,resume:endpoint,wait-root:endpoint," + + "require-exited:endpoint,Terminate:300,Wait:300,Active:300,drain:endpoint,Close:300,dispose:endpoint") + $handoffPath = Join-Path $moduleRoot 'Tests\DaemonHandoff.Live.Tests.ps1' + $handoff = [Management.Automation.Language.Parser]::ParseFile($handoffPath, [ref]$null, [ref]$null) + $normalStop = $handoff.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Stop-ShellNormally' + }, $true) + @($normalStop.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -eq 'Complete-ShellOwnedProcess' + }, $true)).Count | Should -Be 0 + $source = [IO.File]::ReadAllText($handoffPath) + $ownerObservation = $source.IndexOf('$daemonProcess.WaitForExit(7000)', [StringComparison]::Ordinal) + $ownerFinalization = $source.IndexOf('Complete-ShellOwnedProcess $processScope $owner', [StringComparison]::Ordinal) + $ownerObservation | Should -BeGreaterThan 0 + $ownerFinalization | Should -BeGreaterThan $ownerObservation + Close-ShellProcessScope $script:scope + $script:scope.Closed | Should -BeTrue + } + + It "does not finalize a live sibling when the command completes" { + $daemon = New-ControlledLaunch "daemon" + $command = New-ControlledLaunch "command" + $info = [Diagnostics.ProcessStartInfo]::new() + $info.FileName = "C:\mock\fixture.exe" + $info.UseShellExecute = $false + [void](Start-ShellOwnedProcess $script:scope $info) + $script:events.Clear() + + $result = Invoke-OwnedLiveCommand -Scope $script:scope -StartInfo $info + + $result.Output | Should -BeExactly "stdout-command" + $command.Finalized | Should -BeTrue + $daemon.Finalized | Should -BeFalse + $daemon.Process.HasExited | Should -BeFalse + $script:events | Should -Contain "Terminate:400" + $script:events | Should -Not -Contain "Terminate:200" + $script:events | Should -Not -Contain "Terminate:300" + $script:events | Should -Not -Contain "drain:daemon" + Close-ShellProcessScope $script:scope + $script:scope.Closed | Should -BeTrue + } + + It "propagates adapter EOF uncertainty before either command output read" { + $run = New-ControlledLaunch "command" + $run.PipeError = $true + $info = [Diagnostics.ProcessStartInfo]::new() + $info.UseShellExecute = $false + Mock Read-ShellOwnedOutput { throw "unexpected output read" } + $failure = $null + try { Invoke-OwnedLiveCommand -Scope $script:scope -StartInfo $info } catch { $failure = $_.Exception } + + $failure | Should -Not -BeNullOrEmpty + Test-OwnedLiveProcessRetentionRequired $failure | Should -BeTrue + $run.Finalized | Should -BeFalse + $script:scope.Closed | Should -BeFalse + Should -Invoke Read-ShellOwnedOutput -Times 0 -Exactly + } + + It "forwards adapter chunks before exit and drains after completion without duplication" { + $run = New-ControlledLaunch "command" + foreach ($chunk in @("early out", "", "late out", "")) { $run.OutputChunks.Enqueue($chunk) } + foreach ($chunk in @("early err", "", "late err", "")) { $run.ErrorChunks.Enqueue($chunk) } + $run.Output = [Threading.Tasks.Task]::FromResult[string]("early outlate out") + $run.Error = [Threading.Tasks.Task]::FromResult[string]("early errlate err") + $run.Process | Add-Member ScriptMethod WaitForExit { + param([int] $milliseconds) + $script:events.Add("wait-root:$($this.MockName):$milliseconds") + if ($milliseconds -eq 100) { + $this.HasExited = $true + return $true + } + return $false + } -Force + $info = [Diagnostics.ProcessStartInfo]::new() + $info.UseShellExecute = $false + $previousOutput = [Console]::Out + $previousError = [Console]::Error + $output = [IO.StringWriter]::new() + $error = [IO.StringWriter]::new() + try { + [Console]::SetOut($output) + [Console]::SetError($error) + $result = Invoke-OwnedLiveCommand -Scope $script:scope -StartInfo $info -StreamOutput + $stdout = $output.ToString() + $stderr = $error.ToString() + } finally { + [Console]::SetOut($previousOutput) + [Console]::SetError($previousError) + $output.Dispose() + $error.Dispose() + } + + $stdout | Should -BeExactly "early outlate out" + $stderr | Should -BeExactly "early errlate err" + $result.Output | Should -BeExactly $stdout + $result.Error | Should -BeExactly $stderr + $script:starts[0].RedirectStandardOutput | Should -BeTrue + $script:starts[0].RedirectStandardError | Should -BeTrue + $run.Finalized | Should -BeTrue + $script:events.IndexOf("take-output:command") | Should -BeLessThan ` + $script:events.IndexOf("wait-root:command:100") + $script:events.IndexOf("wait-root:command:100") | Should -BeLessThan ` + $script:events.IndexOf("Terminate:300") + $run.OutputChunks.Count | Should -Be 0 + $run.ErrorChunks.Count | Should -Be 0 + Close-ShellProcessScope $script:scope + } + + It "propagates a tagged adapter pump fault without completing the launch" { + $run = New-ControlledLaunch "command" + $run | Add-Member ScriptMethod TakeOutput { throw "mock source pump failed" } -Force + $info = [Diagnostics.ProcessStartInfo]::new() + $info.UseShellExecute = $false + $failure = $null + try { Invoke-OwnedLiveCommand -Scope $script:scope -StartInfo $info -StreamOutput } catch { + $failure = $_.Exception + } + + $failure | Should -Not -BeNullOrEmpty + Test-OwnedLiveProcessRetentionRequired $failure | Should -BeTrue + $run.Finalized | Should -BeFalse + $script:events | Should -Not -Contain "Terminate:300" + $script:scope.Closed | Should -BeFalse + } + + It "runs the source-derived scrubbed CLI through the adapter before disposal" { + $daemon = New-ControlledLaunch "daemon" + $cli = New-ControlledLaunch "cli" + $info = [Diagnostics.ProcessStartInfo]::new() + $info.UseShellExecute = $false + [void](Start-ShellOwnedProcess $script:scope $info) + $script:events.Clear() + + $result = Invoke-ScrubbedCli $script:scope "case" "C:\mock\root" ` + "C:\mock\support" @("projects", "--json") + + $result.ExitCode | Should -Be 7 + $result.Output | Should -BeExactly "stdout-cli" + $result.Error | Should -BeExactly "stderr-cli" + $cli.Finalized | Should -BeTrue + $daemon.Finalized | Should -BeFalse + $daemon.Process.HasExited | Should -BeFalse + $script:starts[1].Environment["GRAPHCODE_SUPPORT_DIR"] | Should -BeExactly "C:\mock\support" + $script:starts[1].RedirectStandardOutput | Should -BeTrue + $script:starts[1].RedirectStandardError | Should -BeTrue + @($script:starts[1].ArgumentList) | Should -Be @("projects", "--json") + $script:events.IndexOf("drain:cli") | Should -BeLessThan $script:events.IndexOf("dispose:cli") + $script:events | Should -Not -Contain "Terminate:300" + Close-ShellProcessScope $script:scope + } + + It "preserves the unredirected startup stream policy through the owned launcher" -TestCases @( + @{ name = "daemon"; variable = "daemonInfo" }, + @{ name = "shell"; variable = "shellInfo" } + ) { + param($name, $variable) + $path = Join-Path $moduleRoot "Tests\ScrubbedShellStartup.Live.Tests.ps1" + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile($path, [ref]$null, [ref]$errors) + $errors.Count | Should -Be 0 + $builders = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq "New-StartInfo" + }, $true)) + $builders.Count | Should -Be 1 + . ([scriptblock]::Create($builders[0].Extent.Text)) + $commands = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -ceq "Start-ShellOwnedProcess" -and + $node.Extent.Text -match ('\$processScope\s+\$' + $variable + '\b') + }, $true)) + $commands.Count | Should -Be 1 + $info = New-StartInfo "C:\mock\$name.exe" "C:\mock\root" + Set-Variable -Name $variable -Value $info + $processScope = $script:scope + $null = New-ControlledLaunch $name + [void](& ([scriptblock]::Create($commands[0].Extent.Text))) + + $script:starts.Count | Should -Be 1 + [object]::ReferenceEquals($script:starts[0], $info) | Should -BeTrue + $info.RedirectStandardOutput | Should -BeFalse + $info.RedirectStandardError | Should -BeFalse + Close-ShellProcessScope $script:scope + } + + It "preserves the unredirected GDI runner stream policy through the owned launcher" { + $path = Join-Path $moduleRoot "Tests\GdiplusStartup.Live.Tests.ps1" + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile($path, [ref]$null, [ref]$errors) + $errors.Count | Should -Be 0 + $builders = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.AssignmentStatementAst] -and + $node.Left.Extent.Text -ceq '$start' -and + $node.Right.Extent.Text -ceq '[Diagnostics.ProcessStartInfo]::new()' + }, $true)) + $builders.Count | Should -Be 1 + $redirects = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.AssignmentStatementAst] -and + $node.Left -is [Management.Automation.Language.MemberExpressionAst] -and + $node.Left.Expression.Extent.Text -ceq '$start' -and + $node.Left.Member.Extent.Text -in @("RedirectStandardOutput", "RedirectStandardError") + }, $true)) + $redirects.Count | Should -Be 0 + $commands = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -ceq "Start-ShellOwnedProcess" -and + $node.Extent.Text -match '\$processScope\s+\$start\b' + }, $true)) + $commands.Count | Should -Be 1 + . ([scriptblock]::Create($builders[0].Extent.Text)) + $processScope = $script:scope + $null = New-ControlledLaunch "gdiplus" + [void](& ([scriptblock]::Create($commands[0].Extent.Text))) + + $script:starts.Count | Should -Be 1 + [object]::ReferenceEquals($script:starts[0], $start) | Should -BeTrue + $start.RedirectStandardOutput | Should -BeFalse + $start.RedirectStandardError | Should -BeFalse + Close-ShellProcessScope $script:scope + } + + It "retains adapter completion failures across the scrubbed CLI cleanup" { + $cli = New-ControlledLaunch "cli" + $cli.PipeError = $true + $failure = $null + try { + Invoke-ScrubbedCli $script:scope "case" "C:\mock\root" "C:\mock\support" @("projects") + } catch { $failure = $_.Exception } + + $failure | Should -Not -BeNullOrEmpty + Test-OwnedLiveProcessRetentionRequired $failure | Should -BeTrue + $cli.Finalized | Should -BeFalse + $script:events | Should -Contain "dispose:cli" + $script:scope.Closed | Should -BeFalse + } + + It "uses the adapter after source-derived graceful daemon shutdown" { + $daemon = New-ControlledLaunch "daemon" + $info = [Diagnostics.ProcessStartInfo]::new() + $info.UseShellExecute = $false + $process = Start-ShellOwnedProcess $script:scope $info + $script:shutdownEvent = [pscustomobject]@{} + $script:shutdownEvent | Add-Member ScriptMethod Set { + $script:events.Add("shutdown-event") + return $true + } + $script:events.Clear() + + Stop-OwnedDaemon $process + + $daemon.Finalized | Should -BeTrue + $script:events.IndexOf("shutdown-event") | Should -BeLessThan $script:events.IndexOf("wait-root:daemon") + $script:events.IndexOf("wait-root:daemon") | Should -BeLessThan $script:events.IndexOf("Terminate:300") + $script:runLog.Count | Should -Be 3 + $script:runLog[1] | Should -BeExactly "daemon-stdout=stdout-daemon" + $script:runLog[2] | Should -BeExactly "daemon-stderr=stderr-daemon" + Close-ShellProcessScope $script:scope + } + + It "preserves the source-derived startup-exit error after adapter completion" { + $daemon = New-ControlledLaunch "daemon" $true + $script:shutdownEvent = [pscustomobject]@{} + $script:shutdownEvent | Add-Member ScriptMethod Reset { return $true } + $script:expectedDaemonPath = "C:\mock\graphcoded.exe" + $script:supportDirectory = "C:\mock\support" + $script:daemonPipe = "\\.\pipe\mock" + $script:shutdownEventName = "Local\mock" + $script:runRoot = "C:\mock\root" + $failure = $null + try { Start-OwnedDaemon } catch { $failure = $_.Exception } + + $failure | Should -Not -BeNullOrEmpty + $failure.Message | Should -Match "graphcoded.exe exited during startup: stderr-daemon" + $script:starts[0].RedirectStandardOutput | Should -BeTrue + $script:starts[0].RedirectStandardError | Should -BeTrue + $daemon.Finalized | Should -BeTrue + $script:events.IndexOf("drain:daemon") | Should -BeLessThan $script:events.IndexOf("dispose:daemon") + Close-ShellProcessScope $script:scope + } + } +} diff --git a/Tools/windows/Tests/OwnedLiveProcessCleanup.Tests.ps1 b/Tools/windows/Tests/OwnedLiveProcessCleanup.Tests.ps1 new file mode 100644 index 00000000..2f0b41d8 --- /dev/null +++ b/Tools/windows/Tests/OwnedLiveProcessCleanup.Tests.ps1 @@ -0,0 +1,1918 @@ +[CmdletBinding()] +param([string] $SourceRoot = $PSScriptRoot) + +$ErrorActionPreference = "Stop" +$compiledValidationAssembly = $env:GRAPHCODE_VALIDATION_ASSEMBLY +if (-not $compiledValidationAssembly) { + $compiledValidationAssembly = & (Join-Path $PSScriptRoot "..\Build-CompiledValidationRunner.ps1") +} +$testsRoot = $SourceRoot +$helper = Join-Path $testsRoot "..\OwnedLiveProcess.ps1" +if (Test-Path -LiteralPath $helper -PathType Leaf) { + . $helper +} + +$script:pidStops = [Collections.Generic.List[int]]::new() +$script:removedPaths = [Collections.Generic.List[string]]::new() +$script:mockRemoveFailure = $false +$script:cleanupEvents = [Collections.Generic.List[string]]::new() +$script:scopeCloseCalls = 0 +$script:scopeCloseFailure = $false +$processScope = $null +function Stop-Process { + [CmdletBinding()] + param([int] $Id, [switch] $Force) + $script:pidStops.Add($Id) +} +function Remove-Item { + [CmdletBinding()] + param([string] $LiteralPath, [switch] $Recurse, [switch] $Force) + $script:removedPaths.Add($LiteralPath) + $script:cleanupEvents.Add("remove") + if ($script:mockRemoveFailure) { throw "mock fixture removal failure" } +} +function Close-ShellProcessScope { + param($Scope) + $script:scopeCloseCalls++ + $script:cleanupEvents.Add("close-job") + if ($script:scopeCloseFailure) { throw "mock job cleanup failure" } +} + +function New-MockProcess([int] $id) { + $process = [Diagnostics.Process]::new() + $process | Add-Member NoteProperty Id $id -Force + $process | Add-Member NoteProperty HasExited $false -Force + $process | Add-Member NoteProperty MockKillCount 0 + $process | Add-Member NoteProperty MockWaitCount 0 + $process | Add-Member NoteProperty MockWaitResult $true + $process | Add-Member NoteProperty MockDisposed $false + $process | Add-Member ScriptMethod Kill { $this.MockKillCount++ } -Force + $process | Add-Member ScriptMethod WaitForExit { + param([int] $milliseconds) + $this.MockWaitCount++ + return $this.MockWaitResult + } -Force + $process | Add-Member ScriptMethod Dispose { $this.MockDisposed = $true } -Force + return $process +} + +function Get-ParsedSource([string] $name) { + $path = Join-Path $testsRoot $name + $tokens = $null + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile( + $path, [ref]$tokens, [ref]$errors) + if ($errors.Count -ne 0) { throw "$name has parser errors: $errors" } + return $ast +} + +function Get-BlockScript([Management.Automation.Language.StatementBlockAst] $block) { + $text = $block.Extent.Text + return [scriptblock]::Create($text.Substring(1, $text.Length - 2)) +} + +function Get-ScrubbedCleanup { + $ast = Get-ParsedSource "ScrubbedShellStartup.Live.Tests.ps1" + $function = $ast.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq "Invoke-Case" + }, $true) + if (-not $function) { throw "Scrubbed startup case function not found" } + $matches = @($function.FindAll({ + param($node) + $node -is [Management.Automation.Language.TryStatementAst] -and + $null -ne $node.Finally -and + $node.Extent.Text -match "onboardingObserved" + }, $true)) + if ($matches.Count -ne 1) { throw "Expected one scrubbed case cleanup; found $($matches.Count)" } + return Get-BlockScript $matches[0].Finally +} + +function Get-GdiplusCleanup { + $ast = Get-ParsedSource "GdiplusStartup.Live.Tests.ps1" + $matches = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.TryStatementAst] -and + $null -ne $node.Finally -and + $node.Extent.Text -match "GraphCodeGdiplusStartupTest" + }, $true)) + if ($matches.Count -ne 1) { throw "Expected one GDI+ runner cleanup; found $($matches.Count)" } + return Get-BlockScript $matches[0].Finally +} + +function Get-GdiplusScratchAdmission { + $ast = Get-ParsedSource "GdiplusStartup.Live.Tests.ps1" + $matches = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.IfStatementAst] -and + $node.Extent.Text -match 'Test-Path -LiteralPath \$scratch' + }, $true)) + if ($matches.Count -eq 0) { throw "GDI+ scratch admission was not found" } + $text = $ast.Extent.Text + $start = $matches[0].Extent.StartOffset + $end = $text.IndexOf('New-Item -ItemType Directory -Force -Path', $start, [StringComparison]::Ordinal) + if ($end -le $start) { throw "GDI+ scratch creation boundary was not found" } + return [scriptblock]::Create($text.Substring($start, $end - $start)) +} + +function Get-ScrubbedScratchAdmission { + $ast = Get-ParsedSource "ScrubbedShellStartup.Live.Tests.ps1" + $function = $ast.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq "Invoke-Case" + }, $true) + $body = $function.Body.EndBlock.Extent.Text + $start = $body.IndexOf('$root = Join-Path $ScratchRoot $name', [StringComparison]::Ordinal) + $end = $body.IndexOf('if ($action -in', [StringComparison]::Ordinal) + if ($start -lt 0 -or $end -le $start) { throw "Scrubbed scratch admission not found" } + return [scriptblock]::Create($body.Substring($start, $end - $start)) +} + +function Get-RoundTripScratchAdmission { + $ast = Get-ParsedSource "DaemonRoundTrip.Live.Tests.ps1" + $text = $ast.Extent.Text + $start = $text.IndexOf('if (Test-Path -LiteralPath $runRoot)', [StringComparison]::Ordinal) + $end = $text.IndexOf('New-Item -ItemType Directory -Force -Path @(', $start, [StringComparison]::Ordinal) + if ($start -lt 0 -or $end -le $start) { throw "Round-trip scratch admission was not found" } + return [scriptblock]::Create($text.Substring($start, $end - $start)) +} + +function Import-SourceFunction([string] $file, [string] $functionName) { + $ast = Get-ParsedSource $file + $definitions = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq $functionName + }, $true)) + if ($definitions.Count -ne 1) { throw "Expected one source function named $functionName" } + return [scriptblock]::Create($definitions[0].Extent.Text) +} + +function Get-RoundTripFallback { + $ast = Get-ParsedSource "DaemonRoundTrip.Live.Tests.ps1" + $matches = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.TryStatementAst] -and + $node.Body.Extent.Text -match '^\{\s*Stop-OwnedDaemon\s+\$daemonProcess\s*;?\s*\}$' -and + $node.CatchClauses.Count -eq 1 + }, $true)) + if ($matches.Count -ne 1) { throw "Expected one round-trip cleanup fallback; found $($matches.Count)" } + return Get-BlockScript $matches[0].CatchClauses[0].Body +} + +function Get-RoundTripCleanup { + $ast = Get-ParsedSource "DaemonRoundTrip.Live.Tests.ps1" + $matches = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.TryStatementAst] -and + $null -ne $node.Finally -and + $node.Extent.Text -match "Round-trip daemon cleanup could not be verified" + }, $true)) + if ($matches.Count -ne 1) { throw "Expected one round-trip cleanup; found $($matches.Count)" } + return Get-BlockScript $matches[0].Finally +} + +function Remove-ValidationRootEnvironment { + $environment = [Environment]::GetEnvironmentVariables( + [EnvironmentVariableTarget]::Process) + if ($environment.Contains("GRAPHCODE_VALIDATION_ROOT")) { + Microsoft.PowerShell.Management\Remove-Item ` + -LiteralPath "Env:\GRAPHCODE_VALIDATION_ROOT" -Force -ErrorAction Stop + } +} + +function Invoke-ReceiptFixture([scriptblock] $test) { + $environment = [Environment]::GetEnvironmentVariables( + [EnvironmentVariableTarget]::Process) + $hadRoot = $environment.Contains("GRAPHCODE_VALIDATION_ROOT") + $previousRoot = if ($hadRoot) { [string]$environment["GRAPHCODE_VALIDATION_ROOT"] } else { $null } + $root = Join-Path $env:TEMP ("owned-live-retention-" + [guid]::NewGuid().ToString("N")) + [void][IO.Directory]::CreateDirectory($root) + try { + & $test $root + } finally { + if ($hadRoot) { + [Environment]::SetEnvironmentVariable( + "GRAPHCODE_VALIDATION_ROOT", $previousRoot, [EnvironmentVariableTarget]::Process) + } else { + Remove-ValidationRootEnvironment + } + $receipt = Join-Path $root "ownership-unproven.json" + if ([IO.File]::Exists($receipt)) { [IO.File]::Delete($receipt) } + $target = Join-Path $root "target" + $targetReceipt = Join-Path $target "ownership-unproven.json" + if ([IO.File]::Exists($targetReceipt)) { [IO.File]::Delete($targetReceipt) } + $nested = Join-Path $target "nested" + $nestedReceipt = Join-Path $nested "ownership-unproven.json" + if ([IO.File]::Exists($nestedReceipt)) { [IO.File]::Delete($nestedReceipt) } + $link = Join-Path $root "link" + if ([IO.Directory]::Exists($link)) { + Microsoft.PowerShell.Management\Remove-Item -LiteralPath $link -Force -ErrorAction Stop + } + if ([IO.Directory]::Exists($nested)) { [IO.Directory]::Delete($nested) } + if ([IO.Directory]::Exists($target)) { [IO.Directory]::Delete($target) } + $rootFile = Join-Path $root "not-directory" + if ([IO.File]::Exists($rootFile)) { [IO.File]::Delete($rootFile) } + $failureLog = Join-Path $root "daemon-roundtrip-test-failure.txt" + if ([IO.File]::Exists($failureLog)) { [IO.File]::Delete($failureLog) } + [IO.Directory]::Delete($root) + } +} + +function Invoke-CapturingConsole([scriptblock] $test) { + $previous = [Console]::Out + $writer = [IO.StringWriter]::new() + try { + [Console]::SetOut($writer) + $value = & $test + } finally { + [Console]::SetOut($previous) + } + $text = $writer.ToString() + $writer.Dispose() + return [pscustomobject]@{ Value = $value; Text = $text } +} + +$failures = [Collections.Generic.List[string]]::new() +$executed = 0 +function Invoke-Case([string] $name, [scriptblock] $test) { + $script:pidStops.Clear() + $script:removedPaths.Clear() + $script:mockRemoveFailure = $false + $script:cleanupEvents.Clear() + $script:scopeCloseCalls = 0 + $script:scopeCloseFailure = $false + $script:executed++ + $previousOutput = [Console]::Out + $capturedOutput = [IO.StringWriter]::new() + try { + [Console]::SetOut($capturedOutput) + $null = & $test + $outcome = "PASS $name" + } catch { + $script:failures.Add("${name}: $($_.Exception.Message)") + $outcome = "FAIL ${name}: $($_.Exception.Message); $($_.ScriptStackTrace)" + } finally { + [Console]::SetOut($previousOutput) + $capturedOutput.Dispose() + } + Write-Output $outcome +} + +$environment = [Environment]::GetEnvironmentVariables([EnvironmentVariableTarget]::Process) +$hadValidationRoot = $environment.Contains("GRAPHCODE_VALIDATION_ROOT") +$originalValidationRoot = if ($hadValidationRoot) { + [string]$environment["GRAPHCODE_VALIDATION_ROOT"] +} else { $null } +Remove-ValidationRootEnvironment +try { + Invoke-Case "scrubbed-cleanup-uses-held-processes" { + $shellProcess = New-MockProcess 91001 + $daemonProcess = New-MockProcess 91002 + $shellStarted = $daemonStarted = $true + $shellHandleHeld = $daemonHandleHeld = $true + $root = "C:\mock-scrubbed-case" + $caseError = $null + & (Get-ScrubbedCleanup) + if ($script:pidStops.Count -ne 0 -or + $shellProcess.MockKillCount -ne 1 -or $daemonProcess.MockKillCount -ne 1 -or + $shellProcess.MockWaitCount -ne 1 -or $daemonProcess.MockWaitCount -ne 1) { + throw "expected two held-process kills and waits, zero PID stops; pid=$($script:pidStops.Count) shell=$($shellProcess.MockKillCount) daemon=$($daemonProcess.MockKillCount)" + } + } + + Invoke-Case "gdiplus-cleanup-uses-held-process" { + $process = New-MockProcess 91003 + $started = $true + $handleHeld = $true + $scratch = "C:\mock-gdiplus-case" + $caseError = $null + & (Get-GdiplusCleanup) + if ($script:pidStops.Count -ne 0 -or + $process.MockKillCount -ne 1 -or $process.MockWaitCount -ne 1) { + throw "expected a held-process kill and wait, zero PID stops; pid=$($script:pidStops.Count) kills=$($process.MockKillCount)" + } + } + + Invoke-Case "roundtrip-fallback-uses-held-process" { + $daemonProcess = New-MockProcess 91004 + try { + throw [TimeoutException]::new("mock graceful shutdown timed out") + } catch { + & (Get-RoundTripFallback) + } + if ($script:pidStops.Count -ne 0 -or + $daemonProcess.MockKillCount -ne 1 -or $daemonProcess.MockWaitCount -ne 1) { + throw "expected a held-process kill and wait, zero PID stops; pid=$($script:pidStops.Count) kills=$($daemonProcess.MockKillCount)" + } + } + + Invoke-Case "scrubbed-unstarted-shell-does-not-block-daemon-cleanup" { + $shellProcess = [Diagnostics.Process]::new() + $shellProcess | Add-Member NoteProperty MockDisposed $false + $shellProcess | Add-Member ScriptMethod Dispose { $this.MockDisposed = $true } -Force + $daemonProcess = New-MockProcess 91006 + $shellStarted = $false + $shellHandleHeld = $false + $daemonStarted = $true + $daemonHandleHeld = $true + $root = "C:\mock-scrubbed-case" + $caseError = [InvalidOperationException]::new("shell did not start") + try { + & (Get-ScrubbedCleanup) + } catch { + throw "unstarted shell blocked daemon cleanup: $($_.Exception.Message)" + } + if (($daemonProcess.MockKillCount + $script:pidStops.Count) -ne 1 -or + $daemonProcess.MockWaitCount -ne 1) { + throw "expected the launched daemon to be stopped and waited after shell start failed; pidStops=$($script:pidStops.Count) kills=$($daemonProcess.MockKillCount) waits=$($daemonProcess.MockWaitCount)" + } + } + + Invoke-Case "gdiplus-unverified-exit-retains-fixture" { + $process = New-MockProcess 91007 + $process.MockWaitResult = $false + $started = $true + $handleHeld = $true + $scratch = "C:\mock-gdiplus-case" + $caseError = $null + $failure = $null + try { + & (Get-GdiplusCleanup) + } catch { + $failure = $_.Exception + } + if (-not $failure) { + throw "cleanup accepted an unverified exit; removed=$($script:removedPaths.Count)" + } + if ($script:removedPaths.Count -ne 0 -or + -not (Test-OwnedLiveProcessRetentionRequired $failure)) { + throw "unverified exit did not preserve the fixture and tag the failure" + } + } + + Invoke-Case "gdiplus-refuses-existing-scratch-without-removing" { + Invoke-ReceiptFixture { + param($root) + $scratch = Join-Path $root "existing-scratch" + [void][IO.Directory]::CreateDirectory($scratch) + try { + $failure = $null + try { & (Get-GdiplusScratchAdmission) } catch { $failure = $_.Exception } + if (-not $failure -or $script:removedPaths.Count -ne 0 -or + -not [IO.Directory]::Exists($scratch)) { + throw "existing GDI+ scratch was reused or removal was attempted" + } + } finally { + [IO.Directory]::Delete($scratch) + } + } + } + + Invoke-Case "scrubbed-refuses-existing-scratch-without-reusing" { + Invoke-ReceiptFixture { + param($root) + $ScratchRoot = $root + $name = "existing-case" + $action = "escape" + $existing = Join-Path $root $name + [void][IO.Directory]::CreateDirectory($existing) + $script:exclusiveCalls = 0 + function New-ValidationFixtureDirectory { $script:exclusiveCalls++ } + try { + $failure = $null + try { & (Get-ScrubbedScratchAdmission) } catch { $failure = $_.Exception } + if (-not $failure -or $script:exclusiveCalls -ne 0 -or + -not [IO.Directory]::Exists($existing)) { + throw "existing scrubbed-case scratch was reused" + } + } finally { + [IO.Directory]::Delete($existing) + } + } + } + + Invoke-Case "gdiplus-refuses-scratch-admission-collision" { + Invoke-ReceiptFixture { + param($root) + $scratch = Join-Path $root "collision" + function New-ValidationFixtureDirectory { + param($Path) + [void][IO.Directory]::CreateDirectory($Path) + throw "mock concurrent directory creation" + } + try { + $failure = $null + try { & (Get-GdiplusScratchAdmission) } catch { $failure = $_.Exception } + if (-not $failure -or -not (Test-OwnedLiveProcessRetentionRequired $failure) -or + $script:removedPaths.Count -ne 0 -or -not [IO.Directory]::Exists($scratch) -or + @($failure.Data["GraphCodeValidationRetainedRoots"]) -cnotcontains $scratch) { + throw "GDI+ admission collision was not retained and tagged" + } + } finally { + if ([IO.Directory]::Exists($scratch)) { [IO.Directory]::Delete($scratch) } + } + } + } + + Invoke-Case "scrubbed-refuses-scratch-admission-collision" { + Invoke-ReceiptFixture { + param($root) + $ScratchRoot = $root + $name = "collision" + $action = "escape" + $scratch = Join-Path $root $name + function New-ValidationFixtureDirectory { + param($Path) + [void][IO.Directory]::CreateDirectory($Path) + throw "mock concurrent directory creation" + } + try { + $failure = $null + try { & (Get-ScrubbedScratchAdmission) } catch { $failure = $_.Exception } + if (-not $failure -or -not (Test-OwnedLiveProcessRetentionRequired $failure) -or + $script:removedPaths.Count -ne 0 -or -not [IO.Directory]::Exists($scratch) -or + @($failure.Data["GraphCodeValidationRetainedRoots"]) -cnotcontains $scratch) { + throw "scrubbed admission collision was not retained and tagged" + } + } finally { + if ([IO.Directory]::Exists($scratch)) { [IO.Directory]::Delete($scratch) } + } + } + } + + foreach ($collision in @($false, $true)) { + Invoke-Case "roundtrip-refuses-existing-or-racing-scratch-$collision" { + Invoke-ReceiptFixture { + param($root) + $runRoot = Join-Path $root "roundtrip" + if (-not $collision) { [void][IO.Directory]::CreateDirectory($runRoot) } + function New-ValidationFixtureDirectory { + param($Path) + [void][IO.Directory]::CreateDirectory($Path) + throw "mock concurrent directory creation" + } + try { + $failure = $null + try { & (Get-RoundTripScratchAdmission) } catch { $failure = $_.Exception } + if (-not $failure -or -not (Test-OwnedLiveProcessRetentionRequired $failure) -or + $script:removedPaths.Count -ne 0 -or -not [IO.Directory]::Exists($runRoot) -or + @($failure.Data["GraphCodeValidationRetainedRoots"]) -cnotcontains $runRoot) { + throw "round-trip scratch collision was not retained and tagged" + } + } finally { + if ([IO.Directory]::Exists($runRoot)) { [IO.Directory]::Delete($runRoot) } + } + } + } + } + + foreach ($kind in @("gdiplus", "scrubbed", "roundtrip")) { + Invoke-Case "$kind-scratch-uses-exclusive-admission" { + Invoke-ReceiptFixture { + param($fixtureRoot) + $ScratchRoot = $fixtureRoot + $name = "exclusive-collision" + $action = "escape" + $scratch = Join-Path $fixtureRoot $name + $runRoot = $scratch + $marker = Join-Path $scratch "foreign.txt" + $script:exclusiveCalls = 0 + $script:exclusiveError = [ComponentModel.Win32Exception]::new( + 183, "controlled exclusive directory collision") + function New-ValidationFixtureDirectory { + param([string] $Path) + $script:exclusiveCalls++ + if ($Path -cne $scratch) { throw "exclusive creator received the wrong path" } + [void][IO.Directory]::CreateDirectory($Path) + [IO.File]::WriteAllText($marker, "foreign content") + throw $script:exclusiveError + } + function New-Item { + [CmdletBinding()] + param([string] $ItemType, [string] $Path) + throw "nonexclusive directory creation was attempted" + } + try { + $failure = $null + try { + switch ($kind) { + "gdiplus" { & (Get-GdiplusScratchAdmission) } + "scrubbed" { & (Get-ScrubbedScratchAdmission) } + "roundtrip" { & (Get-RoundTripScratchAdmission) } + } + } catch { + $failure = $_.Exception + } + if ($script:exclusiveCalls -ne 1) { + throw "scratch admission did not call the exclusive creator exactly once" + } + if (-not $failure -or -not (Test-OwnedLiveProcessRetentionRequired $failure) -or + $script:removedPaths.Count -ne 0 -or + @($failure.Data["GraphCodeValidationRetainedRoots"]) -cnotcontains $scratch) { + throw "exclusive admission failure was not retained and tagged" + } + $originalErrors = @($failure.InnerExceptions | Where-Object { + [object]::ReferenceEquals($_, $script:exclusiveError) + }) + if ($originalErrors.Count -ne 1 -or + [IO.File]::ReadAllText($marker) -cne "foreign content") { + throw "exclusive admission lost its error or changed the foreign fixture" + } + } finally { + if ([IO.File]::Exists($marker)) { [IO.File]::Delete($marker) } + if ([IO.Directory]::Exists($scratch)) { [IO.Directory]::Delete($scratch) } + } + } + } + + Invoke-Case "$kind-scratch-continues-after-exclusive-admission" { + Invoke-ReceiptFixture { + param($fixtureRoot) + $ScratchRoot = $fixtureRoot + $name = "exclusive-success" + $action = "escape" + $scratch = Join-Path $fixtureRoot $name + $runRoot = $scratch + $script:exclusiveCalls = 0 + $script:nestedCalls = 0 + function New-ValidationFixtureDirectory { + param([string] $Path) + if ($Path -cne $scratch) { throw "exclusive creator received the wrong path" } + $script:exclusiveCalls++ + return [pscustomobject]@{ Path = $Path } + } + function New-Item { + [CmdletBinding()] + param([string] $ItemType, [string[]] $Path, [switch] $Force) + if ($script:exclusiveCalls -ne 1) { + throw "nested fixture setup began before exclusive admission" + } + $script:nestedCalls++ + } + switch ($kind) { + "gdiplus" { & (Get-GdiplusScratchAdmission) } + "scrubbed" { & (Get-ScrubbedScratchAdmission) } + "roundtrip" { & (Get-RoundTripScratchAdmission) } + } + $expectedNestedCalls = if ($kind -eq "scrubbed") { 1 } else { 0 } + if ($script:exclusiveCalls -ne 1 -or $script:nestedCalls -ne $expectedNestedCalls -or + $script:removedPaths.Count -ne 0) { + throw "successful exclusive admission did not continue to fixture setup" + } + } + } + + Invoke-Case "$kind-scratch-preserves-exclusive-creation-error" { + Invoke-ReceiptFixture { + param($fixtureRoot) + $ScratchRoot = $fixtureRoot + $name = "exclusive-failure" + $action = "escape" + $scratch = Join-Path $fixtureRoot $name + $runRoot = $scratch + $script:exclusiveCalls = 0 + $script:exclusiveError = [UnauthorizedAccessException]::new( + "controlled exclusive directory creation failure") + function New-ValidationFixtureDirectory { + param([string] $Path) + if ($Path -cne $scratch) { throw "exclusive creator received the wrong path" } + $script:exclusiveCalls++ + throw $script:exclusiveError + } + function New-Item { + [CmdletBinding()] + param([string] $ItemType, [string] $Path) + throw "nonexclusive directory creation was attempted" + } + $failure = $null + try { + switch ($kind) { + "gdiplus" { & (Get-GdiplusScratchAdmission) } + "scrubbed" { & (Get-ScrubbedScratchAdmission) } + "roundtrip" { & (Get-RoundTripScratchAdmission) } + } + } catch { + $failure = $_.Exception + } + if ($script:exclusiveCalls -ne 1 -or + -not [object]::ReferenceEquals($failure, $script:exclusiveError) -or + $script:removedPaths.Count -ne 0 -or (Test-Path -LiteralPath $scratch)) { + throw "exclusive creation failure was changed or followed by fixture cleanup" + } + } + } + } + + Invoke-Case "scratch-consumers-import-exclusive-admission-helper" { + foreach ($file in @( + "GdiplusStartup.Live.Tests.ps1", + "ScrubbedShellStartup.Live.Tests.ps1", + "DaemonRoundTrip.Live.Tests.ps1" + )) { + $source = [IO.File]::ReadAllText((Join-Path $testsRoot $file)) + if (-not $source.Contains( + '. (Join-Path $PSScriptRoot "ValidationFixtureDirectory.ps1")')) { + throw "$file does not import the exclusive admission helper" + } + } + } + + Invoke-Case "gdiplus-creates-missing-scratch-parent" { + Invoke-ReceiptFixture { + param($fixtureRoot) + $parent = Join-Path $fixtureRoot "parent" + $scratch = Join-Path $parent "child" + $script:exclusiveCalls = 0 + function New-ValidationFixtureDirectory { + param([string] $Path) + if ($Path -cne $scratch -or -not [IO.Directory]::Exists($parent)) { + throw "exclusive creation was attempted before the scratch parent existed" + } + $script:exclusiveCalls++ + return [pscustomobject]@{ Path = $Path } + } + try { + & (Get-GdiplusScratchAdmission) + if ($script:exclusiveCalls -ne 1 -or $script:removedPaths.Count -ne 0) { + throw "GDI+ did not create its parent before exclusive scratch admission" + } + } finally { + if ([IO.Directory]::Exists($parent)) { [IO.Directory]::Delete($parent) } + } + } + } + + Invoke-Case "scrubbed-cli-completes-before-output-read" { + $script:cliProcess = New-MockProcess 91033 + $script:cliProcess | Add-Member NoteProperty Handle ([IntPtr]1) -Force + $script:cliProcess | Add-Member NoteProperty ExitCode 0 -Force + $script:cliProcess | Add-Member ScriptMethod WaitForExit { + param([int] $milliseconds) + $this.MockWaitCount++ + $this.HasExited = $true + return $true + } -Force + $script:cliCompleted = $false + $script:cliInfo = $null + $jobScope = [pscustomobject]@{ Name = "mock job" } + $Cli = "C:\mock\graphcode.exe" + function New-StartInfo([string] $file, [string] $root) { + $info = [Diagnostics.ProcessStartInfo]::new() + $info.FileName = $file + $info.UseShellExecute = $false + return $info + } + function Start-ShellOwnedProcess($Scope, $StartInfo) { + if (-not [object]::ReferenceEquals($Scope, $jobScope)) { throw "wrong CLI scope" } + $script:cliInfo = $StartInfo + return $script:cliProcess + } + function Complete-ShellOwnedProcess($Scope, $Process) { + if (-not [object]::ReferenceEquals($Scope, $jobScope) -or + -not [object]::ReferenceEquals($Process, $script:cliProcess) -or + -not $Process.HasExited) { throw "CLI completed before its root exited" } + $script:cliCompleted = $true + } + function Read-ShellOwnedOutput($Scope, $Process, [switch] $StandardError) { + if (-not $script:cliCompleted) { throw "CLI output read preceded completion" } + if ($StandardError) { return "cli stderr" } + return "cli stdout" + } + . (Import-SourceFunction "ScrubbedShellStartup.Live.Tests.ps1" "Invoke-ScrubbedCli") + $result = Invoke-ScrubbedCli $jobScope "case" "C:\mock\root" "C:\mock\support" @("projects") + if (-not $script:cliCompleted -or -not $script:cliProcess.MockDisposed -or + $script:cliProcess.MockKillCount -ne 0 -or $result.ExitCode -ne 0 -or + $result.Output -cne "cli stdout" -or $result.Error -cne "cli stderr" -or + $script:cliInfo.Environment["GRAPHCODE_SUPPORT_DIR"] -cne "C:\mock\support" -or + @($script:cliInfo.ArgumentList).Count -ne 1 -or + $script:cliInfo.ArgumentList[0] -cne "projects") { + throw "scrubbed CLI lost launch arguments, finalization, output or cleanup" + } + } + + Invoke-Case "roundtrip-daemon-completes-after-graceful-exit" { + $daemon = New-MockProcess 91034 + $daemon | Add-Member NoteProperty ExitCode 0 -Force + $daemon | Add-Member ScriptMethod Refresh { } -Force + $daemon | Add-Member ScriptMethod WaitForExit { + param([int] $milliseconds) + $this.MockWaitCount++ + $this.HasExited = $true + return $true + } -Force + $shutdownEvent = [pscustomobject]@{} + $shutdownEvent | Add-Member ScriptMethod Set { return $true } + $processScope = [pscustomobject]@{ Name = "mock job" } + $script:runLog = [Collections.Generic.List[string]]::new() + $script:daemonCompleted = $false + function Complete-ShellOwnedProcess($Scope, $Process) { + if (-not [object]::ReferenceEquals($Scope, $processScope) -or + -not $Process.HasExited -or $Process.MockWaitCount -ne 1) { + throw "daemon completion did not follow graceful root exit" + } + $script:daemonCompleted = $true + } + function Read-ShellOwnedOutput($Scope, $Process, [switch] $StandardError) { + if (-not $script:daemonCompleted) { throw "daemon output read preceded completion" } + if ($StandardError) { return " stderr " } + return " stdout " + } + . (Import-SourceFunction "DaemonRoundTrip.Live.Tests.ps1" "Stop-OwnedDaemon") + Stop-OwnedDaemon $daemon + if (-not $script:daemonCompleted -or $daemon.MockKillCount -ne 0 -or + $script:runLog.Count -ne 3 -or $script:runLog[1] -cne "daemon-stdout=stdout" -or + $script:runLog[2] -cne "daemon-stderr=stderr") { + throw "round-trip daemon finalization lost graceful exit or output" + } + } + + Invoke-Case "owned-command-retains-scope-arguments-and-output" { + $jobScope = [pscustomobject]@{ Name = "mock job" } + $info = [Diagnostics.ProcessStartInfo]::new() + $info.FileName = "C:\mock\zig.exe" + $info.UseShellExecute = $false + [void]$info.ArgumentList.Add("test") + $script:commandProcess = New-MockProcess 91030 + $script:commandProcess | Add-Member NoteProperty ExitCode 7 -Force + $script:commandScope = $null + $script:commandInfo = $null + function Start-ShellOwnedProcess($Scope, $StartInfo) { + $script:commandScope = $Scope + $script:commandInfo = $StartInfo + return $script:commandProcess + } + function Complete-ShellOwnedProcess($Scope, $Process) { + if (-not [object]::ReferenceEquals($Scope, $jobScope) -or + -not [object]::ReferenceEquals($Process, $script:commandProcess) -or + $Process.MockWaitCount -ne 1) { + throw "completion did not follow the owned root exit" + } + $script:commandCompleted = $true + } + function Receive-ShellOwnedOutput { throw "buffered command unexpectedly requested a stream chunk" } + function Read-ShellOwnedOutput($Scope, $Process, [switch] $StandardError) { + if (-not [object]::ReferenceEquals($Scope, $jobScope) -or + -not [object]::ReferenceEquals($Process, $script:commandProcess) -or + -not $script:commandCompleted) { + throw "output read was not bound to the owned launch" + } + if ($StandardError) { return "mock stderr" } + return "mock stdout" + } + $script:commandCompleted = $false + $result = Invoke-OwnedLiveCommand -Scope $jobScope -StartInfo $info + if (-not [object]::ReferenceEquals($script:commandScope, $jobScope) -or + -not [object]::ReferenceEquals($script:commandInfo, $info) -or + $script:commandProcess.MockWaitCount -ne 1 -or $result.ExitCode -ne 7 -or + $result.Output -cne "mock stdout" -or $result.Error -cne "mock stderr" -or + -not $info.RedirectStandardOutput -or -not $info.RedirectStandardError) { + throw "owned command lost its launch, output, or exit status" + } + } + + Invoke-Case "owned-command-streams-before-exit-and-drains-after-completion" { + $jobScope = [pscustomobject]@{ Name = "mock job" } + $info = [Diagnostics.ProcessStartInfo]::new() + $info.UseShellExecute = $false + $script:commandProcess = New-MockProcess 91036 + $script:commandProcess | Add-Member NoteProperty ExitCode 0 -Force + $script:commandEvents = [Collections.Generic.List[string]]::new() + $script:receiveCount = 0 + $script:commandCompleted = $false + $script:commandProcess | Add-Member ScriptMethod WaitForExit { + param($milliseconds = $null) + if ($null -eq $milliseconds) { + $script:commandEvents.Add("wait:blocking") + $this.HasExited = $true + return + } + $script:commandEvents.Add("wait:$milliseconds") + if ($milliseconds -eq 100) { + $this.HasExited = $true + return $true + } + return $false + } -Force + function Start-ShellOwnedProcess($Scope, $StartInfo) { return $script:commandProcess } + function Complete-ShellOwnedProcess($Scope, $Process) { + if (-not $Process.HasExited) { throw "completion preceded root exit" } + $script:commandEvents.Add("complete") + $script:commandCompleted = $true + } + function Receive-ShellOwnedOutput($Scope, $Process, [int] $MaximumCharacters) { + if (-not [object]::ReferenceEquals($Scope, $jobScope) -or + -not [object]::ReferenceEquals($Process, $script:commandProcess) -or + $MaximumCharacters -ne 65536) { + throw "stream read lost its scope, process or bound" + } + $script:receiveCount++ + $script:commandEvents.Add("receive:$script:receiveCount") + switch ($script:receiveCount) { + 1 { return [pscustomobject]@{ Output = "early stdout"; Error = "early stderr" } } + 2 { return [pscustomobject]@{ Output = ""; Error = "" } } + 3 { + if (-not $script:commandCompleted) { throw "late drain preceded completion" } + return [pscustomobject]@{ Output = "late stdout"; Error = "late stderr" } + } + 4 { return [pscustomobject]@{ Output = ""; Error = "" } } + default { throw "unexpected extra stream read" } + } + } + function Read-ShellOwnedOutput($Scope, $Process, [switch] $StandardError) { + if (-not $script:commandCompleted -or $script:receiveCount -ne 4) { + throw "final output preceded completed stream drainage" + } + if ($StandardError) { return "complete stderr" } + return "complete stdout" + } + $previousOutput = [Console]::Out + $previousError = [Console]::Error + $output = [IO.StringWriter]::new() + $error = [IO.StringWriter]::new() + try { + [Console]::SetOut($output) + [Console]::SetError($error) + $result = Invoke-OwnedLiveCommand -Scope $jobScope -StartInfo $info -StreamOutput + $outputText = $output.ToString() + $errorText = $error.ToString() + } finally { + [Console]::SetOut($previousOutput) + [Console]::SetError($previousError) + $output.Dispose() + $error.Dispose() + } + if ($outputText -cne "early stdoutlate stdout" -or + $errorText -cne "early stderrlate stderr" -or + $result.Output -cne "complete stdout" -or $result.Error -cne "complete stderr" -or + ($script:commandEvents -join ",") -cne + "receive:1,wait:0,receive:2,wait:100,complete,receive:3,receive:4") { + throw "streamed output was delayed, duplicated, reordered or incompletely drained" + } + } + + Invoke-Case "owned-command-stream-surfaces-pump-failure" { + $info = [Diagnostics.ProcessStartInfo]::new() + $info.UseShellExecute = $false + $script:commandProcess = New-MockProcess 91037 + $script:completionCalls = 0 + $script:finalReads = 0 + function Start-ShellOwnedProcess($Scope, $StartInfo) { return $script:commandProcess } + function Receive-ShellOwnedOutput { throw "mock incremental output pump failed" } + function Complete-ShellOwnedProcess { $script:completionCalls++ } + function Read-ShellOwnedOutput { $script:finalReads++ } + $failure = $null + try { Invoke-OwnedLiveCommand -Scope ([pscustomobject]@{}) -StartInfo $info -StreamOutput } catch { + $failure = $_.Exception + } + if (-not $failure -or $failure.Message -notmatch "mock incremental output pump failed" -or + $script:completionCalls -ne 0 -or $script:finalReads -ne 0) { + throw "incremental pump failure was hidden or treated as finalized output" + } + } + + Invoke-Case "owned-command-stream-preserves-split-surrogate-pairs" { + $info = [Diagnostics.ProcessStartInfo]::new() + $info.UseShellExecute = $false + $script:commandProcess = New-MockProcess 91041 + $script:commandProcess | Add-Member NoteProperty ExitCode 0 -Force + $script:commandProcess | Add-Member ScriptMethod WaitForExit { + param([int] $milliseconds) + if ($milliseconds -eq 100) { + $this.HasExited = $true + return $true + } + return $false + } -Force + $script:receiveCount = 0 + function Start-ShellOwnedProcess($Scope, $StartInfo) { return $script:commandProcess } + function Complete-ShellOwnedProcess { } + function Receive-ShellOwnedOutput { + $script:receiveCount++ + switch ($script:receiveCount) { + 1 { return [pscustomobject]@{ Output = [string][char]0xD83D; Error = [string][char]0xD83D } } + 2 { return [pscustomobject]@{ Output = ""; Error = "" } } + 3 { return [pscustomobject]@{ Output = [string][char]0xDE80; Error = [string][char]0xDE80 } } + 4 { return [pscustomobject]@{ Output = ""; Error = "" } } + default { throw "unexpected extra stream read" } + } + } + function Read-ShellOwnedOutput { return "complete capture" } + $previousOutput = [Console]::Out + $previousError = [Console]::Error + $outputBytes = [IO.MemoryStream]::new() + $errorBytes = [IO.MemoryStream]::new() + $encoding = [Text.UTF8Encoding]::new($false) + $output = [IO.StreamWriter]::new($outputBytes, $encoding, 1024, $true) + $error = [IO.StreamWriter]::new($errorBytes, $encoding, 1024, $true) + try { + [Console]::SetOut($output) + [Console]::SetError($error) + [void](Invoke-OwnedLiveCommand -Scope ([pscustomobject]@{}) -StartInfo $info -StreamOutput) + $output.Flush() + $error.Flush() + $stdout = $encoding.GetString($outputBytes.ToArray()) + $stderr = $encoding.GetString($errorBytes.ToArray()) + } finally { + [Console]::SetOut($previousOutput) + [Console]::SetError($previousError) + $output.Dispose() + $error.Dispose() + $outputBytes.Dispose() + $errorBytes.Dispose() + } + $expected = [string][char]0xD83D + [string][char]0xDE80 + if ($stdout -cne $expected -or $stderr -cne $expected) { + throw "a split UTF-16 surrogate pair was corrupted while forwarding output" + } + } + + Invoke-Case "owned-command-stream-flushes-final-unpaired-surrogate" { + $info = [Diagnostics.ProcessStartInfo]::new() + $info.UseShellExecute = $false + $script:commandProcess = New-MockProcess 91042 + $script:commandProcess | Add-Member NoteProperty ExitCode 0 -Force + $script:commandProcess | Add-Member ScriptMethod WaitForExit { + param([int] $milliseconds) + if ($milliseconds -eq 100) { + $this.HasExited = $true + return $true + } + return $false + } -Force + $script:receiveCount = 0 + function Start-ShellOwnedProcess($Scope, $StartInfo) { return $script:commandProcess } + function Complete-ShellOwnedProcess { } + function Receive-ShellOwnedOutput { + $script:receiveCount++ + if ($script:receiveCount -eq 1) { + return [pscustomobject]@{ Output = [string][char]0xD83D; Error = "" } + } + return [pscustomobject]@{ Output = ""; Error = "" } + } + function Read-ShellOwnedOutput { return "complete capture" } + $previousOutput = [Console]::Out + $outputBytes = [IO.MemoryStream]::new() + $encoding = [Text.UTF8Encoding]::new($false) + $output = [IO.StreamWriter]::new($outputBytes, $encoding, 1024, $true) + try { + [Console]::SetOut($output) + [void](Invoke-OwnedLiveCommand -Scope ([pscustomobject]@{}) -StartInfo $info -StreamOutput) + $output.Flush() + $stdout = $encoding.GetString($outputBytes.ToArray()) + } finally { + [Console]::SetOut($previousOutput) + $output.Dispose() + $outputBytes.Dispose() + } + if ($stdout -cne [string][char]0xFFFD) { + throw "a final unpaired surrogate was silently discarded" + } + } + + Invoke-Case "owned-command-stream-surfaces-final-drain-failure" { + $info = [Diagnostics.ProcessStartInfo]::new() + $info.UseShellExecute = $false + $script:commandProcess = New-MockProcess 91038 + $script:commandProcess | Add-Member ScriptMethod WaitForExit { + param([int] $milliseconds) + $this.HasExited = $true + return $true + } -Force + $script:completed = $false + $script:finalReads = 0 + function Start-ShellOwnedProcess($Scope, $StartInfo) { return $script:commandProcess } + function Receive-ShellOwnedOutput { + if ($script:completed) { throw "mock final incremental drain failed" } + return [pscustomobject]@{ Output = ""; Error = "" } + } + function Complete-ShellOwnedProcess { $script:completed = $true } + function Read-ShellOwnedOutput { $script:finalReads++ } + $failure = $null + try { Invoke-OwnedLiveCommand -Scope ([pscustomobject]@{}) -StartInfo $info -StreamOutput } catch { + $failure = $_.Exception + } + if (-not $failure -or $failure.Message -notmatch "mock final incremental drain failed" -or + -not $script:completed -or $script:finalReads -ne 0) { + throw "final incremental drain failure was hidden or followed by a full output read" + } + } + + foreach ($invalid in @( + @{ Name = "missing"; Chunk = $null }, + @{ Name = "null-output"; Chunk = [pscustomobject]@{ Output = $null; Error = "" } }, + @{ Name = "nonstring-error"; Chunk = [pscustomobject]@{ Output = ""; Error = 1 } }, + @{ Name = "oversized-output"; Chunk = [pscustomobject]@{ Output = [string]::new([char]"x", 65537); Error = "" } }, + @{ Name = "oversized-error"; Chunk = [pscustomobject]@{ Output = ""; Error = [string]::new([char]"x", 65537) } })) { + Invoke-Case "owned-stream-rejects-invalid-chunk-$($invalid.Name)" { + $script:streamChunk = $invalid.Chunk + $process = New-MockProcess 91039 + $pending = @{ Output = ""; Error = "" } + function Receive-ShellOwnedOutput { return $script:streamChunk } + $result = Invoke-CapturingConsole { + try { + [void](Write-OwnedLiveOutputChunk -Scope ([pscustomobject]@{}) -Process $process -Pending $pending) + return $null + } catch { return $_.Exception } + } + if (-not $result.Value -or + $result.Value.Message -notmatch "invalid or oversized chunk" -or + $result.Text.Length -ne 0) { + throw "invalid incremental output was accepted or partially written" + } + } + } + + Invoke-Case "owned-stream-accepts-exact-chunk-limit" { + $script:streamChunk = [pscustomobject]@{ Output = [string]::new([char]"x", 65536); Error = "" } + $process = New-MockProcess 91040 + $pending = @{ Output = ""; Error = "" } + function Receive-ShellOwnedOutput { return $script:streamChunk } + $result = Invoke-CapturingConsole { + Write-OwnedLiveOutputChunk -Scope ([pscustomobject]@{}) -Process $process -Pending $pending + } + if ($result.Value -ne $true -or $result.Text.Length -ne 65536 -or + $result.Text -cne $script:streamChunk.Output) { + throw "a chunk at the exact limit was not forwarded intact" + } + } + + Invoke-Case "gdiplus-build-selects-streaming-without-reprinting-final-output" { + $source = [IO.File]::ReadAllText((Join-Path $testsRoot "GdiplusStartup.Live.Tests.ps1")) + if ($source -notmatch '\$build\s*=\s*Invoke-OwnedLiveCommand\b[^\r\n]*-StreamOutput' -or + $source -match '\[Console\]::(?:Out|Error)\.Write\(\$build\.(?:Output|Error)\)') { + throw "GDI+ build must stream during the owned run without reprinting its final capture" + } + } + + Invoke-Case "owned-command-surfaces-output-read-failure" { + $info = [Diagnostics.ProcessStartInfo]::new() + $info.UseShellExecute = $false + $script:commandProcess = New-MockProcess 91031 + $script:commandProcess | Add-Member NoteProperty ExitCode 0 -Force + function Start-ShellOwnedProcess($Scope, $StartInfo) { return $script:commandProcess } + function Complete-ShellOwnedProcess($Scope, $Process) { } + function Read-ShellOwnedOutput { throw "mock unproved output EOF" } + $failure = $null + try { Invoke-OwnedLiveCommand -Scope ([pscustomobject]@{}) -StartInfo $info } catch { + $failure = $_.Exception + } + if (-not $failure -or $failure.Message -notmatch "mock unproved output EOF" -or + $script:commandProcess.MockWaitCount -ne 1) { + throw "owned command did not surface the output read failure" + } + } + + Invoke-Case "owned-command-surfaces-launch-failure" { + $info = [Diagnostics.ProcessStartInfo]::new() + $info.UseShellExecute = $false + function Start-ShellOwnedProcess { throw "mock refused owned launch" } + $failure = $null + try { Invoke-OwnedLiveCommand -Scope ([pscustomobject]@{}) -StartInfo $info } catch { + $failure = $_.Exception + } + if (-not $failure -or $failure.Message -notmatch "mock refused owned launch") { + throw "owned command did not surface the launch failure" + } + } + + Invoke-Case "owned-command-surfaces-completion-failure-before-output" { + $info = [Diagnostics.ProcessStartInfo]::new() + $info.UseShellExecute = $false + $script:commandProcess = New-MockProcess 91032 + $script:commandProcess | Add-Member NoteProperty ExitCode 0 -Force + $script:commandReads = 0 + function Start-ShellOwnedProcess($Scope, $StartInfo) { return $script:commandProcess } + function Complete-ShellOwnedProcess { throw "mock unproved descendant cleanup" } + function Read-ShellOwnedOutput { $script:commandReads++ } + $failure = $null + try { Invoke-OwnedLiveCommand -Scope ([pscustomobject]@{}) -StartInfo $info } catch { + $failure = $_.Exception + } + if (-not $failure -or $failure.Message -notmatch "mock unproved descendant cleanup" -or + $script:commandProcess.MockWaitCount -ne 1 -or $script:commandReads -ne 0) { + throw "owned command read output after an unproved launch completion" + } + } + + foreach ($fileName in @( + "ScrubbedShellStartup.Live.Tests.ps1", + "GdiplusStartup.Live.Tests.ps1", + "DaemonRoundTrip.Live.Tests.ps1")) { + Invoke-Case "job-owned-live-launches-$fileName" { + $source = [IO.File]::ReadAllText((Join-Path $testsRoot $fileName)) + if ($source -notmatch 'ShellProcessOwnership\.psm1' -or + $source -notmatch 'New-ShellProcessScope' -or + $source -notmatch 'Start-ShellOwnedProcess' -or + $source -notmatch 'Close-ShellProcessScope') { + throw "live runner does not use a pre-launch owned job through cleanup" + } + } + } + + Invoke-Case "job-contains-gdiplus-build" { + $source = [IO.File]::ReadAllText((Join-Path $testsRoot "GdiplusStartup.Live.Tests.ps1")) + if ($source -match '&\s*\$ZigExecutable\s+build-exe' -or + $source -notmatch 'Invoke-OwnedLiveCommand') { + throw "GDI+ build is not launched through the owned job" + } + } + + Invoke-Case "job-contains-roundtrip-tests" { + $source = [IO.File]::ReadAllText((Join-Path $testsRoot "DaemonRoundTrip.Live.Tests.ps1")) + if ($source -match '&\s*\$ZigExecutable\s+test' -or + $source -notmatch 'Invoke-OwnedLiveCommand') { + throw "daemon round-trip Zig tests are not launched through the owned job" + } + } + + foreach ($successBoundary in @( + @{ File = "GdiplusStartup.Live.Tests.ps1"; Cleanup = "GraphCodeGdiplusStartupTest"; Pass = "GDIPLUS_STARTUP_LIVE: PASS" }, + @{ File = "DaemonRoundTrip.Live.Tests.ps1"; Cleanup = "Round-trip daemon cleanup could not be verified"; Pass = "REAL_DAEMON_ROUNDTRIP: PASS" })) { + Invoke-Case "pass-follows-job-cleanup-$($successBoundary.File)" { + $ast = Get-ParsedSource $successBoundary.File + $blocks = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.TryStatementAst] -and + $null -ne $node.Finally -and + $node.Extent.Text.Contains($successBoundary.Cleanup) + }, $true)) + $passOffset = $ast.Extent.Text.IndexOf($successBoundary.Pass, [StringComparison]::Ordinal) + if ($blocks.Count -ne 1 -or $passOffset -le $blocks[0].Extent.EndOffset) { + throw "live PASS was reported before cleanup finished" + } + } + } + + Invoke-Case "scrubbed-closes-job-before-root-removal" { + $shellProcess = New-MockProcess 91027 + $daemonProcess = New-MockProcess 91028 + $shellStarted = $daemonStarted = $false + $shellHandleHeld = $daemonHandleHeld = $false + $processScope = [pscustomobject]@{ Name = "mock job" } + $root = "C:\mock-scrubbed-case" + $caseError = $null + & (Get-ScrubbedCleanup) + if ($script:scopeCloseCalls -ne 1 -or + ($script:cleanupEvents -join ",") -cne "close-job,remove") { + throw "scrubbed fixture was removed before verified job cleanup" + } + } + + Invoke-Case "gdiplus-job-close-failure-retains-root" { + $process = New-MockProcess 91029 + $process.HasExited = $true + $started = $true + $handleHeld = $true + $processScope = [pscustomobject]@{ Name = "mock job" } + $scratch = "C:\mock-gdiplus-case" + $caseError = [InvalidOperationException]::new("mock primary failure") + $script:scopeCloseFailure = $true + $failure = $null + try { & (Get-GdiplusCleanup) } catch { $failure = $_.Exception } + if (-not $failure -or -not (Test-OwnedLiveProcessRetentionRequired $failure) -or + $script:scopeCloseCalls -ne 1 -or $script:removedPaths.Count -ne 0 -or + -not [object]::ReferenceEquals($failure.InnerExceptions[0], $caseError)) { + throw "GDI+ job failure did not preserve the primary error and root" + } + } + + Invoke-Case "roundtrip-job-close-failure-retains-root" { + Invoke-ReceiptFixture { + param($root) + $target = Join-Path $root "target" + [void][IO.Directory]::CreateDirectory($target) + $daemonProcess = $null + $processScope = [pscustomobject]@{ Name = "mock job" } + $shutdownEvent = $null + $roundtripError = [InvalidOperationException]::new("mock primary failure") + $successful = $true + $runRoot = $target + $evidenceDirectory = $root + $nonce = "test" + $runLog = [Collections.Generic.List[string]]::new() + $script:scopeCloseFailure = $true + $failure = $null + try { $null = & (Get-RoundTripCleanup) } catch { $failure = $_.Exception } + if (-not $failure -or -not (Test-OwnedLiveProcessRetentionRequired $failure) -or + $script:scopeCloseCalls -ne 1 -or + @($script:removedPaths | Where-Object { $_ -ceq $runRoot }).Count -ne 0 -or + -not [object]::ReferenceEquals($failure.InnerExceptions[0], $roundtripError)) { + throw "round-trip job failure did not preserve the primary error and root" + } + } + } + + Invoke-Case "roundtrip-fallback-waits-for-exit" { + $daemonProcess = New-MockProcess 91008 + try { + throw [TimeoutException]::new("mock graceful shutdown timed out") + } catch { + & (Get-RoundTripFallback) + } + if ($daemonProcess.MockWaitCount -ne 1) { + throw "forced fallback did not wait for the launched daemon to exit" + } + } + + Invoke-Case "roundtrip-startup-failure-stops-launched-daemon" { + $ast = Get-ParsedSource "DaemonRoundTrip.Live.Tests.ps1" + $function = $ast.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq "Start-OwnedDaemon" + }, $true) + if (-not $function) { throw "Round-trip daemon startup function not found" } + $source = $function.Extent.Text + if ($source -notmatch 'Start-ShellOwnedProcess') { + throw "Expected an owned daemon launch in the startup function" + } + $script:startupMockProcess = New-MockProcess 91009 + $script:startupMockProcess | Add-Member ScriptMethod Start { return $true } -Force + $script:startupMockProcess | Add-Member ScriptMethod Refresh { } -Force + $script:startupMockProcess | Add-Member NoteProperty Handle ([IntPtr]1) -Force + $script:startupMockProcess | Add-Member NoteProperty StandardOutput ( + [IO.StringReader]::new("")) -Force + $script:startupMockProcess | Add-Member NoteProperty StandardError ( + [IO.StringReader]::new("")) -Force + $script:shutdownEvent = [pscustomobject]@{} + $script:shutdownEvent | Add-Member ScriptMethod Reset { return $true } + $expectedDaemonPath = "C:\mock\graphcoded.exe" + $supportDirectory = "C:\mock\support" + $daemonPipe = "\\.\pipe\mock-daemon" + $shutdownEventName = "mock-shutdown" + $runRoot = "C:\mock-roundtrip-case" + $processScope = [pscustomobject]@{ Name = "mock job" } + function Start-ShellOwnedProcess($Scope, $StartInfo) { return $script:startupMockProcess } + function Get-OwnedDaemon([int] $processId) { throw "mock process discovery failure" } + . ([scriptblock]::Create($source)) + $failure = $null + try { + Start-OwnedDaemon + } catch { + $failure = $_.Exception + } + if (-not $failure -or $failure.Message -notmatch "mock process discovery failure") { + throw "startup did not preserve the discovery failure" + } + if ($script:startupMockProcess.MockKillCount -ne 1 -or + $script:startupMockProcess.MockWaitCount -ne 1 -or + -not $script:startupMockProcess.MockDisposed) { + throw "launched daemon was not stopped, waited, and disposed after startup failure" + } + } + + Invoke-Case "helper-does-not-kill-exited-process" { + $process = New-MockProcess 91010 + $process.HasExited = $true + Stop-OwnedLiveProcess -Process $process -Name "mock" + if ($process.MockKillCount -ne 0 -or $process.MockWaitCount -ne 0) { + throw "already-exited process was killed or waited" + } + } + + Invoke-Case "helper-surfaces-kill-failure-while-live" { + $process = New-MockProcess 91011 + $process | Add-Member ScriptMethod Kill { + $this.MockKillCount++ + throw "mock kill failure" + } -Force + $failure = $null + try { Stop-OwnedLiveProcess -Process $process -Name "mock" } catch { $failure = $_.Exception } + if (-not $failure -or $failure.Message -notmatch "mock kill failure" -or + $process.MockKillCount -ne 1 -or $process.MockWaitCount -ne 0) { + throw "live process kill failure was not preserved" + } + } + + Invoke-Case "helper-allows-confirmed-concurrent-exit" { + $process = New-MockProcess 91012 + $process | Add-Member ScriptMethod Kill { + $this.MockKillCount++ + $this.HasExited = $true + throw "mock process exited during kill" + } -Force + Stop-OwnedLiveProcess -Process $process -Name "mock" + if ($process.MockKillCount -ne 1 -or $process.MockWaitCount -ne 1) { + throw "confirmed concurrent exit was not waited" + } + } + + Invoke-Case "helper-rejects-wait-timeout" { + $process = New-MockProcess 91013 + $process.MockWaitResult = $false + $failure = $null + try { Stop-OwnedLiveProcess -Process $process -Name "mock" } catch { $failure = $_.Exception } + if (-not $failure -or $failure.Message -notmatch "did not exit" -or + $process.MockKillCount -ne 1 -or $process.MockWaitCount -ne 1) { + throw "wait timeout was not surfaced" + } + } + + Invoke-Case "scrubbed-unattested-shell-leaves-it-alone-and-cleans-daemon" { + $shellProcess = New-MockProcess 91014 + $daemonProcess = New-MockProcess 91015 + $shellStarted = $daemonStarted = $true + $shellHandleHeld = $false + $daemonHandleHeld = $true + $root = "C:\mock-scrubbed-case" + $caseError = [InvalidOperationException]::new("mock initial case failure") + $failure = $null + try { & (Get-ScrubbedCleanup) } catch { $failure = $_.Exception } + if (-not $failure -or -not (Test-OwnedLiveProcessRetentionRequired $failure) -or + $shellProcess.MockKillCount -ne 0 -or $daemonProcess.MockKillCount -ne 1 -or + $daemonProcess.MockWaitCount -ne 1 -or $script:removedPaths.Count -ne 0) { + throw "unattested shell was acted on or daemon/retention cleanup was skipped" + } + if ($failure.InnerExceptions[0].Message -ne "mock initial case failure") { + throw "initial case failure was not preserved first" + } + } + + Invoke-Case "scrubbed-continues-after-first-cleanup-failure" { + $shellProcess = New-MockProcess 91016 + $shellProcess | Add-Member ScriptMethod Kill { + $this.MockKillCount++ + throw "mock shell kill failure" + } -Force + $daemonProcess = New-MockProcess 91017 + $shellStarted = $daemonStarted = $true + $shellHandleHeld = $daemonHandleHeld = $true + $root = "C:\mock-scrubbed-case" + $caseError = [InvalidOperationException]::new("mock primary failure") + $failure = $null + try { & (Get-ScrubbedCleanup) } catch { $failure = $_.Exception } + if (-not $failure -or -not (Test-OwnedLiveProcessRetentionRequired $failure) -or + $daemonProcess.MockKillCount -ne 1 -or $daemonProcess.MockWaitCount -ne 1 -or + -not $shellProcess.MockDisposed -or -not $daemonProcess.MockDisposed -or + $script:removedPaths.Count -ne 0) { + throw "first cleanup failure prevented daemon cleanup or root retention" + } + if ($failure.InnerExceptions[0].Message -ne "mock primary failure") { + throw "primary failure was not preserved first" + } + } + + Invoke-Case "scrubbed-tagged-inner-failure-retains-fixture" { + $shellProcess = New-MockProcess 91020 + $daemonProcess = New-MockProcess 91021 + $shellStarted = $false + $shellHandleHeld = $false + $daemonStarted = $true + $daemonHandleHeld = $true + $root = "C:\mock-scrubbed-case" + $caseError = New-OwnedLiveProcessCleanupError ` + -Message "mock CLI ownership failure" -Errors @([InvalidOperationException]::new("mock")) + $null = & (Get-ScrubbedCleanup) + if ($daemonProcess.MockKillCount -ne 1 -or $daemonProcess.MockWaitCount -ne 1 -or + $script:removedPaths.Count -ne 0) { + throw "tagged CLI failure did not preserve the case root after daemon cleanup" + } + } + + Invoke-Case "gdiplus-dispose-failure-preserves-primary" { + $process = New-MockProcess 91022 + $process | Add-Member ScriptMethod Dispose { + throw "mock disposal failure" + } -Force + $started = $true + $handleHeld = $true + $scratch = "C:\mock-gdiplus-case" + $caseError = [InvalidOperationException]::new("mock primary failure") + $failure = $null + try { & (Get-GdiplusCleanup) } catch { $failure = $_.Exception } + if (-not $failure -or -not (Test-OwnedLiveProcessRetentionRequired $failure) -or + $failure.InnerExceptions[0].Message -ne "mock primary failure" -or + $process.MockWaitCount -ne 1 -or $script:removedPaths.Count -ne 0) { + throw "disposal failure masked the primary error or removed the fixture" + } + } + + Invoke-Case "scrubbed-fixture-removal-failure-preserves-primary" { + $shellProcess = New-MockProcess 91023 + $daemonProcess = New-MockProcess 91024 + $shellStarted = $daemonStarted = $false + $shellHandleHeld = $daemonHandleHeld = $false + $root = "C:\mock-scrubbed-case" + $caseError = [InvalidOperationException]::new("mock primary failure") + $script:mockRemoveFailure = $true + $failure = $null + try { & (Get-ScrubbedCleanup) } catch { $failure = $_.Exception } + if ($failure -isnot [AggregateException] -or + -not [object]::ReferenceEquals($failure.InnerExceptions[0], $caseError) -or + $failure.InnerExceptions.Count -lt 2) { + throw "fixture removal failure masked the primary scrubbed-case error" + } + } + + Invoke-Case "gdiplus-fixture-removal-failure-preserves-primary" { + $process = New-MockProcess 91025 + $process.HasExited = $true + $started = $true + $handleHeld = $true + $scratch = "C:\mock-gdiplus-case" + $caseError = [InvalidOperationException]::new("mock primary failure") + $script:mockRemoveFailure = $true + $failure = $null + try { & (Get-GdiplusCleanup) } catch { $failure = $_.Exception } + if ($failure -isnot [AggregateException] -or + -not [object]::ReferenceEquals($failure.InnerExceptions[0], $caseError) -or + $failure.InnerExceptions.Count -lt 2) { + throw "fixture removal failure masked the primary GDI+ error" + } + } + + Invoke-Case "roundtrip-unverified-fallback-tags-failure" { + $daemonProcess = New-MockProcess 91018 + $daemonProcess.MockWaitResult = $false + $roundtripError = [InvalidOperationException]::new("mock primary failure") + $shutdownError = $cleanupError = $retentionError = $null + $runRoot = "C:\mock-roundtrip-case" + try { + throw [TimeoutException]::new("mock shutdown failure") + } catch { + . (Get-RoundTripFallback) + } + if (-not $retentionError -or + -not (Test-OwnedLiveProcessRetentionRequired $retentionError) -or + $daemonProcess.MockWaitCount -ne 1 -or + $retentionError.InnerExceptions[0].Message -ne "mock primary failure") { + throw "failed fallback did not retain the primary failure and request root preservation" + } + } + + Invoke-Case "roundtrip-initializes-primary-error-in-outer-scope" { + $ast = Get-ParsedSource "DaemonRoundTrip.Live.Tests.ps1" + $zig = $ast.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq "Invoke-ZigTest" + }, $true) + if (-not $zig -or $zig.Extent.Text -match '\$roundtripError\s*=\s*\$null' -or + $ast.Extent.Text -notmatch '(?s)\$roundtripError\s*=\s*\$null\s*try\s*\{\s*\$processScope\s*=\s*New-ShellProcessScope') { + throw "round-trip primary error was not initialized in the outer scope" + } + } + + Invoke-Case "roundtrip-dispose-failure-preserves-primary-and-retains" { + Invoke-ReceiptFixture { + param($root) + $daemonProcess = New-MockProcess 91019 + $daemonProcess | Add-Member ScriptMethod Dispose { + throw "mock handle disposal failure" + } -Force + function Stop-OwnedDaemon([Diagnostics.Process] $process) { } + $shutdownEvent = $null + $roundtripError = [InvalidOperationException]::new("mock primary failure") + $successful = $false + $runRoot = Join-Path $root "run" + $evidenceDirectory = $root + $nonce = "test" + $runLog = [Collections.Generic.List[string]]::new() + $failure = $null + try { $null = & (Get-RoundTripCleanup) } catch { $failure = $_.Exception } + if (-not $failure -or -not (Test-OwnedLiveProcessRetentionRequired $failure) -or + $failure.InnerExceptions[0].Message -ne "mock primary failure" -or + @($script:removedPaths | Where-Object { $_ -ceq $runRoot }).Count -ne 0) { + throw "handle disposal failure masked the primary error or failed to retain the run" + } + } + } + + Invoke-Case "roundtrip-report-failure-preserves-retention-tag" { + Invoke-ReceiptFixture { + param($root) + $daemonProcess = $null + $shutdownEvent = $null + $roundtripError = New-OwnedLiveProcessCleanupError ` + -Message "mock ownership failure" -Errors @([InvalidOperationException]::new("mock primary")) + $successful = $false + $runRoot = Join-Path $root "run" + $evidenceDirectory = Join-Path $root "missing-evidence" + $nonce = "test" + $runLog = [Collections.Generic.List[string]]::new() + $failure = $null + try { $null = & (Get-RoundTripCleanup) } catch { $failure = $_.Exception } + if (-not $failure -or -not (Test-OwnedLiveProcessRetentionRequired $failure) -or + -not [object]::ReferenceEquals($failure.InnerExceptions[0], $roundtripError) -or + $failure.InnerExceptions.Count -lt 2) { + $detail = if ($failure) { + "type=$($failure.GetType().FullName); tag=$(Test-OwnedLiveProcessRetentionRequired $failure); inner=$($failure.InnerExceptions.Count); first=$($failure.InnerExceptions[0].Message); message=$($failure.Message)" + } else { "no failure" } + throw "failure reporting masked the tagged original error: $detail" + } + } + } + + foreach ($reportFailure in @($false, $true)) { + Invoke-Case "roundtrip-tagged-shutdown-failure-retains-root-$reportFailure" { + Invoke-ReceiptFixture { + param($root) + $runRoot = Join-Path $root "target" + [void][IO.Directory]::CreateDirectory($runRoot) + $daemonProcess = New-MockProcess 91035 + $daemonProcess.HasExited = $true + $processScope = [pscustomobject]@{ Name = "mock job" } + $shutdownEvent = $null + $roundtripError = [InvalidOperationException]::new("mock primary failure") + $taggedShutdown = [InvalidOperationException]::new("mock ownership-unproven shutdown") + $taggedShutdown.Data["GraphCodeValidationRootRetentionRequired"] = $true + $taggedShutdown.Data["GraphCodeValidationRetainedRoots"] = [string[]]@($runRoot) + function Stop-OwnedDaemon([Diagnostics.Process] $process) { throw $taggedShutdown } + $successful = $false + $evidenceDirectory = if ($reportFailure) { Join-Path $root "missing-evidence" } else { $root } + $nonce = "test" + $runLog = [Collections.Generic.List[string]]::new() + $failure = $null + try { $null = & (Get-RoundTripCleanup) } catch { $failure = $_.Exception } + if (-not $failure -or + $failure.Data["GraphCodeValidationRootRetentionRequired"] -isnot [bool] -or + -not $failure.Data["GraphCodeValidationRootRetentionRequired"] -or + -not [object]::ReferenceEquals($failure.InnerExceptions[0], $roundtripError) -or + @($failure.Data["GraphCodeValidationRetainedRoots"]) -cnotcontains $runRoot -or + @($script:removedPaths | Where-Object { $_ -ceq $runRoot }).Count -ne 0) { + throw "tagged shutdown was lost during aggregate failure propagation" + } + } + } + } + + Invoke-Case "roundtrip-event-dispose-failure-preserves-primary" { + Invoke-ReceiptFixture { + param($root) + $daemonProcess = $null + $shutdownEvent = [pscustomobject]@{} + $shutdownEvent | Add-Member ScriptMethod Dispose { throw "mock event disposal failure" } + $roundtripError = [InvalidOperationException]::new("mock primary failure") + $successful = $false + $runRoot = Join-Path $root "run" + $evidenceDirectory = $root + $nonce = "test" + $runLog = [Collections.Generic.List[string]]::new() + $failure = $null + try { $null = & (Get-RoundTripCleanup) } catch { $failure = $_.Exception } + if ($failure -isnot [AggregateException] -or + -not [object]::ReferenceEquals($failure.InnerExceptions[0], $roundtripError) -or + $failure.InnerExceptions.Count -lt 2) { + throw "event disposal failure masked the primary error" + } + } + } + + Invoke-Case "roundtrip-forced-cleanup-preserves-tagged-primary" { + Invoke-ReceiptFixture { + param($root) + $target = Join-Path $root "target" + [void][IO.Directory]::CreateDirectory($target) + $daemonProcess = New-MockProcess 91026 + function Stop-OwnedDaemon([Diagnostics.Process] $process) { + throw "mock graceful shutdown failure" + } + $shutdownEvent = $null + $roundtripError = [InvalidOperationException]::new("mock ownership failure") + $roundtripError.Data["GraphCodeValidationRootRetentionRequired"] = $true + $roundtripError.Data["GraphCodeValidationRetainedRoots"] = [string[]]@($target) + $successful = $false + $runRoot = $target + $evidenceDirectory = $root + $nonce = "test" + $runLog = [Collections.Generic.List[string]]::new() + $failure = $null + try { $null = & (Get-RoundTripCleanup) } catch { $failure = $_.Exception } + if (-not $failure -or + $failure.Data["GraphCodeValidationRootRetentionRequired"] -isnot [bool] -or + -not $failure.Data["GraphCodeValidationRootRetentionRequired"] -or + -not [object]::ReferenceEquals($failure.InnerExceptions[0], $roundtripError) -or + @($failure.Data["GraphCodeValidationRetainedRoots"]).Count -ne 1 -or + $failure.Data["GraphCodeValidationRetainedRoots"][0] -cne $target) { + throw "forced cleanup failed to propagate the primary retention tag and root list" + } + } + } + + Invoke-Case "roundtrip-report-failure-preserves-retained-roots" { + Invoke-ReceiptFixture { + param($root) + $target = Join-Path $root "target" + [void][IO.Directory]::CreateDirectory($target) + $daemonProcess = $null + $shutdownEvent = $null + $roundtripError = [InvalidOperationException]::new("mock ownership failure") + $roundtripError.Data["GraphCodeValidationRootRetentionRequired"] = $true + $roundtripError.Data["GraphCodeValidationRetainedRoots"] = [string[]]@($target) + $successful = $false + $runRoot = $target + $evidenceDirectory = Join-Path $root "missing-evidence" + $nonce = "test" + $runLog = [Collections.Generic.List[string]]::new() + $failure = $null + try { $null = & (Get-RoundTripCleanup) } catch { $failure = $_.Exception } + if (-not $failure -or -not (Test-OwnedLiveProcessRetentionRequired $failure) -or + @($failure.Data["GraphCodeValidationRetainedRoots"]).Count -ne 1 -or + $failure.Data["GraphCodeValidationRetainedRoots"][0] -cne $target) { + throw "failure reporting discarded the observed retained-root list" + } + } + } + + Invoke-Case "retained-root-list-records-observed-only" { + Invoke-ReceiptFixture { + param($root) + $target = Join-Path $root "target" + $nested = Join-Path $target "nested" + [void][IO.Directory]::CreateDirectory($nested) + $inner = [InvalidOperationException]::new("mock inner") + $inner.Data["GraphCodeValidationRootRetentionRequired"] = $true + $inner.Data["GraphCodeValidationRetainedRoots"] = [string[]]@($nested) + $missing = Join-Path $root "missing" + $error = New-OwnedLiveProcessCleanupError ` + -Message "mock cleanup failure" -Errors @($inner) ` + -RetainedRoots @($target, $missing, $target) + $observed = @($error.Data["GraphCodeValidationRetainedRoots"]) + if ($observed.Count -ne 2 -or $observed -cnotcontains $target -or + $observed -cnotcontains $nested -or $observed -ccontains $missing) { + throw "retained-root list did not preserve distinct observed directories" + } + } + } + + Invoke-Case "absent-receipt-root-emits-sentinel" { + Invoke-ReceiptFixture { + param($root) + Remove-ValidationRootEnvironment + if ([Environment]::GetEnvironmentVariables("Process").Contains("GRAPHCODE_VALIDATION_ROOT")) { + throw "test did not establish an absent validation-root variable" + } + $result = Invoke-CapturingConsole { + New-OwnedLiveProcessCleanupError ` + -Message "mock cleanup failure" -Errors @([InvalidOperationException]::new("mock")) + } + if ($result.Text.Trim() -cne "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven" -or + -not (Test-OwnedLiveProcessRetentionRequired $result.Value) -or + $result.Value.InnerExceptions.Count -ne 2) { + throw "absent root did not emit a tagged receipt-failure sentinel" + } + } + } + + Invoke-Case "failed-sentinel-write-preserves-retention-and-errors" { + Invoke-ReceiptFixture { + param($root) + & (Join-Path $PSScriptRoot "..\Import-CompiledValidationAssembly.ps1") ` + -AssemblyPath $compiledValidationAssembly ` + -RequiredType "GraphCode.Validation.TestFixtures.FailingRetentionSentinelWriter" | Out-Null + [Environment]::SetEnvironmentVariable( + "GRAPHCODE_VALIDATION_ROOT", "relative-root", [EnvironmentVariableTarget]::Process) + $previous = [Console]::Out + $writer = [GraphCode.Validation.TestFixtures.FailingRetentionSentinelWriter]::new() + try { + [Console]::SetOut($writer) + $error = New-OwnedLiveProcessCleanupError ` + -Message "mock cleanup failure" -Errors @([InvalidOperationException]::new("primary cleanup failure")) ` + -RetainedRoots @($root) + } finally { + [Console]::SetOut($previous) + $writer.Dispose() + } + if (-not (Test-OwnedLiveProcessRetentionRequired $error) -or + $error.InnerExceptions.Count -ne 3 -or + $error.ToString() -notmatch "primary cleanup failure" -or + $error.ToString() -notmatch "fully qualified path" -or + $error.ToString() -notmatch "controlled sentinel write failure" -or + @($error.Data["GraphCodeValidationRetainedRoots"]) -cnotcontains $root) { + throw "sentinel failure lost the retention tag, observed root, or contributing errors" + } + + $shellProcess = New-MockProcess 91099 + $daemonProcess = $null + $shellStarted = $true + $shellHandleHeld = $false + $daemonStarted = $daemonHandleHeld = $false + $processScope = $null + $caseError = [InvalidOperationException]::new("source cleanup primary") + $failure = $null + $writer = [GraphCode.Validation.TestFixtures.FailingRetentionSentinelWriter]::new() + try { + [Console]::SetOut($writer) + try { & (Get-ScrubbedCleanup) } catch { $failure = $_.Exception } + } finally { + [Console]::SetOut($previous) + $writer.Dispose() + } + if (-not $failure -or -not (Test-OwnedLiveProcessRetentionRequired $failure) -or + $failure.ToString() -notmatch "source cleanup primary" -or + $failure.ToString() -notmatch "controlled sentinel write failure" -or + @($failure.Data["GraphCodeValidationRetainedRoots"]) -cnotcontains $root -or + $shellProcess.MockKillCount -ne 0 -or $script:removedPaths.Count -ne 0) { + throw "source cleanup lost the retention tag or acted on an unattested process or fixture" + } + } + } + + Invoke-Case "failed-sentinel-flush-preserves-retention-and-errors" { + Invoke-ReceiptFixture { + param($root) + & (Join-Path $PSScriptRoot "..\Import-CompiledValidationAssembly.ps1") ` + -AssemblyPath $compiledValidationAssembly ` + -RequiredType "GraphCode.Validation.TestFixtures.FailingRetentionSentinelFlush" | Out-Null + [Environment]::SetEnvironmentVariable( + "GRAPHCODE_VALIDATION_ROOT", "relative-root", [EnvironmentVariableTarget]::Process) + $previous = [Console]::Out + $writer = [GraphCode.Validation.TestFixtures.FailingRetentionSentinelFlush]::new() + try { + [Console]::SetOut($writer) + $error = New-OwnedLiveProcessCleanupError ` + -Message "mock cleanup failure" -Errors @([InvalidOperationException]::new("primary cleanup failure")) ` + -RetainedRoots @($root) + } finally { + [Console]::SetOut($previous) + $writer.Dispose() + } + if (-not (Test-OwnedLiveProcessRetentionRequired $error) -or + $error.InnerExceptions.Count -ne 3 -or + $error.ToString() -notmatch "primary cleanup failure" -or + $error.ToString() -notmatch "fully qualified path" -or + $error.ToString() -notmatch "controlled sentinel flush failure" -or + @($error.Data["GraphCodeValidationRetainedRoots"]) -cnotcontains $root) { + throw "sentinel flush failure lost the retention tag, observed root, or contributing errors" + } + } + } + + Invoke-Case "receipt-preserves-exact-root-and-tag" { + Invoke-ReceiptFixture { + param($root) + [Environment]::SetEnvironmentVariable( + "GRAPHCODE_VALIDATION_ROOT", $root, [EnvironmentVariableTarget]::Process) + $error = New-OwnedLiveProcessCleanupError ` + -Message "mock cleanup failure" -Errors @([InvalidOperationException]::new("mock")) + $path = Join-Path $root "ownership-unproven.json" + if (-not [IO.File]::Exists($path)) { throw "receipt was not created" } + $bytes = [IO.File]::ReadAllBytes($path) + $receipt = [Text.UTF8Encoding]::new($false, $true).GetString($bytes) | + ConvertFrom-Json + if ($bytes.Length -gt 4096 -or $receipt.schemaVersion -ne 1 -or + $receipt.validationRoot -cne $root -or $receipt.reason -cne "ownership_unproven" -or + -not (Test-OwnedLiveProcessRetentionRequired $error)) { + throw "receipt content or Boolean retention tag was incorrect" + } + } + } + + Invoke-Case "existing-receipt-is-not-overwritten" { + Invoke-ReceiptFixture { + param($root) + [Environment]::SetEnvironmentVariable( + "GRAPHCODE_VALIDATION_ROOT", $root, [EnvironmentVariableTarget]::Process) + $path = Join-Path $root "ownership-unproven.json" + [IO.File]::WriteAllText($path, "preexisting evidence") + $result = Invoke-CapturingConsole { + New-OwnedLiveProcessCleanupError ` + -Message "mock cleanup failure" -Errors @([InvalidOperationException]::new("mock")) + } + if ([IO.File]::ReadAllText($path) -cne "preexisting evidence" -or + $result.Text.Trim() -cne "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven" -or + -not (Test-OwnedLiveProcessRetentionRequired $result.Value) -or + $result.Value.InnerExceptions.Count -ne 2) { + throw "existing receipt was overwritten or its write failure was not surfaced" + } + } + } + + Invoke-Case "invalid-receipt-root-emits-sentinel" { + Invoke-ReceiptFixture { + param($root) + [Environment]::SetEnvironmentVariable( + "GRAPHCODE_VALIDATION_ROOT", "relative-root", [EnvironmentVariableTarget]::Process) + $result = Invoke-CapturingConsole { + New-OwnedLiveProcessCleanupError ` + -Message "mock cleanup failure" -Errors @([InvalidOperationException]::new("mock")) + } + if ($result.Text.Trim() -cne "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven" -or + -not (Test-OwnedLiveProcessRetentionRequired $result.Value) -or + $result.Value.InnerExceptions.Count -ne 2) { + throw "invalid receipt root did not emit a tagged sentinel failure" + } + } + } + + Invoke-Case "empty-receipt-root-emits-sentinel" { + Invoke-ReceiptFixture { + param($root) + [Environment]::SetEnvironmentVariable( + "GRAPHCODE_VALIDATION_ROOT", "", [EnvironmentVariableTarget]::Process) + $environment = [Environment]::GetEnvironmentVariables( + [EnvironmentVariableTarget]::Process) + if (-not $environment.Contains("GRAPHCODE_VALIDATION_ROOT")) { + throw "test did not establish a present empty validation-root variable" + } + $result = Invoke-CapturingConsole { + New-OwnedLiveProcessCleanupError ` + -Message "mock cleanup failure" -Errors @([InvalidOperationException]::new("mock")) + } + if ($result.Text.Trim() -cne "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven" -or + -not (Test-OwnedLiveProcessRetentionRequired $result.Value)) { + throw "present empty root did not emit a tagged sentinel failure" + } + } + } + + Invoke-Case "receipt-root-file-emits-sentinel" { + Invoke-ReceiptFixture { + param($root) + $file = Join-Path $root "not-directory" + [IO.File]::WriteAllText($file, "existing file") + [Environment]::SetEnvironmentVariable( + "GRAPHCODE_VALIDATION_ROOT", $file, [EnvironmentVariableTarget]::Process) + $result = Invoke-CapturingConsole { + New-OwnedLiveProcessCleanupError ` + -Message "mock cleanup failure" -Errors @([InvalidOperationException]::new("mock")) + } + if ($result.Text.Trim() -cne "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven" -or + -not (Test-OwnedLiveProcessRetentionRequired $result.Value) -or + [IO.File]::ReadAllText($file) -cne "existing file") { + throw "a file-valued root was not refused without modification" + } + } + } + + Invoke-Case "reparse-root-emits-sentinel-without-writing-target" { + Invoke-ReceiptFixture { + param($root) + $target = Join-Path $root "target" + $link = Join-Path $root "link" + [void][IO.Directory]::CreateDirectory($target) + [void](New-Item -ItemType Junction -Path $link -Target $target -ErrorAction Stop) + [Environment]::SetEnvironmentVariable( + "GRAPHCODE_VALIDATION_ROOT", $link, [EnvironmentVariableTarget]::Process) + $result = Invoke-CapturingConsole { + New-OwnedLiveProcessCleanupError ` + -Message "mock cleanup failure" -Errors @([InvalidOperationException]::new("mock")) + } + if ($result.Text.Trim() -cne "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven" -or + -not (Test-OwnedLiveProcessRetentionRequired $result.Value) -or + [IO.File]::Exists((Join-Path $target "ownership-unproven.json"))) { + throw "reparse root was not refused without writing through it" + } + } + } + + Invoke-Case "reparse-ancestor-emits-sentinel-without-writing-target" { + Invoke-ReceiptFixture { + param($root) + $target = Join-Path $root "target" + $nested = Join-Path $target "nested" + $link = Join-Path $root "link" + [void][IO.Directory]::CreateDirectory($nested) + [void](New-Item -ItemType Junction -Path $link -Target $target -ErrorAction Stop) + [Environment]::SetEnvironmentVariable( + "GRAPHCODE_VALIDATION_ROOT", (Join-Path $link "nested"), + [EnvironmentVariableTarget]::Process) + $result = Invoke-CapturingConsole { + New-OwnedLiveProcessCleanupError ` + -Message "mock cleanup failure" -Errors @([InvalidOperationException]::new("mock")) + } + if ($result.Text.Trim() -cne "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven" -or + -not (Test-OwnedLiveProcessRetentionRequired $result.Value) -or + [IO.File]::Exists((Join-Path $nested "ownership-unproven.json"))) { + throw "reparse ancestor was not refused without writing through it" + } + } + } + + Invoke-Case "receipt-writer-uses-create-new-and-flush" { + $source = [IO.File]::ReadAllText($helper) + if ($source -notmatch '\[IO\.FileMode\]::CreateNew' -or + $source -notmatch '\$stream\.Flush\(\$true\)') { + throw "receipt writer must create exclusively and flush the file stream" + } + } + + Invoke-Case "nested-retention-tag-is-observed" { + $tagged = [InvalidOperationException]::new("tagged") + $tagged.Data["GraphCodeValidationRootRetentionRequired"] = $true + $wrapped = [AggregateException]::new( + "wrapped", [Exception[]]@([InvalidOperationException]::new("untagged"), $tagged)) + $stringTag = [InvalidOperationException]::new("string tag") + $stringTag.Data["GraphCodeValidationRootRetentionRequired"] = "true" + if (-not (Test-OwnedLiveProcessRetentionRequired $wrapped) -or + (Test-OwnedLiveProcessRetentionRequired ([InvalidOperationException]::new("ordinary"))) -or + (Test-OwnedLiveProcessRetentionRequired $stringTag)) { + throw "nested Boolean retention tag was not distinguished from an ordinary error" + } + } + + Write-Output "OWNED_LIVE_PROCESS_CLEANUP_CASES=$executed FAILURES=$($failures.Count)" + if ($executed -ne 84 -or $failures.Count -ne 0) { + throw "Owned live process cleanup contract failed: $($failures -join '; ')" + } +} finally { + if ($hadValidationRoot) { + [Environment]::SetEnvironmentVariable( + "GRAPHCODE_VALIDATION_ROOT", $originalValidationRoot, [EnvironmentVariableTarget]::Process) + } else { + Remove-ValidationRootEnvironment + } +} diff --git a/Tools/windows/Tests/Packaging.Standalone.Tests.ps1 b/Tools/windows/Tests/Packaging.Standalone.Tests.ps1 index 520eb6f1..c0b31c79 100644 --- a/Tools/windows/Tests/Packaging.Standalone.Tests.ps1 +++ b/Tools/windows/Tests/Packaging.Standalone.Tests.ps1 @@ -175,7 +175,7 @@ function Invoke-Setup( $primaryError = $null $clock = [Diagnostics.Stopwatch]::StartNew() try { - $job = [StandaloneProcessJob]::Create() + $job = [GraphCode.Validation.Ownership.StandaloneProcessJob]::Create() $report.ControllerStage = "process-start" $started = $process.Start() if (-not $started) { throw "Could not start standalone setup host" } @@ -184,7 +184,7 @@ function Invoke-Setup( $stdout = $process.StandardOutput.ReadToEndAsync() $stderr = $process.StandardError.ReadToEndAsync() $report.ControllerStage = "job-assign" - [StandaloneProcessJob]::Assign($job, $process.Handle) + [GraphCode.Validation.Ownership.StandaloneProcessJob]::Assign($job, $process.Handle) $assigned = $true $report.ControllerStage = "gate-release" [IO.File]::WriteAllText($gate, "assigned") @@ -231,9 +231,9 @@ function Invoke-Setup( if ($process.HasExited) { $report.ExitCode = $process.ExitCode } } if ($assigned) { - $report.ActiveBeforeTeardown = [StandaloneProcessJob]::Active($job) - $report.JobBeforeTeardown = [StandaloneProcessJob]::Summary($job) - $report.OwnedProcessesBeforeTeardown = [StandaloneProcessJob]::Processes($job) + $report.ActiveBeforeTeardown = [GraphCode.Validation.Ownership.StandaloneProcessJob]::Active($job) + $report.JobBeforeTeardown = [GraphCode.Validation.Ownership.StandaloneProcessJob]::Summary($job) + $report.OwnedProcessesBeforeTeardown = [GraphCode.Validation.Ownership.StandaloneProcessJob]::Processes($job) } # ReadToEndAsync cannot expose partial pipes; distinguish pending capture from empty output. $report.StdoutBeforeTeardown = if ($stdout -and $stdout.IsCompletedSuccessfully) { @@ -253,16 +253,16 @@ function Invoke-Setup( } catch { $cleanupErrors.Add("capturing standalone stage evidence: $($_.Exception.Message)") } try { if ($assigned) { - [StandaloneProcessJob]::Terminate($job) - [StandaloneProcessJob]::WaitForEmpty($job, 5000) - $report.ActiveAfterTeardown = [StandaloneProcessJob]::Active($job) + [GraphCode.Validation.Ownership.StandaloneProcessJob]::Terminate($job) + [GraphCode.Validation.Ownership.StandaloneProcessJob]::WaitForEmpty($job, 5000) + $report.ActiveAfterTeardown = [GraphCode.Validation.Ownership.StandaloneProcessJob]::Active($job) } elseif ($started -and -not $process.HasExited) { $process.Kill($true) } if ($started -and -not $process.WaitForExit(5000)) { throw "Standalone host survived termination" } } catch { $cleanupErrors.Add("owned process termination: $($_.Exception.Message)") } try { - if ($job -ne [IntPtr]::Zero) { [StandaloneProcessJob]::Close($job) } + if ($job -ne [IntPtr]::Zero) { [GraphCode.Validation.Ownership.StandaloneProcessJob]::Close($job) } } catch { $cleanupErrors.Add("closing owned job: $($_.Exception.Message)") } try { if ($stdout -and $stderr) { @@ -315,100 +315,12 @@ function Invoke-StandaloneFixture { } } -if (-not ("StandaloneProcessJob" -as [type])) { - Add-Type @' -using System; -using System.ComponentModel; -using System.Diagnostics; -using System.Runtime.InteropServices; -public static class StandaloneProcessJob { - [StructLayout(LayoutKind.Sequential)] - struct BasicLimits { public long ProcessTime, JobTime; public uint Flags; public UIntPtr Min, Max; public uint Active; public UIntPtr Affinity; public uint Priority, Scheduling; } - [StructLayout(LayoutKind.Sequential)] - struct IoCounters { public ulong ReadOps, WriteOps, OtherOps, ReadBytes, WriteBytes, OtherBytes; } - [StructLayout(LayoutKind.Sequential)] - struct Limits { public BasicLimits Basic; public IoCounters Io; public UIntPtr ProcessMemory, JobMemory, PeakProcessMemory, PeakJobMemory; } - [StructLayout(LayoutKind.Sequential)] - struct Accounting { public long User, Kernel, PeriodUser, PeriodKernel; public uint Faults, Total, Active, Terminated; } - [DllImport("kernel32.dll", SetLastError=true)] static extern IntPtr CreateJobObjectW(IntPtr attributes, IntPtr name); - [DllImport("kernel32.dll", SetLastError=true)] static extern bool SetInformationJobObject(IntPtr job, int info, ref Limits limits, uint size); - [DllImport("kernel32.dll", SetLastError=true)] static extern bool QueryInformationJobObject(IntPtr job, int info, out Accounting accounting, uint size, IntPtr length); - [DllImport("kernel32.dll", SetLastError=true)] static extern bool QueryInformationJobObject(IntPtr job, int info, IntPtr buffer, uint size, IntPtr length); - [DllImport("kernel32.dll", SetLastError=true)] static extern bool IsProcessInJob(IntPtr process, IntPtr job, out bool result); - [DllImport("kernel32.dll", SetLastError=true)] static extern IntPtr OpenProcess(uint access, bool inherit, uint pid); - [DllImport("kernel32.dll", SetLastError=true)] static extern bool GetProcessTimes(IntPtr process, out long creation, out long exit, out long kernel, out long user); - [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern bool QueryFullProcessImageNameW(IntPtr process, uint flags, System.Text.StringBuilder name, ref uint size); - [DllImport("kernel32.dll", SetLastError=true)] static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process); - [DllImport("kernel32.dll", SetLastError=true)] static extern bool TerminateJobObject(IntPtr job, uint code); - [DllImport("kernel32.dll", SetLastError=true)] static extern bool CloseHandle(IntPtr handle); - public static IntPtr Create() { - var job = CreateJobObjectW(IntPtr.Zero, IntPtr.Zero); - if (job == IntPtr.Zero) throw new Win32Exception(); - var limits = new Limits(); limits.Basic.Flags = 0x2000; // KILL_ON_JOB_CLOSE - if (!SetInformationJobObject(job, 9, ref limits, (uint)Marshal.SizeOf())) { - var error = new Win32Exception(); CloseHandle(job); throw error; - } - return job; - } - public static void Assign(IntPtr job, IntPtr process) { if (!AssignProcessToJobObject(job, process)) throw new Win32Exception(); } - public static uint Active(IntPtr job) { - Accounting value; - if (!QueryInformationJobObject(job, 1, out value, (uint)Marshal.SizeOf(), IntPtr.Zero)) throw new Win32Exception(); - return value.Active; - } - public static string Summary(IntPtr job) { - Accounting value; - if (!QueryInformationJobObject(job, 1, out value, (uint)Marshal.SizeOf(), IntPtr.Zero)) throw new Win32Exception(); - return String.Format("total={0}; active={1}; terminated={2}; user100ns={3}; kernel100ns={4}", - value.Total, value.Active, value.Terminated, value.User, value.Kernel); - } - public static string Processes(IntPtr job) { - const int capacity = 1024; - int size = 8 + capacity * IntPtr.Size; - var buffer = Marshal.AllocHGlobal(size); - try { - if (!QueryInformationJobObject(job, 3, buffer, (uint)size, IntPtr.Zero)) throw new Win32Exception(); - int count = Marshal.ReadInt32(buffer, 4); - if (count < 0 || count > capacity) throw new InvalidOperationException("Owned process snapshot exceeds capacity"); - var identities = new System.Collections.Generic.List(); - for (int i = 0; i < count; i++) { - int pid = checked((int)Marshal.ReadIntPtr(buffer, 8 + i * IntPtr.Size).ToInt64()); - var process = OpenProcess(0x1000, false, (uint)pid); // QUERY_LIMITED_INFORMATION - if (process == IntPtr.Zero) { - identities.Add("pid=" + pid + "; unavailable-win32=" + Marshal.GetLastWin32Error()); - continue; - } - try { - bool owned; - if (!IsProcessInJob(process, job, out owned)) throw new Win32Exception(); - if (!owned) { identities.Add("pid=" + pid + "; no-longer-owned"); continue; } - long creation, exit, kernel, user; - uint length = 32768; - var name = new System.Text.StringBuilder((int)length); - if (!GetProcessTimes(process, out creation, out exit, out kernel, out user) || - !QueryFullProcessImageNameW(process, 0, name, ref length)) { - identities.Add("pid=" + pid + "; identity-unavailable-win32=" + Marshal.GetLastWin32Error()); - continue; - } - identities.Add(String.Format("pid={0}; creationUtcTicks={1}; name={2}", - pid, DateTime.FromFileTimeUtc(creation).Ticks, System.IO.Path.GetFileNameWithoutExtension(name.ToString()))); - } finally { if (!CloseHandle(process)) throw new Win32Exception(); } - } - return String.Join(" | ", identities); - } finally { Marshal.FreeHGlobal(buffer); } - } - public static void Terminate(IntPtr job) { if (!TerminateJobObject(job, 124)) throw new Win32Exception(); } - public static void WaitForEmpty(IntPtr job, int milliseconds) { - var clock = Stopwatch.StartNew(); - while (Active(job) != 0) { - if (clock.ElapsedMilliseconds >= milliseconds) throw new TimeoutException("Owned standalone job still has active processes"); - System.Threading.Thread.Sleep(10); - } - } - public static void Close(IntPtr job) { if (!CloseHandle(job)) throw new Win32Exception(); } -} -'@ +$validationAssemblyPath = $env:GRAPHCODE_VALIDATION_ASSEMBLY +if (-not $validationAssemblyPath) { + $validationAssemblyPath = & (Join-Path $PSScriptRoot "..\Build-CompiledValidationRunner.ps1") } +& (Join-Path $PSScriptRoot "..\Import-CompiledValidationAssembly.ps1") ` + -AssemblyPath $validationAssemblyPath -RequiredType "GraphCode.Validation.Ownership.StandaloneProcessJob" | Out-Null function Test-StandaloneHarness([switch] $diagnosticOnly, [switch] $launcherOnly) { $hostPath = (Get-Command pwsh).Source @@ -694,7 +606,7 @@ Write-Output "CASE PASS: $case" } $process = [Diagnostics.Process]::new() $process.StartInfo = $start - $job = [StandaloneProcessJob]::Create() + $job = [GraphCode.Validation.Ownership.StandaloneProcessJob]::Create() $started = $false $assigned = $false try { @@ -702,16 +614,16 @@ Write-Output "CASE PASS: $case" if (-not $started) { throw "Could not start harness self-test worker" } $stdout = $process.StandardOutput.ReadToEndAsync() $stderr = $process.StandardError.ReadToEndAsync() - [StandaloneProcessJob]::Assign($job, $process.Handle) + [GraphCode.Validation.Ownership.StandaloneProcessJob]::Assign($job, $process.Handle) $assigned = $true [IO.File]::WriteAllText($gate, "assigned") $workerBudget = if ($case -like "diagnostic-*" -or $case -like "launcher-*") { 70000 } else { 12000 } $finished = $process.WaitForExit($workerBudget) $settle = [Diagnostics.Stopwatch]::StartNew() - while ($finished -and [StandaloneProcessJob]::Active($job) -ne 0 -and $settle.ElapsedMilliseconds -lt 250) { + while ($finished -and [GraphCode.Validation.Ownership.StandaloneProcessJob]::Active($job) -ne 0 -and $settle.ElapsedMilliseconds -lt 250) { Start-Sleep -Milliseconds 10 } - $activeBeforeTeardown = [StandaloneProcessJob]::Active($job) + $activeBeforeTeardown = [GraphCode.Validation.Ownership.StandaloneProcessJob]::Active($job) if (-not $finished) { foreach ($role in @("root", "descendant")) { $identityPath = Join-Path $caseRoot "$role-identity" @@ -724,8 +636,8 @@ Write-Output "CASE PASS: $case" } Write-Output "Watchdog readiness: descendantReady=$(Test-Path (Join-Path $caseRoot 'descendant-ready')); markerWritten=$(Test-Path (Join-Path $caseRoot 'marker-written'))" } - [StandaloneProcessJob]::Terminate($job) - [StandaloneProcessJob]::WaitForEmpty($job, 5000) + [GraphCode.Validation.Ownership.StandaloneProcessJob]::Terminate($job) + [GraphCode.Validation.Ownership.StandaloneProcessJob]::WaitForEmpty($job, 5000) if (-not $process.WaitForExit(5000)) { throw "Self-test worker survived owned job termination" } if (-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]] @($stdout, $stderr), 5000)) { throw "Self-test output capture exceeded its deadline" @@ -734,26 +646,26 @@ Write-Output "CASE PASS: $case" Write-Output $output if (-not $finished -or $process.ExitCode -ne 0 -or $activeBeforeTeardown -ne 0 -or $output -notmatch "CASE PASS: $case") { $failures++ - Write-Output "CASE FAIL: $caseId; finished=$finished; exit=$($process.ExitCode); activeBeforeTeardown=$activeBeforeTeardown; activeAfterTeardown=$([StandaloneProcessJob]::Active($job))" + Write-Output "CASE FAIL: $caseId; finished=$finished; exit=$($process.ExitCode); activeBeforeTeardown=$activeBeforeTeardown; activeAfterTeardown=$([GraphCode.Validation.Ownership.StandaloneProcessJob]::Active($job))" if (-not $finished -or $activeBeforeTeardown -ne 0 -or $output -match "Standalone setup (?:output capture )?timed out") { throw "Stopped standalone harness at unexpected timeout or ownership failure: $caseId" } } else { $passed++ - Write-Output "Owned worker job: activeBeforeTeardown=$activeBeforeTeardown; activeAfterTeardown=$([StandaloneProcessJob]::Active($job))" + Write-Output "Owned worker job: activeBeforeTeardown=$activeBeforeTeardown; activeAfterTeardown=$([GraphCode.Validation.Ownership.StandaloneProcessJob]::Active($job))" } } finally { try { if ($assigned) { - [StandaloneProcessJob]::Terminate($job) - [StandaloneProcessJob]::WaitForEmpty($job, 5000) + [GraphCode.Validation.Ownership.StandaloneProcessJob]::Terminate($job) + [GraphCode.Validation.Ownership.StandaloneProcessJob]::WaitForEmpty($job, 5000) } elseif ($started -and -not $process.HasExited) { $process.Kill($true) if (-not $process.WaitForExit(5000)) { throw "Unassigned self-test worker survived termination" } } } finally { - try { [StandaloneProcessJob]::Close($job) } finally { $process.Dispose() } + try { [GraphCode.Validation.Ownership.StandaloneProcessJob]::Close($job) } finally { $process.Dispose() } } } } diff --git a/Tools/windows/Tests/ScrubbedShellStartup.Live.Tests.ps1 b/Tools/windows/Tests/ScrubbedShellStartup.Live.Tests.ps1 index 35591fae..c2f4808f 100644 --- a/Tools/windows/Tests/ScrubbedShellStartup.Live.Tests.ps1 +++ b/Tools/windows/Tests/ScrubbedShellStartup.Live.Tests.ps1 @@ -1,310 +1,403 @@ -[CmdletBinding()] -param( - [Parameter(Mandatory)][string] $Shell, - [Parameter(Mandatory)][string] $Daemon, - [Parameter(Mandatory)][string] $Cli, - [Parameter(Mandatory)][string] $ScratchRoot -) - -$ErrorActionPreference = "Stop" -Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes -Add-Type @' -using System; -using System.Runtime.InteropServices; -public static class ScrubbedStartupNative { - public delegate bool EnumWindowsProc(IntPtr window, IntPtr parameter); - [DllImport("user32.dll")] static extern bool EnumWindows(EnumWindowsProc callback, IntPtr parameter); - [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId); - [DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern int GetClassName(IntPtr window, System.Text.StringBuilder value, int capacity); - [DllImport("user32.dll")] static extern bool IsWindowVisible(IntPtr window); - [DllImport("user32.dll")] static extern bool PostMessage(IntPtr window, uint message, UIntPtr wparam, IntPtr lparam); - public static IntPtr Find(uint processId, string className) { - IntPtr result = IntPtr.Zero; - EnumWindows(delegate(IntPtr window, IntPtr parameter) { - uint owner; - GetWindowThreadProcessId(window, out owner); - if (owner != processId || !IsWindowVisible(window)) return true; - var actual = new System.Text.StringBuilder(128); - GetClassName(window, actual, actual.Capacity); - if (!String.Equals(actual.ToString(), className, StringComparison.Ordinal)) return true; - result = window; - return false; - }, IntPtr.Zero); - return result; - } - public static bool Escape(IntPtr window) { - return PostMessage(window, 0x0100, (UIntPtr)0x1B, IntPtr.Zero) && - PostMessage(window, 0x0101, (UIntPtr)0x1B, IntPtr.Zero); - } - public static bool Command(IntPtr window, uint action) { - return PostMessage(window, 0x0111, (UIntPtr)action, IntPtr.Zero); - } -} -'@ - -$allowedKeys = @( - "SystemRoot", "windir", "USERPROFILE", "LOCALAPPDATA", "APPDATA", - "TEMP", "TMP", "ProgramData", "HOMEDRIVE", "HOMEPATH", "PATH" -) -$forbiddenPathFragments = @( - ".graphcode-tools", "Visual Studio", "Windows Kits", "Swift" -) - -function New-StartInfo([string] $file, [string] $root) { - $profile = Join-Path $root "profile" - $system32 = Join-Path $env:SystemRoot "System32" - $powerShell = Join-Path $system32 "WindowsPowerShell\v1.0" - $info = [Diagnostics.ProcessStartInfo]::new() - $info.FileName = $file - $info.WorkingDirectory = Split-Path -Parent $file - $info.UseShellExecute = $false - $info.Environment.Clear() - $values = [ordered]@{ - SystemRoot = $env:SystemRoot - windir = $env:WINDIR - USERPROFILE = $profile - LOCALAPPDATA = Join-Path $profile "AppData\Local" - APPDATA = Join-Path $profile "AppData\Roaming" - TEMP = Join-Path $root "temp" - TMP = Join-Path $root "temp" - ProgramData = $env:ProgramData - HOMEDRIVE = [IO.Path]::GetPathRoot($profile).TrimEnd("\") - HOMEPATH = $profile.Substring([IO.Path]::GetPathRoot($profile).Length - 1) - PATH = "$system32;$powerShell;$(Split-Path -Parent $file)" - } - foreach ($name in $values.Keys) { $info.Environment[$name] = [string]$values[$name] } - return $info -} - -function Wait-NativeWindow([int] $processId, [string] $className, [int] $seconds) { - $deadline = [DateTime]::UtcNow.AddSeconds($seconds) - do { - $window = [ScrubbedStartupNative]::Find([uint32]$processId, $className) - if ($window -ne [IntPtr]::Zero) { return $window } - Start-Sleep -Milliseconds 50 - } while ([DateTime]::UtcNow -lt $deadline) - return [IntPtr]::Zero -} - -function Wait-NativeWindowClosed([int] $processId, [string] $className, [int] $seconds) { - $deadline = [DateTime]::UtcNow.AddSeconds($seconds) - do { - if ([ScrubbedStartupNative]::Find([uint32]$processId, $className) -eq [IntPtr]::Zero) { - return $true - } - Start-Sleep -Milliseconds 50 - } while ([DateTime]::UtcNow -lt $deadline) - return $false -} - -function Wait-File([string] $path, [int] $seconds) { - $deadline = [DateTime]::UtcNow.AddSeconds($seconds) - do { - if (Test-Path -LiteralPath $path -PathType Leaf) { return $true } - Start-Sleep -Milliseconds 50 - } while ([DateTime]::UtcNow -lt $deadline) - return $false -} - -function Invoke-ScrubbedCli([string] $name, [string] $root, [string] $support, [string[]] $arguments) { - $cliInfo = New-StartInfo $Cli $root - $cliInfo.Environment["GRAPHCODE_SUPPORT_DIR"] = $support - foreach ($argument in $arguments) { [void]$cliInfo.ArgumentList.Add($argument) } - $cliInfo.RedirectStandardOutput = $true - $cliInfo.RedirectStandardError = $true - $cliProcess = [Diagnostics.Process]::new() - $cliProcess.StartInfo = $cliInfo - try { - if (-not $cliProcess.Start()) { throw "$name CLI '$($arguments[0])' could not start" } - $stdout = $cliProcess.StandardOutput.ReadToEndAsync() - $stderr = $cliProcess.StandardError.ReadToEndAsync() - if (-not $cliProcess.WaitForExit(10000)) { throw "$name CLI '$($arguments[0])' timed out" } - return [pscustomobject]@{ - ExitCode = $cliProcess.ExitCode - Output = $stdout.GetAwaiter().GetResult() - Error = $stderr.GetAwaiter().GetResult() - } - } finally { - if (-not $cliProcess.HasExited) { $cliProcess.Kill() } - $cliProcess.Dispose() - } -} - -function Wait-ProjectRow([int] $processId, [string] $projectName, [int] $seconds) { - $processCondition = [Windows.Automation.PropertyCondition]::new( - [Windows.Automation.AutomationElement]::ProcessIdProperty, $processId) - $rowCondition = [Windows.Automation.AndCondition]::new( - [Windows.Automation.PropertyCondition]::new( - [Windows.Automation.AutomationElement]::ControlTypeProperty, - [Windows.Automation.ControlType]::ListItem), - [Windows.Automation.PropertyCondition]::new( - [Windows.Automation.AutomationElement]::NameProperty, $projectName)) - $deadline = [DateTime]::UtcNow.AddSeconds($seconds) - do { - $window = [Windows.Automation.AutomationElement]::RootElement.FindFirst( - [Windows.Automation.TreeScope]::Children, $processCondition) - if ($window) { - $row = $window.FindFirst([Windows.Automation.TreeScope]::Descendants, $rowCondition) - if ($row -and $row.Current.AutomationId.StartsWith("open-project-", [StringComparison]::Ordinal)) { - return $row.Current.AutomationId - } - } - Start-Sleep -Milliseconds 200 - } while ([DateTime]::UtcNow -lt $deadline) - return $null -} - -function Invoke-Case( - [string] $name, - [ValidateSet("escape", "skip", "complete", "marker", "project")] - [string] $action -) { - $root = Join-Path $ScratchRoot $name - $profile = Join-Path $root "profile" - $support = Join-Path $profile ".graphcode" - New-Item -ItemType Directory -Force -Path ` - $support,(Join-Path $profile "AppData\Local"),(Join-Path $profile "AppData\Roaming"),(Join-Path $root "temp") | - Out-Null - if ($action -in @("marker", "project")) { - Set-Content -LiteralPath (Join-Path $support "onboarding-seen") -Value "seen" -NoNewline - } - # A plain folder, not a Git repository: startup must not need Git on PATH. - $projectPath = Join-Path $root "Core" - $projectRow = $null - if ($action -eq "project") { - New-Item -ItemType Directory -Force -Path $projectPath | Out-Null - Set-Content -LiteralPath (Join-Path $projectPath "README.md") -Value "fixture" -NoNewline - } - - $daemonProcess = [Diagnostics.Process]::new() - $daemonInfo = New-StartInfo $Daemon $root - $daemonInfo.Environment["GRAPHCODE_SUPPORT_DIR"] = $support - $daemonProcess.StartInfo = $daemonInfo - $shellProcess = [Diagnostics.Process]::new() - $shellProcess.StartInfo = New-StartInfo $Shell $root - try { - if (-not $daemonProcess.Start()) { throw "$name could not start the production daemon" } - if ($action -eq "project") { - # Register through the production daemon before the shell starts, as the - # native folder picker does, so the shell restores a real daemon graph. - $deadline = [DateTime]::UtcNow.AddSeconds(15) - do { - $status = Invoke-ScrubbedCli $name $root $support @("status", $projectPath) - if ($status.ExitCode -eq 0) { break } - Start-Sleep -Milliseconds 250 - } while ([DateTime]::UtcNow -lt $deadline) - if ($status.ExitCode -ne 0) { throw "$name could not register the project: $($status.Error)" } - } - if (-not $shellProcess.Start()) { throw "$name could not start the production shell" } - [void]$daemonProcess.Handle - [void]$shellProcess.Handle - $keys = @($shellProcess.StartInfo.Environment.Keys | Sort-Object) - if (($keys -join "|") -cne (($allowedKeys | Sort-Object) -join "|")) { - throw "$name environment keys differ: $($keys -join ',')" - } - foreach ($fragment in $forbiddenPathFragments) { - if ($shellProcess.StartInfo.Environment["PATH"].IndexOf( - $fragment, [StringComparison]::OrdinalIgnoreCase) -ge 0) { - throw "$name PATH contains forbidden developer fragment '$fragment'" - } - } - - $onboarding = Wait-NativeWindow $shellProcess.Id "GraphCodeWindowsOnboarding" 10 - if ($action -notin @("marker", "project")) { - if ($onboarding -eq [IntPtr]::Zero) { throw "$name onboarding was not shown" } - switch ($action) { - "escape" { - if (-not [ScrubbedStartupNative]::Escape($onboarding)) { - throw "$name onboarding rejected Escape" - } - } - "skip" { - if (-not [ScrubbedStartupNative]::Command($onboarding, 2)) { - throw "$name onboarding rejected Skip" - } - } - "complete" { - foreach ($page in 1..5) { - if (-not [ScrubbedStartupNative]::Command($onboarding, 4)) { - throw "$name onboarding rejected primary action on page $page" - } - Start-Sleep -Milliseconds 50 - } - } - } - if (-not (Wait-NativeWindowClosed $shellProcess.Id "GraphCodeWindowsOnboarding" 10)) { - throw "$name onboarding did not close" - } - } elseif ($onboarding -ne [IntPtr]::Zero) { - throw "$name onboarding was shown despite the marker" - } - - $main = Wait-NativeWindow $shellProcess.Id "GraphCodeWindowsShell" 10 - $shellProcess.Refresh() - $daemonProcess.Refresh() - if ($main -eq [IntPtr]::Zero -or $shellProcess.HasExited -or $daemonProcess.HasExited) { - throw "$name did not retain live shell/daemon after onboarding" - } - if ($action -eq "project") { - $projectRow = Wait-ProjectRow $shellProcess.Id "Core" 20 - if (-not $projectRow) { throw "$name did not show the registered daemon project row" } - $survivalDeadline = [DateTime]::UtcNow.AddSeconds(5) - do { - $shellProcess.Refresh() - if ($shellProcess.HasExited) { - throw "$name shell exited with 0x$($shellProcess.ExitCode.ToString('X8')) after showing the project" - } - Start-Sleep -Milliseconds 100 - } while ([DateTime]::UtcNow -lt $survivalDeadline) - } - $markerPath = Join-Path $support "onboarding-seen" - if (-not (Wait-File $markerPath 10)) { - throw "$name did not persist the onboarding marker" - } - $logPath = Join-Path $support "graphcode-windows.log" - $log = if (Test-Path $logPath) { Get-Content $logPath -Raw } else { "" } - if ($log -match 'event=fatal') { throw "$name logged a fatal startup event: $log" } - - $projects = Invoke-ScrubbedCli $name $root $support @("projects") - if ($projects.ExitCode -ne 0) { - throw "$name CLI endpoint rejected the request: $($projects.Error)" - } - $stdout = $projects.Output - if ($action -eq "project" -and $stdout -notmatch "(?m)^Core\s") { - throw "$name CLI does not list the registered project" - } - return [ordered]@{ - name = $name - onboardingObserved = $onboarding -ne [IntPtr]::Zero - action = $action - mainWindow = $main.ToInt64() - shellProcessId = $shellProcess.Id - daemonProcessId = $daemonProcess.Id - cliOutput = $stdout.Trim() - projectRow = $projectRow - environmentKeys = $keys - fatalLogAbsent = $true - } - } finally { - foreach ($process in @($shellProcess,$daemonProcess)) { - if ($process -and -not $process.HasExited) { - Stop-Process -Id $process.Id -Force - [void]$process.WaitForExit(10000) - } - if ($process) { $process.Dispose() } - } - Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue - } -} - -New-Item -ItemType Directory -Force -Path $ScratchRoot | Out-Null -$results = @( - Invoke-Case "first-run-escape" "escape" - Invoke-Case "first-run-skip" "skip" - Invoke-Case "first-run-complete" "complete" - Invoke-Case "onboarding-marker" "marker" - Invoke-Case "registered-project" "project" -) -if ($results.Count -ne 5) { throw "Scrubbed startup executed $($results.Count)/5 cases" } -Write-Output ("SCRUBBED_SHELL_STARTUP: PASS; executed=$($results.Count); " + - "developerToolsExcluded=true; results=" + ($results | ConvertTo-Json -Compress -Depth 5)) +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $Shell, + [Parameter(Mandatory)][string] $Daemon, + [Parameter(Mandatory)][string] $Cli, + [Parameter(Mandatory)][string] $ScratchRoot +) + +$ErrorActionPreference = "Stop" +. (Join-Path $PSScriptRoot "..\OwnedLiveProcess.ps1") +. (Join-Path $PSScriptRoot "ValidationFixtureDirectory.ps1") +Import-Module (Join-Path $PSScriptRoot "..\ShellProcessOwnership.psm1") -Force +Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes +Add-Type @' +using System; +using System.Runtime.InteropServices; +public static class ScrubbedStartupNative { + public delegate bool EnumWindowsProc(IntPtr window, IntPtr parameter); + [DllImport("user32.dll")] static extern bool EnumWindows(EnumWindowsProc callback, IntPtr parameter); + [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId); + [DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern int GetClassName(IntPtr window, System.Text.StringBuilder value, int capacity); + [DllImport("user32.dll")] static extern bool IsWindowVisible(IntPtr window); + [DllImport("user32.dll")] static extern bool PostMessage(IntPtr window, uint message, UIntPtr wparam, IntPtr lparam); + public static IntPtr Find(uint processId, string className) { + IntPtr result = IntPtr.Zero; + EnumWindows(delegate(IntPtr window, IntPtr parameter) { + uint owner; + GetWindowThreadProcessId(window, out owner); + if (owner != processId || !IsWindowVisible(window)) return true; + var actual = new System.Text.StringBuilder(128); + GetClassName(window, actual, actual.Capacity); + if (!String.Equals(actual.ToString(), className, StringComparison.Ordinal)) return true; + result = window; + return false; + }, IntPtr.Zero); + return result; + } + public static bool Escape(IntPtr window) { + return PostMessage(window, 0x0100, (UIntPtr)0x1B, IntPtr.Zero) && + PostMessage(window, 0x0101, (UIntPtr)0x1B, IntPtr.Zero); + } + public static bool Command(IntPtr window, uint action) { + return PostMessage(window, 0x0111, (UIntPtr)action, IntPtr.Zero); + } +} +'@ + +$allowedKeys = @( + "SystemRoot", "windir", "USERPROFILE", "LOCALAPPDATA", "APPDATA", + "TEMP", "TMP", "ProgramData", "HOMEDRIVE", "HOMEPATH", "PATH" +) +$forbiddenPathFragments = @( + ".graphcode-tools", "Visual Studio", "Windows Kits", "Swift" +) + +function New-StartInfo([string] $file, [string] $root) { + $profile = Join-Path $root "profile" + $system32 = Join-Path $env:SystemRoot "System32" + $powerShell = Join-Path $system32 "WindowsPowerShell\v1.0" + $info = [Diagnostics.ProcessStartInfo]::new() + $info.FileName = $file + $info.WorkingDirectory = Split-Path -Parent $file + $info.UseShellExecute = $false + $info.Environment.Clear() + $values = [ordered]@{ + SystemRoot = $env:SystemRoot + windir = $env:WINDIR + USERPROFILE = $profile + LOCALAPPDATA = Join-Path $profile "AppData\Local" + APPDATA = Join-Path $profile "AppData\Roaming" + TEMP = Join-Path $root "temp" + TMP = Join-Path $root "temp" + ProgramData = $env:ProgramData + HOMEDRIVE = [IO.Path]::GetPathRoot($profile).TrimEnd("\") + HOMEPATH = $profile.Substring([IO.Path]::GetPathRoot($profile).Length - 1) + PATH = "$system32;$powerShell;$(Split-Path -Parent $file)" + } + foreach ($name in $values.Keys) { $info.Environment[$name] = [string]$values[$name] } + return $info +} + +function Wait-NativeWindow([int] $processId, [string] $className, [int] $seconds) { + $deadline = [DateTime]::UtcNow.AddSeconds($seconds) + do { + $window = [ScrubbedStartupNative]::Find([uint32]$processId, $className) + if ($window -ne [IntPtr]::Zero) { return $window } + Start-Sleep -Milliseconds 50 + } while ([DateTime]::UtcNow -lt $deadline) + return [IntPtr]::Zero +} + +function Wait-NativeWindowClosed([int] $processId, [string] $className, [int] $seconds) { + $deadline = [DateTime]::UtcNow.AddSeconds($seconds) + do { + if ([ScrubbedStartupNative]::Find([uint32]$processId, $className) -eq [IntPtr]::Zero) { + return $true + } + Start-Sleep -Milliseconds 50 + } while ([DateTime]::UtcNow -lt $deadline) + return $false +} + +function Wait-File([string] $path, [int] $seconds) { + $deadline = [DateTime]::UtcNow.AddSeconds($seconds) + do { + if (Test-Path -LiteralPath $path -PathType Leaf) { return $true } + Start-Sleep -Milliseconds 50 + } while ([DateTime]::UtcNow -lt $deadline) + return $false +} + +function Invoke-ScrubbedCli($processScope, [string] $name, [string] $root, [string] $support, [string[]] $arguments) { + $cliInfo = New-StartInfo $Cli $root + $cliInfo.Environment["GRAPHCODE_SUPPORT_DIR"] = $support + foreach ($argument in $arguments) { [void]$cliInfo.ArgumentList.Add($argument) } + $cliInfo.RedirectStandardOutput = $true + $cliInfo.RedirectStandardError = $true + $cliProcess = $null + $cliStarted = $false + $cliHandleHeld = $false + $cliError = $null + try { + $cliProcess = Start-ShellOwnedProcess $processScope $cliInfo + $cliStarted = $true + [void]$cliProcess.Handle + $cliHandleHeld = $true + if (-not $cliProcess.WaitForExit(10000)) { throw "$name CLI '$($arguments[0])' timed out" } + Complete-ShellOwnedProcess $processScope $cliProcess + return [pscustomobject]@{ + ExitCode = $cliProcess.ExitCode + Output = Read-ShellOwnedOutput $processScope $cliProcess + Error = Read-ShellOwnedOutput $processScope $cliProcess -StandardError + } + } catch { + $cliError = $_.Exception + throw + } finally { + $cleanupErrors = [Collections.Generic.List[Exception]]::new() + try { + if ($cliStarted) { + if (-not $cliHandleHeld) { + throw "The started CLI's original process handle was not acquired; no termination attempted." + } + Stop-OwnedLiveProcess -Process $cliProcess -Name "$name CLI" + } + } catch { + $cleanupErrors.Add($_.Exception) + } finally { + if ($cliProcess) { + try { $cliProcess.Dispose() } catch { $cleanupErrors.Add($_.Exception) } + } + } + if ($cleanupErrors.Count -gt 0) { + if ($cliError) { $cleanupErrors.Insert(0, $cliError) } + throw (New-OwnedLiveProcessCleanupError ` + -Message "$name CLI cleanup could not be verified; fixture root: $root" ` + -Errors ([Exception[]]$cleanupErrors.ToArray()) -RetainedRoots @($root)) + } + } +} + +function Wait-ProjectRow([int] $processId, [string] $projectName, [int] $seconds) { + $processCondition = [Windows.Automation.PropertyCondition]::new( + [Windows.Automation.AutomationElement]::ProcessIdProperty, $processId) + $rowCondition = [Windows.Automation.AndCondition]::new( + [Windows.Automation.PropertyCondition]::new( + [Windows.Automation.AutomationElement]::ControlTypeProperty, + [Windows.Automation.ControlType]::ListItem), + [Windows.Automation.PropertyCondition]::new( + [Windows.Automation.AutomationElement]::NameProperty, $projectName)) + $deadline = [DateTime]::UtcNow.AddSeconds($seconds) + do { + $window = [Windows.Automation.AutomationElement]::RootElement.FindFirst( + [Windows.Automation.TreeScope]::Children, $processCondition) + if ($window) { + $row = $window.FindFirst([Windows.Automation.TreeScope]::Descendants, $rowCondition) + if ($row -and $row.Current.AutomationId.StartsWith("open-project-", [StringComparison]::Ordinal)) { + return $row.Current.AutomationId + } + } + Start-Sleep -Milliseconds 200 + } while ([DateTime]::UtcNow -lt $deadline) + return $null +} + +function Invoke-Case( + [string] $name, + [ValidateSet("escape", "skip", "complete", "marker", "project")] + [string] $action +) { + $root = Join-Path $ScratchRoot $name + if (Test-Path -LiteralPath $root) { + throw (New-OwnedLiveProcessCleanupError ` + -Message "$name scratch already exists; ownership is unproven: $root" ` + -Errors @([InvalidOperationException]::new("Existing scratch was not reused.")) ` + -RetainedRoots @($root)) + } + try { + $null = New-ValidationFixtureDirectory -Path $root + } catch { + $creationError = $_.Exception + if (Test-Path -LiteralPath $root) { + throw (New-OwnedLiveProcessCleanupError ` + -Message "$name scratch appeared during admission; ownership is unproven: $root" ` + -Errors @($creationError) -RetainedRoots @($root)) + } + throw + } + $profile = Join-Path $root "profile" + $support = Join-Path $profile ".graphcode" + New-Item -ItemType Directory -Force -Path ` + $support,(Join-Path $profile "AppData\Local"),(Join-Path $profile "AppData\Roaming"),(Join-Path $root "temp") | + Out-Null + if ($action -in @("marker", "project")) { + Set-Content -LiteralPath (Join-Path $support "onboarding-seen") -Value "seen" -NoNewline + } + # A plain folder, not a Git repository: startup must not need Git on PATH. + $projectPath = Join-Path $root "Core" + $projectRow = $null + if ($action -eq "project") { + New-Item -ItemType Directory -Force -Path $projectPath | Out-Null + Set-Content -LiteralPath (Join-Path $projectPath "README.md") -Value "fixture" -NoNewline + } + + $daemonProcess = $null + $daemonInfo = New-StartInfo $Daemon $root + $daemonInfo.Environment["GRAPHCODE_SUPPORT_DIR"] = $support + $shellProcess = $null + $shellInfo = New-StartInfo $Shell $root + $processScope = $null + $daemonStarted = $false + $daemonHandleHeld = $false + $shellStarted = $false + $shellHandleHeld = $false + $caseError = $null + try { + $processScope = New-ShellProcessScope + $daemonProcess = Start-ShellOwnedProcess $processScope $daemonInfo + $daemonStarted = $true + [void]$daemonProcess.Handle + $daemonHandleHeld = $true + if ($action -eq "project") { + # Register through the production daemon before the shell starts, as the + # native folder picker does, so the shell restores a real daemon graph. + $deadline = [DateTime]::UtcNow.AddSeconds(15) + do { + $status = Invoke-ScrubbedCli $processScope $name $root $support @("status", $projectPath) + if ($status.ExitCode -eq 0) { break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if ($status.ExitCode -ne 0) { throw "$name could not register the project: $($status.Error)" } + } + $shellProcess = Start-ShellOwnedProcess $processScope $shellInfo + $shellStarted = $true + [void]$shellProcess.Handle + $shellHandleHeld = $true + $keys = @($shellInfo.Environment.Keys | Sort-Object) + if (($keys -join "|") -cne (($allowedKeys | Sort-Object) -join "|")) { + throw "$name environment keys differ: $($keys -join ',')" + } + foreach ($fragment in $forbiddenPathFragments) { + if ($shellInfo.Environment["PATH"].IndexOf( + $fragment, [StringComparison]::OrdinalIgnoreCase) -ge 0) { + throw "$name PATH contains forbidden developer fragment '$fragment'" + } + } + + $onboarding = Wait-NativeWindow $shellProcess.Id "GraphCodeWindowsOnboarding" 10 + if ($action -notin @("marker", "project")) { + if ($onboarding -eq [IntPtr]::Zero) { throw "$name onboarding was not shown" } + switch ($action) { + "escape" { + if (-not [ScrubbedStartupNative]::Escape($onboarding)) { + throw "$name onboarding rejected Escape" + } + } + "skip" { + if (-not [ScrubbedStartupNative]::Command($onboarding, 2)) { + throw "$name onboarding rejected Skip" + } + } + "complete" { + foreach ($page in 1..5) { + if (-not [ScrubbedStartupNative]::Command($onboarding, 4)) { + throw "$name onboarding rejected primary action on page $page" + } + Start-Sleep -Milliseconds 50 + } + } + } + if (-not (Wait-NativeWindowClosed $shellProcess.Id "GraphCodeWindowsOnboarding" 10)) { + throw "$name onboarding did not close" + } + } elseif ($onboarding -ne [IntPtr]::Zero) { + throw "$name onboarding was shown despite the marker" + } + + $main = Wait-NativeWindow $shellProcess.Id "GraphCodeWindowsShell" 10 + $shellProcess.Refresh() + $daemonProcess.Refresh() + if ($main -eq [IntPtr]::Zero -or $shellProcess.HasExited -or $daemonProcess.HasExited) { + throw "$name did not retain live shell/daemon after onboarding" + } + if ($action -eq "project") { + $projectRow = Wait-ProjectRow $shellProcess.Id "Core" 20 + if (-not $projectRow) { throw "$name did not show the registered daemon project row" } + $survivalDeadline = [DateTime]::UtcNow.AddSeconds(5) + do { + $shellProcess.Refresh() + if ($shellProcess.HasExited) { + throw "$name shell exited with 0x$($shellProcess.ExitCode.ToString('X8')) after showing the project" + } + Start-Sleep -Milliseconds 100 + } while ([DateTime]::UtcNow -lt $survivalDeadline) + } + $markerPath = Join-Path $support "onboarding-seen" + if (-not (Wait-File $markerPath 10)) { + throw "$name did not persist the onboarding marker" + } + $logPath = Join-Path $support "graphcode-windows.log" + $log = if (Test-Path $logPath) { Get-Content $logPath -Raw } else { "" } + if ($log -match 'event=fatal') { throw "$name logged a fatal startup event: $log" } + + $projects = Invoke-ScrubbedCli $processScope $name $root $support @("projects") + if ($projects.ExitCode -ne 0) { + throw "$name CLI endpoint rejected the request: $($projects.Error)" + } + $stdout = $projects.Output + if ($action -eq "project" -and $stdout -notmatch "(?m)^Core\s") { + throw "$name CLI does not list the registered project" + } + return [ordered]@{ + name = $name + onboardingObserved = $onboarding -ne [IntPtr]::Zero + action = $action + mainWindow = $main.ToInt64() + shellProcessId = $shellProcess.Id + daemonProcessId = $daemonProcess.Id + cliOutput = $stdout.Trim() + projectRow = $projectRow + environmentKeys = $keys + fatalLogAbsent = $true + } + } catch { + $caseError = $_.Exception + throw + } finally { + $cleanupErrors = [Collections.Generic.List[Exception]]::new() + foreach ($entry in @( + @{ Process = $shellProcess; Started = $shellStarted; HandleHeld = $shellHandleHeld; Name = "shell" }, + @{ Process = $daemonProcess; Started = $daemonStarted; HandleHeld = $daemonHandleHeld; Name = "daemon" } + )) { + try { + if ($entry.Started) { + if (-not $entry.HandleHeld) { + throw "The started $($entry.Name)'s original process handle was not acquired; no termination attempted." + } + Stop-OwnedLiveProcess -Process $entry.Process -Name "$name $($entry.Name)" + } + } catch { + $cleanupErrors.Add($_.Exception) + } finally { + if ($entry.Process) { + try { $entry.Process.Dispose() } catch { $cleanupErrors.Add($_.Exception) } + } + } + } + if ($processScope) { + try { Close-ShellProcessScope $processScope } catch { $cleanupErrors.Add($_.Exception) } + } + if ($cleanupErrors.Count -gt 0) { + if ($caseError) { $cleanupErrors.Insert(0, $caseError) } + throw (New-OwnedLiveProcessCleanupError ` + -Message "$name process cleanup could not be verified; fixture root: $root" ` + -Errors ([Exception[]]$cleanupErrors.ToArray()) -RetainedRoots @($root)) + } + if ($caseError -and (Test-OwnedLiveProcessRetentionRequired $caseError)) { + Write-Output "SCRUBBED_STARTUP_FIXTURE_RETENTION_REQUESTED=$root" + } else { + try { + Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction Stop + } catch { + if ($caseError) { + throw [AggregateException]::new( + "$name fixture cleanup failed.", [Exception[]]@($caseError, $_.Exception)) + } + throw + } + } + } +} + +New-Item -ItemType Directory -Force -Path $ScratchRoot | Out-Null +$results = @( + Invoke-Case "first-run-escape" "escape" + Invoke-Case "first-run-skip" "skip" + Invoke-Case "first-run-complete" "complete" + Invoke-Case "onboarding-marker" "marker" + Invoke-Case "registered-project" "project" +) +if ($results.Count -ne 5) { throw "Scrubbed startup executed $($results.Count)/5 cases" } +Write-Output ("SCRUBBED_SHELL_STARTUP: PASS; executed=$($results.Count); " + + "developerToolsExcluded=true; results=" + ($results | ConvertTo-Json -Compress -Depth 5)) diff --git a/Tools/windows/Tests/ShellProcessOwnership.Tests.ps1 b/Tools/windows/Tests/ShellProcessOwnership.Tests.ps1 new file mode 100644 index 00000000..86f6352e --- /dev/null +++ b/Tools/windows/Tests/ShellProcessOwnership.Tests.ps1 @@ -0,0 +1,993 @@ +BeforeDiscovery { + Import-Module (Join-Path $PSScriptRoot "..\ShellProcessOwnership.psm1") -Force +} + +BeforeAll { + $repo = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path + $hadValidationAssembly = [Environment]::GetEnvironmentVariables("Process").Contains("GRAPHCODE_VALIDATION_ASSEMBLY") + $savedValidationAssembly = $env:GRAPHCODE_VALIDATION_ASSEMBLY + if (-not $hadValidationAssembly) { + $env:GRAPHCODE_VALIDATION_ASSEMBLY = & (Join-Path $PSScriptRoot "..\Build-CompiledValidationRunner.ps1") + } + $shellPath = Join-Path $repo "Tools\windows\windows-shell.ps1" + $hadValidationRoot = [Environment]::GetEnvironmentVariables().Contains("GRAPHCODE_VALIDATION_ROOT") + $savedValidationRoot = [Environment]::GetEnvironmentVariable("GRAPHCODE_VALIDATION_ROOT") + Remove-Item Env:\GRAPHCODE_VALIDATION_ROOT -ErrorAction SilentlyContinue + [Environment]::GetEnvironmentVariables().Contains("GRAPHCODE_VALIDATION_ROOT") | Should -BeFalse +} + +AfterAll { + if ($hadValidationAssembly) { $env:GRAPHCODE_VALIDATION_ASSEMBLY = $savedValidationAssembly } + else { Remove-Item Env:\GRAPHCODE_VALIDATION_ASSEMBLY -ErrorAction SilentlyContinue } + if ($hadValidationRoot) { + [Environment]::SetEnvironmentVariable("GRAPHCODE_VALIDATION_ROOT", $savedValidationRoot, "Process") + } else { + Remove-Item Env:\GRAPHCODE_VALIDATION_ROOT -ErrorAction SilentlyContinue + } +} + +Describe "Owned shell scope mock-only lifecycle" { + InModuleScope ShellProcessOwnership { + BeforeEach { + $script:events = [Collections.Generic.List[string]]::new() + $script:process = [Diagnostics.Process]::new() + $script:run = [pscustomobject]@{ Process = $script:process; Handle = [IntPtr]123 } + $script:run | Add-Member ScriptMethod Resume { $script:events.Add("resume") } + $script:run | Add-Member ScriptMethod Stop { $script:events.Add("stop-held") } + $script:run | Add-Member ScriptMethod Close { $script:events.Add("close-launch") } + $script:run | Add-Member ScriptMethod RequireExited { $script:events.Add("root-exited") } + $script:run | Add-Member ScriptMethod Drain { $script:events.Add("drain") } + $script:run | Add-Member NoteProperty LaunchJob ([IntPtr]567) + $script:run | Add-Member NoteProperty ScopeAssigned $true + $script:run | Add-Member NoteProperty LaunchAssigned $true + $script:run | Add-Member NoteProperty Finalized $false + $script:run | Add-Member NoteProperty LaunchJobClosed $false + $script:nextJob = 345 + Mock Initialize-ShellProcessOwnership {} + Mock New-ShellSuspendedLaunch { $script:events.Add("suspended"); $script:run } + Mock Invoke-ShellJob { + param($Operation) + $script:events.Add($Operation) + switch ($Operation) { + "Create" { $script:nextJob += 111; [IntPtr]$script:nextJob } + "Contains" { $true } + "Active" { 0 } + } + } + Mock Write-Host {} + $script:scope = New-ShellProcessScope + $script:events.Clear() + } + AfterEach { $script:process.Dispose() } + + It "assigns the suspended launch before releasing any target code" { + $actual = Start-ShellOwnedProcess $script:scope ([Diagnostics.ProcessStartInfo]::new()) + [object]::ReferenceEquals($actual, $script:process) | Should -BeTrue + ($script:events -join ",") | Should -Be "suspended,Assign,Create,Assign,resume" + $script:scope.Runs.Count | Should -Be 1 + } + It "retains and stops only the suspended original when assignment fails" { + Mock Invoke-ShellJob { throw "assignment refused" } -ParameterFilter { $Operation -eq "Assign" } + { Start-ShellOwnedProcess $script:scope ([Diagnostics.ProcessStartInfo]::new()) } | + Should -Throw "*assignment refused*" + ($script:events -join ",") | Should -Be "suspended,stop-held" + $script:scope.Runs.Count | Should -Be 1 + } + It "does not start a target in a closed scope" { + $script:scope.Closed = $true + { Start-ShellOwnedProcess $script:scope ([Diagnostics.ProcessStartInfo]::new()) } | + Should -Throw "*closed*" + Should -Invoke New-ShellSuspendedLaunch -Times 0 -Exactly + } + It "retains the owned launch and stops it when thread release fails" { + $script:run | Add-Member ScriptMethod Resume { throw "resume refused" } -Force + { Start-ShellOwnedProcess $script:scope ([Diagnostics.ProcessStartInfo]::new()) } | + Should -Throw "*resume refused*" + ($script:events -join ",") | Should -Be "suspended,Assign,Create,Assign,stop-held" + $script:scope.Runs.Count | Should -Be 1 + } + It "propagates a changed-suspension retention tag after stopping the original launch" { + $script:run | Add-Member ScriptMethod Resume { + $error = [InvalidOperationException]::new("suspension changed") + $error.Data["GraphCodeValidationRootRetentionRequired"] = $true + throw $error + } -Force + $failure = $null + try { Start-ShellOwnedProcess $script:scope ([Diagnostics.ProcessStartInfo]::new()) } catch { $failure = $_ } + $failure | Should -Not -BeNullOrEmpty + Test-ShellOwnershipUnproven $failure | Should -BeTrue + $script:events | Should -Contain "stop-held" + } + It "propagates pre-return native cleanup uncertainty without inventing a launch record" { + Mock New-ShellSuspendedLaunch { + $error = [InvalidOperationException]::new("native cleanup unproved") + $error.Data["GraphCodeValidationRootRetentionRequired"] = $true + throw $error + } + $failure = $null + try { Start-ShellOwnedProcess $script:scope ([Diagnostics.ProcessStartInfo]::new()) } catch { $failure = $_ } + Test-ShellOwnershipUnproven $failure | Should -BeTrue + $script:scope.Runs.Count | Should -Be 0 + $script:events | Should -Not -Contain "Assign" + } + It "refuses a newly reopened or stale PID object" { + $script:scope.Runs.Add($script:run) + $other = [Diagnostics.Process]::new() + try { + { Stop-ShellOwnedProcess $script:scope $other } | Should -Throw "*UNATTESTED_PROCESS*" + $script:events.Count | Should -Be 0 + } finally { $other.Dispose() } + } + It "uses the retained native launch even after its managed Process is disposed" { + $script:scope.Runs.Add($script:run) + $script:process.Dispose() + Stop-ShellOwnedProcess $script:scope $script:process + ($script:events -join ",") | Should -Be "Contains,stop-held" + } + It "refuses a lost job-membership attestation without termination" { + $script:scope.Runs.Add($script:run) + Mock Invoke-ShellJob { $false } -ParameterFilter { $Operation -eq "Contains" } + { Stop-ShellOwnedProcess $script:scope $script:process } | Should -Throw "*UNATTESTED_PROCESS*" + $script:events | Should -Not -Contain "stop-held" + } + It "waits for an empty job before closing launch and job handles" { + $script:scope.Runs.Add($script:run) + Close-ShellProcessScope $script:scope + ($script:events -join ",") | Should -Be "Terminate,Wait,Active,root-exited,root-exited,Terminate,Wait,Active,drain,Close,close-launch,Close" + $script:scope.Closed | Should -BeTrue + Should -Invoke Write-Host -Times 1 -Exactly + } + It "does not release handles or claim success when the job remains active" { + $script:scope.Runs.Add($script:run) + Mock Invoke-ShellJob { 1 } -ParameterFilter { $Operation -eq "Active" } + { Close-ShellProcessScope $script:scope } | Should -Throw "*OWNED_CLEANUP_UNPROVEN*" + $script:scope.Closed | Should -BeFalse + $script:events | Should -Not -Contain "close-launch" + Should -Invoke Write-Host -Times 0 -Exactly -ParameterFilter { $Object -like "SHELL_OWNED_JOB_CLEANUP=*" } + } + It "shares the original scope termination deadline across all held-root waits" { + $script:budgets = [Collections.Generic.List[int]]::new() + $script:run | Add-Member ScriptMethod RequireExited { + param($milliseconds) + if ($null -ne $milliseconds) { + $script:budgets.Add($milliseconds) + [Threading.Thread]::Sleep(60) + } + } -Force + $other = [pscustomobject]@{ + Process = $script:process; ScopeAssigned = $true; Finalized = $false + LaunchJob = [IntPtr]678; LaunchJobClosed = $false + } + $other | Add-Member ScriptMethod RequireExited { + param($milliseconds) + if ($null -ne $milliseconds) { $script:budgets.Add($milliseconds) } + } + $other | Add-Member ScriptMethod Drain {} + $other | Add-Member ScriptMethod Close {} + $script:scope.Runs.Add($script:run) + $script:scope.Runs.Add($other) + Close-ShellProcessScope $script:scope + $script:budgets.Count | Should -Be 2 + $script:budgets[0] | Should -BeGreaterThan 0 + $script:budgets[0] | Should -BeLessOrEqual 5000 + $script:budgets[1] | Should -BeLessThan $script:budgets[0] + $script:scope.Closed | Should -BeTrue + } + It "does not drain or release authority when the bounded held-root wait refuses" { + $script:run | Add-Member ScriptMethod RequireExited { + param($milliseconds) + $script:events.Add("held-root-wait:$milliseconds") + throw "controlled held root did not signal within its original deadline" + } -Force + $script:scope.Runs.Add($script:run) + $failure = $null + try { Close-ShellProcessScope $script:scope } catch { $failure = $_ } + Test-ShellOwnershipUnproven $failure | Should -BeTrue + $script:scope.Closed | Should -BeFalse + $script:run.Finalized | Should -BeFalse + $script:events | Should -Not -Contain "drain" + $script:events | Should -Not -Contain "Close" + $script:events | Should -Not -Contain "close-launch" + } + It "proves every original root before closing a previously signalled sibling" { + $other = [pscustomobject]@{ Process = $script:process; ScopeAssigned = $true; Finalized = $false } + $other | Add-Member ScriptMethod RequireExited { param($milliseconds) throw "later held root is unproved" } + $script:scope.Runs.Add($script:run) + $script:scope.Runs.Add($other) + { Close-ShellProcessScope $script:scope } | Should -Throw "*later held root is unproved*" + $script:scope.Closed | Should -BeFalse + $script:run.Finalized | Should -BeFalse + $script:events | Should -Not -Contain "drain" + $script:events | Should -Not -Contain "Close" + $script:events | Should -Not -Contain "close-launch" + } + It "does not claim cleanup when output drainage fails" { + $script:run | Add-Member ScriptMethod Drain { throw "output EOF unproved" } -Force + $script:scope.Runs.Add($script:run) + { Close-ShellProcessScope $script:scope } | Should -Throw "*EOF unproved*" + $script:scope.Closed | Should -BeFalse + Should -Invoke Write-Host -Times 0 -Exactly -ParameterFilter { $Object -like "SHELL_OWNED_JOB_CLEANUP=*" } + } + It "does not report success when native job termination fails" { + Mock Invoke-ShellJob { throw "termination refused" } -ParameterFilter { $Operation -eq "Terminate" } + { Close-ShellProcessScope $script:scope } | Should -Throw "*termination refused*" + $script:scope.Closed | Should -BeFalse + Should -Invoke Write-Host -Times 0 -Exactly -ParameterFilter { $Object -like "SHELL_OWNED_JOB_CLEANUP=*" } + } + It "does not reopen a candidate after the scope has closed" { + $script:scope.Closed = $true + { Get-ShellOwnedMember $script:scope 99999999 } | Should -Throw "*scope is closed*" + $script:events.Count | Should -Be 0 + } + It "refuses an unfinalized output read without waiting for inherited pipe EOF" -Tag PipeFinalization { + $script:task = [pscustomobject]@{} + $script:task | Add-Member ScriptMethod Wait { $script:events.Add("EOF-wait"); throw "descendant pipe still open" } + $script:run | Add-Member NoteProperty Output $script:task + $script:scope.Runs.Add($script:run) + { Read-ShellOwnedOutput $script:scope $script:process } | Should -Throw "*explicit launch finalization*" + $script:events | Should -Not -Contain "EOF-wait" + } + It "finalizes only one private launch job before draining its inherited pipes" { + $script:scope.Runs.Add($script:run) + Complete-ShellOwnedProcess $script:scope $script:process + ($script:events -join ",") | Should -Be "root-exited,Terminate,Wait,Active,drain,Close" + $script:run.Finalized | Should -BeTrue + Should -Invoke Invoke-ShellJob -Times 0 -Exactly -ParameterFilter { + $Operation -eq "Terminate" -and $Job -eq [IntPtr]456 + } + Should -Invoke Invoke-ShellJob -Times 1 -Exactly -ParameterFilter { + $Operation -eq "Terminate" -and $Job -eq [IntPtr]567 + } + $script:events | Should -Not -Contain "close-launch" + Complete-ShellOwnedProcess $script:scope $script:process + Should -Invoke Invoke-ShellJob -Times 1 -Exactly -ParameterFilter { $Operation -eq "Terminate" } + } + It "refuses finalization of a still-live root without terminating any job" { + $script:run | Add-Member ScriptMethod RequireExited { throw "original root is still live" } -Force + $script:scope.Runs.Add($script:run) + { Complete-ShellOwnedProcess $script:scope $script:process } | Should -Throw "*still live*" + Should -Invoke Invoke-ShellJob -Times 0 -Exactly -ParameterFilter { $Operation -eq "Terminate" } + $script:run.Finalized | Should -BeFalse + } + It "retains the launch when its private job remains active" { + $script:scope.Runs.Add($script:run) + Mock Invoke-ShellJob { 1 } -ParameterFilter { $Operation -eq "Active" } + $failure = $null + try { Complete-ShellOwnedProcess $script:scope $script:process } catch { $failure = $_ } + Test-ShellOwnershipUnproven $failure | Should -BeTrue + $script:run.Finalized | Should -BeFalse + $script:events | Should -Not -Contain "drain" + $script:events | Should -Not -Contain "Close" + } + It "propagates EOF uncertainty after private-job emptiness without releasing handles" { + $script:scope.Runs.Add($script:run) + $script:run | Add-Member ScriptMethod Drain { throw "inherited pipe EOF unproved" } -Force + $failure = $null + try { Complete-ShellOwnedProcess $script:scope $script:process } catch { $failure = $_ } + Test-ShellOwnershipUnproven $failure | Should -BeTrue + $script:run.Finalized | Should -BeFalse + $script:events | Should -Not -Contain "Close" + $script:events | Should -Not -Contain "close-launch" + } + It "cleans a partially assigned suspended launch through its original handle" { + $script:run.ScopeAssigned = $false + $script:run.LaunchAssigned = $false + $script:run.LaunchJob = [IntPtr]::Zero + $script:scope.Runs.Add($script:run) + Close-ShellProcessScope $script:scope + $script:events | Should -Contain "stop-held" + $script:run.Finalized | Should -BeTrue + $script:scope.Closed | Should -BeTrue + } + It "closes a partially finalized scope idempotently without re-finalizing completed jobs" { + $script:scope.Runs.Add($script:run) + Complete-ShellOwnedProcess $script:scope $script:process + $script:events.Clear() + Close-ShellProcessScope $script:scope + ($script:events -join ",") | Should -Be "Terminate,Wait,Active,close-launch,Close" + $script:events.Clear() + Close-ShellProcessScope $script:scope + $script:events.Count | Should -Be 0 + } + It "leaves a concurrently live sibling launch untouched during explicit finalization" { + $other = [Diagnostics.Process]::new() + try { + $sibling = [pscustomobject]@{ Process = $other; LaunchJob = [IntPtr]678; Finalized = $false } + $sibling | Add-Member ScriptMethod RequireExited { throw "sibling root is still live" } + $script:scope.Runs.Add($script:run) + $script:scope.Runs.Add($sibling) + Complete-ShellOwnedProcess $script:scope $script:process + $sibling.Finalized | Should -BeFalse + Should -Invoke Invoke-ShellJob -Times 0 -Exactly -ParameterFilter { + $Operation -eq "Terminate" -and $Job -ne [IntPtr]567 + } + } finally { $other.Dispose() } + } + It "cleans a retained private job whose suspended launch assignment failed" { + Mock Invoke-ShellJob { throw "private assignment refused" } -ParameterFilter { + $Operation -eq "Assign" -and $Job -eq [IntPtr]567 + } + { Start-ShellOwnedProcess $script:scope ([Diagnostics.ProcessStartInfo]::new()) } | + Should -Throw "*private assignment refused*" + $script:run.ScopeAssigned | Should -BeTrue + $script:run.LaunchAssigned | Should -BeFalse + $script:events | Should -Not -Contain "resume" + Close-ShellProcessScope $script:scope + $script:run.Finalized | Should -BeTrue + $script:run.LaunchJobClosed | Should -BeTrue + $script:scope.Closed | Should -BeTrue + } + It "returns complete stdout and stderr only after explicit finalization" { + $script:run | Add-Member NoteProperty Output ([Threading.Tasks.Task]::FromResult[string]("stdout")) + $script:run | Add-Member NoteProperty Error ([Threading.Tasks.Task]::FromResult[string]("stderr")) + $script:scope.Runs.Add($script:run) + Complete-ShellOwnedProcess $script:scope $script:process + Read-ShellOwnedOutput $script:scope $script:process | Should -BeExactly "stdout" + Read-ShellOwnedOutput $script:scope $script:process -StandardError | Should -BeExactly "stderr" + } + It "receives bounded incremental text without finalizing a live launch" -Tag IncrementalOutput { + $script:run | Add-Member ScriptMethod TakeOutput { param($maximum) "out:$maximum" } + $script:run | Add-Member ScriptMethod TakeError { param($maximum) "err:$maximum" } + $script:scope.Runs.Add($script:run) + $chunk = Receive-ShellOwnedOutput $script:scope $script:process -MaximumCharacters 8 + $chunk.Output | Should -BeExactly "out:8" + $chunk.Error | Should -BeExactly "err:8" + $script:run.Finalized | Should -BeFalse + $script:events.Count | Should -Be 0 + } + It "refuses incremental reads through a different process object" { + $script:scope.Runs.Add($script:run) + $other = [Diagnostics.Process]::new() + try { + { Receive-ShellOwnedOutput $script:scope $other } | Should -Throw "*original launch record*" + $script:events.Count | Should -Be 0 + } finally { $other.Dispose() } + } + It "rejects invalid incremental batch limits before accessing any stream or job" { + $script:scope.Runs.Add($script:run) + { Receive-ShellOwnedOutput $script:scope $script:process -MaximumCharacters 1 } | Should -Throw + { Receive-ShellOwnedOutput $script:scope $script:process -MaximumCharacters 65537 } | Should -Throw + $script:events.Count | Should -Be 0 + } + It "propagates incremental pump faults without returning success-shaped chunks" { + $script:run | Add-Member ScriptMethod TakeOutput { throw [IO.IOException]::new("stdout pump failed") } + $script:run | Add-Member ScriptMethod TakeError { throw "stderr must not be accessed" } + $script:scope.Runs.Add($script:run) + $failure = $null + try { Receive-ShellOwnedOutput $script:scope $script:process } catch { $failure = $_ } + $failure.Exception.Message | Should -Match "stdout pump failed" + Test-ShellOwnershipUnproven $failure | Should -BeTrue + $script:events.Count | Should -Be 0 + } + } +} + +Describe "Original held-root signalling after real native job termination" { + It "closes actual shared-helper scopes only after all original root objects signal and both readers reach EOF" { + $assemblyPath = $env:GRAPHCODE_VALIDATION_ASSEMBLY + $executable = [IO.Path]::ChangeExtension($assemblyPath, '.exe') + Test-Path -LiteralPath $executable -PathType Leaf | Should -BeTrue + $executed = 0 + foreach ($iteration in 1..10) { + $scope = New-ShellProcessScope + try { + foreach ($index in 1..4) { + $start = [Diagnostics.ProcessStartInfo]::new($executable) + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + foreach ($argument in @('--ownership-fixture', 'shell-scope-child', $TestDrive)) { + $start.ArgumentList.Add($argument) + } + $null = Start-ShellOwnedProcess $scope $start + } + $ready = [Collections.Generic.HashSet[int]]::new() + $clock = [Diagnostics.Stopwatch]::StartNew() + while ($ready.Count -ne 4) { + foreach ($run in $scope.Runs) { + if ($ready.Contains($run.Process.Id)) { continue } + $chunk = Receive-ShellOwnedOutput $scope $run.Process + if ($chunk.Output.Contains('shell-scope-ready')) { $null = $ready.Add($run.Process.Id) } + } + if ($clock.ElapsedMilliseconds -gt 5000) { throw 'Controlled held roots did not all become ready.' } + [Threading.Thread]::Sleep(5) + } + Close-ShellProcessScope $scope + $scope.Closed | Should -BeTrue + foreach ($run in $scope.Runs) { + $run.Finalized | Should -BeTrue + $run.LaunchJobClosed | Should -BeTrue + $run.Handle | Should -Be ([IntPtr]::Zero) + $run.Output.IsCompletedSuccessfully | Should -BeTrue + $run.Error.IsCompletedSuccessfully | Should -BeTrue + $run.Output.Result | Should -Match 'shell-scope-ready' + $run.Error.Result | Should -Match 'shell-scope-stderr' + $executed++ + } + Close-ShellProcessScope $scope + } finally { + if (-not $scope.Closed) { + try { Close-ShellProcessScope $scope } catch { + try { [Console]::Out.WriteLine('GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven'); [Console]::Out.Flush() } + finally { [Threading.Thread]::Sleep([Threading.Timeout]::Infinite) } + } + } + } + } + $executed | Should -Be 40 + Write-Host "SHELL_SCOPE_NATIVE_EXECUTED=40 PASSED=40 FAILED=0 scopes=10" + } +} + +Describe "Incremental capture before EOF (managed reader only)" { + BeforeAll { + InModuleScope ShellProcessOwnership { Initialize-ShellProcessOwnership } + $captureType = ("ShellOwnedLaunch" -as [type]).GetNestedType("OutputCapture", [Reflection.BindingFlags]::NonPublic) + & (Join-Path $PSScriptRoot "..\Import-CompiledValidationAssembly.ps1") ` + -AssemblyPath $env:GRAPHCODE_VALIDATION_ASSEMBLY ` + -RequiredType "GraphCode.Validation.TestFixtures.ControlledOutputReader" | Out-Null + $readerType = [GraphCode.Validation.TestFixtures.ControlledOutputReader] + $writerType = [GraphCode.Validation.TestFixtures.FailingForwardWriter] + } + BeforeEach { + $expectReaderFault = $false + $text = "a" + [char]::ConvertFromUtf32(0x1F600) + "b" + $reader = [Activator]::CreateInstance($readerType, [object[]]@($text)) + $capture = [Activator]::CreateInstance($captureType, [object[]]@($reader)) + } + AfterEach { + [void] $reader.End.TrySetResult(0) + try { + if (-not $capture.Completion.Wait(5000)) { throw "Managed capture did not stop" } + } catch { + if (-not $expectReaderFault -or -not $capture.Completion.IsFaulted) { throw } + } + $reader.Dispose() + } + It "forwards bounded characters before EOF without splitting surrogate pairs or losing final text" { + $capture.Completion.IsCompleted | Should -BeFalse + $capture.Take(2) | Should -BeExactly "a" + $capture.Take(2) | Should -BeExactly ([char]::ConvertFromUtf32(0x1F600)) + $capture.Take(2) | Should -BeExactly "b" + $capture.Take(2) | Should -BeExactly "" + ($null -eq $capture.Take(2)) | Should -BeFalse + $capture.Completion.IsCompleted | Should -BeFalse + [void] $reader.End.TrySetResult(0) + $capture.Completion.Wait(5000) | Should -BeTrue + $capture.Completion.GetAwaiter().GetResult() | Should -BeExactly $text + } + It "surfaces a reader fault instead of reporting an empty successful stream" { + $expectReaderFault = $true + [void] $reader.End.TrySetException([IO.IOException]::new("managed reader failed")) + { $capture.Completion.Wait(5000) } | Should -Throw "*managed reader failed*" + { $capture.Take(8) } | Should -Throw "*managed reader failed*" + } + It "enforces the same hard character bounds in the actual managed capture" { + { $capture.Take(1) } | Should -Throw + { $capture.Take(65537) } | Should -Throw + $capture.Take(4) | Should -BeExactly $text + } + It "preserves text that arrives between empty non-EOF polls" { + $capture.Take(4) | Should -BeExactly $text + $capture.Take(4) | Should -BeExactly "" + $capture.Completion.IsCompleted | Should -BeFalse + $reader.Tail = "later" + [void] $reader.End.TrySetResult(5) + $received = "" + $clock = [Diagnostics.Stopwatch]::StartNew() + while ($received.Length -lt 5 -and $clock.ElapsedMilliseconds -lt 5000) { + $chunk = $capture.Take(2) + $chunk.Length | Should -BeLessOrEqual 2 + $received += $chunk + [Threading.Thread]::Sleep(1) + } + $received | Should -BeExactly "later" + $capture.Completion.IsCompleted | Should -BeFalse + [void] $reader.End.TrySetResult(0) + $capture.Completion.Wait(5000) | Should -BeTrue + $capture.Completion.GetAwaiter().GetResult() | Should -BeExactly ($text + "later") + } + It "forwards exactly the 65536-character ceiling and retains the complete capture" { + $large = "x" * 70000 + $largeReader = [Activator]::CreateInstance($readerType, [object[]]@($large)) + $largeCapture = [Activator]::CreateInstance($captureType, [object[]]@($largeReader)) + try { + $largeCapture.Take(65536).Length | Should -Be 65536 + $largeCapture.Take(65536).Length | Should -Be 4464 + $largeCapture.Take(65536) | Should -BeExactly "" + $largeCapture.Completion.IsCompleted | Should -BeFalse + [void] $largeReader.End.TrySetResult(0) + $largeCapture.Completion.Wait(5000) | Should -BeTrue + $largeCapture.Completion.GetAwaiter().GetResult() | Should -BeExactly $large + } finally { + [void] $largeReader.End.TrySetResult(0) + if (-not $largeCapture.Completion.Wait(5000)) { throw "Large managed capture did not stop" } + $largeReader.Dispose() + } + } + It "redirected=: forwards only inherited-style streams before EOF" -TestCases @( + @{ redirected = $false }, @{ redirected = $true } + ) { + param($redirected) + $sink = [IO.StringWriter]::new() + $select = ("ShellOwnedLaunch" -as [type]).GetMethod("ForwardWriter", + [Reflection.BindingFlags]::Static -bor [Reflection.BindingFlags]::NonPublic) + $forward = $select.Invoke($null, [object[]]@($redirected, $sink)) + $liveReader = [Activator]::CreateInstance($readerType, [object[]]@($text)) + $liveCapture = [Activator]::CreateInstance($captureType, [object[]]@($liveReader, $forward)) + try { + $liveCapture.Completion.IsCompleted | Should -BeFalse + $sink.ToString() | Should -BeExactly $(if ($redirected) { "" } else { $text }) + $liveCapture.Take(4) | Should -BeExactly $text + [void] $liveReader.End.TrySetResult(0) + $liveCapture.Completion.Wait(5000) | Should -BeTrue + $liveCapture.Completion.GetAwaiter().GetResult() | Should -BeExactly $text + $sink.ToString() | Should -BeExactly $(if ($redirected) { "" } else { $text }) + } finally { + [void] $liveReader.End.TrySetResult(0) + if (-not $liveCapture.Completion.Wait(5000)) { throw "Live-forward capture did not stop" } + $liveReader.Dispose() + $sink.Dispose() + } + } + It "flushFailure=: surfaces forwarding sink failures in the capture task" -TestCases @( + @{ flushFailure = $false }, @{ flushFailure = $true } + ) { + param($flushFailure) + $sink = [Activator]::CreateInstance($writerType) + $sink.FailFlush = $flushFailure + $liveReader = [Activator]::CreateInstance($readerType, [object[]]@($text)) + $liveCapture = [Activator]::CreateInstance($captureType, [object[]]@($liveReader, $sink)) + try { + $expected = if ($flushFailure) { "*forward flush failed*" } else { "*forward write failed*" } + { $liveCapture.Completion.Wait(5000) } | Should -Throw $expected + { $liveCapture.Take(4) } | Should -Throw $expected + } finally { + [void] $liveReader.End.TrySetResult(0) + $liveReader.Dispose() + $sink.Dispose() + } + } + It "preserves UTF-8 forwarding when a surrogate pair crosses pump read boundaries" -Tag ForwardUnicode { + $large = ("x" * 4095) + [char]::ConvertFromUtf32(0x1F600) + "b" + $bytes = [IO.MemoryStream]::new() + $sink = [IO.StreamWriter]::new($bytes, [Text.UTF8Encoding]::new($false), 1024, $true) + $sink.AutoFlush = $true + $liveReader = [Activator]::CreateInstance($readerType, [object[]]@($large)) + $liveCapture = [Activator]::CreateInstance($captureType, [object[]]@($liveReader, $sink)) + try { + [void] $liveReader.End.TrySetResult(0) + $liveCapture.Completion.Wait(5000) | Should -BeTrue + $liveCapture.Completion.GetAwaiter().GetResult() | Should -BeExactly $large + $forwarded = [Text.UTF8Encoding]::new($false, $true).GetString($bytes.ToArray()) + ($forwarded -ceq $large) | Should -BeTrue -Because "forwarding must not flush half a surrogate pair" + } finally { + [void] $liveReader.End.TrySetResult(0) + if (-not $liveCapture.Completion.Wait(5000)) { throw "UTF-8 forwarding capture did not stop" } + $liveReader.Dispose() + $sink.Dispose() + $bytes.Dispose() + } + } +} + +Describe "Ownership source wiring (not native execution)" { + It "selects live forwarding independently for originally unredirected stdout and stderr" -Tag ForwardPolicy { + InModuleScope ShellProcessOwnership { Initialize-ShellProcessOwnership } + $select = ("ShellOwnedLaunch" -as [type]).GetMethod("ForwardWriter", + [Reflection.BindingFlags]::Static -bor [Reflection.BindingFlags]::NonPublic) + $select | Should -Not -BeNullOrEmpty + $sink = [IO.StringWriter]::new() + try { + ($null -eq $select.Invoke($null, [object[]]@($true, $sink))) | Should -BeTrue + [object]::ReferenceEquals($sink, $select.Invoke($null, [object[]]@($false, $sink))) | Should -BeTrue + } finally { $sink.Dispose() } + $native = Get-Content -LiteralPath (Join-Path $repo "Tools\windows\ShellOwnedProcess.cs") -Raw + $native | Should -Match 'ForwardWriter\(info.RedirectStandardOutput, Console.Out\)' + $native | Should -Match 'ForwardWriter\(info.RedirectStandardError, Console.Error\)' + } + It "preserves each smoke caller's original per-stream capture intent" { + $ast = [Management.Automation.Language.Parser]::ParseFile($shellPath, [ref]$null, [ref]$null) + $function = $ast.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq "Start-SmokeProcess" + }, $true) + . ([scriptblock]::Create($function.Extent.Text)) + $repoRoot = $repo + Mock Get-Command { [pscustomobject]@{ Source = "D:\synthetic.exe" } } + Mock Start-ShellOwnedProcess { param($Scope, $StartInfo) $script:observedStart = $StartInfo } + foreach ($case in @( + @{ output = $false; error = $false }, + @{ output = $true; error = $false }, + @{ output = $false; error = $true }, + @{ output = $true; error = $true } + )) { + Start-SmokeProcess "synthetic" @() -scope ([pscustomobject]@{}) ` + -CaptureOutput:$case.output -CaptureError:$case.error + $script:observedStart.RedirectStandardOutput | Should -Be $case.output + $script:observedStart.RedirectStandardError | Should -Be $case.error + $script:observedStart.WindowStyle | Should -Be ([Diagnostics.ProcessWindowStyle]::Normal) + } + $text = Get-Content -LiteralPath $shellPath -Raw + $text | Should -Match '\$versionProcess = Start-SmokeProcess \$app @\("--version"\) -CaptureOutput -CaptureError' + $text | Should -Match '\$busyApp = Start-SmokeProcess \$app @\("--smoke"\) -CaptureError' + $text | Should -Match '\$inputApp = Start-SmokeProcess \$app @\("--smoke"\) -CaptureError' + } + It "samples surviving bare-name descendants before finalization and reads only afterwards" { + $text = Get-Content -LiteralPath $shellPath -Raw + $text | Should -Match '(?s)\$process.WaitForExit\(\).*?& \$sample\s+Record-TestOwnedSessions\s+Complete-ShellOwnedProcess \$bareProcessScope \$process\s+\$stderr = Read-ShellOwnedOutput \$bareProcessScope \$process -StandardError' + } + It "inherits the current directory for default or empty settings and preserves explicit directories" -Tag WorkingDirectory { + $native = Get-Content -LiteralPath (Join-Path $repo "Tools\windows\ShellOwnedProcess.cs") -Raw + $argument = [regex]::Match($native, 'environment, (.*?), ref startup, out created') + $argument.Success | Should -BeTrue + $argument.Groups[1].Value | Should -BeExactly "SelectWorkingDirectory(info)" + $selector = "ShellOwnedLaunch" -as [type] + $default = [Diagnostics.ProcessStartInfo]::new() + $default.WorkingDirectory | Should -BeExactly "" + ($null -eq $selector::SelectWorkingDirectory($default)) | Should -BeTrue + $default.WorkingDirectory = "" + ($null -eq $selector::SelectWorkingDirectory($default)) | Should -BeTrue + $default.WorkingDirectory = "D:\explicit directory\" + $selector::SelectWorkingDirectory($default) | Should -BeExactly "D:\explicit directory\" + } + It "compiles the existing job mechanism and suspended-launch interop without invoking native APIs" { + InModuleScope ShellProcessOwnership { Initialize-ShellProcessOwnership } + "ShellOwnedLaunch" -as [type] | Should -Not -BeNullOrEmpty + } + It "removes CIM-derived and named zmx termination from both reachable scripts" { + foreach ($path in @($shellPath, (Join-Path $repo "Tools\windows\Tests\DaemonHandoff.Live.Tests.ps1"))) { + $text = Get-Content -LiteralPath $path -Raw + $text | Should -Not -Match 'Stop-Process|\.Kill\(|kill --force' + $text | Should -Match 'Close-ShellProcessScope' + $text | Should -Match 'UNATTESTED_SURVIVORS|Assert-NoOrphanShellProcesses' + } + } + It "requires held job membership before retaining a CIM-discovered handoff candidate" { + $text = Get-Content -LiteralPath (Join-Path $repo "Tools\windows\Tests\DaemonHandoff.Live.Tests.ps1") -Raw + $text | Should -Match 'Get-ShellOwnedMember \$processScope \$children\[0\]\.ProcessId' + $module = Get-Content -LiteralPath (Join-Path $repo "Tools\windows\ShellProcessOwnership.psm1") -Raw + $module | Should -Match '(?s)function Get-ShellOwnedMember.*?Contains.*?Members.Add' + } + It "preserves both registered large-paste sampling contracts with the owned launcher" { + $shellSource = Get-Content -LiteralPath $shellPath -Raw + $path = Join-Path $repo "Tools\windows\Tests\WindowsShell.Tests.ps1" + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile($path, [ref]$null, [ref]$errors) + $errors.Count | Should -Be 0 + $commands = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -eq "Assert-Contract" -and + ($node.Extent.Text.Contains("large-paste sampling") -or + $node.Extent.Text.Contains("large-paste metric is not tied")) + }, $true)) + $commands.Count | Should -Be 2 + function Assert-Contract($condition, $message) { + if (-not $condition) { throw $message } + } + foreach ($command in $commands) { & ([scriptblock]::Create($command.Extent.Text)) } + } + It "requires original/managed generation comparison and an explicit inherited handle list" { + $native = Get-Content -LiteralPath (Join-Path $repo "Tools\windows\ShellOwnedProcess.cs") -Raw + $native | Should -Match 'nativeCreated != managedCreated' + $native | Should -Match 'new IntPtr\(0x20002\)' + $native | Should -Match 'uint flags = 0x4' + } + It "preserves empty, spaced, quoted and trailing-backslash arguments without native execution" { + $quote = ("ShellOwnedLaunch" -as [type]).GetMethod("Quote", + [Reflection.BindingFlags]::Static -bor [Reflection.BindingFlags]::NonPublic) + $fixtures = @( + @{ value = ""; expected = '""' }, + @{ value = "plain"; expected = '"plain"' }, + @{ value = "two words"; expected = '"two words"' }, + @{ value = 'a"b'; expected = '"a\"b"' }, + @{ value = 'D:\two words\'; expected = '"D:\two words\\"' } + ) + foreach ($fixture in $fixtures) { + $quote.Invoke($null, [object[]]@($fixture.value)) | Should -BeExactly $fixture.expected + } + $fixtures.Count | Should -Be 5 + } +} + +Describe "Shell cleanup never promotes advisory process observations" { + It "refuses a disposed launch PID and a same-session CIM impostor without terminating either" { + $source = Get-Content -LiteralPath $shellPath -Raw + $legacy = [regex]::Match($source, + '(?s)if \(\$env:GRAPHCODE_ZMX -and \(Test-Path -LiteralPath \$env:GRAPHCODE_ZMX\)\) \{.*?\r?\n \}') + if (-not $legacy.Success) { + $source | Should -Match 'Close-ShellProcessScope' + $source | Should -Not -Match 'Stop-Process' + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseInput($source, [ref]$null, [ref]$errors) + $function = $ast.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq "Assert-NoOrphanShellProcesses" + }, $true) + . ([scriptblock]::Create($function.Extent.Text)) + function Test-TestSessionProcess { $true } + Mock Get-CimInstance { [pscustomobject]@{ ProcessId = 202; CommandLine = "forged-session" } } + Mock Stop-Process {} + { Assert-NoOrphanShellProcesses } | Should -Throw "*UNATTESTED_SURVIVORS*" + Should -Invoke Stop-Process -Times 0 -Exactly + return + } + + $ownedProcessIds = [Collections.Generic.HashSet[int]]::new() + [void]$ownedProcessIds.Add(101) + $ownedSessionNames = @() + $saved = $env:GRAPHCODE_ZMX + $env:GRAPHCODE_ZMX = "synthetic-zmx" + Mock Test-Path { $true } + Mock Get-Process { [pscustomobject]@{ Id = 101 } } + Mock Get-CimInstance { [pscustomobject]@{ Name = "zmx.exe"; ProcessId = 202 } } + Mock Stop-Process {} + function Get-ProcessTreeIds { @(101) } + function Test-TestSessionProcess { $true } + try { + & ([scriptblock]::Create($legacy.Value)) + Should -Invoke Stop-Process -Times 0 -Exactly + } finally { + $env:GRAPHCODE_ZMX = $saved + } + } +} + +Describe "Handoff teardown refuses unattested image matches" { + It ": only verified owned-job cleanup can release support files" -TestCases @( + @{ case = "same-path impostor"; hasSurvivor = $true }, + @{ case = "normal owned cleanup"; hasSurvivor = $false } + ) { + param($hasSurvivor) + $path = Join-Path $repo "Tools\windows\Tests\DaemonHandoff.Live.Tests.ps1" + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile($path, [ref]$null, [ref]$errors) + $errors.Count | Should -Be 0 + $main = @($ast.EndBlock.Statements | Where-Object { $_ -is [Management.Automation.Language.TryStatementAst] })[-1] + $processScope = [pscustomobject]@{ Closed = $false } + $daemonExecutable = "D:\synthetic-handoff\graphcoded.exe" + $supportDirectory = "D:\synthetic-handoff\support" + $supportCreated = $true + $primaryFailure = $null + function Close-ShellProcessScope { $processScope.Closed = $true } + Mock Get-CimInstance { + if ($hasSurvivor) { [pscustomobject]@{ + Name = "graphcoded.exe"; ProcessId = 303; ExecutablePath = "D:\synthetic-handoff\graphcoded.exe" + } } + } + + Mock Stop-Process {} + Mock Remove-Item {} + Mock Write-Host {} + Mock Write-Host {} -ModuleName ShellProcessOwnership + $body = ($main.Finally.Statements | ForEach-Object { $_.Extent.Text }) -join "`n" + if ($hasSurvivor) { + { & ([scriptblock]::Create($body)) } | Should -Throw "*UNATTESTED_SURVIVORS*" + Should -Invoke Remove-Item -Times 0 -Exactly + } else { + & ([scriptblock]::Create($body)) + Should -Invoke Remove-Item -Times 1 -Exactly + } + $processScope.Closed | Should -BeTrue + Should -Invoke Stop-Process -Times 0 -Exactly + } +} + +Describe "Late bare-stage cleanup preserves earlier failures (mock only)" { + It " preserves primary failure and cleanup evidence" -Tag LateCleanup -TestCases @( + @{ boundary = "outer"; kind = "tagged" }, + @{ boundary = "outer"; kind = "CIM" }, + @{ boundary = "bare"; kind = "tagged" }, + @{ boundary = "bare"; kind = "CIM" }, + @{ boundary = "outer"; kind = "earlier tagged" }, + @{ boundary = "bare"; kind = "earlier tagged" } + ) { + param($boundary, $kind) + $ast = [Management.Automation.Language.Parser]::ParseFile($shellPath, [ref]$null, [ref]$null) + $helper = $ast.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq "New-ShellCleanupFailure" + }, $true) + if ($helper) { . ([scriptblock]::Create($helper.Extent.Text)) } + if ($boundary -eq "bare") { + $function = $ast.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq "Invoke-BareNameZmxShell" + }, $true) + $main = @($function.Body.EndBlock.Statements | + Where-Object { $_ -is [Management.Automation.Language.TryStatementAst] })[-1] + } else { + $main = @($ast.EndBlock.Statements | + Where-Object { $_ -is [Management.Automation.Language.TryStatementAst] })[-1] + } + $root = Join-Path $TestDrive ([guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path $root | Out-Null + $observedRoot = Join-Path $TestDrive ([guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path $observedRoot | Out-Null + $primary = [InvalidOperationException]::new("first failure") + $late = [InvalidOperationException]::new("late $kind refusal") + if ($kind -eq "tagged") { + $late.Data["GraphCodeValidationRootRetentionRequired"] = $true + $late.Data["GraphCodeValidationRetainedRoots"] = @($observedRoot) + } + if ($kind -eq "earlier tagged") { + $primary.Data["GraphCodeValidationRootRetentionRequired"] = $true + $primary.Data["GraphCodeValidationRetainedRoots"] = @($observedRoot) + } + function Remove-BareNameZmxStage {} + function Assert-NoOrphanShellProcesses {} + Mock Remove-BareNameZmxStage { throw $late } + Mock Close-ShellProcessScope { param($scope) $scope.Closed = $true } + Mock Assert-NoOrphanShellProcesses {} + Mock Remove-Item {} + Mock Get-ChildItem {} + Mock Write-Host {} + Mock Write-Host {} -ModuleName ShellProcessOwnership + $failure = $null + try { + & { + foreach ($variable in @($main.Finally.FindAll({ + param($node) + $node -is [Management.Automation.Language.VariableExpressionAst] -and + -not $node.VariablePath.IsDriveQualified + }, $true).VariablePath.UserPath | Sort-Object -Unique)) { + if ($variable -notin @("_", "null", "true", "false")) { Set-Variable $variable $null } + } + $primaryFailure = [Management.Automation.ErrorRecord]::new($primary, "primary", "NotSpecified", $null) + $barePrimaryFailure = $primaryFailure + $processScope = [pscustomobject]@{ Closed = $false } + $bareProcessScope = [pscustomobject]@{ Closed = $false } + $stubSessionHosts = @{} + $shellRoot = $root + $workspaceLayoutBase = "D:\synthetic-layout.json" + $bareZmxStage = $root + $bareZmxProject = $root + $smokeZmxDir = "$root\smoke" + $bareStubResult = "$root\bare-stub.json" + $stubResult = "$root\stub.json" + $busyResult = "$root\busy.json" + $busyError = "$root\busy.err" + $inputError = "$root\input.err" + $body = ($main.Finally.Statements | ForEach-Object { $_.Extent.Text }) -join "`n" + & ([scriptblock]::Create($body)) + } + } catch { $failure = $_ } + $failure | Should -Not -BeNullOrEmpty + $expected = if ($kind -eq "earlier tagged") { "Earlier ownership refusal" } else { "late" } + $failure.Exception.Message | Should -Match "^Shell failed: first failure; cleanup unproved: .*$expected" + [object]::ReferenceEquals($failure.Exception.InnerException, $primary) | Should -BeTrue + Test-ShellOwnershipUnproven $failure | Should -BeTrue + $failure.Exception.Data["GraphCodeValidationRetainedRoots"] | Should -Contain $root + if ($kind -ne "CIM") { + $failure.Exception.Data["GraphCodeValidationRetainedRoots"] | Should -Contain $observedRoot + } + Should -Invoke Remove-BareNameZmxStage -Times $(if ($kind -eq "earlier tagged") { 0 } else { 1 }) -Exactly + Should -Invoke Close-ShellProcessScope -Times $(if ($boundary -eq "outer") { 2 } else { 1 }) -Exactly + Should -Invoke Remove-Item -Times 0 -Exactly -ParameterFilter { $PesterBoundParameters.ContainsKey("LiteralPath") } + Remove-Item -LiteralPath "$root\filter-positive-control" + Should -Invoke Remove-Item -Times 1 -Exactly -ParameterFilter { $PesterBoundParameters.ContainsKey("LiteralPath") } + } +} + +Describe "Ownership-unproven root retention contract (filesystem only)" { + BeforeEach { + $hadRoot = [Environment]::GetEnvironmentVariables().Contains("GRAPHCODE_VALIDATION_ROOT") + $savedRoot = [Environment]::GetEnvironmentVariable("GRAPHCODE_VALIDATION_ROOT") + $root = Join-Path $TestDrive ([guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path $root | Out-Null + $env:GRAPHCODE_VALIDATION_ROOT = $root + Mock Write-Host {} -ModuleName ShellProcessOwnership + } + AfterEach { + if ($hadRoot) { + [Environment]::SetEnvironmentVariable("GRAPHCODE_VALIDATION_ROOT", $savedRoot, "Process") + } else { + Remove-Item Env:\GRAPHCODE_VALIDATION_ROOT -ErrorAction SilentlyContinue + } + [Environment]::GetEnvironmentVariables().Contains("GRAPHCODE_VALIDATION_ROOT") | Should -Be $hadRoot + if ($hadRoot) { + [Environment]::GetEnvironmentVariable("GRAPHCODE_VALIDATION_ROOT") | Should -BeExactly $savedRoot + } + } + It "publishes a bounded CreateNew UTF-8 receipt and an actual retained-root list" { + $error = Set-ShellOwnershipUnproven ([InvalidOperationException]::new("unattested survivor")) @($root, "$root\absent") + $error.Data["GraphCodeValidationRootRetentionRequired"] | Should -BeTrue + @($error.Data["GraphCodeValidationRetainedRoots"]).Count | Should -Be 1 + $receipt = Join-Path $root "ownership-unproven.json" + $bytes = [IO.File]::ReadAllBytes($receipt) + $bytes.Length | Should -BeLessOrEqual 4096 + $payload = [Text.UTF8Encoding]::new($false, $true).GetString($bytes) | ConvertFrom-Json + $payload.schemaVersion | Should -Be 1 + $payload.validationRoot | Should -BeExactly ([IO.Path]::GetFullPath($root)) + $payload.reason | Should -BeExactly "ownership_unproven" + Should -Invoke Write-Host -ModuleName ShellProcessOwnership -Times 1 -Exactly -ParameterFilter { + $Object -eq "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven" + } + } + It "preserves an existing receipt and still flags a receipt-write failure" { + $receipt = Join-Path $root "ownership-unproven.json" + [IO.File]::WriteAllText($receipt, "first retained evidence") + $before = (Get-FileHash -LiteralPath $receipt).Hash + $error = Set-ShellOwnershipUnproven ([InvalidOperationException]::new("unattested survivor")) + (Get-FileHash -LiteralPath $receipt).Hash | Should -BeExactly $before + $error.Data["GraphCodeValidationRetentionReceiptFailure"] | Should -Not -BeNullOrEmpty + $error.Data["GraphCodeValidationRootRetentionRequired"] | Should -BeTrue + Should -Invoke Write-Host -ModuleName ShellProcessOwnership -Times 1 -Exactly -ParameterFilter { + $Object -eq "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven" + } + } + It "flags standalone same-process failures even when no validation root is configured" { + Remove-Item Env:\GRAPHCODE_VALIDATION_ROOT -ErrorAction SilentlyContinue + [Environment]::GetEnvironmentVariables().Contains("GRAPHCODE_VALIDATION_ROOT") | Should -BeFalse + $error = Set-ShellOwnershipUnproven ([InvalidOperationException]::new("unattested survivor")) @($root) + Test-ShellOwnershipUnproven $error | Should -BeTrue + Test-Path (Join-Path $root "ownership-unproven.json") | Should -BeFalse + } + It "refuses a configured file root without suppressing the retention signal" { + $file = Join-Path $root "not-a-directory" + [IO.File]::WriteAllText($file, "preserve") + $env:GRAPHCODE_VALIDATION_ROOT = $file + $error = Set-ShellOwnershipUnproven ([InvalidOperationException]::new("unattested survivor")) + $error.Data["GraphCodeValidationRetentionReceiptFailure"] | Should -Not -BeNullOrEmpty + $error.Data["GraphCodeValidationRootRetentionRequired"] | Should -BeTrue + [IO.File]::ReadAllText($file) | Should -BeExactly "preserve" + } + It "preserves the tagged primary when diagnostic transport fails: " -TestCases @( + @{ case = "sentinel"; failSentinel = $true; failReceiptDiagnostic = $false; blockedRoot = $false; absentRoot = $false; failures = 1 } + @{ case = "receipt diagnostic"; failSentinel = $false; failReceiptDiagnostic = $true; blockedRoot = $true; absentRoot = $false; failures = 1 } + @{ case = "both channels"; failSentinel = $true; failReceiptDiagnostic = $true; blockedRoot = $true; absentRoot = $false; failures = 2 } + @{ case = "standalone sentinel"; failSentinel = $true; failReceiptDiagnostic = $false; blockedRoot = $false; absentRoot = $true; failures = 1 } + ) { + param($failSentinel, $failReceiptDiagnostic, $blockedRoot, $absentRoot, $failures) + if ($failSentinel) { + Mock Write-Host { throw [IO.IOException]::new("sentinel transport failed") } -ModuleName ShellProcessOwnership -ParameterFilter { + $Object -eq "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven" + } + } + if ($failReceiptDiagnostic) { + Mock Write-Host { throw [IO.IOException]::new("receipt diagnostic transport failed") } -ModuleName ShellProcessOwnership -ParameterFilter { + $Object -like "SHELL_RETENTION_RECEIPT_UNPROVEN=*" + } + } + if ($blockedRoot) { + $file = Join-Path $root "blocked-root" + [IO.File]::WriteAllText($file, "preserve") + $env:GRAPHCODE_VALIDATION_ROOT = $file + } elseif ($absentRoot) { + Remove-Item Env:\GRAPHCODE_VALIDATION_ROOT + } + $primary = [InvalidOperationException]::new("unattested survivor") + $returned = Set-ShellOwnershipUnproven $primary @($root) + [object]::ReferenceEquals($returned, $primary) | Should -BeTrue + Test-ShellOwnershipUnproven $returned | Should -BeTrue + $returned.Message | Should -BeExactly "unattested survivor" + $returned.Data["GraphCodeValidationRetainedRoots"] | Should -Contain $root + @($returned.Data["GraphCodeValidationRetentionDiagnosticFailures"]).Count | Should -Be $failures + foreach ($failure in $returned.Data["GraphCodeValidationRetentionDiagnosticFailures"]) { + $failure | Should -BeOfType ([IO.IOException]) + $failure.Message | Should -BeLike "*transport failed" + } + if ($blockedRoot) { + $returned.Data["GraphCodeValidationRetentionReceiptFailure"] | Should -Not -BeNullOrEmpty + [IO.File]::ReadAllText($file) | Should -BeExactly "preserve" + } + $receipt = Join-Path $root "ownership-unproven.json" + (Test-Path -LiteralPath $receipt) | Should -Be (-not $blockedRoot -and -not $absentRoot) + if (-not $blockedRoot -and -not $absentRoot) { + (Get-Content -LiteralPath $receipt -Raw | ConvertFrom-Json).reason | Should -BeExactly "ownership_unproven" + } + } + It "recognizes the Boolean tag through a native-method exception wrapper" { + $inner = [InvalidOperationException]::new("unattested survivor") + $inner.Data["GraphCodeValidationRootRetentionRequired"] = $true + $outer = [InvalidOperationException]::new("wrapper", $inner) + Test-ShellOwnershipUnproven $outer | Should -BeTrue + $inner.Data["GraphCodeValidationRootRetentionRequired"] = "true" + Test-ShellOwnershipUnproven $outer | Should -BeFalse + } +} diff --git a/Tools/windows/Tests/ShellSmokeExit.Live.Tests.ps1 b/Tools/windows/Tests/ShellSmokeExit.Live.Tests.ps1 new file mode 100644 index 00000000..1c8eab5a --- /dev/null +++ b/Tools/windows/Tests/ShellSmokeExit.Live.Tests.ps1 @@ -0,0 +1,139 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $AppPath, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $WinghosttyRoot, + [Parameter(Mandatory)][string] $ZmxRoot, + [Parameter(Mandatory)][string] $Zig0152, + [Parameter(Mandatory)][string] $Zig0160, + [ValidateSet('legacy-busy', 'owned-busy', 'legacy-success', 'owned-success', 'owned-unactivated', 'owned-no-focus')] + [string] $Worker +) + +$ErrorActionPreference = 'Stop' +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path +Import-Module (Join-Path $repoRoot 'Tools\windows\ShellProcessOwnership.psm1') + +function New-FixtureStart([string] $File, [string[]] $Arguments) { + $start = [Diagnostics.ProcessStartInfo]::new($File) + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + foreach ($argument in $Arguments) { $start.ArgumentList.Add($argument) } + $start +} + +$null = New-Item -ItemType Directory -Force -Path $ArtifactRoot +if ($Worker) { + $env:GRAPHCODE_SUPPORT_DIR = Join-Path $ArtifactRoot 'support' + $env:LOCALAPPDATA = Join-Path $ArtifactRoot 'local' + $env:GRAPHCODE_WORKSPACE_LAYOUT = Join-Path $ArtifactRoot 'workspace.json' + $env:TEMP = Join-Path $ArtifactRoot 'temp' + $env:TMP = $env:TEMP + $null = New-Item -ItemType Directory -Force -Path $env:GRAPHCODE_SUPPORT_DIR,$env:LOCALAPPDATA,$env:TEMP + [IO.File]::WriteAllText((Join-Path $env:GRAPHCODE_SUPPORT_DIR 'onboarding-seen'), '') + Remove-Item Env:\GRAPHCODE_SHELL_WORKSPACE_ACTIONS -ErrorAction SilentlyContinue + if ($Worker -eq 'owned-no-focus') { + $failure = $null + try { + & (Join-Path $repoRoot 'Tools\windows\windows-shell.ps1') ` + -WinghosttyRoot $WinghosttyRoot -ZmxRoot $ZmxRoot -Zig0152 $Zig0152 -Zig0160 $Zig0160 ` + -SkipBuild -UseStubDaemon -StubResponseDelayMilliseconds 150 -SkipTrayLive -Stress ` + -WorkspaceActions 'create,split,select,close,restart' + } catch { $failure = $_ } + if ($null -ne $failure -and (Test-ShellOwnershipUnproven $failure)) { throw $failure } + if ($null -eq $failure -or + $failure.Exception.Message -notmatch '(?s)exited with code 1: .*Smoke contract failed: scripted=true .*focus=false') { + throw "Omitted-focus scenario did not refuse for its actual missing focus effect: $failure" + } + [IO.File]::WriteAllText((Join-Path $ArtifactRoot 'stderr.txt'), ($failure | Out-String)) + [ordered]@{ id = $Worker; exitCode = 1; stderrLength = $failure.Exception.Message.Length } | + ConvertTo-Json -Compress | Set-Content -LiteralPath (Join-Path $ArtifactRoot 'result.json') + exit 0 + } + $pipe = 'graphcode-smoke-exit-' + [guid]::NewGuid().ToString('N') + $result = Join-Path $ArtifactRoot 'daemon.json' + $errorPath = Join-Path $ArtifactRoot 'stderr.txt' + $scope = New-ShellProcessScope + try { + $stubStart = New-FixtureStart (Join-Path $PSHOME 'pwsh.exe') @( + '-NoProfile', '-File', (Join-Path $repoRoot 'Tools\windows\Stub-Daemon.ps1'), + '-PipeName', $pipe, '-ResultPath', $result, '-NonReading' + ) + $null = Start-ShellOwnedProcess $scope $stubStart + $env:GRAPHCODE_DAEMON_PIPE = "\\.\pipe\$pipe" + $busy = $Worker.EndsWith('-busy', [StringComparison]::Ordinal) -or $Worker -eq 'owned-unactivated' + if ($busy) { + $env:GRAPHCODE_SHELL_REQUIRE_DAEMON = '1' + $env:GRAPHCODE_SHELL_EXPECT_TRANSPORT_ERROR = '1' + } else { + Remove-Item Env:\GRAPHCODE_SHELL_REQUIRE_DAEMON -ErrorAction SilentlyContinue + Remove-Item Env:\GRAPHCODE_SHELL_EXPECT_TRANSPORT_ERROR -ErrorAction SilentlyContinue + } + if ($Worker -eq 'owned-unactivated') { $env:GRAPHCODE_SHELL_WORKSPACE_ACTIONS = '1' } + if ($Worker.StartsWith('legacy-', [StringComparison]::Ordinal)) { + # The worker is already suspended-assigned to the caller's held job. + $app = Start-Process -FilePath $AppPath -ArgumentList @('--smoke') -PassThru ` + -RedirectStandardError $errorPath + [void] $app.Handle + if (-not $app.WaitForExit(8000)) { throw "$Worker exceeded the original smoke deadline" } + $app.WaitForExit() + $app.Refresh() + $stderr = [IO.File]::ReadAllText($errorPath) + } else { + $app = Start-ShellOwnedProcess $scope (New-FixtureStart $AppPath @('--smoke')) + if (-not $app.WaitForExit(8000)) { throw "$Worker exceeded the original smoke deadline" } + Complete-ShellOwnedProcess $scope $app + $stderr = Read-ShellOwnedOutput $scope $app -StandardError + [IO.File]::WriteAllText($errorPath, $stderr) + } + $exitCode = $app.ExitCode + $daemon = Get-Content -LiteralPath $result -Raw | ConvertFrom-Json + if (-not $daemon.busyObserved) { throw "$Worker did not exercise a nonreading daemon" } + if ($busy) { + if ($exitCode -eq 0 -or $stderr -notmatch '(?i)Smoke daemon status: .*daemon') { + throw "$Worker failed smoke-exit contract: exit=$exitCode stderrLength=$($stderr.Length)" + } + if ($Worker -eq 'owned-unactivated' -and $stderr -notmatch 'Smoke contract failed: scripted=false') { + throw 'Unactivated workspace scenario did not prove its requested actions remained unexecuted' + } + } elseif ($exitCode -ne 0) { + throw "$Worker changed successful requested-exit behavior: exit=$exitCode" + } + [ordered]@{ id = $Worker; exitCode = $exitCode; stderrLength = $stderr.Length } | + ConvertTo-Json -Compress | Set-Content -LiteralPath (Join-Path $ArtifactRoot 'result.json') + } finally { + Close-ShellProcessScope $scope + } + exit 0 +} + +$executed = 0 +foreach ($case in @('legacy-busy', 'owned-busy', 'legacy-success', 'owned-success', 'owned-unactivated', 'owned-no-focus')) { + $caseRoot = Join-Path $ArtifactRoot $case + $null = New-Item -ItemType Directory -Force -Path $caseRoot + $scope = New-ShellProcessScope + try { + $start = New-FixtureStart (Join-Path $PSHOME 'pwsh.exe') @( + '-NoProfile', '-File', $PSCommandPath, '-AppPath', $AppPath, + '-ArtifactRoot', $caseRoot, '-Worker', $case, '-WinghosttyRoot', $WinghosttyRoot, + '-ZmxRoot', $ZmxRoot, '-Zig0152', $Zig0152, '-Zig0160', $Zig0160 + ) + $process = Start-ShellOwnedProcess $scope $start + $workerDeadline = if ($case -eq 'owned-no-focus') { 45000 } else { 15000 } + if (-not $process.WaitForExit($workerDeadline)) { throw "$case worker exceeded its owned deadline" } + Complete-ShellOwnedProcess $scope $process + [IO.File]::WriteAllText((Join-Path $caseRoot 'worker-stderr.txt'), + (Read-ShellOwnedOutput $scope $process -StandardError)) + if ($process.ExitCode -ne 0) { throw "$case worker failed: exit=$($process.ExitCode); evidence=$caseRoot" } + $row = Get-Content -LiteralPath (Join-Path $caseRoot 'result.json') -Raw | ConvertFrom-Json + if ($row.id -cne $case) { throw "$case worker returned mismatched evidence" } + $executed++ + Write-Host "SHELL_SMOKE_EXIT_CASE=$case EXIT=$($row.exitCode)" + } finally { + Close-ShellProcessScope $scope + } +} +if ($executed -ne 6) { throw "Smoke exit fixture did not execute all six controls: $executed" } +Write-Host "SHELL_SMOKE_EXIT_CASES=$executed" diff --git a/Tools/windows/Tests/ValidationFixtureDirectory.Tests.ps1 b/Tools/windows/Tests/ValidationFixtureDirectory.Tests.ps1 new file mode 100644 index 00000000..f8386157 --- /dev/null +++ b/Tools/windows/Tests/ValidationFixtureDirectory.Tests.ps1 @@ -0,0 +1,322 @@ +[CmdletBinding()] +param( + [string] $ScratchRoot = (Join-Path ([IO.Path]::GetTempPath()) ( + "validation-fixture-" + [guid]::NewGuid().ToString("N"))) +) + +$ErrorActionPreference = "Stop" +if (-not $env:GRAPHCODE_VALIDATION_ASSEMBLY) { + $env:GRAPHCODE_VALIDATION_ASSEMBLY = & (Join-Path $PSScriptRoot "..\Build-CompiledValidationRunner.ps1") +} +. (Join-Path $PSScriptRoot "ValidationFixtureDirectory.ps1") +$results = [Collections.Generic.List[object]]::new() + +function Assert-Fixture([bool] $Condition, [string] $Message) { + if (-not $Condition) { throw $Message } +} + +function Assert-FixtureNativeCollision($Failure) { + $exception = $Failure.Exception + while ($null -ne $exception -and $exception -isnot [ComponentModel.Win32Exception]) { + $exception = $exception.InnerException + } + Assert-Fixture ($null -ne $exception) "Exclusive-create failure lost its Win32 error" + Assert-Fixture ($exception.NativeErrorCode -eq 183) "Exclusive create did not report ERROR_ALREADY_EXISTS" + Write-Output "NATIVE_CREATE_ERROR=$($exception.NativeErrorCode)" +} + +function Invoke-FixtureCase([string] $CaseName, [scriptblock] $Action) { + try { + & $Action + $results.Add([pscustomobject]@{ name = $CaseName; passed = $true }) + Write-Output "PASS $CaseName" + } catch { + $results.Add([pscustomobject]@{ name = $CaseName; passed = $false }) + Write-Output "FAIL ${CaseName}: $($_.Exception.Message)" + } +} + +Invoke-FixtureCase "helper import does not load or invoke its native binding" { + Assert-Fixture ($null -eq ("GraphCode.ValidationFixtureDirectoryNative" -as [type])) ` + "Helper import eagerly loaded its native binding" +} + +$root = (New-ValidationFixtureDirectory -Path $ScratchRoot).Path + +Invoke-FixtureCase "harness acquires scratch exclusively and retains its contents" { + $source = [IO.File]::ReadAllText($PSCommandPath) + Assert-Fixture ($source.Contains( + '$root = (New-ValidationFixtureDirectory -Path $ScratchRoot).Path')) "Harness root admission is not exclusive" + Assert-Fixture (-not ($source -match 'Remove-Item\s+-LiteralPath\s+\$root\b')) ` + "Harness removes unrecognized scratch contents" +} + +foreach ($name in @("validation-runner-untracked", "OwnershipSentinel")) { + Invoke-FixtureCase "$name creates missing parent containers without owning their cleanup" { + $parent = Join-Path $root "$name-missing-parent\Sources\GraphcodeWindowsContracts" + $path = Join-Path $parent $name + Assert-Fixture (-not [IO.Directory]::Exists($parent)) "Clean parent hierarchy already exists" + $fixture = New-ValidationFixtureDirectory -Path $path + Assert-Fixture ($fixture.Path -ceq $path) "Receipt adopted a parent rather than the exclusive leaf" + Remove-ValidationFixtureDirectory -Fixture $fixture + Assert-Fixture (-not [IO.Directory]::Exists($path)) "Owned leaf survived cleanup" + Assert-Fixture ([IO.Directory]::Exists($parent)) "Cleanup removed a generated parent container" + Assert-Fixture ([IO.Directory]::Exists((Join-Path $root "$name-missing-parent\Sources"))) ` + "Cleanup removed a generated ancestor" + } + + Invoke-FixtureCase "$name preserves an existing parent's contents after leaf cleanup" { + $parent = Join-Path $root "$name-existing-parent" + $parentFixture = New-ValidationFixtureDirectory -Path $parent + New-ValidationFixtureFile -Fixture $parentFixture -Name "retained.txt" -Content "existing parent" + $marker = Join-Path $parent "retained.txt" + $before = (Get-FileHash -LiteralPath $marker).Hash + $path = Join-Path $parent $name + $fixture = New-ValidationFixtureDirectory -Path $path + Assert-Fixture ($fixture.Path -ceq $path) "Receipt adopted the existing parent" + Remove-ValidationFixtureDirectory -Fixture $fixture + Assert-Fixture ([IO.Directory]::Exists($parent)) "Existing parent was removed" + Assert-Fixture ((Get-FileHash -LiteralPath $marker).Hash -ceq $before) "Existing parent contents changed" + } + + Invoke-FixtureCase "$name preserves pre-existing directory contents" { + $path = Join-Path $root "$name-existing-directory" + [void](New-Item -ItemType Directory -Path $path) + $file = Join-Path $path "Unformatted.swift" + [IO.File]::WriteAllText($file, "pre-existing content") + $before = (Get-FileHash -LiteralPath $file).Hash + $failure = $null + try { $null = New-ValidationFixtureDirectory -Path $path } catch { $failure = $_ } + Assert-Fixture ($null -ne $failure) "Existing directory was accepted as owned" + Assert-FixtureNativeCollision $failure + Assert-Fixture ((Get-FileHash -LiteralPath $file).Hash -ceq $before) "Existing contents changed" + } + + Invoke-FixtureCase "$name preserves pre-existing file" { + $path = Join-Path $root "$name-existing-file" + [IO.File]::WriteAllText($path, "pre-existing file") + $before = (Get-FileHash -LiteralPath $path).Hash + $failure = $null + try { $null = New-ValidationFixtureDirectory -Path $path } catch { $failure = $_ } + Assert-Fixture ($null -ne $failure) "Existing file was accepted as owned" + Assert-FixtureNativeCollision $failure + Assert-Fixture ((Get-FileHash -LiteralPath $path).Hash -ceq $before) "Existing file changed" + } + + Invoke-FixtureCase "$name refuses a collision at the directory-create boundary" { + $path = Join-Path $root "$name-create-collision" + $file = Join-Path $path "retained.txt" + $script:fixtureCollisionCount = 0 + $script:fixtureCollisionCleanupCount = 0 + $nativeCreate = Get-Command Invoke-ValidationFixtureDirectoryCreate -ErrorAction SilentlyContinue + $nativeCreateBody = if ($null -ne $nativeCreate) { $nativeCreate.ScriptBlock } else { $null } + + function Add-FixtureCollision { + [void][IO.Directory]::CreateDirectory($path) + [IO.File]::WriteAllText($file, "concurrent creator") + $script:fixtureCollisionCount++ + } + + # Model the provider's idempotent final create after its earlier existence check. + function New-Item { + param($ItemType, $Path, $ErrorAction) + Add-FixtureCollision + return Get-Item -LiteralPath $Path + } + + function Invoke-ValidationFixtureDirectoryCreate { + param([string] $Path) + Add-FixtureCollision + & $nativeCreateBody -Path $Path + } + + function Remove-ValidationFixtureDirectory { + param($Fixture) + $script:fixtureCollisionCleanupCount++ + } + + $failure = $null + $fixture = $null + try { + $fixture = New-ValidationFixtureDirectory -Path $path + try { } finally { Remove-ValidationFixtureDirectory -Fixture $fixture } + } catch { $failure = $_ } + Assert-Fixture ($script:fixtureCollisionCount -eq 1) "Collision did not occur at the create boundary" + Assert-Fixture ($null -ne $failure -and $null -eq $fixture) "Concurrent directory was accepted as owned" + Assert-Fixture ($script:fixtureCollisionCleanupCount -eq 0) "Failed acquisition reached cleanup" + Assert-Fixture ([IO.File]::ReadAllText($file) -ceq "concurrent creator") "Concurrent contents changed" + if ($null -ne $nativeCreateBody) { + Assert-FixtureNativeCollision $failure + } + } + + Invoke-FixtureCase "$name removes only a successfully created fixture" { + $path = Join-Path $root "$name-owned" + $sibling = Join-Path $root "$name-sibling" + [IO.File]::WriteAllText($sibling, "sibling") + $fixture = New-ValidationFixtureDirectory -Path $path + try { + Assert-Fixture ([IO.Directory]::Exists($path)) "Owned directory was not created" + New-ValidationFixtureFile -Fixture $fixture -Name "Unformatted.swift" -Content "let value=1" + } finally { + Remove-ValidationFixtureDirectory -Fixture $fixture + } + Assert-Fixture (-not (Test-Path -LiteralPath $path)) "Owned directory survived cleanup" + Assert-Fixture ([IO.File]::ReadAllText($sibling) -ceq "sibling") "Sibling changed" + } + + Invoke-FixtureCase "$name cleans its owned fixture after workload failure" { + $path = Join-Path $root "$name-failed-workload" + $fixture = New-ValidationFixtureDirectory -Path $path + $primary = [InvalidOperationException]::new("controlled workload failure") + $failure = $null + try { + try { throw $primary } finally { Remove-ValidationFixtureDirectory -Fixture $fixture } + } catch { $failure = $_ } + Assert-Fixture ([object]::ReferenceEquals($primary, $failure.Exception)) "Primary error changed" + Assert-Fixture (-not (Test-Path -LiteralPath $path)) "Failed workload left its owned directory" + } + + foreach ($kind in @("file", "directory")) { + Invoke-FixtureCase "$name cleanup preserves an unexpected child $kind" { + $path = Join-Path $root "$name-unexpected-$kind" + $fixture = New-ValidationFixtureDirectory -Path $path + if ($name -eq "validation-runner-untracked") { + New-ValidationFixtureFile -Fixture $fixture -Name "Unformatted.swift" -Content "let value=1" + Assert-Fixture ($fixture.OwnedFiles.Count -eq 1) "Untracked known-file path was not exercised" + $ownedFile = Join-Path $path "Unformatted.swift" + $ownedBefore = (Get-FileHash -LiteralPath $ownedFile).Hash + } + $unexpected = Join-Path $path "unexpected" + $file = $unexpected + if ($kind -eq "directory") { + [void][IO.Directory]::CreateDirectory($unexpected) + $file = Join-Path $unexpected "retained.txt" + } + [IO.File]::WriteAllText($file, "unexpected child") + $before = (Get-FileHash -LiteralPath $file).Hash + $failure = $null + try { Remove-ValidationFixtureDirectory -Fixture $fixture } catch { $failure = $_ } + Assert-Fixture ($null -ne $failure) "Cleanup accepted unexpected contents" + Assert-Fixture ((Get-FileHash -LiteralPath $file).Hash -ceq $before) "Unexpected contents changed" + if ($name -eq "validation-runner-untracked") { + Assert-Fixture ((Get-FileHash -LiteralPath $ownedFile).Hash -ceq $ownedBefore) ` + "Cleanup removed its known file before refusing unexpected contents" + Write-Output "UNTRACKED_OWNED_FILE_PREFLIGHT_KIND=$kind OWNED_FILES=1 PRESERVED=1" + } + } + } + + Invoke-FixtureCase "$name refuses an existing file without acquiring cleanup ownership" { + $path = Join-Path $root "$name-file-create-collision" + $fixture = New-ValidationFixtureDirectory -Path $path + $file = Join-Path $path "Unformatted.swift" + [IO.File]::WriteAllText($file, "concurrent file") + $failure = $null + try { + New-ValidationFixtureFile -Fixture $fixture -Name "Unformatted.swift" -Content "let value=1" + } catch { $failure = $_ } + Assert-Fixture ($null -ne $failure) "Existing file was overwritten" + Assert-Fixture ($fixture.OwnedFiles.Count -eq 0) "Failed file acquisition granted cleanup ownership" + $cleanupFailure = $null + try { Remove-ValidationFixtureDirectory -Fixture $fixture } catch { $cleanupFailure = $_ } + Assert-Fixture ($null -ne $cleanupFailure) "Unowned file did not prevent directory cleanup" + Assert-Fixture ([IO.File]::ReadAllText($file) -ceq "concurrent file") "Existing file contents changed" + } + + Invoke-FixtureCase "$name preserves both workload and cleanup errors" { + $path = Join-Path $root "$name-workload-and-cleanup-errors" + $fixture = New-ValidationFixtureDirectory -Path $path + $file = Join-Path $path "unexpected.txt" + [IO.File]::WriteAllText($file, "preserved") + $primary = [InvalidOperationException]::new("controlled primary failure") + $primaryRecord = $null + $failure = $null + try { + try { throw $primary } catch { + $primaryRecord = $_ + throw + } finally { + Remove-ValidationFixtureDirectory -Fixture $fixture -PrimaryError $primaryRecord + } + } catch { $failure = $_ } + Assert-Fixture ($failure.Exception -is [AggregateException]) "Cleanup masked the workload error" + Assert-Fixture ($failure.Exception.InnerExceptions.Count -eq 2) "Both errors were not retained" + Assert-Fixture ([object]::ReferenceEquals($primary, $failure.Exception.InnerExceptions[0])) ` + "Original workload exception was replaced" + Assert-Fixture ($failure.Exception.InnerExceptions[1].Message -like "*Unexpected validation fixture contents*") ` + "Cleanup refusal was not retained" + Assert-Fixture ([IO.File]::ReadAllText($file) -ceq "preserved") "Unexpected file changed" + } +} + +Invoke-FixtureCase "cleanup refuses a replaced fixture and preserves its contents" { + $path = Join-Path $root "replaced" + $fixture = New-ValidationFixtureDirectory -Path $path + [IO.Directory]::Delete($path, $false) + [void](New-Item -ItemType Directory -Path $path) + [IO.Directory]::SetCreationTimeUtc($path, $fixture.CreationTimeUtc.AddDays(-1)) + $file = Join-Path $path "retained.txt" + [IO.File]::WriteAllText($file, "replacement") + $failure = $null + try { Remove-ValidationFixtureDirectory -Fixture $fixture } catch { $failure = $_ } + Assert-Fixture ($null -ne $failure) "Replacement directory was removed" + Assert-Fixture ([IO.File]::ReadAllText($file) -ceq "replacement") "Replacement contents changed" +} + +Invoke-FixtureCase "cleanup refuses a replaced owned file and preserves its contents" { + $path = Join-Path $root "replaced-file" + $fixture = New-ValidationFixtureDirectory -Path $path + New-ValidationFixtureFile -Fixture $fixture -Name "Unformatted.swift" -Content "let value=1" + $file = Join-Path $path "Unformatted.swift" + [IO.File]::Delete($file) + [IO.File]::WriteAllText($file, "replacement file") + [IO.File]::SetCreationTimeUtc($file, $fixture.OwnedFiles[0].CreationTimeUtc.AddDays(-1)) + $failure = $null + try { Remove-ValidationFixtureDirectory -Fixture $fixture } catch { $failure = $_ } + Assert-Fixture ($null -ne $failure) "Replacement file was removed" + Assert-Fixture ([IO.File]::ReadAllText($file) -ceq "replacement file") "Replacement file contents changed" +} + +# Check the actual compiled entry's focused fixture consumer. +$runnerSource = [IO.File]::ReadAllText((Join-Path $PSScriptRoot "..\ValidationRunner\Adapters\HostTasks.ps1")) +foreach ($binding in @( + @{ name = "untracked"; path = "untrackedDirectory"; fixture = "untrackedFixture"; failure = "untrackedFailure" }, + @{ name = "sentinel"; path = "foreignJunction"; fixture = "foreignFixture"; failure = "foreignFailure" } + )) { + Invoke-FixtureCase "$($binding.name) runner wiring acquires before try and cleans only its fixture" { + $acquire = '\$' + $binding.fixture + ' = New-ValidationFixtureDirectory -Path \$' + + $binding.path + '\s+try \{' + $cleanup = '\} finally \{\s+Remove-ValidationFixtureDirectory -Fixture \$' + + $binding.fixture + ' -PrimaryError \$' + $binding.failure + '\s+\}' + $primary = '\} catch \{\s+\$' + $binding.failure + ' = \$_\s+throw\s+' + $cleanup + Assert-Fixture ($runnerSource -match $acquire) "Runner does not acquire an exclusive fixture before try" + Assert-Fixture ($runnerSource -match $cleanup) "Runner cleanup does not use its acquired fixture" + Assert-Fixture ($runnerSource -match $primary) "Runner does not retain its primary workload error" + Assert-Fixture ($runnerSource -notmatch ( + 'New-Item -ItemType Directory -Force \$' + $binding.path)) "Runner still adopts a pre-existing directory" + Assert-Fixture ($runnerSource -notmatch ( + 'Remove-Item -LiteralPath \$' + $binding.path)) "Runner still removes the unowned fixed path" + Assert-Fixture ($runnerSource.Contains( + '. (Join-Path $RepositoryRoot "Tools\windows\Tests\ValidationFixtureDirectory.ps1")')) "Runner does not import fixture helper" + if ($binding.name -eq "untracked") { + Assert-Fixture ($runnerSource.Contains( + 'New-ValidationFixtureFile -Fixture $untrackedFixture -Name "Unformatted.swift" -Content "let value=1"')) ` + "Runner does not acquire its known unformatted file exclusively" + } + } +} + +$failed = @($results | Where-Object { -not $_.passed }).Count +[ordered]@{ + suite = "ValidationFixtureDirectory" + executed = $results.Count + passed = $results.Count - $failed + failed = $failed + skipped = 0 + notRun = 0 +} | ConvertTo-Json -Compress | Write-Output +if ($results.Count -ne 28 -or $failed -ne 0) { + throw "Validation fixture contracts failed; retained evidence: $root" +} +Write-Output "FIXTURE_SCRATCH_RETAINED=$root" diff --git a/Tools/windows/Tests/ValidationFixtureDirectory.ps1 b/Tools/windows/Tests/ValidationFixtureDirectory.ps1 new file mode 100644 index 00000000..13b568cc --- /dev/null +++ b/Tools/windows/Tests/ValidationFixtureDirectory.ps1 @@ -0,0 +1,107 @@ +function Invoke-ValidationFixtureDirectoryCreate { + [CmdletBinding()] + param([Parameter(Mandatory)][string] $Path) + + if (-not $env:GRAPHCODE_VALIDATION_ASSEMBLY) { + throw "Build with Tools\windows\Build-CompiledValidationRunner.ps1 and set GRAPHCODE_VALIDATION_ASSEMBLY to its output" + } + & (Join-Path $PSScriptRoot "..\Import-CompiledValidationAssembly.ps1") ` + -AssemblyPath (Join-Path ([IO.Path]::GetDirectoryName($env:GRAPHCODE_VALIDATION_ASSEMBLY)) "ValidationFixtureBindings.dll") ` + -RequiredType "GraphCode.ValidationFixtureDirectoryNative" | Out-Null + [GraphCode.ValidationFixtureDirectoryNative]::Create($Path) +} + +function New-ValidationFixtureDirectory { + [CmdletBinding()] + param([Parameter(Mandatory)][string] $Path) + + $fullPath = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($Path)) + $parent = [IO.Path]::GetDirectoryName($fullPath) + if (-not [string]::IsNullOrEmpty($parent)) { + # Parent containers are shared, never part of the fixture's cleanup ownership. + [void][IO.Directory]::CreateDirectory($parent) + } + Invoke-ValidationFixtureDirectoryCreate -Path $fullPath + $directory = Get-Item -LiteralPath $fullPath -Force -ErrorAction Stop + return [pscustomobject]@{ + Path = $directory.FullName + CreationTimeUtc = $directory.CreationTimeUtc + OwnedFiles = [Collections.Generic.List[object]]::new() + } +} + +function Get-ValidationFixtureDirectory { + [CmdletBinding()] + param([Parameter(Mandatory)] $Fixture) + + $directory = Get-Item -LiteralPath $Fixture.Path -Force -ErrorAction Stop + if (-not $directory.PSIsContainer -or + ($directory.Attributes -band [IO.FileAttributes]::ReparsePoint) -or + $directory.CreationTimeUtc -ne $Fixture.CreationTimeUtc) { + throw "Validation fixture identity changed; refusing cleanup: $($Fixture.Path)" + } + return $directory +} + +function New-ValidationFixtureFile { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Fixture, + [Parameter(Mandatory)][string] $Name, + [Parameter(Mandatory)][string] $Content + ) + + if ([IO.Path]::GetFileName($Name) -cne $Name -or $Name -in @(".", "..")) { + throw "Validation fixture file must be a leaf name: $Name" + } + $directory = Get-ValidationFixtureDirectory -Fixture $Fixture + $path = Join-Path $directory.FullName $Name + $stream = [IO.File]::Open($path, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) + try { + $file = Get-Item -LiteralPath $path -Force -ErrorAction Stop + $Fixture.OwnedFiles.Add([pscustomobject]@{ + Path = $file.FullName + CreationTimeUtc = $file.CreationTimeUtc + }) + $bytes = [Text.UTF8Encoding]::new($false).GetBytes($Content + [Environment]::NewLine) + $stream.Write($bytes, 0, $bytes.Length) + } finally { + $stream.Dispose() + } +} + +function Remove-ValidationFixtureDirectory { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Fixture, + [Management.Automation.ErrorRecord] $PrimaryError + ) + + try { + $directory = Get-ValidationFixtureDirectory -Fixture $Fixture + $ownedPaths = @($Fixture.OwnedFiles | ForEach-Object { $_.Path }) + foreach ($entry in [IO.Directory]::EnumerateFileSystemEntries($directory.FullName)) { + if ($ownedPaths -cnotcontains $entry) { + throw "Unexpected validation fixture contents; refusing cleanup: $entry" + } + } + $files = @(foreach ($owned in $Fixture.OwnedFiles) { + $file = Get-Item -LiteralPath $owned.Path -Force -ErrorAction Stop + if ($file.PSIsContainer -or ($file.Attributes -band [IO.FileAttributes]::ReparsePoint) -or + $file.CreationTimeUtc -ne $owned.CreationTimeUtc) { + throw "Validation fixture file identity changed; refusing cleanup: $($owned.Path)" + } + $file + }) + foreach ($file in $files) { + [IO.File]::Delete($file.FullName) + } + [IO.Directory]::Delete($directory.FullName, $false) + } catch { + if ($null -ne $PrimaryError) { + throw [AggregateException]::new("Validation workload and fixture cleanup both failed.", + [Exception[]]@($PrimaryError.Exception, $_.Exception)) + } + throw + } +} diff --git a/Tools/windows/Tests/ValidationIsolation.Tests.ps1 b/Tools/windows/Tests/ValidationIsolation.Tests.ps1 index a7156f8e..085a7fb9 100644 --- a/Tools/windows/Tests/ValidationIsolation.Tests.ps1 +++ b/Tools/windows/Tests/ValidationIsolation.Tests.ps1 @@ -21,6 +21,7 @@ BeforeAll { } $requiredFunctions = @( + "Invoke-Native", "Get-WindowsShellProfileSnapshot", "Assert-WindowsShellProfileUnchanged", "Invoke-WindowsShellValidationIsolation" @@ -51,6 +52,8 @@ BeforeEach { Out-Null $capturePath = Join-Path $caseRoot "environment.json" $savedEnvironment = @{} + $savedEnvironmentMembership = @{} + $processEnvironment = [Environment]::GetEnvironmentVariables("Process") foreach ($name in @( "GRAPHCODE_VALIDATION_ROOT", "GRAPHCODE_SUPPORT_DIR", @@ -58,6 +61,7 @@ BeforeEach { "TEMP", "TMP" )) { + $savedEnvironmentMembership[$name] = $processEnvironment.Contains($name) $savedEnvironment[$name] = [Environment]::GetEnvironmentVariable($name) [Environment]::SetEnvironmentVariable($name, "before-$name") } @@ -65,7 +69,11 @@ BeforeEach { AfterEach { foreach ($entry in $savedEnvironment.GetEnumerator()) { - [Environment]::SetEnvironmentVariable($entry.Key, $entry.Value) + if ($savedEnvironmentMembership[$entry.Key]) { + [Environment]::SetEnvironmentVariable($entry.Key, $entry.Value) + } elseif (Test-Path -LiteralPath "Env:\$($entry.Key)") { + Remove-Item -LiteralPath "Env:\$($entry.Key)" -ErrorAction Stop + } } Remove-Item -LiteralPath $caseRoot -Recurse -Force -ErrorAction SilentlyContinue } @@ -134,7 +142,7 @@ AfterEach { $env:TMP | Should -BeExactly "before-TMP" } - It "restores and cleans owned roots when the workload fails" { + It "restores and retains owned roots when the workload fails" { $missingFunctions.Count | Should -Be 0 $caught = $null try { @@ -152,7 +160,8 @@ AfterEach { $caught.Exception.Message | Should -BeExactly "controlled workload failure" $ownedRoot = Get-Content -LiteralPath $capturePath -Raw - Test-Path -LiteralPath $ownedRoot.Trim() | Should -Be $false + Test-Path -LiteralPath $ownedRoot.Trim() | Should -Be $true + $caught.Exception.Data["GraphCodeValidationRootRetentionReason"] | Should -BeExactly "workload_failed_without_cleanup_proof" $env:GRAPHCODE_VALIDATION_ROOT | Should -BeExactly "before-GRAPHCODE_VALIDATION_ROOT" $env:GRAPHCODE_SUPPORT_DIR | Should -BeExactly "before-GRAPHCODE_SUPPORT_DIR" $env:LOCALAPPDATA | Should -BeExactly "before-LOCALAPPDATA" @@ -160,6 +169,339 @@ AfterEach { $env:TMP | Should -BeExactly "before-TMP" } + It "opaque child with unavailable receipt and sentinel" -TestCases @( + @{ outcome = "failure retains isolation"; childExit = 1; retain = $true }, + @{ outcome = "forced-zero counterexample remains a limitation"; childExit = 0; retain = $false } + ) { + param($childExit, $retain) + $helperPath = Join-Path $repoRoot "Tools\windows\OwnedLiveProcess.ps1" + $helperLiteral = "'" + $helperPath.Replace("'", "''") + "'" + $childSource = @' +using System; +using System.IO; +using System.Text; +public sealed class UnavailableOwnershipOutput : TextWriter { + public override Encoding Encoding { get { return Encoding.UTF8; } } + public override void WriteLine(string value) { throw new IOException("mock stdout unavailable"); } +} +'@ + $childCommand = @" +`$ErrorActionPreference = 'Stop' +. $helperLiteral +Add-Type -TypeDefinition @' +$childSource +'@ +`$env:GRAPHCODE_VALIDATION_ROOT = Join-Path `$env:GRAPHCODE_VALIDATION_ROOT 'absent-child-root' +[Console]::Error.WriteLine('OPAQUE_CHILD_RECEIPT_UNAVAILABLE=true') +[Console]::SetOut([UnavailableOwnershipOutput]::new()) +try { + throw (New-OwnedLiveProcessCleanupError -Message 'mock ownership cleanup refusal' -Errors @([IO.IOException]::new('mock survivor'))) +} catch { + if ($childExit -eq 0) { + [Console]::Error.WriteLine('OPAQUE_CHILD_FORCED_EXIT_ZERO=true') + exit 0 + } + throw +} +"@ + $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($childCommand)) + $pwsh = (Get-Command pwsh -ErrorAction Stop).Source + $caught = $null + $output = [Collections.Generic.List[string]]::new() + $oldExitCode = $global:LASTEXITCODE + $observedExit = $null + try { + Invoke-WindowsShellValidationIsolation -WorkspaceRoot $workspaceRoot ` + -ValidationRootParent (Join-Path $caseRoot "short") ` + -UserProfileRoot $userProfileRoot -LocalAppDataRoot $localAppDataRoot -Action { + Set-Content -LiteralPath $capturePath -Value $env:GRAPHCODE_VALIDATION_ROOT + Invoke-Native "opaque mock child" { + & $pwsh -NoProfile -NonInteractive -EncodedCommand $encoded + } | ForEach-Object { $output.Add([string]$_) } + } + $observedExit = $global:LASTEXITCODE + } catch { $caught = $_; $observedExit = $global:LASTEXITCODE } + finally { $global:LASTEXITCODE = $oldExitCode } + $ownedRoot = (Get-Content -LiteralPath $capturePath -Raw).Trim() + $observedExit | Should -Be $childExit + @($output | Where-Object { $_ -ceq "OPAQUE_CHILD_RECEIPT_UNAVAILABLE=true" }).Count | Should -Be 1 + @($output | Where-Object { $_ -ceq "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven" }).Count | Should -Be 0 + Test-Path -LiteralPath (Join-Path $ownedRoot "ownership-unproven.json") | Should -BeFalse + Test-Path -LiteralPath $ownedRoot | Should -Be $retain + if ($retain) { + $caught | Should -Not -BeNullOrEmpty + $caught.Exception.Data["GraphCodeValidationRootRetentionRequired"] | Should -BeTrue + @($caught.Exception.Data["GraphCodeValidationRetainedRoots"]) | Should -Contain $ownedRoot + } else { + $caught | Should -BeNullOrEmpty + @($output | Where-Object { $_ -ceq "OPAQUE_CHILD_FORCED_EXIT_ZERO=true" }).Count | Should -Be 1 + Write-Host "OPAQUE_EXIT_ZERO_COUNTEREXAMPLE=observed rootRemoved=true realProducerSwallowingProven=false" + } + foreach ($name in $savedEnvironment.Keys) { + [Environment]::GetEnvironmentVariable($name) | Should -BeExactly "before-$name" + } + } + + It "setup failure before workload entry removes only its partial owned root" { + $original = [IO.IOException]::new("controlled setup failure") + $script:actionRan = $false + Mock New-Item { + $partialRoot = Split-Path -Path $Path[0] -Parent + [void][IO.Directory]::CreateDirectory($partialRoot) + [IO.File]::WriteAllText($capturePath, $partialRoot) + throw $original + } -ParameterFilter { @($Path).Count -eq 3 } + $caught = $null + try { + Invoke-WindowsShellValidationIsolation -WorkspaceRoot $workspaceRoot ` + -ValidationRootParent (Join-Path $caseRoot "short") ` + -UserProfileRoot $userProfileRoot -LocalAppDataRoot $localAppDataRoot ` + -Action { $script:actionRan = $true } + } catch { $caught = $_ } + $script:actionRan | Should -BeFalse + [object]::ReferenceEquals($caught.Exception, $original) | Should -BeTrue + Test-Path -LiteralPath ([IO.File]::ReadAllText($capturePath)) | Should -BeFalse + $caught.Exception.Data["GraphCodeValidationRootRetentionRequired"] | Should -BeNullOrEmpty + } + + It "tagged ownership failure retains evidence and restores the environment" { + $original = [InvalidOperationException]::new("controlled unattested survivor") + $original.Data["GraphCodeValidationRootRetentionRequired"] = $true + $caught = $null + try { + Invoke-WindowsShellValidationIsolation ` + -WorkspaceRoot $workspaceRoot ` + -UserProfileRoot $userProfileRoot ` + -LocalAppDataRoot $localAppDataRoot ` + -Action { + Set-Content -LiteralPath $capturePath -Value $env:GRAPHCODE_VALIDATION_ROOT + Set-Content -LiteralPath (Join-Path $env:GRAPHCODE_VALIDATION_ROOT "evidence.log") -Value "advisory snapshot" + throw $original + } + } catch { $caught = $_ } + [object]::ReferenceEquals($caught.Exception, $original) | Should -Be $true + $ownedRoot = (Get-Content -LiteralPath $capturePath -Raw).Trim() + Test-Path -LiteralPath $ownedRoot | Should -Be $true + Get-Content -LiteralPath (Join-Path $ownedRoot "evidence.log") | Should -BeExactly "advisory snapshot" + @($caught.Exception.Data["GraphCodeValidationRetainedRoots"]) | Should -Contain $ownedRoot + foreach ($name in $savedEnvironment.Keys) { + [Environment]::GetEnvironmentVariable($name) | Should -BeExactly "before-$name" + } + } + + It "tagged nested failure propagates both retained roots" { + $original = [InvalidOperationException]::new("controlled nested ownership failure") + $original.Data["GraphCodeValidationRootRetentionRequired"] = $true + $caught = $null + try { + Invoke-WindowsShellValidationIsolation ` + -WorkspaceRoot $workspaceRoot ` + -UserProfileRoot $userProfileRoot ` + -LocalAppDataRoot $localAppDataRoot ` + -Action { + Invoke-WindowsShellValidationIsolation ` + -WorkspaceRoot (Join-Path $workspaceRoot "inner") ` + -UserProfileRoot $userProfileRoot ` + -LocalAppDataRoot $localAppDataRoot ` + -Action { throw $original } + } + } catch { $caught = $_ } + [object]::ReferenceEquals($caught.Exception, $original) | Should -Be $true + $roots = @($caught.Exception.Data["GraphCodeValidationRetainedRoots"]) + $roots.Count | Should -Be 2 + foreach ($root in $roots) { Test-Path -LiteralPath $root | Should -Be $true } + foreach ($name in $savedEnvironment.Keys) { + [Environment]::GetEnvironmentVariable($name) | Should -BeExactly "before-$name" + } + } + + It "tagged retention reporting failure preserves the original error and root" { + Mock Write-Host { throw [IO.IOException]::new("controlled retention reporting failure") } ` + -ParameterFilter { "$Object" -like "WINDOWS_SHELL_VALIDATION_ISOLATION_ROOT_RETAINED=*" } + $original = [InvalidOperationException]::new("controlled original ownership failure") + $original.Data["GraphCodeValidationRootRetentionRequired"] = $true + $caught = $null + try { + Invoke-WindowsShellValidationIsolation ` + -WorkspaceRoot $workspaceRoot ` + -UserProfileRoot $userProfileRoot ` + -LocalAppDataRoot $localAppDataRoot ` + -Action { + Set-Content -LiteralPath $capturePath -Value $env:GRAPHCODE_VALIDATION_ROOT + throw $original + } + } catch { $caught = $_ } + $caught.Exception | Should -BeOfType ([AggregateException]) + [object]::ReferenceEquals($caught.Exception.InnerExceptions[0], $original) | Should -Be $true + $caught.Exception.InnerExceptions[1].Message | Should -BeExactly "controlled retention reporting failure" + $caught.Exception.Data["GraphCodeValidationRootRetentionRequired"] | Should -Be $true + $ownedRoot = (Get-Content -LiteralPath $capturePath -Raw).Trim() + Test-Path -LiteralPath $ownedRoot | Should -Be $true + @($caught.Exception.Data["GraphCodeValidationRetainedRoots"]) | Should -Contain $ownedRoot + foreach ($name in $savedEnvironment.Keys) { + [Environment]::GetEnvironmentVariable($name) | Should -BeExactly "before-$name" + } + } + + It "tagged missing root reports retention failure without masking the original" { + $original = [InvalidOperationException]::new("controlled missing-root ownership failure") + $original.Data["GraphCodeValidationRootRetentionRequired"] = $true + $caught = $null + try { + Invoke-WindowsShellValidationIsolation ` + -WorkspaceRoot $workspaceRoot ` + -UserProfileRoot $userProfileRoot ` + -LocalAppDataRoot $localAppDataRoot ` + -Action { + Remove-Item -LiteralPath $env:GRAPHCODE_VALIDATION_ROOT -Recurse -Force + throw $original + } + } catch { $caught = $_ } + $caught.Exception | Should -BeOfType ([AggregateException]) + [object]::ReferenceEquals($caught.Exception.InnerExceptions[0], $original) | Should -Be $true + $caught.Exception.InnerExceptions[1].Message | Should -Match "no longer exists" + $caught.Exception.Data["GraphCodeValidationRootRetentionRequired"] | Should -Be $true + foreach ($name in $savedEnvironment.Keys) { + [Environment]::GetEnvironmentVariable($name) | Should -BeExactly "before-$name" + } + } + + It "tagged external receipt retains the exact root" -TestCases @( + @{ variant = "valid"; invalid = $false }, + @{ variant = "valid-limit"; invalid = $false }, + @{ variant = "wrong-root"; invalid = $true }, + @{ variant = "malformed"; invalid = $true }, + @{ variant = "oversize"; invalid = $true }, + @{ variant = "wrong-schema"; invalid = $true }, + @{ variant = "wrong-reason"; invalid = $true } + ) { + param($variant, $invalid) + $original = [InvalidOperationException]::new("controlled child failure without exception data") + $caught = $null + try { + Invoke-WindowsShellValidationIsolation ` + -WorkspaceRoot $workspaceRoot ` + -UserProfileRoot $userProfileRoot ` + -LocalAppDataRoot $localAppDataRoot ` + -Action { + Set-Content -LiteralPath $capturePath -Value $env:GRAPHCODE_VALIDATION_ROOT + $receipt = [ordered]@{ + schemaVersion = 1; validationRoot = $env:GRAPHCODE_VALIDATION_ROOT + reason = "ownership_unproven" + } + if ($variant -eq "wrong-root") { $receipt.validationRoot += "-foreign" } + if ($variant -eq "wrong-schema") { $receipt.schemaVersion = 2 } + if ($variant -eq "wrong-reason") { $receipt.reason = "ordinary_failure" } + $text = if ($variant -eq "malformed") { "not-json" } elseif ($variant -eq "oversize") { + "x" * 4097 + } else { $receipt | ConvertTo-Json -Compress } + if ($variant -eq "valid-limit") { $text += " " * (4096 - [Text.Encoding]::UTF8.GetByteCount($text)) } + [IO.File]::WriteAllText((Join-Path $env:GRAPHCODE_VALIDATION_ROOT "ownership-unproven.json"), + $text, [Text.UTF8Encoding]::new($false)) + throw $original + } + } catch { $caught = $_ } + $ownedRoot = (Get-Content -LiteralPath $capturePath -Raw).Trim() + Test-Path -LiteralPath $ownedRoot | Should -Be $true + $caught.Exception.Data["GraphCodeValidationRootRetentionRequired"] | Should -Be $true + @($caught.Exception.Data["GraphCodeValidationRetainedRoots"]) | Should -Contain $ownedRoot + if ($invalid) { + $caught.Exception | Should -BeOfType ([AggregateException]) + [object]::ReferenceEquals($caught.Exception.InnerExceptions[0], $original) | Should -Be $true + if ($variant -eq "oversize") { $caught.Exception.InnerExceptions[1].Message | Should -Match "4096-byte" } + } else { + [object]::ReferenceEquals($caught.Exception, $original) | Should -Be $true + } + } + + It "tagged native refusal propagates without a writable receipt" -TestCases @( + @{ variant = "failed-exit"; childExit = 1; terminating = $false }, + @{ variant = "zero-exit"; childExit = 0; terminating = $false }, + @{ variant = "terminating"; childExit = 1; terminating = $true } + ) { + param($variant, $childExit, $terminating) + $caught = $null + $oldExitCode = $global:LASTEXITCODE + try { + Invoke-WindowsShellValidationIsolation ` + -WorkspaceRoot $workspaceRoot ` + -UserProfileRoot $userProfileRoot ` + -LocalAppDataRoot $localAppDataRoot ` + -Action { + Set-Content -LiteralPath $capturePath -Value $env:GRAPHCODE_VALIDATION_ROOT + Invoke-Native "mocked ownership child" { + $global:LASTEXITCODE = $childExit + Write-Output "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven" + if ($terminating) { throw "controlled terminating child" } + } + } + } catch { $caught = $_ } finally { $global:LASTEXITCODE = $oldExitCode } + $caught | Should -Not -BeNullOrEmpty + $caught.Exception.Data["GraphCodeValidationRootRetentionRequired"] | Should -Be $true + $ownedRoot = (Get-Content -LiteralPath $capturePath -Raw).Trim() + Test-Path -LiteralPath $ownedRoot | Should -Be $true + @($caught.Exception.Data["GraphCodeValidationRetainedRoots"]) | Should -Contain $ownedRoot + if ($terminating) { $caught.Exception.Message | Should -BeExactly "controlled terminating child" } + foreach ($name in $savedEnvironment.Keys) { + [Environment]::GetEnvironmentVariable($name) | Should -BeExactly "before-$name" + } + } + + It "tagged string truthiness does not prove cleanup after workload failure" { + $original = [InvalidOperationException]::new("controlled ordinary failure") + $original.Data["GraphCodeValidationRootRetentionRequired"] = "true" + $caught = $null + try { + Invoke-WindowsShellValidationIsolation ` + -WorkspaceRoot $workspaceRoot ` + -UserProfileRoot $userProfileRoot ` + -LocalAppDataRoot $localAppDataRoot ` + -Action { + Set-Content -LiteralPath $capturePath -Value $env:GRAPHCODE_VALIDATION_ROOT + throw $original + } + } catch { $caught = $_ } + [object]::ReferenceEquals($caught.Exception, $original) | Should -Be $true + Test-Path -LiteralPath (Get-Content -LiteralPath $capturePath -Raw).Trim() | Should -Be $true + $caught.Exception.Data["GraphCodeValidationRootRetentionReason"] | Should -BeExactly "workload_failed_without_cleanup_proof" + } + + It "tagged restoration preserves originally membership" -TestCases @( + foreach ($variable in @("GRAPHCODE_VALIDATION_ROOT", "GRAPHCODE_SUPPORT_DIR", "LOCALAPPDATA", "TEMP", "TMP")) { + foreach ($initialState in @("absent", "empty", "valued")) { + @{ state = $initialState; variableName = $variable } + } + } + ) { + param($state, $variableName) + if ($state -eq "absent") { + Remove-Item -LiteralPath "Env:\$variableName" -ErrorAction Stop + } else { + [Environment]::SetEnvironmentVariable($variableName, $(if ($state -eq "empty") { "" } else { "original-value" }), "Process") + } + $expected = [Environment]::GetEnvironmentVariables("Process") + $expected.Contains($variableName) | Should -Be ($state -ne "absent") + $original = [InvalidOperationException]::new("controlled membership restoration") + $original.Data["GraphCodeValidationRootRetentionRequired"] = $true + $caught = $null + try { + Invoke-WindowsShellValidationIsolation ` + -WorkspaceRoot $workspaceRoot ` + -UserProfileRoot $userProfileRoot ` + -LocalAppDataRoot $localAppDataRoot ` + -Action { + $isolated = [Environment]::GetEnvironmentVariables("Process") + $isolated.Contains($variableName) | Should -Be $true + $isolated[$variableName] | Should -Not -BeNullOrEmpty + throw $original + } + } catch { $caught = $_ } + [object]::ReferenceEquals($caught.Exception, $original) | Should -Be $true + $actual = [Environment]::GetEnvironmentVariables("Process") + $actual.Contains($variableName) | Should -Be $expected.Contains($variableName) + if ($expected.Contains($variableName)) { $actual[$variableName] | Should -BeExactly $expected[$variableName] } + } + It "uses an explicit short validation parent and removes only the owned run root" { $missingFunctions.Count | Should -Be 0 $shortParent = Join-Path $caseRoot "short" diff --git a/Tools/windows/Tests/WindowsCiTopology.Tests.ps1 b/Tools/windows/Tests/WindowsCiTopology.Tests.ps1 index 048b9e3f..9eab560e 100644 --- a/Tools/windows/Tests/WindowsCiTopology.Tests.ps1 +++ b/Tools/windows/Tests/WindowsCiTopology.Tests.ps1 @@ -7,6 +7,8 @@ $portWorkflow = Get-Content ` (Join-Path $repoRoot ".github\workflows\windows-port-validation.yml") -Raw $hardeningWorkflow = Get-Content ` (Join-Path $repoRoot ".github\workflows\windows-hardening.yml") -Raw +$portWorkflow = $portWorkflow.Replace("`r`n", "`n") +$hardeningWorkflow = $hardeningWorkflow.Replace("`r`n", "`n") $checks = 0 function Assert-Contract([object] $Condition, [string] $Message) { @@ -32,6 +34,41 @@ function Get-WorkflowJobs([string] $Workflow) { $jobs } +function Get-CompiledInvocationCount([string] $Text) { + [regex]::Matches($Text, + '(?m)^\s*\.\\Tools\\windows\\ValidationRunner\\bin\\Release\\net10\.0-windows\\ValidationRunner\.exe --repository-root \$PWD --artifact-root \.ci-validation-runner[ \t]*$').Count +} + +function Assert-CompiledBuild([string] $Job) { + Assert-Contract ($Job -match 'actions/setup-dotnet@[0-9a-f]{40}' -and + $Job -match '(?m)^\s+dotnet-version: 10\.0\.403[ \t]*$') ` + "the compiled runner must install the exact pinned SDK" + $steps = @([regex]::Matches($Job, '(?ms)^ - .*?(?=^ - |\z)') | + ForEach-Object Value | Where-Object { $_ -match '(?m)^\s+dotnet build -c Release[ \t]*$' }) + Assert-Contract ($steps.Count -eq 1 -and + $steps[0] -match '(?m)^ working-directory: Tools/windows/ValidationRunner[ \t]*$' -and + $steps[0] -match 'dotnet --version' -and + $steps[0] -match "\`$version -cne '10\.0\.403'" -and + $steps[0] -match '\$LASTEXITCODE -ne 0') ` + "the host build must select global.json from its nested directory and fail on an SDK/build error" +} + +function Assert-CompleteProof([string] $Text) { + Assert-Contract ($Text -match '\$exitCode -ne 0' -and + $Text.Contains('coverage.json') -and + $Text -match '-not \$report\.complete' -and + $Text -match '\$report\.requiredCount -le 0' -and + $Text -match '\$report\.passedCount -ne \$report\.requiredCount' -and + $Text -match '@\(\$report\.units\)\.Count -ne \$report\.requiredCount' -and + $Text -match '@\(\$report\.errors\)\.Count -ne 0') ` + "normal execution must require exit zero and complete positive full coverage, not an empty or filtered report" + Assert-Contract ($Text.Contains('CompiledValidationRunner: PASS') -and + $Text.Contains('VALIDATION_RUNNER_EXECUTED=1') -and + $Text -match '\$passMarkers -ne 1' -and + $Text -match '\$executedMarkers -ne 1') ` + "normal execution must require exactly one full PASS marker and one positive execution marker" +} + $portJobs = Get-WorkflowJobs $portWorkflow $hardeningJobs = Get-WorkflowJobs $hardeningWorkflow @@ -39,7 +76,8 @@ Assert-Contract $portJobs.Contains("validation-runner") ` "Windows port CI needs a dedicated validation-runner job" $runnerJob = $portJobs["validation-runner"] Assert-Contract ($runnerJob -match '(?m)^ needs: changes\s*$' -and - $runnerJob -match "needs\.changes\.outputs\.windows == 'true'") ` + $runnerJob -match "needs\.changes\.outputs\.windows == 'true'" -and + $runnerJob -match "needs\.changes\.result != 'success'") ` "the validation-runner job must use the fail-closed Windows path gate" Assert-Contract ($runnerJob -match '(?m)^ runs-on: windows-2022\s*$' -and $runnerJob -notmatch '(?m)^\s+(strategy|matrix):') ` @@ -49,28 +87,26 @@ Assert-Contract ($runnerJob -match $runnerJob -match 'swift-version: swift-6\.3\.3-release' -and $runnerJob -match 'swift-build: 6\.3\.3-RELEASE') ` "the validation-runner job must install the pinned Swift formatter" -Assert-Contract ([regex]::Matches( - $runnerJob, - '(?m)^\s+run: \./Tools/windows/Invoke-ValidationRunnerContract\.ps1\s*$' - ).Count -eq 1) ` - "the dedicated job must run the counted validation runner contract exactly once" +Assert-CompiledBuild $runnerJob +Assert-Contract ((Get-CompiledInvocationCount $runnerJob) -eq 1 -and + $runnerJob -notmatch '--suite\b|--self-test\b|--fixture\b|ValidationRunner\.Tests\.ps1|Invoke-ValidationRunnerContract\.ps1') ` + "the dedicated job must run the normal full compiled host exactly once, never a filtered, fixture, or legacy entry" +Assert-CompleteProof $runnerJob Assert-Contract ($runnerJob -notmatch 'bootstrap\.ps1|\.ci-providers|validate\.ps1|continue-on-error') ` "the source-only validation runner job must not bootstrap providers or hide failures" $spikesSwift = $portJobs["spikes-swift"] Assert-Contract ($spikesSwift -notmatch - 'ValidationRunner\.Tests\.ps1|Invoke-ValidationRunnerContract\.ps1') ` + 'ValidationRunner\.Tests\.ps1|Invoke-ValidationRunnerContract\.ps1|ValidationRunner\.exe') ` "the sequential Swift job must not repeat the validation runner contract" Assert-Contract ([regex]::Matches( $spikesSwift, '(?m)^\s+run: \./Tools/windows/Tests/WindowsCiTopology\.Tests\.ps1\s*$' ).Count -eq 1) ` "the Swift job must verify runner scheduling before expensive setup" -Assert-Contract ([regex]::Matches( - $portWorkflow, - '(?m)^\s+run: \./Tools/windows/Invoke-ValidationRunnerContract\.ps1\s*$' - ).Count -eq 1) ` - "Windows port CI must declare exactly one validation runner step" +Assert-Contract ((Get-CompiledInvocationCount $portWorkflow) -eq 1 -and + $portWorkflow -notmatch '(?m)^\s+run:.*(?:ValidationRunner\.Tests\.ps1|Invoke-ValidationRunnerContract\.ps1)') ` + "Windows port CI must declare exactly one direct compiled validation run and no legacy invocation" $aggregate = $portJobs["windows-spikes"] Assert-Contract ($aggregate -match @@ -81,35 +117,40 @@ Assert-Contract ($aggregate -match $hardeningRunnerStep = [regex]::Match( $hardeningJobs["deterministic"], - '(?ms)^ - name: Verify runner contract\s+.*?(?=^ - |\z)' + '(?ms)^ - name: Verify complete compiled validation runner contract\s+.*?(?=^ - |\z)' ) $hardeningRunnerIf = [regex]::Match( $hardeningRunnerStep.Value, '(?m)^ if: ([^\r\n]+)\s*$' ) Assert-Contract ($hardeningRunnerStep.Success -and $hardeningRunnerIf.Success -and - $hardeningRunnerStep.Value -match - '(?m)^ run: \./Tools/windows/Invoke-ValidationRunnerContract\.ps1\s*$' -and + (Get-CompiledInvocationCount $hardeningRunnerStep.Value) -eq 1 -and $hardeningRunnerIf.Groups[1].Value -match "env\.HARDENING_REQUIRED == 'true'" -and $hardeningRunnerIf.Groups[1].Value -match "github\.event_name != 'pull_request'" -and $hardeningRunnerIf.Groups[1].Value -match "matrix\.os == 'windows-2022'") ` "hardening must keep one scheduled/manual fallback without duplicating the PR contract" -Assert-Contract ([regex]::Matches( - $hardeningWorkflow, - '(?m)^\s+run: \./Tools/windows/Invoke-ValidationRunnerContract\.ps1\s*$' - ).Count -eq 1) ` - "hardening must declare exactly one non-PR validation runner fallback" +Assert-Contract ((Get-CompiledInvocationCount $hardeningWorkflow) -eq 1 -and + $hardeningWorkflow -notmatch '(?m)^\s+run:.*(?:ValidationRunner\.Tests\.ps1|Invoke-ValidationRunnerContract\.ps1)') ` + "hardening must declare exactly one non-PR compiled runner fallback and no legacy invocation" +Assert-CompiledBuild $hardeningJobs["deterministic"] +Assert-CompleteProof $hardeningRunnerStep.Value +Assert-Contract ($runnerJob.Contains('path: .ci-validation-runner') -and + $hardeningJobs["deterministic"].Contains('path: .ci-validation-runner')) ` + "both canonical execution paths must retain their compiled coverage and payload evidence" $wrapper = Get-Content ` (Join-Path $repoRoot "Tools\windows\Invoke-ValidationRunnerContract.ps1") -Raw -Assert-Contract ([regex]::Matches( - $wrapper, - '(?m)^& \$pwsh -NoProfile -File \$runner 2>&1 \| ForEach-Object \{\s*$' - ).Count -eq 1 -and - $wrapper -match '\$exitCode -ne 0' -and - $wrapper -match '\$passMarkers -ne 1' -and - $wrapper -match 'VALIDATION_RUNNER_EXECUTED=1') ` - "the validation runner wrapper must execute once, fail closed, and report a positive count" +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') ` + "the compatibility wrapper must build and execute only the normal compiled host" +Assert-CompleteProof $wrapper + +$privateSdkArtifacts = Join-Path $repoRoot (".build\private-sdk-caller-" + [guid]::NewGuid().ToString("N")) +$null = New-Item -ItemType Directory -Path $privateSdkArtifacts +& (Join-Path $repoRoot "Tools\windows\ValidationRunner\Host\CiContracts\PrivateSdkCaller.Tests.ps1") ` + -RepositoryRoot $repoRoot.Path -ArtifactRoot $privateSdkArtifacts Write-Host "Windows CI topology: $checks checks, 0 failures" exit 0 diff --git a/Tools/windows/Tests/WindowsShell.Tests.ps1 b/Tools/windows/Tests/WindowsShell.Tests.ps1 index 38a31b02..9d0f9040 100644 --- a/Tools/windows/Tests/WindowsShell.Tests.ps1 +++ b/Tools/windows/Tests/WindowsShell.Tests.ps1 @@ -340,7 +340,7 @@ Assert-Contract ($appSource -match '(?s)smoke_tick >= 16 and !app\.smoke_input_requested.*?if \(app\.workspace\) \|workspace\| \{\s*app\.smoke_input_requested = true;') ` "smoke input must wait for its workspace instead of consuming the one-shot action early" Assert-Contract ($shellSource -match - '(?s)\$inputDeadline = .*?AddSeconds\(8\).*?\$inputApp = Start-Process.*?\$attachReady.*?pwsh.*?Write-OwnedResourceMetrics "windows-shell:large-paste".*?WaitForExit\(\$remainingMilliseconds\)') ` + '(?s)\$inputDeadline = .*?AddSeconds\(8\).*?\$inputApp = Start-SmokeProcess.*?\$attachReady.*?pwsh.*?Write-OwnedResourceMetrics "windows-shell:large-paste".*?WaitForExit\(\$remainingMilliseconds\)') ` "large-paste sampling must observe the owned attach within the shared eight-second deadline" Assert-Contract ($shellSource -match '(?s)while \(\[DateTime\]::UtcNow -lt \$inputDeadline.*?Write-OwnedResourceMetrics "windows-shell:large-paste".*?Start-Sleep -Milliseconds 50') ` @@ -442,7 +442,7 @@ Assert-Contract ($shellSource -match if ($shellSource -match '(?m)^\s*Write-OwnedResourceMetrics\s*$') { throw "Windows shell contract: empty resource metric phase" } -Assert-Contract ($shellSource -match '(?s)\$inputApp\s*=\s*Start-Process.*?\$inputApp\.Id.*?Write-OwnedResourceMetrics "windows-shell:large-paste" @\(\$inputApp\.Id\)') ` +Assert-Contract ($shellSource -match '(?s)\$inputApp\s*=\s*Start-SmokeProcess.*?\$inputApp\.Id.*?Write-OwnedResourceMetrics "windows-shell:large-paste" @\(\$inputApp\.Id\)') ` "large-paste metric is not tied to the recorded inputApp PID" Assert-Contract ($shellSource -match '(?s)GraphCode Windows shell restart smoke.*?Invoke-ShellProcess \$arguments "windows-shell:restart"') ` "restart snapshot is not assigned the restart phase" @@ -517,6 +517,18 @@ function Resolve-TestZig { throw "A working Zig executable is required for executable Windows shell tests." } +Invoke-Native "Shell process ownership mock contracts" { + . (Join-Path $PSScriptRoot "..\Import-PinnedPester.ps1") + $ownershipResults = Invoke-Pester -Path (Join-Path $PSScriptRoot "ShellProcessOwnership.Tests.ps1") -PassThru + if ($ownershipResults.TotalCount -ne 70 -or + $ownershipResults.PassedCount -ne $ownershipResults.TotalCount -or + $ownershipResults.FailedCount -ne 0 -or $ownershipResults.SkippedCount -ne 0) { + throw "Shell process ownership mock contracts did not pass all 70 unskipped cases" + } + Write-Host "SHELL_PROCESS_OWNERSHIP_MOCK_CASES=$($ownershipResults.TotalCount)" + $global:LASTEXITCODE = 0 +} + foreach ($path in @( "build.zig", "build.zig.zon", @@ -832,6 +844,21 @@ Invoke-Native "Onboarding executable tests" { -lc -luser32 -lgdi32 "-I$include" } finally { Pop-Location } } +Invoke-Native "Owned live process cleanup mock contracts" { + & (Join-Path $PSScriptRoot "OwnedLiveProcessCleanup.Tests.ps1") + $global:LASTEXITCODE = 0 +} +Invoke-Native "Owned live caller and adapter mock integration" { + . (Join-Path $PSScriptRoot "..\Import-PinnedPester.ps1") + $adapterResults = Invoke-Pester -Path (Join-Path $PSScriptRoot "OwnedLiveAdapter.Tests.ps1") -PassThru + if ($adapterResults.TotalCount -ne 13 -or $adapterResults.PassedCount -ne 13 -or + $adapterResults.FailedCount -ne 0 -or $adapterResults.SkippedCount -ne 0 -or + $adapterResults.NotRunCount -ne 0) { + throw "Owned live caller and adapter mock integration did not pass all 13 cases" + } + Write-Host "OWNED_LIVE_ADAPTER_MOCK_CASES=$($adapterResults.PassedCount)" + $global:LASTEXITCODE = 0 +} Invoke-Native "GDI+ asynchronous startup live test" { $winghosttyRoot = [Environment]::GetEnvironmentVariable("GRAPHCODE_WINGHOSTTY_ROOT") if (-not $winghosttyRoot) { diff --git a/Tools/windows/Tests/WireTargeting.Tests.ps1 b/Tools/windows/Tests/WireTargeting.Tests.ps1 index 5b49abe1..6071fc12 100644 --- a/Tools/windows/Tests/WireTargeting.Tests.ps1 +++ b/Tools/windows/Tests/WireTargeting.Tests.ps1 @@ -56,8 +56,24 @@ public static class WireFixture { [DllImport("user32.dll")] public static extern bool DestroyWindow(IntPtr window); [DllImport("user32.dll")] public static extern IntPtr SetThreadDpiAwarenessContext(IntPtr context); [DllImport("user32.dll")] public static extern IntPtr GetThreadDpiAwarenessContext(); + [StructLayout(LayoutKind.Sequential)] struct Point { public int X,Y; } + [StructLayout(LayoutKind.Sequential)] struct Message { + public IntPtr Window; public uint Id; public UIntPtr WParam; public IntPtr LParam; + public uint Time; public Point Point; public uint Private; + } + [DllImport("user32.dll")] 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); + 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); + } + public static uint OwnerProcess(IntPtr window) { + uint processId; GetWindowThreadProcessId(window,out processId); return processId; + } public static IntPtr Create(IntPtr parent, bool child, string name) { - IntPtr result=CreateWindowExW(0x08000000,child ? "WireTargetFixtureContent" : "WireTargetFixture", + // Isolate only owned top-level fixtures without taking keyboard focus. + IntPtr result=CreateWindowExW(child ? 0x08000000u : 0x08000008u,child ? "WireTargetFixtureContent" : "WireTargetFixture", name,child ? 0x50000000u : 0x90000000u, 20,20,250,96,parent,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero); if(result==IntPtr.Zero) throw new Exception("Fixture CreateWindow failed."); @@ -229,8 +245,41 @@ try { } Reject-WireCase "reject sheet $fault" {Resolve-CapOwnedSheet $rows 1 $PID} $message } + Test-WireCase 'native unrelated topmost fixture refuses a pointer before posting' { + $b=@($nativeRows|Where-Object Handle -EQ $hostWindow.ToInt64())[0].Bounds + $overlay=[IntPtr]::Zero + $previous=[WireFixture]::SetThreadDpiAwarenessContext([IntPtr](-4)) + Assert-WireCase ($previous -ne [IntPtr]::Zero) 'Overlay PMv2 setup failed.' + try { + $overlay=[WireFixture]::Create([IntPtr]::Zero,$false,'wire fixture unrelated overlay') + Assert-WireCase ([WireFixture]::AtPoint(($b[0]+10),($b[1]+10)) -eq $overlay -and + [WireFixture]::OwnerProcess($overlay) -eq $PID) 'Controlled unrelated overlay is not the actual recipient.' + Assert-WireCase (-not [WireFixture]::HasPointerMessage($hostWindow) -and + -not [WireFixture]::HasPointerMessage($overlay)) 'Pointer message was already queued before the refusal control.' + $failure=$null + try {[CapNative]::PostPointerClickScreen($hostWindow,($b[0]+10),($b[1]+10))|Out-Null} + catch {$failure=$_} + Assert-WireCase ($null -ne $failure -and + $failure.Exception.Message.Contains('Pointer recipient differs from intended owned HWND')) ` + '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.' + } finally { + try { + if ($overlay -ne [IntPtr]::Zero -and -not [WireFixture]::DestroyWindow($overlay)) { + throw 'Controlled overlay destruction failed.' + } + } 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)) + Assert-WireCase ($preflight -ne [IntPtr]::Zero) 'Owned pointer fixture PMv2 preflight failed.' + try { + Assert-WireCase ([WireFixture]::AtPoint(($b[0]+10),($b[1]+10)) -eq $hostWindow -and + [WireFixture]::OwnerProcess($hostWindow) -eq $PID) 'Owned pointer fixture recipient preflight refused.' + } finally {$null=[WireFixture]::SetThreadDpiAwarenessContext($preflight)} $previous=[WireFixture]::SetThreadDpiAwarenessContext([IntPtr](-1)) try { $before=[WireFixture]::GetThreadDpiAwarenessContext() @@ -301,4 +350,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 {56})) {throw 'Wire targeting executed count differs.'} +if ($results.Count -ne $(if ($LegacyBehavior) {2} else {57})) {throw 'Wire targeting executed count differs.'} diff --git a/Tools/windows/UiaWindowActivation.cs b/Tools/windows/UiaWindowActivation.cs new file mode 100644 index 00000000..24718782 --- /dev/null +++ b/Tools/windows/UiaWindowActivation.cs @@ -0,0 +1,46 @@ +using System; + +namespace GraphCode.Windows { + public struct UiaActivationCallResult { + public readonly bool Succeeded; + public readonly int Error; + + public UiaActivationCallResult(bool succeeded, int error) { + Succeeded = succeeded; + Error = error; + } + } + + public interface IUiaWindowActivationOperations { + uint WindowProcessId(IntPtr window); + bool IsRetainedProcessAlive(uint processId); + void ShowWindow(IntPtr window, int command); + UiaActivationCallResult BringWindowToTop(IntPtr window); + UiaActivationCallResult SetForegroundWindow(IntPtr window); + bool IsForegroundWindow(IntPtr window); + } + + public static class UiaWindowActivation { + public static bool ActivateWindow( + IUiaWindowActivationOperations operations, IntPtr window, out string diagnostic + ) { + if (operations == null) throw new ArgumentNullException(nameof(operations)); + uint expectedProcessId = operations.WindowProcessId(window); + if (window == IntPtr.Zero || !operations.IsRetainedProcessAlive(expectedProcessId)) { + diagnostic = "invalid target HWND or no retained live launch identity"; + return false; + } + operations.ShowWindow(window, 9); + UiaActivationCallResult broughtToTop = operations.BringWindowToTop(window); + UiaActivationCallResult foregroundSet = operations.SetForegroundWindow(window); + bool observed = operations.IsForegroundWindow(window) && + operations.WindowProcessId(window) == expectedProcessId && + operations.IsRetainedProcessAlive(expectedProcessId); + diagnostic = String.Format( + "BringWindowToTop={0} GetLastError={1} SetForegroundWindow={2} GetLastError={3} observedOwnedForeground={4}; no synthetic keys or input-queue attachment", + broughtToTop.Succeeded, broughtToTop.Succeeded ? 0 : broughtToTop.Error, + foregroundSet.Succeeded, foregroundSet.Succeeded ? 0 : foregroundSet.Error, observed); + return observed; + } + } +} diff --git a/Tools/windows/ValidationFixtureDirectoryNative.cs b/Tools/windows/ValidationFixtureDirectoryNative.cs new file mode 100644 index 00000000..8f44ead8 --- /dev/null +++ b/Tools/windows/ValidationFixtureDirectoryNative.cs @@ -0,0 +1,21 @@ +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; + +namespace GraphCode; + +public static class ValidationFixtureDirectoryNative +{ + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CreateDirectoryW(string path, IntPtr securityAttributes); + + public static void Create(string path) + { + if (!CreateDirectoryW(path, IntPtr.Zero)) + { + var error = Marshal.GetLastWin32Error(); + throw new Win32Exception(error, $"Exclusive validation fixture create failed (Win32 {error}): {path}"); + } + } +} diff --git a/Tools/windows/ValidationRunner/.gitignore b/Tools/windows/ValidationRunner/.gitignore new file mode 100644 index 00000000..5ed3dd92 --- /dev/null +++ b/Tools/windows/ValidationRunner/.gitignore @@ -0,0 +1,3 @@ +bin/ +obj/ +.local/ diff --git a/Tools/windows/ValidationRunner/Adapters/EvidenceParser.cs b/Tools/windows/ValidationRunner/Adapters/EvidenceParser.cs new file mode 100644 index 00000000..9906aea8 --- /dev/null +++ b/Tools/windows/ValidationRunner/Adapters/EvidenceParser.cs @@ -0,0 +1,99 @@ +using System.Text.Json; +using System.IO; +using System.Text.RegularExpressions; + +namespace GraphCode.Validation.Adapters; + +public static class EvidenceParser +{ + public static UnitResult Structured( + string unitId, string suite, string output, int minimumCount, int? exactCount = null, + bool requireSkipped = true) + { + var matches = new List(); + foreach (var line in Lines(output)) + { + if (!line.StartsWith("{", StringComparison.Ordinal)) continue; + using var document = JsonDocument.Parse(line); + if (document.RootElement.TryGetProperty("suite", out var name) && + name.ValueKind == JsonValueKind.String && name.GetString() == suite) + matches.Add(document.RootElement.Clone()); + } + if (matches.Count != 1) throw new InvalidDataException($"{suite}: expected one structured summary; observed {matches.Count}"); + var summary = matches[0]; + var executed = Integer(summary, "executed"); + if (executed < minimumCount || (exactCount.HasValue && executed != exactCount.Value) || + Integer(summary, "passed") != executed || Integer(summary, "failed") != 0 || + (requireSkipped && Integer(summary, "skipped") != 0) || + (summary.TryGetProperty("notRun", out _) && Integer(summary, "notRun") != 0)) + throw new InvalidDataException($"{suite}: incomplete or failed suite summary"); + return new UnitResult(unitId, UnitStatus.Passed, executed, $"{suite}: executed={executed}; passed={executed}; failed=0; skipped=0"); + } + + public static UnitResult PesterVersion(string output) + { + const string marker = "PesterVersion.Tests.ps1: PASS; executed=14"; + var lines = Lines(output); + var cases = lines.Where(line => line.StartsWith("PASS: ", StringComparison.Ordinal)).ToArray(); + if (lines.Count(line => line == marker) != 1 || cases.Length != 14 || + cases.Distinct(StringComparer.Ordinal).Count() != 14) + throw new InvalidDataException("PesterVersion: expected 14 distinct executed cases and exactly one summary"); + return new UnitResult("ps.pester-version", UnitStatus.Passed, cases.Length, marker); + } + + public static UnitResult IsolatedEnvironment(string output) + { + var summaries = Lines(output).Where(line => + Regex.IsMatch(line, "^Isolated process environment contracts: [0-9]+ cases; phase=REGRESSION$", + RegexOptions.CultureInvariant)).ToArray(); + if (summaries.Length != 1) throw new InvalidDataException("Isolated environment: missing or duplicate regression count"); + var match = Regex.Match(summaries[0], "contracts: ([0-9]+) cases", RegexOptions.CultureInvariant); + var count = int.Parse(match.Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture); + if (count != 14) throw new InvalidDataException($"Isolated environment: expected all 14 cases; observed {count}"); + return new UnitResult("ps.isolated-process-environment", UnitStatus.Passed, count, summaries[0]); + } + + public static UnitResult AgentCommand(string output) + { + const string marker = "Invoke-AgentCommand.Tests.ps1: PASS; executed=6; cases=argument-stream-marker,zero-arguments,existing-console,existing-marker,parse-failure,child-nonzero"; + var lines = Lines(output); + if (lines.Count != 1 || lines[0] != marker) + throw new InvalidDataException("Agent command: expected the exact six-case production suite summary"); + return new UnitResult("ps.agent-command", UnitStatus.Passed, 6, marker); + } + + public static IReadOnlyList CaptureGroups(string reportPath, IReadOnlyList required) + { + using var report = JsonDocument.Parse(File.ReadAllText(reportPath)); + var cases = report.RootElement.GetProperty("cases").EnumerateArray().ToArray(); + foreach (var name in required) + { + var matches = cases.Where(row => row.GetProperty("name").GetString() == name).ToArray(); + if (matches.Length != 1 || matches[0].GetProperty("passed").ValueKind != JsonValueKind.True) + throw new InvalidDataException($"Capture group did not pass exactly once: {name}"); + } + return required.ToArray(); + } + + public static bool CaptureDiagnostics(string text, string suiteRoot) + { + var lines = Lines(text); + if (lines.Count == 0) return true; + const string selector = "CAP_TRACE_FAILURE command=CapSelectorRefusal: Cannot find an overload for \"ToInt64\" and the argument count: \"0\"."; + var denied = Path.Combine(suiteRoot, "manifest-creation-budget", "snapshots"); + var journal = "JournalObservationFailure while reporting JournalUnavailable: JournalObservationFailure: " + + $"Exception calling \"Open\" with \"4\" argument(s): \"Access to the path '{denied}' is denied.\""; + return lines.Count == 3 && lines[0] == selector && lines[1] == selector && lines[2] == journal; + } + + private static int Integer(JsonElement element, string property) + { + if (!element.TryGetProperty(property, out var value) || value.ValueKind != JsonValueKind.Number || + !value.TryGetInt32(out var count)) + throw new InvalidDataException($"Suite summary requires integer {property}"); + return count; + } + + private static IReadOnlyList Lines(string text) => + text.Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries); +} diff --git a/Tools/windows/ValidationRunner/Adapters/HostSourceContracts.ps1 b/Tools/windows/ValidationRunner/Adapters/HostSourceContracts.ps1 new file mode 100644 index 00000000..ecb0b4e9 --- /dev/null +++ b/Tools/windows/ValidationRunner/Adapters/HostSourceContracts.ps1 @@ -0,0 +1,129 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][ValidateSet("host-prerequisite", "provider-boundary", "delayed-responses")][string] $Contract +) + +$ErrorActionPreference = "Stop" +$source = [IO.File]::ReadAllText((Join-Path $RepositoryRoot "Tools\windows\validate.ps1")) +$cases = [Collections.Generic.List[string]]::new() +function Assert-Source([bool] $Condition, [string] $Message) { + if (-not $Condition) { throw "Compiled validation source contract: $Message" } +} +function Get-TaskBody([string] $Text, [string] $Task) { + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseInput($Text, [ref]$null, [ref]$errors) + Assert-Source ($errors.Count -eq 0) "validation source does not parse" + $function = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq "Invoke-Task" + }, $true)) + Assert-Source ($function.Count -eq 1) "task dispatcher is missing or duplicated" + $switches = @($function[0].FindAll({ + param($node) + $node -is [Management.Automation.Language.SwitchStatementAst] + }, $true)) + $clauses = @($switches.Clauses | Where-Object { $_.Item1.Value -ceq $Task }) + Assert-Source ($clauses.Count -eq 1) "task branch missing or duplicated: $Task" + return $clauses[0].Item2 +} +function Confirm-HostOrder([string] $Text) { + $body = Get-TaskBody $Text "windows-shell" + $commands = @($body.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] + }, $true)) + $stages = @{} + foreach ($entry in @( + @{ key = "build"; title = '"Pinned Winghostty host build for App contracts"' }, + @{ key = "smoke"; title = '"Pinned GraphCode Windows shell build and smoke"' } + )) { + $values = @($commands | Where-Object { + $_.GetCommandName() -ceq "Invoke-Native" -and $_.CommandElements[1].Extent.Text -ceq $entry.title + }) + Assert-Source ($values.Count -eq 1) "missing or duplicated $($entry.key)" + $stages[$entry.key] = $values[0].Extent + } + $calls = @($commands | Where-Object { + $_.InvocationOperator -eq [Management.Automation.Language.TokenKind]::Ampersand -and + $_.CommandElements[0].Extent.Text -match 'Tools\\windows\\Tests\\WindowsShell\.Tests\.ps1' + }) + Assert-Source ($calls.Count -eq 1) "App contracts call missing or duplicated" + $stages.contracts = $calls[0].Extent + $guards = @($body.FindAll({ + param($node) + $node -is [Management.Automation.Language.IfStatementAst] + }, $true)) + foreach ($entry in @( + @{ key = "pin"; pattern = '\$actualWinghosttyPin -ne \$pins\.winghostty\.sha' }, + @{ key = "clean"; pattern = '\$providerStatus\.Count -ne 0' }, + @{ key = "library"; pattern = 'Test-Path -LiteralPath \$winghosttyLib -PathType Leaf' } + )) { + $values = @($guards | Where-Object { $_.Extent.Text -match $entry.pattern }) + Assert-Source ($values.Count -eq 1) "missing or duplicated $($entry.key) guard" + $stages[$entry.key] = $values[0].Extent + } + Assert-Source ($stages.pin.EndOffset -lt $stages.build.StartOffset -and + $stages.clean.EndOffset -lt $stages.build.StartOffset -and + $stages.build.EndOffset -lt $stages.library.StartOffset -and + $stages.library.EndOffset -lt $stages.contracts.StartOffset -and + $stages.contracts.EndOffset -lt $stages.smoke.StartOffset) "App host prerequisite ordering changed" + Assert-Source ($stages.build.Text -match + '(?s)Push-Location \$winghosttyRoot.*& \$zig0152 build -Demit-win32-host=true.*finally \{ Pop-Location \}') ` + "host compile is not the pinned build with restored working directory" +} +switch ($Contract) { + "host-prerequisite" { + Confirm-HostOrder $source + $cases.Add("actual-pinned-order") + $call = [regex]::Match($source, + '(?s)& \(Join-Path \$repoRoot "Tools\\windows\\Tests\\WindowsShell\.Tests\.ps1"\)\s*`\s*-ZigExecutable \$zig0152').Value + Assert-Source (-not [string]::IsNullOrEmpty($call)) "cannot construct early-call mutation" + $variants = [ordered]@{ + "missing-build" = $source.Replace('& $zig0152 build -Demit-win32-host=true', 'Write-Output "build removed"') + "missing-stage" = $source.Replace('"Pinned Winghostty host build for App contracts"', '"build removed"') + "early-contracts" = $source.Replace($call, "").Replace( + 'Invoke-Native "Pinned Winghostty host build for App contracts"', + $call + "`n" + 'Invoke-Native "Pinned Winghostty host build for App contracts"') + } + foreach ($name in $variants.Keys) { + Assert-Source ($variants[$name] -cne $source) "ineffective mutation: $name" + $rejected = $false + try { Confirm-HostOrder $variants[$name] } catch { $rejected = $true } + Assert-Source $rejected "unsafe prerequisite mutation admitted: $name" + $cases.Add($name) + } + } + "provider-boundary" { + $body = (Get-TaskBody $source "provider-build").Extent.Text + Assert-Source ($body -match 'Invoke-ProviderBuild' -and + $body -notmatch '(?i)terminal-gate\.ps1|TerminalGate\.Tests|windows-shell\.ps1|uia|Stress') ` + "build-only task runs more than the shared provider compile" + $cases.Add("build-only-dispatch") + $terminal = (Get-TaskBody $source "terminal-gate").Extent.Text + Assert-Source ($terminal -match '(?s)Invoke-ProviderBuild.*?terminal-gate\.ps1.*?-SkipProviderBuild.*?-Stress') ` + "terminal gate no longer reuses the shared compile" + $cases.Add("terminal-shared-build") + foreach ($consumer in @("terminal-gate.ps1", "windows-shell.ps1")) { + $text = [IO.File]::ReadAllText((Join-Path $RepositoryRoot "Tools\windows\$consumer")) + Assert-Source ($text -match 'provider-build\.ps1' -and + $text -notmatch '-Demit-win32-host=true|-Dtarget=x86_64-windows-gnu') "consumer duplicates build flags: $consumer" + $cases.Add($consumer) + } + $provider = [IO.File]::ReadAllText((Join-Path $RepositoryRoot "Tools\windows\provider-build.ps1")) + Assert-Source ($provider.Contains('-Demit-win32-host=true') -and $provider.Contains('-Dtarget=x86_64-windows-gnu')) ` + "canonical provider build flags are absent" + $cases.Add("canonical-flags") + } + "delayed-responses" { + foreach ($path in @("Tools\windows\validate.ps1", "Tools\windows\Tests\Hardening.Tests.ps1")) { + $text = [IO.File]::ReadAllText((Join-Path $RepositoryRoot $path)) + Assert-Source ($text.Contains('-StubResponseDelayMilliseconds 150')) "delayed correlated response not exercised: $path" + $cases.Add($path) + } + } +} +Assert-Source ($cases.Count -gt 0) "no cases executed" +[ordered]@{ + suite = "HostSource.$Contract"; executed = $cases.Count; passed = $cases.Count; failed = 0; skipped = 0 +} | ConvertTo-Json -Compress | Write-Output diff --git a/Tools/windows/ValidationRunner/Adapters/HostTasks.ps1 b/Tools/windows/ValidationRunner/Adapters/HostTasks.ps1 new file mode 100644 index 00000000..48f1f5b6 --- /dev/null +++ b/Tools/windows/ValidationRunner/Adapters/HostTasks.ps1 @@ -0,0 +1,174 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][ValidateSet("inventory", "dry-run", "unknown", "untracked-format", "foreign-fixture", "missing-providers")][string] $Contract +) + +$ErrorActionPreference = "Stop" +$runner = Join-Path $RepositoryRoot "Tools\windows\validate.ps1" +$pwsh = [Environment]::ProcessPath +$executed = 0 +$toolchain = $null + +function Assert-HostTask([bool] $Condition, [string] $Message) { + if (-not $Condition) { throw "Compiled host task contract: $Message" } +} + +function Assert-DryRunBeforeEffects([string] $Source) { + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseInput($Source, [ref]$null, [ref]$errors) + Assert-HostTask ($errors.Count -eq 0) "dry-run producer does not parse" + $functions = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq "Invoke-Task" + }, $true)) + Assert-HostTask ($functions.Count -eq 1) "task dispatcher missing or duplicated" + $first = $functions[0].Body.EndBlock.Statements[0] + Assert-HostTask ($first -is [Management.Automation.Language.IfStatementAst] -and + $first.Clauses.Count -eq 1 -and $first.Clauses[0].Item1.Extent.Text -ceq '$DryRun') ` + "dry-run does not precede task effects" + $body = $first.Clauses[0].Item2 + Assert-HostTask ($body.Statements[-1] -is [Management.Automation.Language.ReturnStatementAst]) "dry-run falls through to execution" + $effects = @($body.FindAll({ + param($node) + ($node -is [Management.Automation.Language.CommandAst] -and $node.GetCommandName() -cne "Write-Output") -or + $node -is [Management.Automation.Language.InvokeMemberExpressionAst] -or + $node -is [Management.Automation.Language.FileRedirectionAst] + }, $true)) + Assert-HostTask ($effects.Count -eq 0) "dry-run performs an invocation, file write, or task action" +} + +switch ($Contract) { + "inventory" { + $expected = @("swift-portable", "swift-contracts", "swift-production", "swift-paths", + "swift-process", "swift-named-pipe", "remote-bridge", "remote-e2e", "swift-format", + "visual-baseline", "tdd-evidence", "privacy", "terminal-gate", "windows-shell", + "packaging", "hardening", "provider-build") + $actual = @(& $runner -List) + Assert-HostTask ($LASTEXITCODE -eq 0) "task listing failed" + Assert-HostTask ($actual.Count -eq $expected.Count) "task count changed" + foreach ($task in $expected) { + Assert-HostTask (@($actual | Where-Object { $_ -ceq $task }).Count -eq 1) "missing or duplicated task $task" + $executed++ + } + } + "dry-run" { + $source = [IO.File]::ReadAllText($runner) + Assert-DryRunBeforeEffects $source + $executed++ + $mutations = @( + $source.Replace('function Invoke-Task([string] $name) {', + 'function Invoke-Task([string] $name) { Invoke-Native "unexpected build" {}'), + $source.Replace('if ($DryRun) {', 'if ($DryRun) { [IO.File]::WriteAllText("unexpected", "effect");') + ) + foreach ($mutation in $mutations) { + Assert-HostTask ($mutation -cne $source) "ineffective dry-run negative control" + $refused = $false + try { Assert-DryRunBeforeEffects $mutation } catch { $refused = $true } + Assert-HostTask $refused "dry-run source admitted a build/write negative control" + $executed++ + } + $single = @(& $pwsh -NoLogo -NoProfile -NonInteractive -File $runner -Task swift-paths -DryRun) + Assert-HostTask ($LASTEXITCODE -eq 0 -and $single.Count -eq 1 -and $single[0] -ceq "task=swift-paths") ` + "single-task dry run did not select exactly swift-paths" + $executed++ + $provider = @(& $pwsh -NoLogo -NoProfile -NonInteractive -File $runner -Task provider-build -DryRun) + Assert-HostTask ($LASTEXITCODE -eq 0 -and $provider.Count -eq 1 -and $provider[0] -ceq "task=provider-build") ` + "build-only dry run changed" + $executed++ + $all = @(& $pwsh -NoLogo -NoProfile -NonInteractive -File $runner -Task all -DryRun) + Assert-HostTask ($LASTEXITCODE -eq 0 -and $all.Count -eq 16 -and + $all -cnotcontains "task=provider-build" -and $all -ccontains "task=terminal-gate") "all-task selection changed" + $executed++ + $skipped = @(& $pwsh -NoLogo -NoProfile -NonInteractive -File $runner -Task all -SkipTask swift-paths -DryRun) + Assert-HostTask ($LASTEXITCODE -eq 0 -and $skipped.Count -eq 15 -and + $skipped -cnotcontains "task=swift-paths") "skip-task selection changed" + $executed++ + } + "unknown" { + $failure = $null + try { & $runner -Task not-a-task | Out-Null } catch { $failure = $_ } + Assert-HostTask ($null -ne $failure -and + $failure.Exception -is [Management.Automation.ParameterBindingException]) "unknown task did not fail parameter validation" + $executed++ + } + "untracked-format" { + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile($runner, [ref]$null, [ref]$errors) + Assert-HostTask ($errors.Count -eq 0) "validation source does not parse" + foreach ($name in @("Resolve-SwiftExecutable", "Initialize-SwiftEnvironment")) { + $definitions = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq $name + }, $true)) + Assert-HostTask ($definitions.Count -eq 1) "missing or duplicated actual toolchain helper: $name" + . ([scriptblock]::Create($definitions[0].Extent.Text)) + } + $SwiftExecutable = $env:GRAPHCODE_SWIFT633 + $toolchain = Resolve-SwiftExecutable + Initialize-SwiftEnvironment $toolchain + $version = @(& $toolchain --version 2>&1) + Assert-HostTask ($LASTEXITCODE -eq 0 -and $version.Count -gt 0 -and + [string]$version[0] -cmatch '^Swift version 6\.3\.3(?: \(|$)') "real formatter control requires pinned Swift 6.3.3" + . (Join-Path $RepositoryRoot "Tools\windows\Tests\ValidationFixtureDirectory.ps1") + $untrackedDirectory = Join-Path $RepositoryRoot "investigation\spikes\validation-runner-untracked" + $untrackedFailure = $null + $untrackedFixture = New-ValidationFixtureDirectory -Path $untrackedDirectory + try { + New-ValidationFixtureFile -Fixture $untrackedFixture -Name "Unformatted.swift" -Content "let value=1" + $failure = $null + try { & $runner -Task swift-format -SwiftExecutable $toolchain 2>&1 | ForEach-Object { Write-Host $_ } } catch { $failure = $_ } + $expectedPrefix = "Swift formatting: " + (Join-Path $untrackedDirectory "Unformatted.swift") + " failed with exit code " + Assert-HostTask ($null -ne $failure -and + $failure.Exception.Message.StartsWith($expectedPrefix, [StringComparison]::Ordinal)) ` + "untracked formatter did not report its actual formatting failure" + $executed++ + } catch { + $untrackedFailure = $_ + throw + } finally { + Remove-ValidationFixtureDirectory -Fixture $untrackedFixture -PrimaryError $untrackedFailure + } + } + "foreign-fixture" { + . (Join-Path $RepositoryRoot "Tools\windows\Tests\ValidationFixtureDirectory.ps1") + $foreignJunction = Join-Path $RepositoryRoot ` + "investigation\spikes\swift-contracts\Sources\GraphcodeWindowsContracts\OwnershipSentinel" + $foreignFailure = $null + $foreignFixture = New-ValidationFixtureDirectory -Path $foreignJunction + try { + & $pwsh -NoLogo -NoProfile -NonInteractive -File $runner -Task swift-format -DryRun | Out-Null + Assert-HostTask ($LASTEXITCODE -eq 0) "foreign-fixture dry run failed" + Assert-HostTask (Test-Path -LiteralPath $foreignJunction) "validation removed another task's resource" + $executed++ + } catch { + $foreignFailure = $_ + throw + } finally { + Remove-ValidationFixtureDirectory -Fixture $foreignFixture -PrimaryError $foreignFailure + } + } + "missing-providers" { + $absent = Join-Path ([IO.Path]::GetTempPath()) ("missing-" + [guid]::NewGuid().ToString("N")) + Assert-HostTask (-not (Test-Path -LiteralPath $absent)) "missing-provider fixture already exists" + $env:GRAPHCODE_WINGHOSTTY_ROOT = Join-Path $absent "winghostty" + $env:GRAPHCODE_ZMX_ROOT = Join-Path $absent "zmx" + $output = @(& ([Environment]::ProcessPath) -NoLogo -NoProfile -NonInteractive -File $runner ` + -Task terminal-gate 2>&1 | ForEach-Object { "$_" }) + $exitCode = $LASTEXITCODE + Assert-HostTask ($exitCode -ne 0 -and ($output -join "`n").Contains( + "Windows terminal gate provider worktrees unavailable; real smoke is mandatory.")) ` + "terminal-gate did not refuse the actual missing pinned providers" + $executed++ + } +} + +Assert-HostTask ($executed -gt 0) "no cases ran" +[ordered]@{ + suite = "HostTasks.$Contract" + executed = $executed + passed = $executed + failed = 0 + skipped = 0 + toolchain = $toolchain +} | ConvertTo-Json -Compress | Write-Output diff --git a/Tools/windows/ValidationRunner/Adapters/LegacyOwnership.Contracts.ps1 b/Tools/windows/ValidationRunner/Adapters/LegacyOwnership.Contracts.ps1 new file mode 100644 index 00000000..a5ac2d5b --- /dev/null +++ b/Tools/windows/ValidationRunner/Adapters/LegacyOwnership.Contracts.ps1 @@ -0,0 +1,74 @@ +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath, + [Parameter(Mandatory)][ValidateSet("shell-scope", "live-adapter", "live-cleanup", "sdk-prerequisites", "compiled-environment")][string] $Contract +) +$ErrorActionPreference = "Stop" +$tests = Join-Path $RepositoryRoot "Tools\windows\Tests" +$caseRoot = Join-Path $ArtifactRoot ("legacy-mock-" + [guid]::NewGuid().ToString("N")) +[void][IO.Directory]::CreateDirectory($caseRoot) +$previous = [Console]::Out +$capturedConsole = [IO.StringWriter]::new() +$output = @() +$run = $null +try { + # Legacy suites inject mocked ownership diagnostics. Keep those signals in + # their evidence rather than treating them as a native owner's lifetime signal. + [Console]::SetOut($capturedConsole) + if ($Contract -ceq "live-cleanup") { + $output = @(& (Join-Path $tests "OwnedLiveProcessCleanup.Tests.ps1") 6>&1 3>&1 4>&1 5>&1) + } else { + . (Join-Path $RepositoryRoot "Tools\windows\Import-PinnedPester.ps1") + $files = if ($Contract -ceq "shell-scope") { @("ShellProcessOwnership.Tests.ps1") } ` + elseif ($Contract -ceq "sdk-prerequisites") { @("DotNetSdk.Tests.ps1", "Bootstrap.Tests.ps1") } ` + elseif ($Contract -ceq "compiled-environment") { @("CompiledValidationEnvironment.Tests.ps1") } ` + else { @("OwnedLiveAdapter.Tests.ps1") } + $paths = @($files | ForEach-Object { Join-Path $tests $_ }) + $output = @(Invoke-Pester -Path $paths -PassThru -Output None 6>&1 3>&1 4>&1 5>&1) + $runs = @($output | Where-Object { + $null -ne $_.PSObject.Properties["TotalCount"] -and + $null -ne $_.PSObject.Properties["PassedCount"] -and + $null -ne $_.PSObject.Properties["FailedCount"] + }) + if ($runs.Count -ne 1) { throw "Original ownership Pester suite did not return exactly one run result" } + $run = $runs[0] + } +} finally { + [Console]::SetOut($previous) + [IO.File]::WriteAllText((Join-Path $caseRoot "mock-console.txt"), $capturedConsole.ToString(), [Text.UTF8Encoding]::new($false)) + $capturedConsole.Dispose() + [IO.File]::WriteAllText((Join-Path $caseRoot "mock-powershell-streams.txt"), + (($output | ForEach-Object { "$_" }) -join "`n"), [Text.UTF8Encoding]::new($false)) +} +if ($Contract -ceq "live-cleanup") { + $summary = @($output | Where-Object { "$_" -ceq "OWNED_LIVE_PROCESS_CLEANUP_CASES=84 FAILURES=0" }) + if ($summary.Count -ne 1) { throw "Original owned live cleanup suite did not pass all 84 actual controls" } + $count = 84 +} else { + foreach ($property in @("TotalCount", "PassedCount", "FailedCount", "SkippedCount", "NotRunCount")) { + if ($null -eq $run.PSObject.Properties[$property]) { throw "Pester result omits $property" } + } + [IO.File]::WriteAllText((Join-Path $caseRoot "pester-summary.json"), ([ordered]@{ + executed = $run.TotalCount; passed = $run.PassedCount; failed = $run.FailedCount + skipped = $run.SkippedCount; notRun = $run.NotRunCount + } | ConvertTo-Json -Compress), [Text.UTF8Encoding]::new($false)) + if ($run.TotalCount -le 0 -or $run.PassedCount -ne $run.TotalCount -or + $run.FailedCount -ne 0 -or $run.SkippedCount -ne 0 -or $run.NotRunCount -ne 0) { + throw "Original ownership Pester suite failed or omitted controls; retained evidence: $caseRoot" + } + $count = $run.TotalCount +} +$id = if ($Contract -ceq "sdk-prerequisites") { "host.sdk-prerequisites" } ` + elseif ($Contract -ceq "compiled-environment") { "host.compiled-assembly-environment" } ` + else { "host.legacy-ownership.$Contract" } +$description = if ($Contract -ceq "sdk-prerequisites") { + "SDK prerequisites: actual installed-SDK probe plus controlled bootstrap/dependency cases" +} elseif ($Contract -ceq "compiled-environment") { + "Actual scoped caller publication, supplied/empty/missing state, failure identity and native child environment controls" +} else { "Actual original $Contract mock-only suite" } +$row = [ordered]@{ + id = $id; status = "Passed"; executedCount = $count + evidence = "$description; positive count and zero failed/skipped/not-run; retained detailed evidence: $caseRoot" +} +Write-Output ("HOST_CASE_RESULT=" + ($row | ConvertTo-Json -Compress)) diff --git a/Tools/windows/ValidationRunner/Adapters/PowerShellPayload.cs b/Tools/windows/ValidationRunner/Adapters/PowerShellPayload.cs new file mode 100644 index 00000000..1721e286 --- /dev/null +++ b/Tools/windows/ValidationRunner/Adapters/PowerShellPayload.cs @@ -0,0 +1,77 @@ +using System.Diagnostics; +using System.IO; +using System.Reflection; +using System.Text; +using System.Security.Cryptography; +using GraphCode.Validation.Ownership; +using GraphCode.Validation.Host; + +namespace GraphCode.Validation.Adapters; + +public static class PowerShellPayload +{ + public sealed record Receipt(string Output, string ArtifactRoot, string StdoutTextSha256); + + public static string CreateScratchRoot(string repositoryRoot) + { + var parent = Path.Combine(Path.GetPathRoot(repositoryRoot) ?? + throw new InvalidDataException("Repository has no volume root"), "gc-v"); + Directory.CreateDirectory(parent); + var path = Path.Combine(parent, Guid.NewGuid().ToString("N")[..16]); + GraphCode.ValidationFixtureDirectoryNative.Create(path); + return path; + } + + public static async Task RunAsync( + ValidationContext context, string id, string relativeScript, IReadOnlyList arguments, + CancellationToken cancellationToken, Func? expectedDiagnostics = null) + { + var executable = Environment.GetEnvironmentVariable("GRAPHCODE_VALIDATION_PWSH") ?? + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "PowerShell", "7", "pwsh.exe"); + if (!Path.IsPathFullyQualified(executable) || !File.Exists(executable)) + throw new FileNotFoundException("Set GRAPHCODE_VALIDATION_PWSH to the supported PowerShell executable", executable); + var script = Path.GetFullPath(Path.Combine(context.RepositoryRoot, relativeScript)); + if (!script.StartsWith(Path.TrimEndingDirectorySeparator(context.RepositoryRoot) + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase) || !File.Exists(script)) + throw new FileNotFoundException("Validation payload must exist in the current repository", script); + if (Path.GetFileName(script).Equals("ValidationRunner.Tests.ps1", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("The legacy monolithic runner is not an allowed payload"); + var artifacts = Path.Combine(context.ArtifactRoot, id + "-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(artifacts); + var temp = CreateScratchRoot(context.RepositoryRoot); + await File.WriteAllTextAsync(Path.Combine(artifacts, "scratch-root.txt"), temp, cancellationToken); + var start = new ProcessStartInfo(executable) + { + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = context.RepositoryRoot, + StandardOutputEncoding = new UTF8Encoding(false, true), + StandardErrorEncoding = new UTF8Encoding(false, true) + }; + foreach (var argument in new[] { "-NoLogo", "-NoProfile", "-NonInteractive", "-File", script }.Concat(arguments)) + start.ArgumentList.Add(argument); + start.Environment["TEMP"] = temp; + start.Environment["TMP"] = temp; + start.Environment["GRAPHCODE_VALIDATION_ASSEMBLY"] = Assembly.GetExecutingAssembly().Location; + var result = await OwnedChildSupervisor.RunAsync(start, TimeSpan.FromMinutes(12), artifacts, cancellationToken); + if (!result.Cleanup.CanRelease || !result.Cleanup.ResourcesClosed) + throw new InvalidOperationException($"{id}: owned child cleanup is UNKNOWN; artifacts retained: {artifacts}"); + var unknown = false; + var observer = new RetentionSignalObserver(() => unknown = true); + observer.OnText(result.StandardOutput); + observer.OnEof(); + if (unknown) + { + TerminalSafety.MarkUnknown(); + var failure = new InvalidOperationException($"{id}: descendant reported UNKNOWN ownership; artifacts retained: {artifacts}"); + failure.Data["GraphCodeValidationRootRetentionRequired"] = true; + throw failure; + } + if (result.ExitCode != 0 || (!string.IsNullOrWhiteSpace(result.StandardError) && + (expectedDiagnostics is null || !expectedDiagnostics(result.StandardError)))) + throw new InvalidOperationException($"{id}: protected payload failed (exit={result.ExitCode}); " + + $"stdout/stderr and scratch receipt retained: {artifacts}"); + return new Receipt(result.StandardOutput, artifacts, + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(result.StandardOutput)))); + } +} diff --git a/Tools/windows/ValidationRunner/Adapters/Provenance.Contracts.ps1 b/Tools/windows/ValidationRunner/Adapters/Provenance.Contracts.ps1 new file mode 100644 index 00000000..32d4b1d6 --- /dev/null +++ b/Tools/windows/ValidationRunner/Adapters/Provenance.Contracts.ps1 @@ -0,0 +1,36 @@ +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) +$ErrorActionPreference = "Stop" +$loader = Join-Path $RepositoryRoot "Tools\windows\Import-CompiledValidationAssembly.ps1" +$source = Join-Path $RepositoryRoot "Tools\windows\ValidationFixtureDirectoryNative.cs" +$before = [IO.File]::ReadAllBytes($source) +$backup = Join-Path $ArtifactRoot ("provenance-source-" + [guid]::NewGuid().ToString("N") + ".before") +[IO.File]::WriteAllBytes($backup, $before) +function Write-ProvenanceCase([string] $Id, [string] $Evidence) { + $row = [ordered]@{ id = $Id; status = "Passed"; executedCount = 1; evidence = $Evidence } + Write-Output ("HOST_CASE_RESULT=" + ($row | ConvertTo-Json -Compress)) +} +& $loader -AssemblyPath $AssemblyPath -RequiredType "GraphCode.ValidationFixtureDirectoryNative" | Out-Null +Write-ProvenanceCase "host.provenance.current-source" "Actual compiled source/reference fingerprints and file MVID admitted; native create not invoked" +try { + $changed = [Text.Encoding]::UTF8.GetBytes([Text.Encoding]::UTF8.GetString($before) + "`n// Controlled stale-source contract.`n") + [IO.File]::WriteAllBytes($source, $changed) + $failure = $null + try { & $loader -AssemblyPath $AssemblyPath -RequiredType "GraphCode.ValidationFixtureDirectoryNative" | Out-Null } + catch { $failure = $_ } + if ($null -eq $failure -or + $failure.Exception.Message -cne "Compiled validation inputs changed after build; rebuild with Build-CompiledValidationRunner.ps1") { + throw "Same-worktree source change was not refused before native binding use" + } + Write-ProvenanceCase "host.provenance.source-changed" "Actual same-worktree C# input changed without rebuilding; already-loaded same-path assembly refused" +} finally { + [IO.File]::WriteAllBytes($source, $before) +} +if ((Get-FileHash -LiteralPath $source).Hash -cne (Get-FileHash -LiteralPath $backup).Hash) { + throw "Provenance negative control did not restore its exact owned source bytes" +} +& $loader -AssemblyPath $AssemblyPath -RequiredType "GraphCode.ValidationFixtureDirectoryNative" | Out-Null +Write-ProvenanceCase "host.provenance.source-restored" "Exact original source bytes restored; same binding admitted again; native create not invoked" diff --git a/Tools/windows/ValidationRunner/Adapters/ProviderBuild.Contracts.ps1 b/Tools/windows/ValidationRunner/Adapters/ProviderBuild.Contracts.ps1 new file mode 100644 index 00000000..e7e23713 --- /dev/null +++ b/Tools/windows/ValidationRunner/Adapters/ProviderBuild.Contracts.ps1 @@ -0,0 +1,85 @@ +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) +$ErrorActionPreference = "Stop" +$root = Join-Path ([IO.Path]::GetTempPath()) ("providers-" + [guid]::NewGuid().ToString("N").Substring(0,8)) +[void][IO.Directory]::CreateDirectory($root) +$compiler = Join-Path $root "compiler.cmd" +$executable = [IO.Path]::ChangeExtension($AssemblyPath, ".exe") +[IO.File]::WriteAllText($compiler, '@"' + $executable + '" --host-fixture provider-compiler %*' + "`r`n", + [Text.ASCIIEncoding]::new()) +$pins = [ordered]@{ schemaVersion = 1 } +foreach ($provider in @("winghostty", "zmx")) { + $path = Join-Path $root $provider + [void][IO.Directory]::CreateDirectory($path) + & git -C $path init --quiet + if ($LASTEXITCODE -ne 0) { throw "Controlled provider Git init failed" } + & git -C $path config core.autocrlf false + if ($LASTEXITCODE -ne 0) { throw "Controlled provider Git line-ending isolation failed" } + [IO.File]::WriteAllText((Join-Path $path ".gitignore"), "zig-out/`n", [Text.UTF8Encoding]::new($false)) + & git -C $path add .gitignore + & git -C $path -c user.name=ValidationFixture -c user.email=validation@example.invalid commit --quiet -s ` + -m "Pin controlled provider fixture" -m "Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>" + if ($LASTEXITCODE -ne 0) { throw "Controlled provider Git pin failed" } + $sha = & git -C $path rev-parse HEAD + if ($LASTEXITCODE -ne 0) { throw "Controlled provider Git revision failed" } + $pins[$provider] = [ordered]@{ sha = $sha } +} +$pinsPath = Join-Path $root "pins.json" +$pins | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $pinsPath +$providerScript = Join-Path $RepositoryRoot "Tools\windows\provider-build.ps1" +$pwsh = [Environment]::ProcessPath +$executed = 0 +function Invoke-CompilerCase([string] $Behavior = "") { + foreach ($provider in @("winghostty", "zmx")) { + $generated = Join-Path $root "$provider\zig-out" + if (Test-Path -LiteralPath $generated) { Remove-Item -LiteralPath $generated -Recurse -Force } + } + $log = Join-Path $root ("compiler-" + [guid]::NewGuid().ToString("N") + ".jsonl") + $env:GRAPHCODE_PROVIDER_COMPILER_LOG = $log + $env:GRAPHCODE_PROVIDER_COMPILER_BEHAVIOR = $Behavior + $output = @(& $pwsh -NoLogo -NoProfile -NonInteractive -File $providerScript ` + -WinghosttyRoot (Join-Path $root "winghostty") -ZmxRoot (Join-Path $root "zmx") ` + -Zig0152 $compiler -Zig0160 $compiler -PinsPath $pinsPath -ZmxAttempts 1 2>&1 | + ForEach-Object { "$_" }) + $exit = $LASTEXITCODE + $calls = if (Test-Path -LiteralPath $log) { @([IO.File]::ReadAllLines($log)) } else { @() } + return [pscustomobject]@{ exit = $exit; output = $output; calls = $calls } +} +function Assert-Provider([bool] $Condition, [string] $Message) { + if (-not $Condition) { throw "Controlled provider-build contract: $Message" } +} +$pass = Invoke-CompilerCase +Assert-Provider ($pass.exit -eq 0 -and $pass.calls.Count -eq 2 -and + @($pass.output | Where-Object { $_ -ceq "PROVIDER_BUILD_EXECUTED=2" }).Count -eq 1 -and + @($pass.output | Where-Object { $_ -like "PROVIDER_BUILD_STEP=*" }).Count -eq 2) "complete two-provider build not counted exactly" +Assert-Provider ($pass.calls[0] -ceq '["build","-Demit-win32-host=true"]' -and + $pass.calls[1] -ceq '["build","-Dtarget=x86_64-windows-gnu"]') "canonical flags changed" +Assert-Provider (($pass.output -join "`n") -notmatch '(?i)terminal gate|smoke|TerminalGate\.Tests|zmx send|uia') ` + "build-only provider script dispatched a terminal test" +$executed++ +foreach ($behavior in @("missing", "fail")) { + $actual = Invoke-CompilerCase $behavior + Assert-Provider ($actual.exit -ne 0 -and + @($actual.output | Where-Object { $_ -like "PROVIDER_BUILD_EXECUTED=*" }).Count -eq 0) "partial compiler result admitted: $behavior" + $executed++ +} +$dirty = Join-Path $root "zmx\dirty.txt" +[IO.File]::WriteAllText($dirty, "controlled untracked source") +$actual = Invoke-CompilerCase +Assert-Provider ($actual.exit -ne 0 -and $actual.calls.Count -eq 0) "dirty provider compiled" +Remove-Item -LiteralPath $dirty +$executed++ +$pins.zmx.sha = "0" * 40 +$pins | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $pinsPath +$actual = Invoke-CompilerCase +Assert-Provider ($actual.exit -ne 0 -and $actual.calls.Count -eq 0) "unpinned provider compiled" +$executed++ +Assert-Provider ($executed -eq 5) "not all actual production dispatch controls ran" +$row = [ordered]@{ + id = "host.provider-build"; status = "Passed"; executedCount = $executed + evidence = "Actual provider-build.ps1 with ordinary compiled controlled compiler; two canonical flags, partial/error/dirty/wrong-pin controls" +} +Write-Output ("HOST_CASE_RESULT=" + ($row | ConvertTo-Json -Compress)) diff --git a/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs b/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs new file mode 100644 index 00000000..d2bfea75 --- /dev/null +++ b/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs @@ -0,0 +1,148 @@ +using System.IO; +using System.Reflection; +using System.Security.Cryptography; +using System.Text.Json; +using GraphCode.Validation.Host; + +namespace GraphCode.Validation.Adapters; + +public static class StandaloneSuites +{ + public static IReadOnlyList Create(IReadOnlyList required) => + [ + new ScriptSuite("ps.pester-version", "PesterVersion.Tests.ps1", EvidenceParser.PesterVersion), + new ScriptSuite("ps.isolated-process-environment", "IsolatedProcessEnvironment.Contracts.Tests.ps1", EvidenceParser.IsolatedEnvironment), + new ScriptSuite("ps.fixture-directory", "ValidationFixtureDirectory.Tests.ps1", + output => EvidenceParser.Structured("ps.fixture-directory", "ValidationFixtureDirectory", output, 28, 28)), + new ScriptSuite("ps.ui-scenario", "UiScenario.Tests.ps1", + output => EvidenceParser.Structured("ps.ui-scenario", "UiScenario", output, 1, requireSkipped: false)), + 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)), + new ScriptSuite("ps.action-identity", "ActionWindowIdentity.Tests.ps1", + output => EvidenceParser.Structured("ps.action-identity", "ActionWindowIdentity", output, 62, 62)), + 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)), + 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), + new HostTaskSuite("host.unknown-task", "unknown", 1), + new HostTaskSuite("host.foreign-fixture", "foreign-fixture", 1), + new HostTaskSuite("host.untracked-format", "untracked-format", 1), + new HostTaskSuite("host.missing-providers", "missing-providers", 1), + new HostSourceSuite("host.shell-prerequisite", "host-prerequisite", 4), + new HostSourceSuite("host.provider-task-boundary", "provider-boundary", 5), + new HostSourceSuite("ui.root.source.line-5346", "delayed-responses", 2), + new CaseScriptSuite("host.provenance", "Tools\\windows\\ValidationRunner\\Adapters\\Provenance.Contracts.ps1", + "HOST_CASE_RESULT=", ["host.provenance.current-source", "host.provenance.source-changed", "host.provenance.source-restored"]), + new CaseScriptSuite("host.provider-build", "Tools\\windows\\ValidationRunner\\Adapters\\ProviderBuild.Contracts.ps1", + "HOST_CASE_RESULT=", ["host.provider-build"]), + new CaseScriptSuite("host.legacy-ownership.shell-scope", "Tools\\windows\\ValidationRunner\\Adapters\\LegacyOwnership.Contracts.ps1", + "HOST_CASE_RESULT=", ["host.legacy-ownership.shell-scope"], ["-Contract", "shell-scope"]), + new CaseScriptSuite("host.legacy-ownership.live-adapter", "Tools\\windows\\ValidationRunner\\Adapters\\LegacyOwnership.Contracts.ps1", + "HOST_CASE_RESULT=", ["host.legacy-ownership.live-adapter"], ["-Contract", "live-adapter"]), + new CaseScriptSuite("host.compiled-assembly-environment", "Tools\\windows\\ValidationRunner\\Adapters\\LegacyOwnership.Contracts.ps1", + "HOST_CASE_RESULT=", ["host.compiled-assembly-environment"], ["-Contract", "compiled-environment"]), + new CaseScriptSuite("host.legacy-ownership.live-cleanup", "Tools\\windows\\ValidationRunner\\Adapters\\LegacyOwnership.Contracts.ps1", + "HOST_CASE_RESULT=", ["host.legacy-ownership.live-cleanup"], ["-Contract", "live-cleanup"]), + new CaseScriptSuite("host.sdk-prerequisites", "Tools\\windows\\ValidationRunner\\Adapters\\LegacyOwnership.Contracts.ps1", + "HOST_CASE_RESULT=", ["host.sdk-prerequisites"], ["-Contract", "sdk-prerequisites"]), + new CaseScriptSuite("ui.source-contracts", "Tools\\windows\\ValidationRunner\\Ui\\UiSourceContracts.ps1", + "UI_CASE_RESULT=", required.Where(unit => unit.Id.StartsWith("ui.activation.source.", StringComparison.Ordinal) || + unit.Id.StartsWith("ui.popup.source.", StringComparison.Ordinal)).Select(unit => unit.Id).ToArray()) + ]; + + private sealed class ScriptSuite(string id, string script, Func parse) : IValidationSuite + { + public string Id => id; + public IReadOnlyList UnitIds => [id]; + public async Task> RunAsync(ValidationContext context, CancellationToken cancellationToken) + { + var output = await PowerShellPayload.RunAsync(context, id, "Tools\\windows\\Tests\\" + script, [], cancellationToken); + var result = parse(output.Output); + return [WithReceipt(result, output)]; + } + } + + private sealed class HostTaskSuite(string id, string contract, int count) : IValidationSuite + { + public string Id => id; + public IReadOnlyList UnitIds => [id]; + public async Task> RunAsync(ValidationContext context, CancellationToken cancellationToken) + { + var output = await PowerShellPayload.RunAsync(context, id, + "Tools\\windows\\ValidationRunner\\Adapters\\HostTasks.ps1", + ["-RepositoryRoot", context.RepositoryRoot, "-Contract", contract], cancellationToken); + return [WithReceipt(EvidenceParser.Structured(id, "HostTasks." + contract, output.Output, count, count), output)]; + } + + } + + private sealed class HostSourceSuite(string id, string contract, int count) : IValidationSuite + { + public string Id => id; + public IReadOnlyList UnitIds => [id]; + public async Task> RunAsync(ValidationContext context, CancellationToken cancellationToken) + { + var output = await PowerShellPayload.RunAsync(context, id, + "Tools\\windows\\ValidationRunner\\Adapters\\HostSourceContracts.ps1", + ["-RepositoryRoot", context.RepositoryRoot, "-Contract", contract], cancellationToken); + return [WithReceipt(EvidenceParser.Structured(id, "HostSource." + contract, output.Output, count, count), output)]; + } + } + + private sealed class CaptureSuite(RequiredUnit required) : IValidationSuite + { + public string Id => "ps.ui-capture"; + public IReadOnlyList UnitIds => [Id]; + public async Task> RunAsync(ValidationContext context, CancellationToken cancellationToken) + { + const string script = "Tools\\windows\\Tests\\UiCaptureDriver.Tests.ps1"; + var captureRoot = PowerShellPayload.CreateScratchRoot(context.RepositoryRoot); + await File.WriteAllTextAsync(Path.Combine(context.ArtifactRoot, "capture-root.txt"), captureRoot, cancellationToken); + var prebuilt = Path.Combine(captureRoot, "prebuilt"); + await PowerShellPayload.RunAsync(context, Id + ".prepare", script, + ["-PrepareFixturesOnly", "-ScratchRoot", prebuilt], 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"); + var output = await PowerShellPayload.RunAsync(context, Id, script, + ["-ScratchRoot", suiteRoot, "-PrebuiltFixtureManifest", manifest, "-PrebuiltFixtureManifestSha256", hash], + cancellationToken, value => EvidenceParser.CaptureDiagnostics(value, suiteRoot)); + var result = WithReceipt(EvidenceParser.Structured(Id, "UiCaptureDriver", output.Output, 182, 182), output); + return [result with { Groups = EvidenceParser.CaptureGroups(Path.Combine(suiteRoot, "report.json"), + required.RequiredGroups ?? []) }]; + } + } + + public sealed class CaseScriptSuite(string id, string script, string prefix, IReadOnlyList unitIds, + IReadOnlyList? additionalArguments = null) : IValidationSuite + { + public string Id => id; + public IReadOnlyList UnitIds => unitIds; + public async Task> RunAsync(ValidationContext context, CancellationToken cancellationToken) + { + var output = await PowerShellPayload.RunAsync(context, id, script, + ["-RepositoryRoot", context.RepositoryRoot, "-ArtifactRoot", context.ArtifactRoot, + "-AssemblyPath", Assembly.GetExecutingAssembly().Location, .. additionalArguments ?? []], cancellationToken); + var rows = output.Output.Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries) + .Where(line => line.StartsWith(prefix, StringComparison.Ordinal)).Select(line => + JsonSerializer.Deserialize(line[prefix.Length..], Coverage.JsonOptions) ?? + throw new InvalidDataException($"{id}: null case evidence")).ToArray(); + if (rows.Length == 0) throw new InvalidDataException($"{id}: no case evidence"); + return rows.Select(result => result with + { + Evidence = $"Case evidence retained in payload logs; evidence-text-sha256=" + + Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(result.Evidence))) + + $"; stdout-text-sha256={output.StdoutTextSha256}; artifacts={output.ArtifactRoot}" + }).ToArray(); + } + } + + private static UnitResult WithReceipt(UnitResult result, PowerShellPayload.Receipt receipt) => result with + { + Evidence = $"{result.Evidence}; stdout-text-sha256={receipt.StdoutTextSha256}; artifacts={receipt.ArtifactRoot}" + }; +} diff --git a/Tools/windows/ValidationRunner/Bindings/Fixtures/ValidationTestFixtures.csproj b/Tools/windows/ValidationRunner/Bindings/Fixtures/ValidationTestFixtures.csproj new file mode 100644 index 00000000..819cef26 --- /dev/null +++ b/Tools/windows/ValidationRunner/Bindings/Fixtures/ValidationTestFixtures.csproj @@ -0,0 +1,15 @@ + + + netstandard2.1 + latest + enable + true + false + + + + + + + + diff --git a/Tools/windows/ValidationRunner/Bindings/ValidationFixtureBindings.csproj b/Tools/windows/ValidationRunner/Bindings/ValidationFixtureBindings.csproj new file mode 100644 index 00000000..5328c53a --- /dev/null +++ b/Tools/windows/ValidationRunner/Bindings/ValidationFixtureBindings.csproj @@ -0,0 +1,14 @@ + + + netstandard2.1 + latest + enable + true + false + + + + + + + diff --git a/Tools/windows/ValidationRunner/Bindings/Windows/WindowsValidationBindings.csproj b/Tools/windows/ValidationRunner/Bindings/Windows/WindowsValidationBindings.csproj new file mode 100644 index 00000000..13359609 --- /dev/null +++ b/Tools/windows/ValidationRunner/Bindings/Windows/WindowsValidationBindings.csproj @@ -0,0 +1,22 @@ + + + net8.0-windows + true + true + latest + enable + true + false + + + + + + + + + + + diff --git a/Tools/windows/ValidationRunner/BuildProvenance.targets b/Tools/windows/ValidationRunner/BuildProvenance.targets new file mode 100644 index 00000000..d3ee099c --- /dev/null +++ b/Tools/windows/ValidationRunner/BuildProvenance.targets @@ -0,0 +1,19 @@ + + + + <_ValidationInput Include="@(Compile)" Exclude="$(BaseIntermediateOutputPath)**" /> + <_ValidationInput Include="@(ReferencePath)" /> + <_ValidationInput Include="$(MSBuildProjectFullPath);$(MSBuildThisFileFullPath);$(MSBuildThisFileDirectory)global.json" /> + + + + + + + + + + + + diff --git a/Tools/windows/ValidationRunner/Contracts.cs b/Tools/windows/ValidationRunner/Contracts.cs new file mode 100644 index 00000000..747817e7 --- /dev/null +++ b/Tools/windows/ValidationRunner/Contracts.cs @@ -0,0 +1,19 @@ +namespace GraphCode.Validation; + +public interface IValidationSuite +{ + string Id { get; } + IReadOnlyList UnitIds { get; } + Task> RunAsync(ValidationContext context, CancellationToken cancellationToken); +} + +public sealed record ValidationContext(string RepositoryRoot, string ArtifactRoot); + +public enum UnitStatus { Passed, Failed, Skipped, Incomplete } + +public sealed record UnitResult( + string Id, + UnitStatus Status, + int ExecutedCount, + string Evidence, + IReadOnlyList? Groups = null); diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/AdapterSupport.ps1 b/Tools/windows/ValidationRunner/Host/CiContracts/AdapterSupport.ps1 new file mode 100644 index 00000000..93d833b0 --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/CiContracts/AdapterSupport.ps1 @@ -0,0 +1,42 @@ +$ErrorActionPreference = 'Stop' +$script:RootCases = [Collections.Generic.List[object]]::new() + +function Test-RootCase([string] $Id, [scriptblock] $Body) { + try { + & $Body | Out-Null + $script:RootCases.Add([ordered]@{ id = $Id; passed = $true; error = $null }) + } catch { + $script:RootCases.Add([ordered]@{ id = $Id; passed = $false; error = $_.Exception.Message }) + } +} + +function Assert-Root([bool] $Condition, [string] $Message) { + if (-not $Condition) { throw $Message } +} + +function Get-ProductionFunction([string] $Path, [string] $Name) { + $tokens = $null + $errors = $null + $tree = [Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$errors) + if ($errors.Count -ne 0) { throw "Production source does not parse: $Path" } + $found = @($tree.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq $Name + }, $true)) + if ($found.Count -ne 1) { throw "Expected one production function $Name; found $($found.Count)" } + [scriptblock]::Create($found[0].Extent.Text) +} + +function Write-RootSummary([string] $Suite) { + $failures = @($script:RootCases | Where-Object { -not $_.passed }) + $report = Join-Path $ArtifactRoot ($Suite + '-cases-' + [guid]::NewGuid().ToString('N') + '.json') + $script:RootCases.ToArray() | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $report + $row = [ordered]@{ + id = $Suite + status = $(if ($failures.Count -eq 0) { 'Passed' } else { 'Failed' }) + executedCount = $script:RootCases.Count + evidence = "Production-backed cases: executed=$($script:RootCases.Count); failed=$($failures.Count); report=$report" + groups = @($script:RootCases | Where-Object passed | ForEach-Object { $_.id }) + } + Write-Output ('HOST_CASE_RESULT=' + ($row | ConvertTo-Json -Depth 8 -Compress)) +} diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/Aggregate.ps1 b/Tools/windows/ValidationRunner/Host/CiContracts/Aggregate.ps1 new file mode 100644 index 00000000..645508cd --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/CiContracts/Aggregate.ps1 @@ -0,0 +1,32 @@ +param([Parameter(Mandatory)] [string] $RepositoryRoot, + [Parameter(Mandatory)] [string] $ArtifactRoot, [string] $AssemblyPath) +. (Join-Path $PSScriptRoot 'AdapterSupport.ps1') +$gate = Join-Path $RepositoryRoot 'Tools\windows\Assert-CiPartResults.ps1' +$cases = @( + @{ Id = 'required.success'; Required = 'true'; Changes = 'success'; A = 'success'; B = 'success'; Pass = $true } + @{ Id = 'required.skipped'; Required = 'true'; Changes = 'success'; A = 'success'; B = 'skipped'; Pass = $false } + @{ Id = 'required.failure'; Required = 'true'; Changes = 'failure'; A = 'success'; B = 'failure'; Pass = $false } + @{ Id = 'required.cancelled'; Required = 'true'; Changes = 'success'; A = 'cancelled'; B = 'success'; Pass = $false } + @{ Id = 'not-required.skipped'; Required = 'false'; Changes = 'success'; A = 'skipped'; B = 'skipped'; Pass = $true } + @{ Id = 'not-required.failure'; Required = 'false'; Changes = 'success'; A = 'failure'; B = 'skipped'; Pass = $false } + @{ Id = 'not-required.classifier-failure'; Required = 'false'; Changes = 'failure'; A = 'skipped'; B = 'skipped'; Pass = $false } + @{ Id = 'invalid-requirement'; Required = 'unknown'; Changes = 'success'; A = 'success'; B = 'success'; Pass = $false } +) +foreach ($case in $cases) { + Test-RootCase $case.Id { + $needs = [ordered]@{ + changes = @{ result = $case.Changes; outputs = @{} } + a = @{ result = $case.A; outputs = @{} } + b = @{ result = $case.B; outputs = @{} } + } | ConvertTo-Json -Depth 4 -Compress + $accepted = $true + try { & $gate -NeedsJson $needs -Required $case.Required | Out-Null } catch { $accepted = $false } + Assert-Root ($accepted -eq $case.Pass) "Aggregate decision changed for $($case.Id)" + } +} +Test-RootCase 'empty-parts' { + $accepted = $true + try { & $gate -NeedsJson '{"changes":{"result":"success"}}' -Required true | Out-Null } catch { $accepted = $false } + Assert-Root (-not $accepted) 'Empty aggregate passed' +} +Write-RootSummary 'host.ci-aggregate' diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/Focused.ps1 b/Tools/windows/ValidationRunner/Host/CiContracts/Focused.ps1 new file mode 100644 index 00000000..0315dac2 --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/CiContracts/Focused.ps1 @@ -0,0 +1,34 @@ +param( + [Parameter(Mandatory)] [string] $RepositoryRoot, + [Parameter(Mandatory)] [string] $ArtifactRoot, + [Parameter(Mandatory)] [string] $AssemblyPath, + [string] $Suite +) +$ErrorActionPreference = 'Stop' +$null = [Reflection.Assembly]::LoadFrom($AssemblyPath) +$null = [Reflection.Assembly]::LoadFrom((Join-Path (Split-Path $AssemblyPath -Parent) 'WindowsValidationBindings.dll')) +$exitCode = 1 +try { + $context = [GraphCode.Validation.ValidationContext]::new($RepositoryRoot, $ArtifactRoot) + $suites = @([GraphCode.Validation.Host.RootContracts.SuiteRegistration]::CreateSuites()) + if ($Suite) { + $suites = @($suites | Where-Object Id -CEQ $Suite) + if ($suites.Count -ne 1) { throw "Unknown root suite: $Suite" } + } + $results = [Collections.Generic.List[object]]::new() + foreach ($executor in $suites) { + $rows = $executor.RunAsync($context, [Threading.CancellationToken]::None).GetAwaiter().GetResult() + foreach ($row in $rows) { + $results.Add($row) + $row | ConvertTo-Json -Depth 8 -Compress | Write-Output + } + } + if ($results.Count -eq $suites.Count -and @($results | Where-Object { $_.Status -ne 'Passed' -or $_.ExecutedCount -le 0 }).Count -eq 0) { + $exitCode = 0 + Write-Output "ROOT_FOCUSED_PASSED=$($results.Count)" + } +} finally { + $null = [GraphCode.Validation.Host.TerminalSafety]::PreserveRetainedAuthorityAsync( + [GraphCode.Validation.Ownership.OwnedChildSupervisor]::RetainedLeaseCount).GetAwaiter().GetResult() +} +exit $exitCode diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/Partition.ps1 b/Tools/windows/ValidationRunner/Host/CiContracts/Partition.ps1 new file mode 100644 index 00000000..c8a7c028 --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/CiContracts/Partition.ps1 @@ -0,0 +1,97 @@ +param([Parameter(Mandatory)] [string] $RepositoryRoot, + [Parameter(Mandatory)] [string] $ArtifactRoot, [string] $AssemblyPath) +. (Join-Path $PSScriptRoot 'AdapterSupport.ps1') +$runner = Join-Path $RepositoryRoot 'Tools\windows\validate.ps1' +$expected = @(& $runner -List) +$observed = [Collections.Generic.List[string]]::new() + +function Assert-PartitionParts([string[]] $Lines) { + $shell = @($Lines | Where-Object { $_ -like 'task=windows-shell *' }) + Assert-Root (@($shell | Where-Object { $_ -match 'part=(all|integration) ' }).Count -gt 0) 'No shell integration coverage' + $complete = $false + foreach ($count in @($shell | ForEach-Object { + if ($_ -notmatch 'part=(all|unit|integration) shard=(\d+)/(\d+)') { throw 'Malformed shell dry-run evidence' } + if ($Matches[1] -ne 'integration') { [int]$Matches[3] } + } | Sort-Object -Unique)) { + $seen = @($shell | ForEach-Object { + if ($_ -match "part=(all|unit) shard=(\d+)/$count(?: |$)") { [int]$Matches[2] } + } | Sort-Object -Unique) + if ($count -gt 0 -and $seen.Count -eq $count -and ($seen -join ',') -ceq ((0..($count - 1)) -join ',')) { + $complete = $true + } + } + Assert-Root $complete 'No complete shell unit shard partition' + $packaging = @($Lines | Where-Object { $_ -like 'task=packaging *' } | ForEach-Object { ($_ -split 'part=')[1] }) + Assert-Root ($packaging -contains 'all' -or ($packaging -contains 'contracts' -and $packaging -contains 'real')) 'Missing packaging part' +} + +foreach ($file in @('windows-shell.yml', 'windows-port-validation.yml', 'windows-hardening.yml')) { + $source = (Get-Content -LiteralPath (Join-Path $RepositoryRoot ".github\workflows\$file") -Raw).Replace("`r`n", "`n") + $jobsAt = [regex]::Match($source, '(?m)^jobs:\s*$') + if (-not $jobsAt.Success) { throw "Missing jobs mapping: $file" } + $body = $source.Substring($jobsAt.Index + $jobsAt.Length) + $heads = [regex]::Matches($body, '(?m)^ ([A-Za-z0-9_-]+):[ \t]*$') + foreach ($index in 0..($heads.Count - 1)) { + $end = if ($index + 1 -lt $heads.Count) { $heads[$index + 1].Index } else { $body.Length } + $job = $body.Substring($heads[$index].Index, $end - $heads[$index].Index) + if ($job -match '(?m)^ if:.*github\.event_name ==') { continue } + $shards = @('') + $matrix = [regex]::Match($job, '(?m)^\s+shard:\s*\[([0-9, ]+)\]') + if ($matrix.Success) { $shards = @($matrix.Groups[1].Value -split ',\s*') } + $runs = [regex]::Matches($job, '(?m)^\s*run:\s*\./Tools/windows/validate\.ps1 (.+)$') + $ordinal = 0 + foreach ($run in $runs) { + foreach ($shard in $shards) { + $id = "workflow.$file.$($heads[$index].Groups[1].Value).$ordinal.$shard".TrimEnd('.') + Test-RootCase $id { + $text = $run.Groups[1].Value.Trim().Replace('${{ matrix.shard }}', $shard) + Assert-Root (-not $text.Contains('${{')) 'Unsupported expression in workflow validation arguments' + $tokens = @($text -split '\s+') + $arguments = @{ DryRun = $true } + for ($position = 0; $position -lt $tokens.Count; $position++) { + $name = $tokens[$position].TrimStart('-') + switch ($name) { + { $_ -in @('SkipTrayLive', 'SkipWslRemoteE2E') } { $arguments[$name] = $true } + { $_ -in @('Task', 'SkipTask') } { $arguments[$name] = @($tokens[++$position] -split ',') } + { $_ -in @('ShellPart', 'ShellTestShard', 'ShellTestShardCount', 'ShellTestManifest', 'PackagingPart') } { + $arguments[$name] = $tokens[++$position] + } + default { throw "Unsupported dry-run parameter: $name" } + } + } + $output = @(& $runner @arguments) + $lines = @($output | Where-Object { "$_" -match '^task=' }) + Assert-Root ($lines.Count -gt 0) 'Workflow dry-run executed no tasks' + foreach ($line in $lines) { $observed.Add("$line") } + } + } + $ordinal++ + } + } +} +Test-RootCase 'coverage.all-listed-tasks' { + Assert-Root ($expected.Count -gt 0 -and $observed.Count -gt 0) 'No task inventory or dry-run output' + $covered = @($observed | ForEach-Object { ($_ -split ' ')[0].Substring(5) } | Sort-Object -Unique) + $missing = @($expected | Where-Object { $covered -cnotcontains $_ }) + Assert-Root ($missing.Count -eq 0) "Missing PR tasks: $($missing -join ', ')" +} +Test-RootCase 'coverage.all-parts' { Assert-PartitionParts $observed.ToArray() } +$fixture = @( + 'task=windows-shell part=unit shard=0/3', 'task=windows-shell part=unit shard=1/3', + 'task=windows-shell part=unit shard=2/3', 'task=windows-shell part=integration shard=0/1', + 'task=packaging part=contracts', 'task=packaging part=real' +) +foreach ($kind in @('complete', 'missing-integration', 'missing-unit-shard', 'missing-packaging-real')) { + Test-RootCase "control.$kind" { + $lines = switch ($kind) { + 'complete' { $fixture } + 'missing-integration' { $fixture | Where-Object { $_ -notmatch 'part=integration' } } + 'missing-unit-shard' { $fixture | Where-Object { $_ -notmatch 'shard=2/3' } } + 'missing-packaging-real' { $fixture | Where-Object { $_ -cne 'task=packaging part=real' } } + } + $accepted = $true + try { Assert-PartitionParts @($lines) } catch { $accepted = $false } + Assert-Root ($accepted -eq ($kind -eq 'complete')) "Partition guard accepted $kind" + } +} +Write-RootSummary 'host.ci-partition' diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/PrivateSdkCaller.Tests.ps1 b/Tools/windows/ValidationRunner/Host/CiContracts/PrivateSdkCaller.Tests.ps1 new file mode 100644 index 00000000..4eaff3d2 --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/CiContracts/PrivateSdkCaller.Tests.ps1 @@ -0,0 +1,156 @@ +param([Parameter(Mandatory)] [string] $RepositoryRoot, + [Parameter(Mandatory)] [string] $ArtifactRoot) +. (Join-Path $PSScriptRoot 'AdapterSupport.ps1') +$wrapper = Join-Path $RepositoryRoot 'Tools\windows\Invoke-ValidationRunnerContract.ps1' +. (Get-ProductionFunction $wrapper 'Invoke-WithValidationRunnerRuntime') +$sdk = 'C:\fixture\private-sdk\dotnet.exe' +$expectedRoot = 'C:\fixture\private-sdk' +$names = @('GRAPHCODE_DOTNET10403', 'DOTNET_ROOT', 'DOTNET_ROOT_X64') +$before = @{} +$priorExit = Get-Variable LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue +$priorExitValue = if ($null -ne $priorExit) { $priorExit.Value } else { $null } +$environment = [Environment]::GetEnvironmentVariables('Process') +foreach ($name in $names) { $before[$name] = @{ present = $environment.Contains($name); value = $environment[$name] } } +function Test-Path($LiteralPath, $PathType) { $LiteralPath -ceq $sdk -and $PathType -ceq 'Leaf' } + +function Set-PrivateSdkTestState([string] $Kind) { + [Environment]::SetEnvironmentVariable('GRAPHCODE_DOTNET10403', $sdk, 'Process') + foreach ($name in @('DOTNET_ROOT', 'DOTNET_ROOT_X64')) { + $value = switch ($Kind) { + 'absent' { $null } + 'empty' { '' } + 'valued' { "C:\existing\$name" } + 'mixed' { if ($name -ceq 'DOTNET_ROOT') { $null } else { 'C:\existing\x64' } } + } + [Environment]::SetEnvironmentVariable($name, $value, 'Process') + } + $environment = [Environment]::GetEnvironmentVariables('Process') + if ($Kind -ceq 'empty') { + Assert-Root ($environment.Contains('DOTNET_ROOT') -and $environment.Contains('DOTNET_ROOT_X64')) ` + 'This empty-state case must actually begin with present empty environment entries' + } + $snapshot = @{} + foreach ($name in @('DOTNET_ROOT', 'DOTNET_ROOT_X64')) { + $snapshot[$name] = @{ present = $environment.Contains($name); value = $environment[$name] } + } + return $snapshot +} + +function Assert-PrivateSdkStateRestored($Snapshot) { + $environment = [Environment]::GetEnvironmentVariables('Process') + foreach ($name in @('DOTNET_ROOT', 'DOTNET_ROOT_X64')) { + Assert-Root ($environment.Contains($name) -eq $Snapshot[$name].present -and + $environment[$name] -ceq $Snapshot[$name].value) "Exact process environment state was not restored: $name" + } +} + +try { + foreach ($kind in @('absent', 'empty', 'valued', 'mixed')) { + foreach ($outcome in @('success', 'failure')) { + Test-RootCase "private-sdk.$kind.$outcome" { + $snapshot = Set-PrivateSdkTestState $kind + $calls = @{ Count = 0 } + $primary = [InvalidOperationException]::new('PRIVATE_SDK_PRIMARY_FAILURE') + $failure = $null + try { + Invoke-WithValidationRunnerRuntime { + $calls.Count++ + Assert-Root ([Environment]::GetEnvironmentVariable('DOTNET_ROOT', 'Process') -ceq $expectedRoot -and + [Environment]::GetEnvironmentVariable('DOTNET_ROOT_X64', 'Process') -ceq $expectedRoot) ` + 'The apphost action did not observe the selected private runtime in both resolution variables' + if ($outcome -ceq 'failure') { throw $primary } + } + } catch { $failure = $_ } + Assert-Root ($calls.Count -eq 1) 'The apphost action did not execute exactly once' + Assert-Root (($outcome -ceq 'success' -and $null -eq $failure) -or + ($outcome -ceq 'failure' -and [object]::ReferenceEquals($failure.Exception, $primary))) ` + 'The original apphost action failure was swallowed or replaced' + Assert-PrivateSdkStateRestored $snapshot + } + } + } + Test-RootCase 'private-sdk.unconfigured-ci-root' { + $snapshot = Set-PrivateSdkTestState 'valued' + [Environment]::SetEnvironmentVariable('GRAPHCODE_DOTNET10403', $null, 'Process') + Invoke-WithValidationRunnerRuntime { Assert-PrivateSdkStateRestored $snapshot } + Assert-PrivateSdkStateRestored $snapshot + } + Test-RootCase 'private-sdk.empty-selection-ci-root' { + $snapshot = Set-PrivateSdkTestState 'valued' + [Environment]::SetEnvironmentVariable('GRAPHCODE_DOTNET10403', '', 'Process') + Invoke-WithValidationRunnerRuntime { Assert-PrivateSdkStateRestored $snapshot } + Assert-PrivateSdkStateRestored $snapshot + } + foreach ($entry in @( + @{ Id = 'relative'; Path = 'dotnet.exe' }, + @{ Id = 'missing'; Path = 'C:\fixture\missing\dotnet.exe' })) { + Test-RootCase "private-sdk.invalid.$($entry.Id)" { + $snapshot = Set-PrivateSdkTestState 'valued' + [Environment]::SetEnvironmentVariable('GRAPHCODE_DOTNET10403', $entry.Path, 'Process') + $calls = @{ Count = 0 } + $failure = $null + try { Invoke-WithValidationRunnerRuntime { $calls.Count++ } } catch { $failure = $_ } + Assert-Root ($calls.Count -eq 0 -and $null -ne $failure -and + $failure.Exception.Message.Contains('GRAPHCODE_DOTNET10403')) 'Invalid private SDK selection was silently accepted' + Assert-PrivateSdkStateRestored $snapshot + } + } + Test-RootCase 'private-sdk.action-output-only' { + $snapshot = Set-PrivateSdkTestState 'mixed' + $output = @(Invoke-WithValidationRunnerRuntime { 'first'; 'second' }) + Assert-Root ($output.Count -eq 2 -and ($output -join ',') -ceq 'first,second') 'Runtime scoping polluted the apphost output stream' + Assert-PrivateSdkStateRestored $snapshot + } + Test-RootCase 'private-sdk.error-stream-and-native-exit' { + $snapshot = Set-PrivateSdkTestState 'empty' + $output = @(Invoke-WithValidationRunnerRuntime { + 'stdout' + Write-Error 'PRIVATE_SDK_TEST_STDERR' -ErrorAction Continue + $global:LASTEXITCODE = 17 + } 2>&1) + Assert-Root ($output.Count -eq 2 -and $output[0] -ceq 'stdout' -and + $output[1] -is [Management.Automation.ErrorRecord] -and $global:LASTEXITCODE -eq 17) ` + 'Runtime scoping changed output/error identity or native exit state' + Assert-PrivateSdkStateRestored $snapshot + } + Test-RootCase 'private-sdk.unconfigured-absent-root' { + $snapshot = Set-PrivateSdkTestState 'absent' + [Environment]::SetEnvironmentVariable('GRAPHCODE_DOTNET10403', $null, 'Process') + Invoke-WithValidationRunnerRuntime { Assert-PrivateSdkStateRestored $snapshot } + Assert-PrivateSdkStateRestored $snapshot + } + Test-RootCase 'private-sdk.actual-normal-command-is-scoped' { + $tokens = $null + $errors = $null + $tree = [Management.Automation.Language.Parser]::ParseFile($wrapper, [ref]$tokens, [ref]$errors) + Assert-Root ($errors.Count -eq 0) 'Actual wrapper does not parse' + $scopes = @($tree.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -ceq 'Invoke-WithValidationRunnerRuntime' + }, $true)) + Assert-Root ($scopes.Count -eq 1 -and + $scopes[0].CommandElements[1] -is [Management.Automation.Language.ScriptBlockExpressionAst]) ` + 'Actual wrapper must scope exactly one action' + $calls = @($scopes[0].CommandElements[1].ScriptBlock.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] -and + $node.InvocationOperator -eq [Management.Automation.Language.TokenKind]::Ampersand -and + $node.CommandElements[0].Extent.Text -ceq '$runner' + }, $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' + } +} finally { + foreach ($name in $names) { + $value = if ($before[$name].present) { $before[$name].value } else { $null } + [Environment]::SetEnvironmentVariable($name, $value, 'Process') + } + if ($null -eq $priorExit) { Remove-Variable LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue } + else { $global:LASTEXITCODE = $priorExitValue } +} +Write-RootSummary 'host.private-sdk-caller-controls' +if ($script:RootCases.Count -ne 16 -or @($script:RootCases | Where-Object { -not $_.passed }).Count -ne 0) { + throw 'Private SDK caller controls require exactly 16 passing cases' +} diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/README.md b/Tools/windows/ValidationRunner/Host/CiContracts/README.md new file mode 100644 index 00000000..9ff93a37 --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/CiContracts/README.md @@ -0,0 +1,103 @@ +# Compiled host root contracts + +`GraphCode.Validation.Host.RootContracts.SuiteRegistration.CreateSuites()` supplies +exactly six mandatory `IValidationSuite` implementations. The host owner registers +this method and merges `manifest-fragment.json` into the existing host manifest; +do not load the fragment as a fourth manifest or register the units twice. + +The five PowerShell payloads run through the host's public `CaseScriptSuite`, +which supplies owned-child supervision, isolated TEMP/TMP, raw stream capture and +cleanup/retention authority. They independently exercise actual production +functions or scripts. AST extraction is restricted to production functions; +no part of the old monolithic test program is loaded or executed. Aggregate and +section-manifest scripts are invoked within that supervised payload, not through +unowned nested native processes. No provider, product, UI or Zig executable is +launched by these six families. + +The C# source contracts use typed predicates, with explicit positive and negative +controls. They preserve the original workflow scheduling, required job names, +partitioning, source observations and measured timeout headroom. New execution +guards require the normal full compiled entry, exact SDK, nested build working +directory, complete coverage with positive equal required/passed counts, exit +zero and exactly one of each terminal success marker. They intentionally reject +the old PowerShell entry; a pre-cutover workflow is a real source-contract failure. +These source observations are not evidence that GitHub Actions executed. + +`source-correspondence.json` identifies every expanded case and its frozen source +condition. The manifest inventory is not counted as execution. Adapter case JSON +under the requested artifact root records individual pass/fail results. Successful +unit groups must match the runtime case inventory exactly, including positive +counts; missing, extra or duplicate successful adapter groups fail closed. + +Build from `Tools\windows\ValidationRunner` so `global.json` selects SDK 10.0.403: + +```powershell +Push-Location Tools\windows\ValidationRunner +dotnet build -c Release +Pop-Location +``` + +For focused development before the host registration lands: + +```powershell +$artifacts = Join-Path $PWD '.build\root-contracts' +New-Item -ItemType Directory -Force $artifacts | Out-Null +pwsh -NoLogo -NoProfile -File Tools\windows\ValidationRunner\Host\CiContracts\Focused.ps1 ` + -RepositoryRoot $PWD.Path -ArtifactRoot $artifacts ` + -AssemblyPath (Join-Path $PWD 'Tools\windows\ValidationRunner\bin\Release\net10.0-windows\ValidationRunner.dll') +``` + +`-Suite` selects one of the six IDs for local diagnostics only. This focused +entry reports only those suites and never emits the full-host PASS marker. +Canonical CI must use the normal compiled EXE, without a suite selector. + +Local implementation evidence used PowerShell 7.6.9 on .NET 10, not PowerShell +7.4. The first no-restore build failed because project assets were absent; +restore then exposed missing `System.IO` imports. Those compile failures are +not behavioral RED evidence. The first focused run exposed two CRLF-sensitive +negative-control fixtures; normalizing those fixtures fixed them. Subsequent +focused execution passed the five behavioral families and all topology controls, +while four topology observations correctly remained RED against the legacy +workflow/wrapper before the host owner's cutover. No full coverage or CI success +is claimed by that partial result. + +After the four-file CI/caller lease was transferred to this lane, the actual +workflow and wrapper cutover made all 35 topology cases pass. The six families +plus the deduplicated residual suite then executed **143/143 passing cases**; +the standalone topology script passed 24 checks and the integrated host passed +61 self-tests. The initial topology-only focused invocation exposed a lazy +binding-load error in the harness's exit barrier after its 35 cases had passed. +Explicitly loading the real Windows bindings fixed that harness error without +replacing or weakening the retained-authority barrier. These focused results +remain distinct from the integration owner's normal full-host run and from +hosted CI, neither of which this lane ran. + +The compatibility wrapper scopes `DOTNET_ROOT` and `DOTNET_ROOT_X64` to the +directory of a configured `GRAPHCODE_DOTNET10403` executable while launching +the apphost, then restores exact process-level presence and values in `finally`. +An unconfigured caller retains its existing CI/global runtime resolution. +`PrivateSdkCaller.Tests.ps1` extracts that actual production helper and runs +16 in-memory action/environment controls, including absent, empty, valued and +mixed roots, original failure identity, output/error streams, native exit state +and unchanged normal arguments. The standalone topology driver runs these +controls too. They are reported separately from the seven mandatory root units +and do not claim an actual private SDK installation or full-host execution. + +Both canonical CI jobs now prepare Pester through the existing pinned loader. +They reuse loaded or available major 5 first; only its absence saves version +5.7.1 under the workspace's `.ci-modules` and propagates that process-only module +path through `GITHUB_ENV`. No global installation or user environment setting +is used. The compiled topology family is now **36 cases**, including a new +missing-prerequisite control, making the seven-family focused total **144**. +Local verification reused real Pester 5.7.1 in both actual step bodies without +downloading anything. Eight separate workflow-body mocks covered loaded, +available, missing and save-failure branches; they are not full-host cases. + +The compiled evidence directory is hidden by name. Both explicitly scoped +uploads now set `include-hidden-files: true` for the exact +`.ci-validation-runner` path; no repository, home or broader directory is added +to either upload. A source observation plus missing/false negative controls +raise the topology count from 36 to **39**, and the focused root total from +144 to **147**. The pre-fix run executed 39 cases with exactly the new source +observation failing; this is a source-wiring regression, not a local execution +of GitHub's artifact-upload service. diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/ResidualScope-README.md b/Tools/windows/ValidationRunner/Host/CiContracts/ResidualScope-README.md new file mode 100644 index 00000000..a9f1c042 --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/CiContracts/ResidualScope-README.md @@ -0,0 +1,41 @@ +# Residual root source wiring + +Register +`GraphCode.Validation.Host.RootContracts.ResidualScopeRegistration.CreateSuites()` +once, in addition to the frozen six-family registration. Merge the single +`ResidualScope-manifest-fragment.json` entry into the existing host unit rather +than adding a duplicate unit. + +The residual audit leaves seven original packaging predicates: six contract +invocations inside the fixture-only part guard before real packaging, and real +input initialization before the real packaging invocation. The suite expands +these to **16 cases: seven production-source observations and nine negative +controls**. Successful adapter groups must match those 16 identities exactly. + +These are actual PowerShell AST observations against `validate.ps1`, not matches +against comments, inventories or a reimplemented packaging executor. The checks +require the correct task clause, correct part guards, unique direct command +invocations and source ordering. Independently authored source mutations remove +each contract call, remove initialization, invert the guard and comment out a +call. Mutants are parsed and inspected only; production packaging is never run +by this unit. + +The long existing mandatory unit ID +`host.root-source-scope-mock-environment-error-streams` is retained for manifest +compatibility. It does not imply that these static wiring cases prove runtime +scope restoration, error streams, LASTEXITCODE behavior, packaging lifecycle or +native UIA behavior. Those remain the responsibility of their actual behavioral +suites. + +`ResidualScope-source-correspondence.json` records exact frozen lines and foreign +ownership. In particular, the existing Ui unit `ui.root.source.line-5342` +already covers ProviderPins-before-TerminalGate. The smoke-before-UIA predicate +is `ui.root.source.line-5350`, not line 5342. Neither is counted here. The two +delayed-stub observations at line 5346 remain with the Host implementation. + +Build the nested host project normally. For focused development, use +`ResidualScopeFocused.ps1` with `-RepositoryRoot`, `-ArtifactRoot` and +`-AssemblyPath`, as with `Focused.ps1`. It invokes the public protected +`CaseScriptSuite` transport, preserves unknown ownership through the outer +terminal hold, requires a positive exact count and never emits a full-host +success marker. diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/ResidualScope-manifest-fragment.json b/Tools/windows/ValidationRunner/Host/CiContracts/ResidualScope-manifest-fragment.json new file mode 100644 index 00000000..bc847c02 --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/CiContracts/ResidualScope-manifest-fragment.json @@ -0,0 +1,10 @@ +{ + "units": [ + { + "id": "host.root-source-scope-mock-environment-error-streams", + "owner": "Host", + "minimumCount": 16, + "exactCount": 16 + } + ] +} diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/ResidualScope-source-correspondence.json b/Tools/windows/ValidationRunner/Host/CiContracts/ResidualScope-source-correspondence.json new file mode 100644 index 00000000..765a4b66 --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/CiContracts/ResidualScope-source-correspondence.json @@ -0,0 +1,32 @@ +{ + "schemaVersion": 1, + "baselineManifestSha256": "CC8ED5141AE38CB425F4E41AB7F2E4EF1F07F5AE4CAD7FA969A68645B07A0A2F", + "baselineSource": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "unit": "host.root-source-scope-mock-environment-error-streams", + "expectedExpandedCount": 16, + "originalPredicates": 7, + "newNegativeControls": 9, + "inventoryIsExecutionEvidence": false, + "production": "Tools/windows/validate.ps1: actual packaging switch clause, part guards and invoked commands", + "cases": [ + { "originalLines": "5322-5324", "ids": ["source.packaging.signing", "control.packaging.signing.missing-call"] }, + { "originalLines": "5325-5327", "ids": ["source.packaging.rollback", "control.packaging.rollback.missing-call"] }, + { "originalLines": "5328-5330", "ids": ["source.packaging.standalone", "control.packaging.standalone.missing-call"] }, + { "originalLines": "5331-5335", "expandedValue": "Packaging.ScriptSigning.Tests.ps1", "ids": ["source.packaging.script_signing", "control.packaging.script_signing.missing-call"] }, + { "originalLines": "5331-5335", "expandedValue": "Packaging.Scheduler.Tests.ps1", "ids": ["source.packaging.scheduler", "control.packaging.scheduler.missing-call"] }, + { "originalLines": "5331-5335", "expandedValue": "Packaging.Uninstall.Tests.ps1", "ids": ["source.packaging.uninstall", "control.packaging.uninstall.missing-call"] }, + { "originalLines": "5336-5338", "ids": ["source.packaging.real-input-order", "control.packaging.real-missing-initialization"] }, + { "originalLines": null, "purpose": "New controls prove an inverted part guard and a commented-out command are not accepted as actual packaging wiring", "ids": ["control.packaging.wrong-part-guard", "control.packaging.comment-is-not-invocation"] } + ], + "foreignCoverageNotExecutedOrCountedByThisUnit": [ + { "originalLines": "5031-5033", "owner": "Host", "coverage": "ps.pester-version, ps.isolated-process-environment, ps.fixture-directory" }, + { "originalLines": "5035-5137", "owner": "Host", "coverage": "task inventory/dry-run, provider task boundary, unknown task, real formatter, foreign fixture" }, + { "originalLines": "5143-5222", "owner": "RootContracts", "coverage": "host.hardening-source-and-diagnostics" }, + { "originalLines": "5223-5307", "owner": "RootContracts and Host", "coverage": "host.ci-topology plus Host provider cache/build suites" }, + { "originalLines": "5308-5321", "owner": "RootContracts and Host/Ownership", "coverage": "host.zig-resolver, host.shell-prerequisite and pre-UIA isolation adapters" }, + { "originalLines": "5339-5341", "owner": "RootContracts", "coverage": "host.ci-partition, host.shell-section-gate, host.ci-aggregate" }, + { "originalLines": "5342-5344", "owner": "Ui", "coverage": "ui.root.source.line-5342", "predicate": "ProviderPins.Tests.ps1 precedes TerminalGate.Tests.ps1" }, + { "originalLines": "5345-5349", "owner": "Host", "coverage": "ui.root.source.line-5346", "expandedChecks": 2, "predicate": "150ms delayed stub wiring in validate.ps1 and Hardening.Tests.ps1" }, + { "originalLines": "5350-5352", "owner": "Ui", "coverage": "ui.root.source.line-5350", "predicate": "Shell build/smoke precedes native UIA live gate" } + ] +} diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/ResidualScope.ps1 b/Tools/windows/ValidationRunner/Host/CiContracts/ResidualScope.ps1 new file mode 100644 index 00000000..19b5fcec --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/CiContracts/ResidualScope.ps1 @@ -0,0 +1,116 @@ +param([Parameter(Mandatory)] [string] $RepositoryRoot, + [Parameter(Mandatory)] [string] $ArtifactRoot, [string] $AssemblyPath) +. (Join-Path $PSScriptRoot 'AdapterSupport.ps1') + +function Read-ResidualTree([string] $Source) { + $tokens = $null + $errors = $null + $tree = [Management.Automation.Language.Parser]::ParseInput($Source, [ref]$tokens, [ref]$errors) + Assert-Root ($errors.Count -eq 0) 'Production wiring source does not parse' + return $tree +} + +function Get-ResidualTaskClause($Tree, [string] $Name) { + $switches = @($Tree.FindAll({ + param($node) + $node -is [Management.Automation.Language.SwitchStatementAst] + }, $true)) + $matches = @($switches.Clauses | Where-Object { $_.Item1.Value -ceq $Name }) + Assert-Root ($matches.Count -eq 1) "Expected exactly one $Name task clause" + return $matches[0].Item2 +} + +function Get-ResidualGuard($Clause, [string] $ExcludedPart) { + $expected = '$PackagingPart -ne "' + $ExcludedPart + '"' + $guards = @($Clause.FindAll({ + param($node) + $node -is [Management.Automation.Language.IfStatementAst] -and + $node.Clauses.Count -eq 1 -and $node.Clauses[0].Item1.Extent.Text -ceq $expected + }, $true)) + Assert-Root ($guards.Count -eq 1) "Missing unique packaging part guard excluding $ExcludedPart" + return $guards[0].Clauses[0].Item2 +} + +function Get-ResidualScriptCall($Tree, [string] $RelativePath) { + $expected = '(Join-Path $repoRoot "' + $RelativePath + '")' + $calls = @($Tree.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] -and + $node.InvocationOperator -eq [Management.Automation.Language.TokenKind]::Ampersand -and + $node.CommandElements[0].Extent.Text -ceq $expected + }, $true)) + Assert-Root ($calls.Count -eq 1) "Missing or duplicated actual script invocation: $RelativePath" + return $calls[0] +} + +function Assert-ResidualPackagingContract($Tree, [string] $File) { + $clause = Get-ResidualTaskClause $Tree 'packaging' + $contracts = Get-ResidualGuard $clause 'real' + $real = Get-ResidualGuard $clause 'contracts' + $contract = Get-ResidualScriptCall $contracts "Tools\windows\Tests\$File" + $package = Get-ResidualScriptCall $real 'Tools\windows\Tests\Packaging.Tests.ps1' + Assert-Root ($contract.Parent.Parent -eq $contracts -and + $contract.Extent.EndOffset -lt $package.Extent.StartOffset) 'Packaging contract must be a direct part-guarded invocation before real packaging' +} + +function Assert-ResidualRealPackaging($Tree) { + $clause = Get-ResidualTaskClause $Tree 'packaging' + $real = Get-ResidualGuard $clause 'contracts' + $initializers = @($real.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] -and $node.GetCommandName() -ceq 'Initialize-PackagingInputs' + }, $true)) + $package = Get-ResidualScriptCall $real 'Tools\windows\Tests\Packaging.Tests.ps1' + Assert-Root ($initializers.Count -eq 1 -and $initializers[0].Parent.Parent -eq $real -and + $package.Parent.Parent -eq $real -and $initializers[0].Extent.EndOffset -lt $package.Extent.StartOffset) ` + 'Real packaging must initialize its own inputs before its actual test invocation' +} + +function Assert-ResidualMutation([string] $Source, [string] $Before, [string] $After, [scriptblock] $Predicate) { + $mutant = $Source.Replace($Before, $After) + Assert-Root ($mutant -cne $Source) 'Residual source mutation did not apply' + $rejected = $false + try { & $Predicate (Read-ResidualTree $mutant) } catch { $rejected = $true } + Assert-Root $rejected 'Residual source wiring guard accepted a deliberate negative control' +} + +$runnerSource = Get-Content -LiteralPath (Join-Path $RepositoryRoot 'Tools\windows\validate.ps1') -Raw +$runnerTree = Read-ResidualTree $runnerSource +$contracts = [ordered]@{ + signing = 'Packaging.Signing.Tests.ps1' + rollback = 'Packaging.Rollback.Tests.ps1' + standalone = 'Packaging.Standalone.Tests.ps1' + script_signing = 'Packaging.ScriptSigning.Tests.ps1' + scheduler = 'Packaging.Scheduler.Tests.ps1' + uninstall = 'Packaging.Uninstall.Tests.ps1' +} +foreach ($entry in $contracts.GetEnumerator()) { + Test-RootCase "source.packaging.$($entry.Key)" { Assert-ResidualPackagingContract $runnerTree $entry.Value } + Test-RootCase "control.packaging.$($entry.Key).missing-call" { + Assert-ResidualMutation $runnerSource $entry.Value ('Disabled.' + $entry.Value) { + param($tree) + Assert-ResidualPackagingContract $tree $entry.Value + } + } +} +Test-RootCase 'source.packaging.real-input-order' { Assert-ResidualRealPackaging $runnerTree } +Test-RootCase 'control.packaging.real-missing-initialization' { + Assert-ResidualMutation $runnerSource 'Initialize-PackagingInputs' 'Disabled-PackagingInputs' { + param($tree) + Assert-ResidualRealPackaging $tree + } +} +Test-RootCase 'control.packaging.wrong-part-guard' { + Assert-ResidualMutation $runnerSource '$PackagingPart -ne "real"' '$PackagingPart -eq "real"' { + param($tree) + Assert-ResidualPackagingContract $tree 'Packaging.Signing.Tests.ps1' + } +} +Test-RootCase 'control.packaging.comment-is-not-invocation' { + $call = '& (Join-Path $repoRoot "Tools\windows\Tests\Packaging.Signing.Tests.ps1")' + Assert-ResidualMutation $runnerSource $call ("# " + $call) { + param($tree) + Assert-ResidualPackagingContract $tree 'Packaging.Signing.Tests.ps1' + } +} +Write-RootSummary 'host.root-source-scope-mock-environment-error-streams' diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/ResidualScopeFocused.ps1 b/Tools/windows/ValidationRunner/Host/CiContracts/ResidualScopeFocused.ps1 new file mode 100644 index 00000000..48c8464e --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/CiContracts/ResidualScopeFocused.ps1 @@ -0,0 +1,24 @@ +param( + [Parameter(Mandatory)] [string] $RepositoryRoot, + [Parameter(Mandatory)] [string] $ArtifactRoot, + [Parameter(Mandatory)] [string] $AssemblyPath +) +$ErrorActionPreference = 'Stop' +$null = [Reflection.Assembly]::LoadFrom($AssemblyPath) +$null = [Reflection.Assembly]::LoadFrom((Join-Path (Split-Path $AssemblyPath -Parent) 'WindowsValidationBindings.dll')) +$exitCode = 1 +try { + $context = [GraphCode.Validation.ValidationContext]::new($RepositoryRoot, $ArtifactRoot) + $suites = [GraphCode.Validation.Host.RootContracts.ResidualScopeRegistration]::CreateSuites() + if ($suites.Count -ne 1) { throw 'Residual registration must expose exactly one suite' } + $rows = $suites[0].RunAsync($context, [Threading.CancellationToken]::None).GetAwaiter().GetResult() + $rows | ConvertTo-Json -Depth 8 -Compress | Write-Output + if ($rows.Count -eq 1 -and $rows[0].Status -eq 'Passed' -and $rows[0].ExecutedCount -eq 16) { + $exitCode = 0 + Write-Output 'RESIDUAL_FOCUSED_PASSED=16' + } +} finally { + $null = [GraphCode.Validation.Host.TerminalSafety]::PreserveRetainedAuthorityAsync( + [GraphCode.Validation.Ownership.OwnedChildSupervisor]::RetainedLeaseCount).GetAwaiter().GetResult() +} +exit $exitCode diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/ResidualScopeRegistration.cs b/Tools/windows/ValidationRunner/Host/CiContracts/ResidualScopeRegistration.cs new file mode 100644 index 00000000..aa7584eb --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/CiContracts/ResidualScopeRegistration.cs @@ -0,0 +1,42 @@ +using System.IO; +using GraphCode.Validation.Adapters; + +namespace GraphCode.Validation.Host.RootContracts; + +public static class ResidualScopeRegistration +{ + public static IReadOnlyList CreateSuites() => [new ResidualSuite()]; + + private static readonly string[] Cases = + [ + "source.packaging.signing", "control.packaging.signing.missing-call", + "source.packaging.rollback", "control.packaging.rollback.missing-call", + "source.packaging.standalone", "control.packaging.standalone.missing-call", + "source.packaging.script_signing", "control.packaging.script_signing.missing-call", + "source.packaging.scheduler", "control.packaging.scheduler.missing-call", + "source.packaging.uninstall", "control.packaging.uninstall.missing-call", + "source.packaging.real-input-order", "control.packaging.real-missing-initialization", + "control.packaging.wrong-part-guard", "control.packaging.comment-is-not-invocation" + ]; + + private sealed class ResidualSuite : IValidationSuite + { + public string Id => "host.root-source-scope-mock-environment-error-streams"; + public IReadOnlyList UnitIds => [Id]; + + public async Task> RunAsync(ValidationContext context, CancellationToken cancellationToken) + { + var suite = new StandaloneSuites.CaseScriptSuite(Id, + "Tools\\windows\\ValidationRunner\\Host\\CiContracts\\ResidualScope.ps1", "HOST_CASE_RESULT=", [Id]); + var results = await suite.RunAsync(context, cancellationToken); + if (results.Count != 1 || results[0].Id != Id) + throw new InvalidDataException("Residual root scope requires exactly one matching unit result"); + var row = results[0]; + if (row.ExecutedCount != Cases.Length || (row.Status == UnitStatus.Passed && + (row.Groups is null || row.Groups.Count != Cases.Length || + !row.Groups.Order(StringComparer.Ordinal).SequenceEqual(Cases.Order(StringComparer.Ordinal), StringComparer.Ordinal)))) + row = row with { Status = UnitStatus.Failed, Evidence = row.Evidence + "; exact residual case inventory/count mismatch" }; + return [row]; + } + } +} diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/ShellSections.ps1 b/Tools/windows/ValidationRunner/Host/CiContracts/ShellSections.ps1 new file mode 100644 index 00000000..e30d9fb2 --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/CiContracts/ShellSections.ps1 @@ -0,0 +1,123 @@ +param([Parameter(Mandatory)] [string] $RepositoryRoot, + [Parameter(Mandatory)] [string] $ArtifactRoot, [string] $AssemblyPath) +. (Join-Path $PSScriptRoot 'AdapterSupport.ps1') +$shell = Join-Path $RepositoryRoot 'Tools\windows\Tests\WindowsShell.Tests.ps1' +. (Get-ProductionFunction $shell 'Invoke-Native') +$sectionCatalog = @('Probe section') +$sectionPlan = [pscustomobject]@{ Assignment = @{ 'Probe section' = 0 } } +$Shard = 0 +$ShardCount = 1 +$executedSections = [Collections.Generic.List[object]]::new() +$summaries = [ordered]@{ + zero_all = 'All 0 tests passed.' + failed = '0 passed; 0 skipped; 1 failed.' + skipped_only = '0 passed; 2 skipped; 0 failed.' +} +foreach ($entry in $summaries.GetEnumerator()) { + Test-RootCase "section.reject.$($entry.Key)" { + $rejected = $false + try { + Invoke-Native 'Probe section' { + # $zig test src\Probe.zig --test-filter unmatched + $entry.Value + $global:LASTEXITCODE = 0 + } 6> $null + } catch { $rejected = $true } + Assert-Root ($rejected -and $executedSections.Count -eq 0) 'Zero executed tests were recorded as passing' + } +} +Test-RootCase 'section.positive-two-invocations' { + Invoke-Native 'Probe section' { + # $zig test src\Probe.zig + # $zig test src\Second.zig + 'All 3 tests passed.' + '2 passed; 1 skipped; 0 failed.' + $global:LASTEXITCODE = 0 + } 6> $null + Assert-Root ($executedSections.Count -eq 1 -and $executedSections[0].positiveSummaries -eq 2 -and + $executedSections[0].zigTestInvocations -eq 2) 'Positive per-invocation evidence was not recorded' +} +Test-RootCase 'section.reject.missing-second-summary' { + $rejected = $false + try { + Invoke-Native 'Probe section' { + # $zig test src\Probe.zig + # $zig test src\Second.zig + 'All 3 tests passed.' + $global:LASTEXITCODE = 0 + } 6> $null + } catch { $rejected = $true } + Assert-Root ($rejected -and $executedSections.Count -eq 1) 'One summary satisfied two Zig invocations' +} +Test-RootCase 'section.reject.native-nonzero' { + $rejected = $false + try { + Invoke-Native 'Probe section' { + # $zig test src\Probe.zig + 'All 3 tests passed.' + $global:LASTEXITCODE = 7 + } 6> $null + } catch { $rejected = $true } + Assert-Root ($rejected -and $executedSections.Count -eq 1) 'Nonzero exit was recorded as passing' +} + +$plans = @() +foreach ($index in 0..2) { + $plans += & $shell -PlanOnly -Shard $index -ShardCount 3 | Out-String | ConvertFrom-Json +} +$whole = & $shell -PlanOnly | Out-String | ConvertFrom-Json +$catalog = @($whole.catalog) +Test-RootCase 'plan.unsharded-catalog' { + Assert-Root ($catalog.Count -ge 40 -and (@($whole.assigned) -join "`n") -ceq ($catalog -join "`n")) ` + 'Unsharded catalog is empty, truncated, or reordered' +} +Test-RootCase 'plan.complete-disjoint-three-shards' { + $assigned = @($plans | ForEach-Object { $_.assigned }) + Assert-Root ($assigned.Count -eq $catalog.Count -and @($assigned | Sort-Object -Unique).Count -eq $catalog.Count -and + @($catalog | Where-Object { $assigned -cnotcontains $_ }).Count -eq 0 -and + @($plans | Where-Object { @($_.assigned).Count -eq 0 }).Count -eq 0) 'Shard assignment lost completeness or disjointness' +} +Test-RootCase 'plan.workflow-agreement' { + $text = Get-Content -LiteralPath (Join-Path $RepositoryRoot '.github\workflows\windows-shell.yml') -Raw + Assert-Root ($text -match '(?m)^\s+shard: \[0, 1, 2\]\s*$' -and + $text -match '-ShellTestShardCount 3 ' -and + $text -match 'Test-ShellSectionManifests\.ps1 -Directory \.ci-sections -ShardCount 3') 'Workflow and section shard counts disagree' +} +$scratch = Join-Path ([IO.Path]::GetTempPath()) ('root-sections-' + [guid]::NewGuid().ToString('N')) +$verifier = Join-Path $RepositoryRoot 'Tools\windows\Test-ShellSectionManifests.ps1' +foreach ($kind in @('complete', 'missing-section', 'duplicate-section', 'zero-tests', 'skipped-assignment', 'missing-shard')) { + Test-RootCase "manifest.$kind" { + $manifests = @($plans | ForEach-Object { + [pscustomobject]@{ + schemaVersion = 1; shard = $_.shard; shardCount = 3; catalog = @($_.catalog); assigned = @($_.assigned) + executed = @($_.assigned | ForEach-Object { + [pscustomobject]@{ name = $_; seconds = 1; zigTestInvocations = 1; positiveSummaries = 1 } + }) + } + }) + switch ($kind) { + 'missing-section' { + $manifests[1].executed = @($manifests[1].executed | Select-Object -Skip 1) + $manifests[1].assigned = @($manifests[1].assigned | Select-Object -Skip 1) + } + 'duplicate-section' { + $manifests[0].executed += @($manifests[1].executed[0]) + $manifests[0].assigned += @($manifests[1].assigned[0]) + } + 'zero-tests' { $manifests[2].executed[0].positiveSummaries = 0 } + 'skipped-assignment' { $manifests[2].executed = @($manifests[2].executed | Select-Object -SkipLast 1) } + 'missing-shard' { $manifests[2] = $null } + } + $directory = Join-Path $scratch $kind + $null = New-Item -ItemType Directory -Path $directory -Force + foreach ($manifest in $manifests) { + if ($null -ne $manifest) { + $manifest | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $directory "shard-$($manifest.shard).json") + } + } + $accepted = $true + try { & $verifier -Directory $directory -ShardCount 3 | Out-Null } catch { $accepted = $false } + Assert-Root ($accepted -eq ($kind -eq 'complete')) "Manifest verifier changed its decision for $kind" + } +} +Write-RootSummary 'host.shell-section-gate' diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/SourceContracts.cs b/Tools/windows/ValidationRunner/Host/CiContracts/SourceContracts.cs new file mode 100644 index 00000000..bb870b69 --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/CiContracts/SourceContracts.cs @@ -0,0 +1,433 @@ +using System.IO; +using System.Text.RegularExpressions; + +namespace GraphCode.Validation.Host.RootContracts; + +internal sealed class SourceCases +{ + private readonly List<(string Id, string? Error)> results = []; + + internal void Check(string id, Action body) + { + try { body(); results.Add((id, null)); } + catch (Exception error) when (error is InvalidDataException or ArgumentException or KeyNotFoundException) + { results.Add((id, error.Message)); } + } + + internal UnitResult Result(string id) + { + var failed = results.Where(row => row.Error is not null).ToArray(); + return new UnitResult(id, failed.Length == 0 ? UnitStatus.Passed : UnitStatus.Failed, results.Count, + $"Typed source contracts: executed={results.Count}; failed={failed.Length}" + + string.Concat(failed.Select(row => $"; {row.Id}: {row.Error}")), + results.Where(row => row.Error is null).Select(row => row.Id).ToArray()); + } + + internal static void Require(bool condition, string message) + { + if (!condition) throw new InvalidDataException(message); + } + + internal static bool Match(string text, string pattern) => + Regex.IsMatch(text, pattern, RegexOptions.CultureInvariant, TimeSpan.FromSeconds(2)); +} + +internal sealed class WorkflowText +{ + internal string Text { get; } + internal IReadOnlyDictionary Jobs { get; } + + internal WorkflowText(string text) + { + Text = text.Replace("\r\n", "\n", StringComparison.Ordinal); + var start = Regex.Match(Text, @"(?m)^jobs:[ \t]*$", RegexOptions.CultureInvariant); + SourceCases.Require(start.Success, "Workflow has no jobs mapping"); + var body = Text[(start.Index + start.Length)..]; + var headings = Regex.Matches(body, @"(?m)^ ([A-Za-z0-9_-]+):[ \t]*$", RegexOptions.CultureInvariant); + var jobs = new Dictionary(StringComparer.Ordinal); + for (var index = 0; index < headings.Count; index++) + { + var end = index + 1 < headings.Count ? headings[index + 1].Index : body.Length; + SourceCases.Require(jobs.TryAdd(headings[index].Groups[1].Value, + body[headings[index].Index..end]), "Workflow repeats a job identifier"); + } + SourceCases.Require(jobs.Count > 0, "Workflow jobs mapping is empty"); + Jobs = jobs; + } + + internal static IReadOnlyList Steps(string job) => + Regex.Matches(job, @"(?ms)^ - .*?(?=^ - |\z)", RegexOptions.CultureInvariant) + .Select(match => match.Value).ToArray(); +} + +internal static class SourceContracts +{ + internal static UnitResult Hardening(string root) + { + var cases = new SourceCases(); + var hardening = File.ReadAllText(Path.Combine(root, "Tools", "windows", "Tests", "Hardening.Tests.ps1")); + var workflow = File.ReadAllText(Path.Combine(root, ".github", "workflows", "windows-hardening.yml")); + cases.Check("source.full-pinned-real-hardening", () => SourceCases.Require(SourceCases.Match(workflow, + @"(?s)full-pinned:.*bootstrap\.ps1.*validate\.ps1 -Task all.*Hardening\.Tests\.ps1 -Environment"), + "Full pinned CI must run real hardening after provider setup")); + cases.Check("source.owned-environment", () => SourceCases.Require(workflow.Contains("GRAPHCODE_HARDENING_TARGET", + StringComparison.Ordinal), "Full pinned CI must supply an owned environment harness")); + foreach (var stage in new[] { "real zmx/ConPTY terminal matrix", "real GraphCode shell matrix" }) + cases.Check("source.native-exit." + (stage.Contains("zmx", StringComparison.Ordinal) ? "terminal" : "shell"), + () => SourceCases.Require(SourceCases.Match(hardening, + Regex.Escape(stage) + @" failed \(exit=\$LASTEXITCODE; hex="), "Native stage failure lost exit/hex diagnostics")); + cases.Check("source.high-output-failure-diagnostics", () => SourceCases.Require(SourceCases.Match(hardening, + @"(?s)if \(-not \$completed\).*?Get-HighOutputDiagnostics.*?HARDENING_OUTPUT_DIAGNOSTICS_JSON=.*?Assert-True \$completed"), + "High-output failure lost bounded transcript diagnostics")); + cases.Check("source.repeat-preserves-child-diagnostics", () => SourceCases.Require(SourceCases.Match(hardening, + "(?s)if \\(\\$LASTEXITCODE -ne 0\\) \\{\\s*\\$output \\| Write-Output\\s*throw \"hardening repeated run"), + "Failed repeated hardening discards child diagnostics")); + cases.Check("source.release-shell-version", () => SourceCases.Require(SourceCases.Match(hardening, + @"(?s)\$shellVersion\s*=\s*\(& \$shell --version.*?-Version \$shellVersion"), + "Post-release hardening lost the built shell version")); + return cases.Result("host.hardening-source-and-diagnostics"); + } + + internal static UnitResult Aggregate(string root) + { + var cases = new SourceCases(); + foreach (var (file, id, exempt) in new[] + { + ("windows-shell.yml", "windows-shell", Array.Empty()), + ("windows-port-validation.yml", "windows-spikes", new[] { "investigation-privacy" }) + }) + { + var workflow = new WorkflowText(File.ReadAllText(Path.Combine(root, ".github", "workflows", file))); + cases.Check("source.aggregate." + id, () => AssertAggregate(workflow, id, exempt)); + } + var fixture = """ + jobs: + changes: + runs-on: ubuntu-latest + part: + runs-on: windows-2022 + gate: + needs: [changes, part] + if: ${{ !cancelled() }} + env: + NEEDS_JSON: ${{ toJSON(needs) }} + steps: + - run: ./Tools/windows/Assert-CiPartResults.ps1 -NeedsJson $env:NEEDS_JSON -Required $env:WINDOWS_REQUIRED + """.Replace("\r\n", "\n", StringComparison.Ordinal); + cases.Check("control.aggregate.valid", () => AssertAggregate(new WorkflowText(fixture), "gate", [])); + foreach (var (id, before, after) in new[] + { + ("missing-part", "[changes, part]", "[changes]"), + ("renamed-required-check", " gate:\n", " gate:\n name: renamed\n"), + ("success-only", "${{ !cancelled() }}", "${{ success() }}"), + ("missing-needs-json", "NEEDS_JSON:", "UNUSED_JSON:"), + ("missing-required-argument", " -Required $env:WINDOWS_REQUIRED", "") + }) + { + cases.Check("control.aggregate." + id, () => + { + var mutant = Mutate(fixture, before, after); + Reject(() => AssertAggregate(new WorkflowText(mutant), "gate", [])); + }); + } + return cases.Result("host.ci-aggregate"); + } + + private static void AssertAggregate(WorkflowText workflow, string id, IReadOnlyList exempt) + { + var aggregate = workflow.Jobs[id]; + SourceCases.Require(!SourceCases.Match(aggregate, @"(?m)^ name:"), + "Required aggregate must retain its job ID as its check name"); + var match = Regex.Match(aggregate, @"(?m)^ needs:\s*\[([^\]]+)\]", RegexOptions.CultureInvariant); + SourceCases.Require(match.Success, "Aggregate does not declare needed parts"); + var needed = match.Groups[1].Value.Split(',').Select(value => value.Trim()).ToArray(); + SourceCases.Require(workflow.Jobs.Keys.All(name => name == id || exempt.Contains(name) || needed.Contains(name)), + "Aggregate does not wait for every non-exempt part"); + SourceCases.Require(SourceCases.Match(aggregate, @"(?m)^ if: \$\{\{ !cancelled\(\) \}\}\s*$") && + aggregate.Contains("NEEDS_JSON: ${{ toJSON(needs) }}", StringComparison.Ordinal) && + aggregate.Contains("Assert-CiPartResults.ps1 -NeedsJson $env:NEEDS_JSON -Required $env:WINDOWS_REQUIRED", StringComparison.Ordinal), + "Aggregate must fail closed over every part"); + } + + internal static UnitResult Topology(string root) + { + var cases = new SourceCases(); + var directory = Path.Combine(root, ".github", "workflows"); + var port = new WorkflowText(File.ReadAllText(Path.Combine(directory, "windows-port-validation.yml"))); + var shell = new WorkflowText(File.ReadAllText(Path.Combine(directory, "windows-shell.yml"))); + var hardening = new WorkflowText(File.ReadAllText(Path.Combine(directory, "windows-hardening.yml"))); + var warmer = new WorkflowText(File.ReadAllText(Path.Combine(directory, "windows-cache-warmer.yml"))); + foreach (var (workflow, id, cap, steps) in new[] + { + (port, "spikes-swift", 45, 5), (port, "spikes-other", 40, 6), (shell, "packaging-real", 50, 6) + }) + { + cases.Check("source.cold-cache." + id, () => + { + var text = workflow.Jobs[id]; + SourceCases.Require(SourceCases.Match(text, $@"(?m)^ timeout-minutes: {cap}\s*$") && + Regex.Matches(text, @"(?m)^ timeout-minutes: \d+\s*$").Count >= steps && + SourceCases.Match(text, @"(?s)name: Bootstrap exact Windows dependencies.*?timeout-minutes: 30"), + "Measured cold-cache job/step/bootstrap headroom was removed"); + }); + } + cases.Check("source.runner-topology-timeout", () => + { + var step = WorkflowText.Steps(port.Jobs["spikes-swift"]) + .SingleOrDefault(text => text.Contains("name: Verify validation runner contract", StringComparison.Ordinal)) ?? ""; + var cap = Regex.Match(step, @"(?m)^ timeout-minutes: (\d+)\s*$"); + SourceCases.Require(cap.Success && int.Parse(cap.Groups[1].Value, + System.Globalization.CultureInfo.InvariantCulture) >= 12, "Runner topology step lost measured timeout headroom"); + }); + foreach (var key in new[] + { + "windows-zig-v1-${{ hashFiles('Tools/windows/bootstrap.ps1') }}", + "windows-providers-v1-${{ hashFiles('graphcode-windows/provider-pins.json', 'Tools/windows/bootstrap.ps1') }}-emit-win32-host-x86_64-windows-gnu" + }) + cases.Check("source.warmer-key." + (key.Contains("providers", StringComparison.Ordinal) ? "providers" : "zig"), + () => SourceCases.Require(warmer.Text.Contains(key, StringComparison.Ordinal), "Warmer does not seed the production immutable key")); + cases.Check("source.warmer-events-and-build-only", () => SourceCases.Require( + SourceCases.Match(warmer.Text, @"(?m)^ push:\s*$") && + SourceCases.Match(warmer.Text, @"(?m)^ branches: \[main\]\s*$") && + SourceCases.Match(warmer.Text, @"(?m)^ schedule:\s*$") && + SourceCases.Match(warmer.Text, "(?m)^ - cron: \"23 4 \\* \\* 1\"\\s*$") && + SourceCases.Match(warmer.Text, @"(?m)^ workflow_dispatch:\s*$") && + SourceCases.Match(warmer.Text, @"(?s)validate\.ps1 -Task provider-build.*?actions/cache/save@") && + !warmer.Text.Contains("validate.ps1 -Task terminal-gate", StringComparison.Ordinal), + "Warmer lost main/weekly/manual events or build-only execution")); + foreach (var (name, workflow) in new[] { ("shell", shell), ("port", port) }) + cases.Check("source.uia-diagnostics." + name, () => SourceCases.Require(SourceCases.Match(workflow.Text, + @"(?s)if: failure\(\).*?actions/upload-artifact@.*?gu-\*.*?logs\\\*\.json"), "Failed UIA diagnostics are not retained")); + foreach (var (name, workflow) in new[] { ("shell", shell), ("port", port), ("hardening", hardening) }) + cases.Check("source.pinned-swift." + name, () => SourceCases.Require( + workflow.Text.Contains("compnerd/gha-setup-swift@397094e75494a93fa8d81db0268dbc8f5d6cf7c6", StringComparison.Ordinal) && + workflow.Text.Contains("swift-version: swift-6.3.3-release", StringComparison.Ordinal) && + workflow.Text.Contains("swift-build: 6.3.3-RELEASE", StringComparison.Ordinal), "Pinned Swift setup changed")); + cases.Check("source.shell-bootstrap", () => SourceCases.Require(shell.Text.Contains("bootstrap.ps1", StringComparison.Ordinal), + "Shell CI no longer bootstraps pinned dependencies")); + cases.Check("source.hosted-explicit-skip-limits", () => SourceCases.Require( + shell.Text.Contains("validate.ps1 -Task windows-shell -SkipTrayLive", StringComparison.Ordinal) && + port.Text.Contains("validate.ps1 -Task all -SkipTrayLive -SkipWslRemoteE2E", StringComparison.Ordinal) && + hardening.Text.Contains("validate.ps1 -Task all -SkipTrayLive -SkipWslRemoteE2E", StringComparison.Ordinal) && + hardening.Text.Contains("Hardening.Tests.ps1 -Environment -SkipTrayLive", StringComparison.Ordinal), + "Hosted CI must explicitly declare unsupported tray/WSL fixtures")); + cases.Check("source.shell-live-task", () => SourceCases.Require(SourceCases.Match(shell.Text, + @"(?m)^\s*run:\s*\./Tools/windows/validate\.ps1 -Task windows-shell\b"), "Shell CI no longer invokes its live task")); + cases.Check("source.runner-single-win2022-fail-closed", () => + { + var job = port.Jobs["validation-runner"]; + SourceCases.Require(SourceCases.Match(job, @"(?m)^ needs: changes\s*$") && + job.Contains("needs.changes.outputs.windows == 'true'", StringComparison.Ordinal) && + job.Contains("needs.changes.result != 'success'", StringComparison.Ordinal) && + SourceCases.Match(job, @"(?m)^ runs-on: windows-2022\s*$") && + !SourceCases.Match(job, @"(?m)^\s+(strategy|matrix):"), "Dedicated runner job must execute once with the fail-closed Windows path gate"); + }); + cases.Check("source.runner-no-providers-or-hidden-failure", () => SourceCases.Require( + !SourceCases.Match(port.Jobs["validation-runner"], @"bootstrap\.ps1|\.ci-providers|continue-on-error"), + "Source-only runner job must not bootstrap providers or hide failure")); + cases.Check("source.runner-new-sdk-build-execution", () => AssertCanonicalJob(port.Jobs["validation-runner"])); + cases.Check("source.runner-port-exactly-once", () => SourceCases.Require(ExeInvocations(port.Text) == 1 && + !SourceCases.Match(port.Jobs["spikes-swift"], @"ValidationRunner\.Tests\.ps1|Invoke-ValidationRunnerContract\.ps1|ValidationRunner\.exe"), + "Port workflow must execute the full compiled runner exactly once, never the legacy program")); + cases.Check("source.runner-aggregate-required", () => SourceCases.Require(SourceCases.Match(port.Jobs["windows-spikes"], + @"(?m)^ needs: \[changes, validation-runner, spikes-swift, spikes-other\]\s*$"), + "Required aggregate must include the dedicated compiled runner")); + cases.Check("source.runner-hardening-single-fallback", () => + { + var candidates = WorkflowText.Steps(hardening.Jobs["deterministic"]) + .Where(step => ExeInvocations(step) > 0).ToArray(); + SourceCases.Require(candidates.Length == 1 && ExeInvocations(hardening.Text) == 1 && + candidates[0].Contains("env.HARDENING_REQUIRED == 'true'", StringComparison.Ordinal) && + candidates[0].Contains("github.event_name != 'pull_request'", StringComparison.Ordinal) && + candidates[0].Contains("matrix.os == 'windows-2022'", StringComparison.Ordinal), + "Hardening must keep one non-PR Windows 2022 fallback without duplicate PR execution"); + AssertCanonicalJob(hardening.Jobs["deterministic"]); + }); + cases.Check("source.runner-compatibility-no-monolith", () => + { + var wrapper = File.ReadAllText(Path.Combine(root, "Tools", "windows", "Invoke-ValidationRunnerContract.ps1")); + SourceCases.Require(wrapper.Contains("ValidationRunner.exe", StringComparison.Ordinal) && + !wrapper.Contains("ValidationRunner.Tests.ps1", StringComparison.Ordinal), + "Compatibility entry must execute only the compiled host, never the old monolith"); + AssertProof(wrapper); + }); + cases.Check("source.runner-hidden-evidence-scoped", () => + { + AssertCompiledEvidenceUpload(port.Jobs["validation-runner"]); + AssertCompiledEvidenceUpload(hardening.Jobs["deterministic"]); + }); + const string evidenceFixture = """ + runner: + steps: + - name: Retain compiled validation evidence + uses: actions/upload-artifact@pinned + with: + path: .ci-validation-runner + include-hidden-files: true + """; + foreach (var (id, replacement) in new[] { ("missing", ""), ("false", "include-hidden-files: false") }) + cases.Check("control.evidence.hidden-files-" + id, () => + { + AssertCompiledEvidenceUpload(evidenceFixture); + Reject(() => AssertCompiledEvidenceUpload( + Mutate(evidenceFixture, "include-hidden-files: true", replacement))); + }); + AddCanonicalControls(cases); + return cases.Result("host.ci-topology"); + } + + private static void AssertCompiledEvidenceUpload(string job) + { + var uploads = WorkflowText.Steps(job).Where(step => + step.Contains("name: Retain compiled validation", StringComparison.Ordinal) && + SourceCases.Match(step, @"(?m)^ uses: actions/upload-artifact@")).ToArray(); + SourceCases.Require(uploads.Length == 1, "Expected exactly one explicitly scoped compiled evidence upload"); + var upload = uploads[0]; + var paths = Regex.Matches(upload, @"(?m)^ path: ([^\r\n]+)"); + SourceCases.Require(paths.Count == 1 && paths[0].Groups[1].Value.Trim() == ".ci-validation-runner" && + SourceCases.Match(upload, @"(?m)^ include-hidden-files: true[ \t]*\r?$"), + "Compiled evidence upload must include hidden files only under the exact .ci-validation-runner root"); + } + + internal static int ExeInvocations(string text) => Regex.Matches(text, + @"(?m)^\s*(?:run:\s*)?\.\\Tools\\windows\\ValidationRunner\\bin\\Release\\net10\.0-windows\\ValidationRunner\.exe --repository-root \$PWD --artifact-root \.ci-validation-runner[ \t]*$").Count; + + private static void AssertCanonicalJob(string job) + { + SourceCases.Require(SourceCases.Match(job, @"uses: actions/setup-dotnet@") && + SourceCases.Match(job, @"(?m)^\s+dotnet-version: ['""]?10\.0\.403['""]?[ \t]*$"), "Compiled host requires the exact .NET SDK"); + var build = WorkflowText.Steps(job).Where(step => SourceCases.Match(step, @"(?m)^\s+dotnet build -c Release[ \t]*$")).ToArray(); + SourceCases.Require(build.Length == 1 && SourceCases.Match(build[0], + @"(?m)^ working-directory: Tools/windows/ValidationRunner[ \t]*$") && + build[0].Contains("dotnet --version", StringComparison.Ordinal) && + build[0].Contains("10.0.403", StringComparison.Ordinal), "Host build must select global.json from the nested project directory and assert its SDK"); + SourceCases.Require(ExeInvocations(job) == 1 && + !SourceCases.Match(job, @"--suite\b|--self-test\b|--fixture\b|ValidationRunner\.Tests\.ps1|Invoke-ValidationRunnerContract\.ps1"), + "CI must execute exactly one normal full compiled host, not a filtered/self-test/legacy entry"); + AssertPesterPrerequisite(job); + AssertProof(job); + } + + private static void AssertPesterPrerequisite(string job) + { + var steps = WorkflowText.Steps(job); + var preparation = steps.Where(step => step.Contains( + @". .\Tools\windows\Import-PinnedPester.ps1", StringComparison.Ordinal)).ToArray(); + SourceCases.Require(preparation.Length == 1, "The canonical host requires one explicit pinned Pester prerequisite"); + var prerequisite = preparation[0]; + var execution = steps.Single(step => ExeInvocations(step) == 1); + SourceCases.Require(job.IndexOf(prerequisite, StringComparison.Ordinal) < job.IndexOf(execution, StringComparison.Ordinal) && + Regex.Match(prerequisite, @"(?m)^ if: (.+)$").Value == + Regex.Match(execution, @"(?m)^ if: (.+)$").Value, + "Pester preparation must precede and share the canonical execution gate"); + SourceCases.Require(prerequisite.Contains("Get-Module -Name Pester", StringComparison.Ordinal) && + prerequisite.Contains("Get-Module -ListAvailable -Name Pester", StringComparison.Ordinal) && + prerequisite.Contains("$_.Version.Major -eq 5", StringComparison.Ordinal) && + SourceCases.Match(prerequisite, + @"(?s)if \(\$pester5\.Count -eq 0\) \{.*?Save-Module -Name Pester -RequiredVersion 5\.7\.1 -Path \$moduleRoot") && + prerequisite.Contains("Join-Path $PWD '.ci-modules'", StringComparison.Ordinal) && + prerequisite.Contains("$env:PSModulePath = $moduleRoot + [IO.Path]::PathSeparator + $env:PSModulePath", StringComparison.Ordinal) && + prerequisite.Contains("AppendAllText($env:GITHUB_ENV", StringComparison.Ordinal) && + prerequisite.Contains("PSModulePath=$env:PSModulePath", StringComparison.Ordinal) && + prerequisite.Contains("$loaded.Count -ne 1", StringComparison.Ordinal) && + !prerequisite.Contains("Install-Module", StringComparison.OrdinalIgnoreCase), + "Pester preparation must reuse major 5, conditionally save 5.7.1 only to an owned path, propagate its process path and verify the pinned loader"); + } + + private static void AssertProof(string text) + { + SourceCases.Require(text.Contains("coverage.json", StringComparison.Ordinal) && + SourceCases.Match(text, @"(?i)-not\s+\$[A-Za-z][A-Za-z0-9_]*\.complete\b") && + SourceCases.Match(text, @"(?i)\$[A-Za-z][A-Za-z0-9_]*\.requiredCount\s+(?:-le\s+0|-lt\s+1)") && + SourceCases.Match(text, @"(?i)\$([A-Za-z][A-Za-z0-9_]*)\.passedCount\s+-ne\s+\$\1\.requiredCount\b") && + text.Contains("CompiledValidationRunner: PASS", StringComparison.Ordinal) && + text.Contains("VALIDATION_RUNNER_EXECUTED=1", StringComparison.Ordinal) && + SourceCases.Match(text, @"(?i)\$exitCode\s+-ne\s+0|\$LASTEXITCODE\s+-ne\s+0") && + SourceCases.Match(text, @"(?i)\$passMarkers\s+-ne\s+1") && + SourceCases.Match(text, @"(?i)\$executedMarkers\s+-ne\s+1"), + "Normal execution must prove exit zero, one full PASS/count marker, complete coverage and positive required/passed counts"); + } + + private static void AddCanonicalControls(SourceCases cases) + { + var fixture = """ + runner: + steps: + - uses: actions/setup-dotnet@pinned + with: + dotnet-version: 10.0.403 + - name: Build compiled host + working-directory: Tools/windows/ValidationRunner + run: | + if ((dotnet --version) -ne '10.0.403') { throw 'SDK' } + dotnet build -c Release + - name: Run compiled host + run: | + .\Tools\windows\ValidationRunner\bin\Release\net10.0-windows\ValidationRunner.exe --repository-root $PWD --artifact-root .ci-validation-runner + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0) { throw 'exit' } + $report = Get-Content .ci-validation-runner\coverage.json | ConvertFrom-Json + if (-not $report.complete -or $report.requiredCount -le 0 -or $report.passedCount -ne $report.requiredCount) { throw 'coverage' } + $passMarkers = @($output | Where-Object { $_ -ceq 'CompiledValidationRunner: PASS' }).Count + $executedMarkers = @($output | Where-Object { $_ -ceq 'VALIDATION_RUNNER_EXECUTED=1' }).Count + if ($passMarkers -ne 1 -or $executedMarkers -ne 1) { throw 'markers' } + """.Replace("\r\n", "\n", StringComparison.Ordinal); + const string prerequisite = """ + - name: Prepare owned Pester 5 prerequisite + run: | + $pester5 = @(Get-Module -Name Pester | Where-Object { $_.Version.Major -eq 5 }) + if ($pester5.Count -eq 0) { + $pester5 = @(Get-Module -ListAvailable -Name Pester | Where-Object { $_.Version.Major -eq 5 }) + } + if ($pester5.Count -eq 0) { + $moduleRoot = Join-Path $PWD '.ci-modules' + Save-Module -Name Pester -RequiredVersion 5.7.1 -Path $moduleRoot + $env:PSModulePath = $moduleRoot + [IO.Path]::PathSeparator + $env:PSModulePath + [IO.File]::AppendAllText($env:GITHUB_ENV, "PSModulePath=$env:PSModulePath`n") + } + . .\Tools\windows\Import-PinnedPester.ps1 + $loaded = @(Get-Module -Name Pester | Where-Object { $_.Version.Major -eq 5 }) + if ($loaded.Count -ne 1) { throw 'Pester' } + + """; + fixture = fixture.Replace(" - name: Run compiled host", + prerequisite.Replace("\r\n", "\n", StringComparison.Ordinal) + " - name: Run compiled host", StringComparison.Ordinal); + cases.Check("control.canonical.valid", () => AssertCanonicalJob(fixture)); + foreach (var (id, before, after) in new[] + { + ("wrong-sdk", "dotnet-version: 10.0.403", "dotnet-version: 10.0.x"), + ("wrong-build-cwd", "working-directory: Tools/windows/ValidationRunner", "working-directory: ."), + ("missing-pester-prerequisite", @". .\Tools\windows\Import-PinnedPester.ps1", "Write-Output 'missing Pester prerequisite'"), + ("filtered-run", "--artifact-root .ci-validation-runner", "--artifact-root .ci-validation-runner --suite ui.activation"), + ("missing-run", @".\Tools\windows\ValidationRunner\bin\Release\net10.0-windows\ValidationRunner.exe", "Write-Output"), + ("missing-complete", "$report.complete", "$report.unused"), + ("missing-positive-count", "$report.requiredCount -le 0", "$report.requiredCount -le -1"), + ("missing-full-count-equality", "$report.passedCount -ne $report.requiredCount", "$report.passedCount -lt 0"), + ("inverted-complete", "-not $report.complete", "$report.complete"), + ("missing-pass-count", "$passMarkers -ne 1", "$passMarkers -ne 0"), + ("missing-executed-count", "$executedMarkers -ne 1", "$executedMarkers -ne 0"), + ("ignored-exit", "$exitCode -ne 0", "$exitCode -ne 1") + }) + cases.Check("control.canonical." + id, () => + { + var mutant = Mutate(fixture, before, after); + Reject(() => AssertCanonicalJob(mutant)); + }); + cases.Check("control.canonical.duplicate-run", () => Reject(() => AssertCanonicalJob(fixture + + "\n .\\Tools\\windows\\ValidationRunner\\bin\\Release\\net10.0-windows\\ValidationRunner.exe --repository-root $PWD --artifact-root .ci-validation-runner\n"))); + } + + private static void Reject(Action body) + { + var rejected = false; + try { body(); } catch (InvalidDataException) { rejected = true; } + SourceCases.Require(rejected, "Deliberate negative control was accepted"); + } + + private static string Mutate(string text, string before, string after) + { + var mutant = text.Replace(before, after, StringComparison.Ordinal); + SourceCases.Require(mutant != text, "Negative control did not change its fixture"); + return mutant; + } +} diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/SuiteRegistration.cs b/Tools/windows/ValidationRunner/Host/CiContracts/SuiteRegistration.cs new file mode 100644 index 00000000..9e4911a0 --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/CiContracts/SuiteRegistration.cs @@ -0,0 +1,104 @@ +using System.IO; +using GraphCode.Validation.Adapters; + +namespace GraphCode.Validation.Host.RootContracts; + +public static class SuiteRegistration +{ + public static IReadOnlyList CreateSuites() => + [ + new RootSuite("host.hardening-source-and-diagnostics", "Host\\Hardening\\Diagnostics.ps1", HardeningCases, + SourceContracts.Hardening, 7), + new RootSuite("host.ci-partition", "Host\\CiContracts\\Partition.ps1", PartitionCases), + new RootSuite("host.ci-aggregate", "Host\\CiContracts\\Aggregate.ps1", AggregateCases, SourceContracts.Aggregate, 8), + new RootSuite("host.ci-topology", null, [], SourceContracts.Topology, 39), + new RootSuite("host.shell-section-gate", "Host\\CiContracts\\ShellSections.ps1", SectionCases), + new RootSuite("host.zig-resolver", "Host\\CiContracts\\ZigResolver.ps1", ResolverCases) + ]; + + private static readonly string[] HardeningCases = + [ + "diagnostics.positions-and-bounds", "diagnostics.end-without-start", "diagnostics.empty-capture", + "payload.complete.plain", "payload.complete.csi", "payload.complete.wrapped", "payload.complete.osc_bell", + "payload.complete.osc_st_inside", "payload.complete.osc_st_outside", + "payload.pending.empty", "payload.pending.missing_end", "payload.pending.missing_start", + "payload.pending.reversed", "payload.pending.unterminated_osc", "payload.completed-empty" + ]; + + private static readonly string[] AggregateCases = + [ + "required.success", "required.skipped", "required.failure", "required.cancelled", "not-required.skipped", + "not-required.failure", "not-required.classifier-failure", "invalid-requirement", "empty-parts" + ]; + + private static readonly string[] PartitionCases = + [ + "workflow.windows-shell.yml.shell-unit.0.0", "workflow.windows-shell.yml.shell-unit.0.1", + "workflow.windows-shell.yml.shell-unit.0.2", "workflow.windows-shell.yml.shell-integration.0", + "workflow.windows-shell.yml.shell-integration.1", "workflow.windows-shell.yml.packaging-contracts.0", + "workflow.windows-shell.yml.packaging-real.0", "workflow.windows-port-validation.yml.investigation-privacy.0", + "workflow.windows-port-validation.yml.spikes-swift.0", "workflow.windows-port-validation.yml.spikes-other.0", + "workflow.windows-hardening.yml.deterministic.0", "coverage.all-listed-tasks", "coverage.all-parts", + "control.complete", "control.missing-integration", "control.missing-unit-shard", "control.missing-packaging-real" + ]; + + private static readonly string[] SectionCases = + [ + "section.reject.zero_all", "section.reject.failed", "section.reject.skipped_only", + "section.positive-two-invocations", "section.reject.missing-second-summary", "section.reject.native-nonzero", + "plan.unsharded-catalog", "plan.complete-disjoint-three-shards", "plan.workflow-agreement", + "manifest.complete", "manifest.missing-section", "manifest.duplicate-section", "manifest.zero-tests", + "manifest.skipped-assignment", "manifest.missing-shard" + ]; + + private static readonly string[] ResolverCases = + [ + "probe.stdout-stderr-native-exit", "probe.launch-failure-nullable-exit", "resolver.configured-success", + "resolver.fallback-order-deduplication", "resolver.native-exit-redacted-fallback", + "resolver.env-exit.invalid", "resolver.env-zero-not-new-gate.invalid", + "resolver.env-exit.json", "resolver.env-zero-not-new-gate.json", + "resolver.launch-exception.version", "resolver.launch-exception.env", + "resolver.writer-failure", "resolver.both-writers-fail", "resolver.both-writers-original-missing-guard", + "diagnostic.unsafe-version.unicode.version", "diagnostic.unsafe-version.unicode.env", + "diagnostic.unsafe-version.over_limit.version", "diagnostic.unsafe-version.over_limit.env", + "diagnostic.candidate.userinfo", "diagnostic.candidate.secret", "diagnostic.candidate.over_limit" + ]; + + private sealed class RootSuite( + string id, string? script, IReadOnlyList cases, + Func? source = null, int sourceCount = 0) : IValidationSuite + { + public string Id => id; + public IReadOnlyList UnitIds => [id]; + + public async Task> RunAsync(ValidationContext context, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var rows = new List(); + if (script is not null) + { + var adapter = new StandaloneSuites.CaseScriptSuite(id, + "Tools\\windows\\ValidationRunner\\" + script, "HOST_CASE_RESULT=", [id]); + var observed = await adapter.RunAsync(context, cancellationToken); + if (observed.Count != 1 || observed[0].Id != id) + throw new InvalidDataException($"{id}: expected exactly one matching adapter result"); + var row = observed[0]; + if (row.ExecutedCount != cases.Count || (row.Status == UnitStatus.Passed && + (row.Groups is null || row.Groups.Count != cases.Count || + !row.Groups.Order(StringComparer.Ordinal).SequenceEqual(cases.Order(StringComparer.Ordinal), StringComparer.Ordinal)))) + row = row with { Status = UnitStatus.Failed, Evidence = row.Evidence + "; exact case inventory/count mismatch" }; + rows.Add(row); + } + if (source is not null) + { + var row = source(context.RepositoryRoot); + if (row.Id != id || row.ExecutedCount != sourceCount) + row = row with { Status = UnitStatus.Failed, Evidence = row.Evidence + "; exact source case inventory/count mismatch" }; + rows.Add(row); + } + return [new UnitResult(id, rows.All(row => row.Status == UnitStatus.Passed) ? UnitStatus.Passed : UnitStatus.Failed, + rows.Sum(row => row.ExecutedCount), string.Join("; ", rows.Select(row => row.Evidence)), + rows.SelectMany(row => row.Groups ?? []).ToArray())]; + } + } +} diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/ZigResolver.ps1 b/Tools/windows/ValidationRunner/Host/CiContracts/ZigResolver.ps1 new file mode 100644 index 00000000..c5491345 --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/CiContracts/ZigResolver.ps1 @@ -0,0 +1,219 @@ +param([Parameter(Mandatory)] [string] $RepositoryRoot, + [Parameter(Mandatory)] [string] $ArtifactRoot, [string] $AssemblyPath) +. (Join-Path $PSScriptRoot 'AdapterSupport.ps1') +$production = Join-Path $RepositoryRoot 'Tools\windows\validate.ps1' +foreach ($name in @('Invoke-ZigResolverProbe', 'Write-ZigResolverDiagnostic', 'Resolve-ZigVersion')) { + . (Get-ProductionFunction $production $name) +} +$canary = 'ghp_PRIVATE_CANARY' +function RootProbeCommand([string] $operation) { + if ($operation -eq 'throw') { throw [ComponentModel.Win32Exception]::new(5, $canary) } + $global:LASTEXITCODE = -1073741502 + '0.15.2' + Write-Error $canary -ErrorAction Continue +} +Test-RootCase 'probe.stdout-stderr-native-exit' { + $result = Invoke-ZigResolverProbe 'RootProbeCommand' 'version' + Assert-Root ($result.Output -ceq '0.15.2' -and $result.ExitCode -eq -1073741502 -and + $result.ErrorText.Contains($canary) -and $null -eq $result.Failure) 'Real probe lost stream separation or native exit' +} +Test-RootCase 'probe.launch-failure-nullable-exit' { + $global:LASTEXITCODE = 123 + $result = Invoke-ZigResolverProbe 'RootProbeCommand' 'throw' + Assert-Root ($null -eq $result.ExitCode -and $null -ne $result.Failure) 'Launch failure reused a stale exit code' +} + +$configured = 'C:\fixture\configured\zig.exe' +$pathCandidate = 'C:\fixture\path\zig.exe' +$discovered = 'C:\fixture\discovered\zig.exe' +$repoRoot = 'C:\fixture\repo' +$environmentName = 'GRAPHCODE_ROOT_ZIG_TEST' +$prior = [Environment]::GetEnvironmentVariable($environmentName) +$state = @{ + Exists = @{}; Probes = @{} + Calls = [Collections.Generic.List[string]]::new() + Lines = [Collections.Generic.List[string]]::new() + Warnings = [Collections.Generic.List[string]]::new() + WriterFails = $false; WarningFails = $false +} +function Get-Command($Name, $ErrorAction) { [pscustomobject]@{ Source = $pathCandidate } } +function Get-ChildItem($Path, [switch] $Recurse, $Filter, [switch] $File, $ErrorAction) { + foreach ($value in @($configured, $discovered)) { [pscustomobject]@{ FullName = $value } } +} +function Test-Path($LiteralPath, $PathType, $ErrorAction) { $state.Exists[$LiteralPath] -eq $true } +function Resolve-Path($LiteralPath) { [pscustomobject]@{ Path = $LiteralPath } } +function Invoke-ZigResolverProbe([string] $candidate, [string] $operation) { + $key = "$candidate|$operation" + $state.Calls.Add($key) + if (-not $state.Probes.ContainsKey($key)) { throw "Unplanned probe $key" } + $state.Probes[$key] +} +function Write-Host($Object) { + if ($state.WriterFails) { throw $canary } + $state.Lines.Add([string]$Object) +} +function Write-Warning($Message, $WarningAction) { + $state.Warnings.Add([string]$Message) + if ($state.WarningFails) { throw $canary } +} +function New-RootProbe($Output, $Exit = 0, $ErrorText = '', $Failure = $null) { + [pscustomobject]@{ Output = $Output; ExitCode = $Exit; ErrorText = $ErrorText; Failure = $Failure } +} +function Reset-RootResolver { + $state.Calls.Clear() + $state.Lines.Clear() + $state.Warnings.Clear() + $state.WriterFails = $false + $state.WarningFails = $false + $state.Exists = @{ $configured = $true; $pathCandidate = $true; $discovered = $true } + $state.Probes = @{} + foreach ($candidate in @($configured, $pathCandidate, $discovered)) { + $state.Probes["$candidate|version"] = New-RootProbe '0.15.2' + $state.Probes["$candidate|env"] = New-RootProbe '{"version":"0.15.2","lib_dir":"C:\\fixture\\lib"}' + } + [Environment]::SetEnvironmentVariable($environmentName, $configured) +} +function Read-RootDiagnostic([int] $Index = 0) { + Assert-Root ($state.Lines.Count -gt $Index) 'Diagnostic was not emitted' + $line = $state.Lines[$Index] + Assert-Root ($line.StartsWith('ZIG_RESOLVER_DIAGNOSTIC ') -and $line.Length -lt 2500 -and + $line -notmatch 'ghp_PRIVATE_CANARY|userinfo|GITHUB_TOKEN') 'Diagnostic tag, bounds, or secret redaction changed' + $line.Substring('ZIG_RESOLVER_DIAGNOSTIC '.Length) | ConvertFrom-Json +} +try { + Test-RootCase 'resolver.configured-success' { + Reset-RootResolver + $result = @(Resolve-ZigVersion '0.15.2' $environmentName) + Assert-Root ($result.Count -eq 1 -and $result[0] -ceq $configured -and $state.Lines.Count -eq 0 -and + ($state.Calls -join ',') -ceq "$configured|version,$configured|env") 'Configured success changed probes, return stream, or diagnostics' + } + Test-RootCase 'resolver.fallback-order-deduplication' { + Reset-RootResolver + $state.Exists[$configured] = $false + $state.Probes["$pathCandidate|version"] = New-RootProbe '0.16.0' + $result = @(Resolve-ZigVersion '0.15.2' $environmentName) + Assert-Root ($result.Count -eq 1 -and $result[0] -ceq $discovered -and + ($state.Calls -join ',') -ceq "$pathCandidate|version,$discovered|version,$discovered|env") 'Fallback order or candidate deduplication changed' + $missing = Read-RootDiagnostic + $mismatch = Read-RootDiagnostic 1 + Assert-Root (-not $missing.exists -and $null -eq $missing.version -and $missing.source -eq 'configured' -and + $mismatch.source -eq 'PATH' -and $mismatch.version.observedVersion -eq '0.16.0' -and + $null -eq $mismatch.env) 'Missing candidate or version mismatch evidence changed' + } + Test-RootCase 'resolver.native-exit-redacted-fallback' { + Reset-RootResolver + $state.Probes["$configured|version"] = New-RootProbe $canary -1073741502 $canary + $result = Resolve-ZigVersion '0.15.2' $environmentName + $diagnostic = Read-RootDiagnostic + Assert-Root ($result -ceq $pathCandidate -and $diagnostic.version.exitCodeHex -eq '0xC0000142' -and + $null -eq $diagnostic.version.observedVersion -and $diagnostic.version.stderr.reason -eq 'unclassified') ` + 'Nonzero exit, fallback, or redaction changed' + } + foreach ($variant in @('invalid', 'json')) { + $text = if ($variant -eq 'invalid') { "invalid $canary" } else { + '{"version":"0.15.2","lib_dir":"C:\\fixture\\absent","env":{"GITHUB_TOKEN":"ghp_PRIVATE_CANARY"}}' + } + Test-RootCase "resolver.env-exit.$variant" { + Reset-RootResolver + $state.Probes["$configured|env"] = New-RootProbe $text 9 "error: unable to find zig installation directory $canary" + $result = Resolve-ZigVersion '0.15.2' $environmentName + $diagnostic = Read-RootDiagnostic + Assert-Root ($result -ceq $pathCandidate -and $diagnostic.reason -eq 'env-exit' -and + $diagnostic.env.exitCode -eq 9 -and $diagnostic.env.stderr.reason -eq 'unable to find zig installation directory') ` + 'Env failure or safe known reason changed' + if ($variant -eq 'invalid') { + Assert-Root ($diagnostic.env.parse -eq 'metadata-unavailable') 'Invalid JSON metadata changed' + } else { + Assert-Root ($diagnostic.env.parse -eq 'parsed' -and $diagnostic.env.libDirectoryPresent -eq $false) 'Missing lib metadata changed' + } + } + Test-RootCase "resolver.env-zero-not-new-gate.$variant" { + Reset-RootResolver + $state.Probes["$configured|env"] = New-RootProbe $text + $result = Resolve-ZigVersion '0.15.2' $environmentName + Assert-Root ($result -ceq $configured -and $state.Lines.Count -eq 0) 'Diagnostic metadata introduced a new acceptance gate' + } + } + foreach ($operation in @('version', 'env')) { + Test-RootCase "resolver.launch-exception.$operation" { + Reset-RootResolver + $errorRecord = [Management.Automation.ErrorRecord]::new( + [Management.Automation.RuntimeException]::new($canary, [ComponentModel.Win32Exception]::new(5, $canary)), + 'Launch', [Management.Automation.ErrorCategory]::OpenError, $null) + $state.Probes["$configured|$operation"] = New-RootProbe $null $null '' $errorRecord + $failure = $null + try { Resolve-ZigVersion '0.15.2' $environmentName | Out-Null } catch { $failure = $_ } + $diagnostic = Read-RootDiagnostic + $expectedCalls = if ($operation -eq 'version') { 1 } else { 2 } + Assert-Root ($null -ne $failure -and $failure.ToString() -notmatch $canary -and + $state.Calls.Count -eq $expectedCalls -and $diagnostic.reason -eq "$operation-exception" -and + $null -eq $diagnostic.$operation.exitCode -and $diagnostic.$operation.nativeErrorCode -eq 5) ` + 'Launch exception did not stop safely with nullable exit and native error' + } + } + Test-RootCase 'resolver.writer-failure' { + Reset-RootResolver + $state.Probes["$configured|version"] = New-RootProbe '0.16.0' + $state.WriterFails = $true + $result = Resolve-ZigVersion '0.15.2' $environmentName + Assert-Root ($result -ceq $pathCandidate -and $state.Warnings.Count -eq 1) 'Writer failure changed fallback' + } + Test-RootCase 'resolver.both-writers-fail' { + Reset-RootResolver + $state.Probes["$configured|version"] = New-RootProbe '0.16.0' + $state.WriterFails = $true + $state.WarningFails = $true + $result = @(Resolve-ZigVersion '0.15.2' $environmentName) + Assert-Root ($result.Count -eq 1 -and $result[0] -ceq $pathCandidate -and + ($state.Calls -join ',') -ceq "$configured|version,$pathCandidate|version,$pathCandidate|env") 'Both writers changed selection' + } + Test-RootCase 'resolver.both-writers-original-missing-guard' { + Reset-RootResolver + $state.WriterFails = $true + $state.WarningFails = $true + $state.Exists.Clear() + $failure = $null + try { Resolve-ZigVersion '0.15.2' $environmentName | Out-Null } catch { $failure = $_ } + Assert-Root ($null -ne $failure -and + $failure.ToString() -ceq "Zig 0.15.2 is required for the pinned Windows provider; set $environmentName.") 'Diagnostics masked the missing Zig guard' + } + $unsafeVersions = [ordered]@{ + unicode = ([string][char]0x0661) + '.2.3' + over_limit = '1.2.3+' + ('a' * 65) + } + foreach ($entry in $unsafeVersions.GetEnumerator()) { + foreach ($operation in @('version', 'env')) { + Test-RootCase "diagnostic.unsafe-version.$($entry.Key).$operation" { + Reset-RootResolver + $text = if ($operation -eq 'version') { $entry.Value } else { @{ version = $entry.Value } | ConvertTo-Json -Compress } + $probe = New-RootProbe $text 1 + if ($operation -eq 'version') { + Write-ZigResolverDiagnostic $configured 'configured' '0.15.2' $true 'version-exit' $probe $null + } else { + Write-ZigResolverDiagnostic $configured 'configured' '0.15.2' $true 'env-exit' (New-RootProbe '0.15.2') $probe + } + $diagnostic = Read-RootDiagnostic + $summary = $diagnostic.$operation + Assert-Root ($null -eq $summary.observedVersion -and + $summary.stdout.bytes -eq [Text.Encoding]::UTF8.GetByteCount($text) -and + $summary.stdout.sha256.Length -eq 64 -and -not $state.Lines[0].Contains($entry.Value)) 'Unsafe version was not reduced to exact byte count/hash' + } + } + } + $unsafeCandidates = [ordered]@{ + userinfo = 'https://userinfo@github.com/zig' + secret = 'C:\ghp_PRIVATE_CANARY\zig.exe' + over_limit = 'C:\' + ('x' * 520) + } + foreach ($entry in $unsafeCandidates.GetEnumerator()) { + Test-RootCase "diagnostic.candidate.$($entry.Key)" { + Reset-RootResolver + Write-ZigResolverDiagnostic $entry.Value 'configured' '0.15.2' $true 'version-exit' (New-RootProbe ($canary * 2000) 1) $null + $diagnostic = Read-RootDiagnostic + Assert-Root ($diagnostic.candidate -ceq '[omitted]' -and $diagnostic.version.stdout.bytes -gt 16384) 'Candidate redaction or output cap changed' + } + } +} finally { + [Environment]::SetEnvironmentVariable($environmentName, $prior) +} +Write-RootSummary 'host.zig-resolver' diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/manifest-fragment.json b/Tools/windows/ValidationRunner/Host/CiContracts/manifest-fragment.json new file mode 100644 index 00000000..b027898c --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/CiContracts/manifest-fragment.json @@ -0,0 +1,10 @@ +{ + "units": [ + { "id": "host.hardening-source-and-diagnostics", "owner": "Host", "minimumCount": 22, "exactCount": 22 }, + { "id": "host.ci-partition", "owner": "Host", "minimumCount": 17, "exactCount": 17 }, + { "id": "host.ci-aggregate", "owner": "Host", "minimumCount": 17, "exactCount": 17 }, + { "id": "host.ci-topology", "owner": "Host", "minimumCount": 39, "exactCount": 39 }, + { "id": "host.shell-section-gate", "owner": "Host", "minimumCount": 15, "exactCount": 15 }, + { "id": "host.zig-resolver", "owner": "Host", "minimumCount": 21, "exactCount": 21 } + ] +} diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/source-correspondence.json b/Tools/windows/ValidationRunner/Host/CiContracts/source-correspondence.json new file mode 100644 index 00000000..6c65c75d --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/CiContracts/source-correspondence.json @@ -0,0 +1,89 @@ +{ + "schemaVersion": 1, + "baselineManifestSha256": "CC8ED5141AE38CB425F4E41AB7F2E4EF1F07F5AE4CAD7FA969A68645B07A0A2F", + "baselineSource": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "inventoryIsExecutionEvidence": false, + "executionEvidence": "UnitResult.executedCount and groups, plus per-adapter case JSON written beneath ArtifactRoot", + "families": [ + { + "unit": "host.hardening-source-and-diagnostics", + "expectedExpandedCount": 22, + "production": "Tools/windows/Tests/Hardening.Tests.ps1", + "cases": [ + { "originalLines": "5144-5155", "ids": ["source.full-pinned-real-hardening", "source.owned-environment", "source.native-exit.terminal", "source.native-exit.shell"] }, + { "originalLines": "5170-5176", "ids": ["diagnostics.positions-and-bounds"] }, + { "originalLines": "5177-5180", "ids": ["diagnostics.end-without-start"] }, + { "originalLines": "5181-5186", "ids": ["diagnostics.empty-capture"] }, + { "originalLines": "5188-5200", "ids": ["payload.complete.plain", "payload.complete.csi", "payload.complete.wrapped", "payload.complete.osc_bell", "payload.complete.osc_st_inside", "payload.complete.osc_st_outside"] }, + { "originalLines": "5201-5205", "ids": ["payload.pending.empty", "payload.pending.missing_end", "payload.pending.missing_start", "payload.pending.reversed", "payload.pending.unterminated_osc"] }, + { "originalLines": "5206-5210", "ids": ["payload.completed-empty"] }, + { "originalLines": "5211-5222", "ids": ["source.high-output-failure-diagnostics", "source.repeat-preserves-child-diagnostics", "source.release-shell-version"] } + ] + }, + { + "unit": "host.ci-partition", + "expectedExpandedCount": 17, + "production": "Tools/windows/validate.ps1 -List and -DryRun, with actual PR workflow invocations expanded over the declared matrix", + "cases": [ + { "originalLines": "4384-4403", "ids": ["workflow.windows-shell.yml.shell-unit.0.0", "workflow.windows-shell.yml.shell-unit.0.1", "workflow.windows-shell.yml.shell-unit.0.2", "workflow.windows-shell.yml.shell-integration.0", "workflow.windows-shell.yml.shell-integration.1", "workflow.windows-shell.yml.packaging-contracts.0", "workflow.windows-shell.yml.packaging-real.0", "workflow.windows-port-validation.yml.investigation-privacy.0", "workflow.windows-port-validation.yml.spikes-swift.0", "workflow.windows-port-validation.yml.spikes-other.0", "workflow.windows-hardening.yml.deterministic.0"] }, + { "originalLines": "4404-4410", "ids": ["coverage.all-listed-tasks"] }, + { "originalLines": "4413-4432", "ids": ["coverage.all-parts"] }, + { "originalLines": null, "purpose": "New positive and deletion controls for the migrated partition predicate", "ids": ["control.complete", "control.missing-integration", "control.missing-unit-shard", "control.missing-packaging-real"] } + ] + }, + { + "unit": "host.ci-aggregate", + "expectedExpandedCount": 17, + "production": "Tools/windows/Assert-CiPartResults.ps1", + "cases": [ + { "originalLines": "4434-4457", "ids": ["source.aggregate.windows-shell", "source.aggregate.windows-spikes"] }, + { "originalLines": "4459-4475", "ids": ["required.success", "required.skipped", "required.failure", "required.cancelled", "not-required.skipped", "not-required.failure", "not-required.classifier-failure"] }, + { "originalLines": null, "purpose": "Additional actual producer refusal cases", "ids": ["invalid-requirement", "empty-parts"] }, + { "originalLines": null, "purpose": "New positive/mutation controls for compiled aggregate source contracts", "ids": ["control.aggregate.valid", "control.aggregate.missing-part", "control.aggregate.renamed-required-check", "control.aggregate.success-only", "control.aggregate.missing-needs-json", "control.aggregate.missing-required-argument"] } + ] + }, + { + "unit": "host.ci-topology", + "expectedExpandedCount": 39, + "production": ".github/workflows/windows-*.yml and Tools/windows/Invoke-ValidationRunnerContract.ps1", + "cases": [ + { "originalLines": "5233-5257", "ids": ["source.cold-cache.spikes-swift", "source.cold-cache.spikes-other", "source.cold-cache.packaging-real", "source.runner-topology-timeout"] }, + { "originalLines": null, "purpose": "Hosted artifact loss regression: exact compiled evidence root must opt into hidden files; both missing and explicit false options are rejected", "ids": ["source.runner-hidden-evidence-scoped", "control.evidence.hidden-files-missing", "control.evidence.hidden-files-false"] }, + { "originalLines": "5258-5273", "ids": ["source.warmer-key.zig", "source.warmer-key.providers", "source.warmer-events-and-build-only"] }, + { "originalLines": "5277-5282", "ids": ["source.uia-diagnostics.shell", "source.uia-diagnostics.port"] }, + { "originalLines": "5283-5291", "ids": ["source.pinned-swift.shell", "source.pinned-swift.port", "source.pinned-swift.hardening"] }, + { "originalLines": "5292-5307", "ids": ["source.shell-bootstrap", "source.hosted-explicit-skip-limits", "source.shell-live-task"] }, + { "originalSource": "Tools/windows/Tests/WindowsCiTopology.Tests.ps1:39-115", "purpose": "Preserve scheduling and required names; replace old monolithic execution assertions with the compiled host contract", "ids": ["source.runner-single-win2022-fail-closed", "source.runner-no-providers-or-hidden-failure", "source.runner-new-sdk-build-execution", "source.runner-port-exactly-once", "source.runner-aggregate-required", "source.runner-hardening-single-fallback", "source.runner-compatibility-no-monolith"] }, + { "originalLines": null, "purpose": "New SDK/CWD/Pester-prerequisite/full-execution/coverage/exit/marker positive and negative controls; not executions of GitHub Actions", "ids": ["control.canonical.valid", "control.canonical.wrong-sdk", "control.canonical.wrong-build-cwd", "control.canonical.missing-pester-prerequisite", "control.canonical.filtered-run", "control.canonical.missing-run", "control.canonical.missing-complete", "control.canonical.missing-positive-count", "control.canonical.missing-full-count-equality", "control.canonical.inverted-complete", "control.canonical.missing-pass-count", "control.canonical.missing-executed-count", "control.canonical.ignored-exit", "control.canonical.duplicate-run"] } + ] + }, + { + "unit": "host.shell-section-gate", + "expectedExpandedCount": 15, + "production": "Tools/windows/Tests/WindowsShell.Tests.ps1 and Tools/windows/Test-ShellSectionManifests.ps1", + "cases": [ + { "originalLines": "4739-4750", "ids": ["section.reject.zero_all", "section.reject.failed", "section.reject.skipped_only"] }, + { "originalLines": "4751-4761", "ids": ["section.positive-two-invocations"] }, + { "originalLines": null, "purpose": "Additional refusal cases against the actual section runner", "ids": ["section.reject.missing-second-summary", "section.reject.native-nonzero"] }, + { "originalLines": "4764-4783", "ids": ["plan.unsharded-catalog", "plan.complete-disjoint-three-shards", "plan.workflow-agreement"] }, + { "originalLines": "4788-4825", "ids": ["manifest.complete", "manifest.missing-section", "manifest.duplicate-section", "manifest.zero-tests", "manifest.skipped-assignment", "manifest.missing-shard"] } + ] + }, + { + "unit": "host.zig-resolver", + "expectedExpandedCount": 21, + "production": "Tools/windows/validate.ps1: Invoke-ZigResolverProbe, Write-ZigResolverDiagnostic, Resolve-ZigVersion", + "cases": [ + { "originalLines": "4851-4862", "ids": ["probe.stdout-stderr-native-exit", "probe.launch-failure-nullable-exit"] }, + { "originalLines": "4926-4929", "ids": ["resolver.configured-success"] }, + { "originalLines": "4930-4939", "ids": ["resolver.fallback-order-deduplication"] }, + { "originalLines": "4941-4946", "ids": ["resolver.native-exit-redacted-fallback"] }, + { "originalLines": "4947-4963", "ids": ["resolver.env-exit.invalid", "resolver.env-zero-not-new-gate.invalid", "resolver.env-exit.json", "resolver.env-zero-not-new-gate.json"] }, + { "originalLines": "4965-4980", "ids": ["resolver.launch-exception.version", "resolver.launch-exception.env"] }, + { "originalLines": "4981-4993", "ids": ["resolver.writer-failure", "resolver.both-writers-fail", "resolver.both-writers-original-missing-guard"] }, + { "originalLines": "4995-5016", "ids": ["diagnostic.unsafe-version.unicode.version", "diagnostic.unsafe-version.unicode.env", "diagnostic.unsafe-version.over_limit.version", "diagnostic.unsafe-version.over_limit.env"] }, + { "originalLines": "5017-5023", "ids": ["diagnostic.candidate.userinfo", "diagnostic.candidate.secret", "diagnostic.candidate.over_limit"] } + ] + } + ] +} diff --git a/Tools/windows/ValidationRunner/Host/ConsoleReport.cs b/Tools/windows/ValidationRunner/Host/ConsoleReport.cs new file mode 100644 index 00000000..193f0fa5 --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/ConsoleReport.cs @@ -0,0 +1,18 @@ +using System.Text.RegularExpressions; + +namespace GraphCode.Validation.Host; + +public static class ConsoleReport +{ + public static bool ValidId(string? id) => + id is not null && Regex.IsMatch(id, "^[a-z0-9][a-z0-9.-]{0,160}\\z", RegexOptions.CultureInvariant); + + public static string Unit(UnitResult result) => + $"{result.Status}: {(ValidId(result.Id) ? result.Id : "[invalid-id]")} executed={result.ExecutedCount}"; + + public static string Error(int index) => $"ERROR {index}: details retained in coverage.json"; + + public static string SuiteFailure(string suiteId, string exceptionType) => + $"SUITE_FAILURE id={(ValidId(suiteId) ? suiteId : "[invalid-id]")} " + + $"exception={(Regex.IsMatch(exceptionType, "^[A-Za-z_][A-Za-z0-9_.+`]{0,255}\\z", RegexOptions.CultureInvariant) ? exceptionType : "[invalid-type]")}; details retained in coverage.json"; +} diff --git a/Tools/windows/ValidationRunner/Host/Coverage.cs b/Tools/windows/ValidationRunner/Host/Coverage.cs new file mode 100644 index 00000000..72070264 --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/Coverage.cs @@ -0,0 +1,253 @@ +using System.Text.Json; +using System.IO; +using System.Text.Json.Serialization; + +namespace GraphCode.Validation.Host; + +public sealed record RequiredUnit( + string Id, string Owner, int MinimumCount, IReadOnlyList? RequiredGroups = null, + int? ExactCount = null, IReadOnlyList? CompositeOf = null, + int? SourceTemplateCount = null, string? SourceMap = null); + +public sealed record CompositeTemplate(string SourceId, IReadOnlyList UnitIds, int ExpectedCount); +public sealed record CompositeSourceMap(IReadOnlyList Templates); + +public sealed record UnitManifest(IReadOnlyList Units); + +public sealed record CoverageReport( + string Runner, bool Complete, int RequiredCount, int PassedCount, + IReadOnlyList Units, IReadOnlyList Errors, + long ActualExecutedCount = 0, int DerivedProofCount = 0, + bool FullInvocation = false, IReadOnlyList? SelectedSuites = null); + +public static class Coverage +{ + public static CoverageReport ForInvocation(CoverageReport report, IReadOnlyCollection selectedSuites) => + report with + { + Complete = report.Complete && selectedSuites.Count == 0, + FullInvocation = selectedSuites.Count == 0, + SelectedSuites = selectedSuites.Order(StringComparer.Ordinal).ToArray(), + Errors = report.Complete && selectedSuites.Count != 0 + ? [.. report.Errors, "A selected diagnostic invocation is not canonical full-run proof"] + : report.Errors + }; + + public static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = false, + WriteIndented = true, + RespectRequiredConstructorParameters = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + Converters = { new JsonStringEnumConverter() } + }; + + public static IReadOnlyList Load(string directory) + { + var units = new List(); + if (!Directory.Exists(directory)) throw new DirectoryNotFoundException(directory); + foreach (var name in new[] { "host.json", "ui.json", "ownership.json" }) + { + var path = Path.Combine(directory, name); + if (!File.Exists(path)) + { + units.Add(new RequiredUnit($"missing-manifest.{name}", "Host", 1)); + continue; + } + var manifest = JsonSerializer.Deserialize(File.ReadAllText(path), JsonOptions) + ?? throw new InvalidDataException($"Null required manifest: {path}"); + units.AddRange(manifest.Units); + } + return units; + } + + public static CoverageReport Evaluate( + IReadOnlyList required, + IReadOnlyList suites, + IReadOnlyList results, + IReadOnlyList? executionErrors = null, + string? sourceDirectory = null) + { + var errors = new List(executionErrors ?? []); + var ledger = new List(); + var (requirements, executors, definitionErrors) = Definitions(required, suites); + errors.AddRange(definitionErrors); + var observed = new Dictionary>(StringComparer.Ordinal); + foreach (var result in results) + { + if (!requirements.ContainsKey(result.Id)) errors.Add($"Undeclared result unit: {result.Id}"); + if (requirements.TryGetValue(result.Id, out var declaration) && declaration.CompositeOf is not null) + errors.Add($"Composite evidence must be derived by the host, not submitted: {result.Id}"); + if (!observed.TryGetValue(result.Id, out var rows)) + { + rows = []; + observed.Add(result.Id, rows); + } + rows.Add(result); + } + foreach (var (id, unit) in requirements.Where(pair => pair.Value.CompositeOf is null)) + { + var count = executors.GetValueOrDefault(id); + if (count != 1) + { + var detail = count == 0 ? "Executor is not implemented" : $"Duplicate executors: {count}"; + errors.Add($"{id}: {detail}"); + ledger.Add(new UnitResult(id, UnitStatus.Incomplete, 0, detail)); + continue; + } + if (!observed.TryGetValue(id, out var rows) || rows.Count != 1) + { + var detail = rows is null ? "Mandatory unit did not run" : $"Duplicate results: {rows.Count}"; + errors.Add($"{id}: {detail}"); + ledger.Add(new UnitResult(id, UnitStatus.Incomplete, 0, detail)); + continue; + } + ledger.Add(ValidateResult(unit, rows[0], errors)); + } + foreach (var unit in requirements.Values.Where(unit => unit.CompositeOf is not null)) + { + try + { + if (executors.GetValueOrDefault(unit.Id) != 1 || observed.ContainsKey(unit.Id)) + throw new InvalidDataException("A composite has a duplicate or directly submitted executor/result"); + var composite = DeriveComposite(unit, ledger, sourceDirectory); + ledger.Add(ValidateResult(unit, composite, errors)); + } + catch (Exception error) when (error is InvalidDataException or IOException or JsonException or ArgumentException or OverflowException) + { + errors.Add($"{unit.Id}: {error.Message}"); + ledger.Add(new UnitResult(unit.Id, UnitStatus.Incomplete, 0, "Composite proof is incomplete; see coverage errors")); + } + } + var passed = ledger.Count(result => result.Status == UnitStatus.Passed); + return new CoverageReport("CompiledValidationRunner", errors.Count == 0 && passed > 0 && + passed == required.Count, required.Count, passed, ledger, errors, + ledger.Where(result => requirements[result.Id].CompositeOf is null && result.ExecutedCount > 0) + .Sum(result => (long)result.ExecutedCount), + ledger.Count(result => requirements[result.Id].CompositeOf is not null && result.Status == UnitStatus.Passed)); + } + + public static IReadOnlyList RegistryErrors(IReadOnlyList required, IReadOnlyList suites) => + Definitions(required, suites).Errors; + + private static (Dictionary Requirements, Dictionary Executors, List Errors) + Definitions(IReadOnlyList required, IReadOnlyList suites) + { + var errors = new List(); + var requirements = new Dictionary(StringComparer.Ordinal); + if (required.Count == 0) errors.Add("Required-unit manifest is empty"); + foreach (var unit in required) + { + if (unit is null || unit.Id is null) + { + errors.Add("Required-unit manifest contains a null unit or ID"); + continue; + } + if (!ConsoleReport.ValidId(unit.Id) || string.IsNullOrWhiteSpace(unit.Owner) || + unit.MinimumCount <= 0 || (unit.ExactCount is not null && unit.ExactCount < unit.MinimumCount)) + errors.Add($"Invalid required unit: {unit.Id}"); + if (!requirements.TryAdd(unit.Id, unit)) errors.Add($"Duplicate required unit: {unit.Id}"); + if (unit.RequiredGroups is not null && + (unit.RequiredGroups.Any(string.IsNullOrWhiteSpace) || + unit.RequiredGroups.Distinct(StringComparer.Ordinal).Count() != unit.RequiredGroups.Count)) + errors.Add($"Invalid required groups: {unit.Id}"); + if (unit.CompositeOf is not null && + (unit.CompositeOf.Count == 0 || unit.CompositeOf.Distinct(StringComparer.Ordinal).Count() != unit.CompositeOf.Count || + unit.CompositeOf.Contains(unit.Id, StringComparer.Ordinal) || unit.SourceTemplateCount is null or <= 0 || + string.IsNullOrWhiteSpace(unit.SourceMap))) + errors.Add($"Invalid composite declaration: {unit.Id}"); + } + var executors = new Dictionary(StringComparer.Ordinal); + foreach (var unit in requirements.Values.Where(unit => unit.CompositeOf is not null)) + { + executors[unit.Id] = 1; + foreach (var id in unit.CompositeOf!) + if (!requirements.TryGetValue(id, out var dependency) || dependency.CompositeOf is not null) + errors.Add($"Composite dependency is unknown or nested: {id}"); + } + var suiteNames = new HashSet(StringComparer.Ordinal); + foreach (var suite in suites) + { + if (string.IsNullOrWhiteSpace(suite.Id) || !suiteNames.Add(suite.Id)) + errors.Add($"Invalid or duplicate suite ID: {suite.Id}"); + if (suite.UnitIds.Count == 0) errors.Add($"Suite has no units: {suite.Id}"); + foreach (var id in suite.UnitIds) + { + if (!ConsoleReport.ValidId(id)) + { + errors.Add($"Invalid executor unit ID in suite: {suite.Id}"); + continue; + } + if (!requirements.ContainsKey(id)) errors.Add($"Undeclared executor unit: {id}"); + executors[id] = executors.GetValueOrDefault(id) + 1; + } + } + return (requirements, executors, errors); + } + + private static UnitResult ValidateResult(RequiredUnit unit, UnitResult result, List errors) + { + var problems = new List(); + if (result.Status != UnitStatus.Passed) problems.Add($"Status is {result.Status}"); + if (result.ExecutedCount < unit.MinimumCount) problems.Add($"Executed {result.ExecutedCount}; minimum {unit.MinimumCount}"); + if (unit.ExactCount is not null && result.ExecutedCount != unit.ExactCount) + problems.Add($"Executed {result.ExecutedCount}; expected exactly {unit.ExactCount}"); + if (string.IsNullOrWhiteSpace(result.Evidence)) problems.Add("No execution evidence"); + foreach (var group in unit.RequiredGroups ?? []) + if ((result.Groups ?? []).Count(value => string.Equals(value, group, StringComparison.Ordinal)) != 1) + problems.Add($"Required group did not pass exactly once: {group}"); + if (problems.Count != 0) + { + errors.Add($"{unit.Id}: {string.Join("; ", problems)}"); + return result with + { + Status = result.Status is UnitStatus.Incomplete or UnitStatus.Skipped ? result.Status : UnitStatus.Failed, + Evidence = $"{result.Evidence}; {string.Join("; ", problems)}" + }; + } + return result; + } + + private static UnitResult DeriveComposite(RequiredUnit unit, IReadOnlyList ledger, string? sourceDirectory) + { + if (unit.CompositeOf is null || sourceDirectory is null || unit.SourceMap is null || Path.IsPathRooted(unit.SourceMap)) + throw new InvalidDataException("Composite declaration/source directory is missing or invalid"); + var directory = Path.TrimEndingDirectorySeparator(Path.GetFullPath(sourceDirectory)); + var path = Path.GetFullPath(Path.Combine(directory, unit.SourceMap)); + if (!path.StartsWith(directory + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("Composite source map escapes the current source directory"); + var map = JsonSerializer.Deserialize(File.ReadAllText(path), JsonOptions) + ?? throw new InvalidDataException("Composite source map is null"); + if (map.Templates is null || map.Templates.Count != unit.SourceTemplateCount || + map.Templates.Any(template => template is null || string.IsNullOrWhiteSpace(template.SourceId) || template.UnitIds is null || + template.UnitIds.Count == 0 || template.ExpectedCount <= 0) || + map.Templates.Select(template => template.SourceId).Distinct(StringComparer.Ordinal).Count() != map.Templates.Count) + throw new InvalidDataException("Composite source templates are missing, duplicate, or malformed"); + var mapped = map.Templates.SelectMany(template => template.UnitIds).ToArray(); + if (mapped.Distinct(StringComparer.Ordinal).Count() != mapped.Length || + mapped.Length != unit.CompositeOf.Count || + !mapped.ToHashSet(StringComparer.Ordinal).SetEquals(unit.CompositeOf)) + throw new InvalidDataException("Composite dependencies do not correspond exactly once to every source template"); + var dependencies = new Dictionary(StringComparer.Ordinal); + foreach (var id in unit.CompositeOf) + { + var matches = ledger.Where(result => result.Id == id).ToArray(); + if (matches.Length != 1 || matches[0].Status != UnitStatus.Passed || matches[0].ExecutedCount <= 0) + throw new InvalidDataException("A mandatory composite dependency did not pass exactly once"); + dependencies.Add(id, matches[0]); + } + foreach (var template in map.Templates) + if (template.UnitIds.Sum(id => dependencies[id].ExecutedCount) != template.ExpectedCount) + throw new InvalidDataException("A source template did not execute its exact original expanded case count"); + var groups = dependencies.Values.SelectMany(result => result.Groups ?? []).Distinct(StringComparer.Ordinal).ToArray(); + return new UnitResult(unit.Id, UnitStatus.Passed, dependencies.Values.Sum(result => result.ExecutedCount), + $"Derived from {map.Templates.Count} independently mapped original source templates and actual dependency receipts", groups); + } + + public static void ValidateProvenance(IValidationSuite suite, IReadOnlyList results) + { + if (results.Any(result => !suite.UnitIds.Contains(result.Id, StringComparer.Ordinal))) + throw new InvalidDataException("A suite returned evidence for a unit it does not own"); + } +} diff --git a/Tools/windows/ValidationRunner/Host/Fixtures/ShellStreamFixtures.cs b/Tools/windows/ValidationRunner/Host/Fixtures/ShellStreamFixtures.cs new file mode 100644 index 00000000..f515d014 --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/Fixtures/ShellStreamFixtures.cs @@ -0,0 +1,64 @@ +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; + +namespace GraphCode.Validation.TestFixtures; + +public sealed class ControlledOutputReader(string prefix) : TextReader +{ + private int position; + public string Tail = ""; + public TaskCompletionSource End = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public override Task ReadAsync(char[] buffer, int offset, int count) + { + if (position < prefix.Length) + { + var length = Math.Min(count, prefix.Length - position); + prefix.CopyTo(position, buffer, offset, length); + position += length; + return Task.FromResult(length); + } + return ReadNext(buffer, offset); + } + + private async Task ReadNext(char[] buffer, int offset) + { + var length = await End.Task.ConfigureAwait(false); + if (length > 0) + { + Tail.CopyTo(0, buffer, offset, length); + End = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + return length; + } +} + +public sealed class FailingForwardWriter : TextWriter +{ + public bool FailFlush; + public override Encoding Encoding => Encoding.UTF8; + public override void Write(char[] buffer, int offset, int count) + { + if (!FailFlush) throw new IOException("forward write failed"); + } + + public override void Flush() + { + if (FailFlush) throw new IOException("forward flush failed"); + } +} + +public sealed class FailingRetentionSentinelWriter : TextWriter +{ + public override Encoding Encoding => Encoding.UTF8; + public override void WriteLine(string? value) => throw new IOException("controlled sentinel write failure"); +} + +public sealed class FailingRetentionSentinelFlush : TextWriter +{ + public override Encoding Encoding => Encoding.UTF8; + public override void WriteLine(string? value) { } + public override void Flush() => throw new IOException("controlled sentinel flush failure"); +} diff --git a/Tools/windows/ValidationRunner/Host/Hardening/Diagnostics.ps1 b/Tools/windows/ValidationRunner/Host/Hardening/Diagnostics.ps1 new file mode 100644 index 00000000..754adc94 --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/Hardening/Diagnostics.ps1 @@ -0,0 +1,54 @@ +param([Parameter(Mandatory)] [string] $RepositoryRoot, + [Parameter(Mandatory)] [string] $ArtifactRoot, [string] $AssemblyPath) +. (Join-Path $PSScriptRoot '..\CiContracts\AdapterSupport.ps1') +$production = Join-Path $RepositoryRoot 'Tools\windows\Tests\Hardening.Tests.ps1' +foreach ($name in @('Find-Bytes', 'Get-HighOutputDiagnostics', 'Get-HighOutputPayloadText')) { + . (Get-ProductionFunction $production $name) +} + +Test-RootCase 'diagnostics.positions-and-bounds' { + $bytes = [Text.Encoding]::ASCII.GetBytes('START' + ('A' * 1024) + 'END') + $actual = Get-HighOutputDiagnostics $bytes 'START' 'END' + Assert-Root ($actual.capturedBytes -eq 1032 -and $actual.startOffset -eq 0 -and + $actual.endOffset -eq 1029 -and $actual.prefix.Length -eq 512 -and $actual.suffix.Length -eq 512) ` + 'Captured byte count, marker offsets, or bounded excerpts changed' +} +Test-RootCase 'diagnostics.end-without-start' { + $actual = Get-HighOutputDiagnostics ([Text.Encoding]::ASCII.GetBytes('END')) 'START' 'END' + Assert-Root ($actual.startOffset -eq -1 -and $actual.endOffset -eq 0) 'Missing start hid the end marker' +} +Test-RootCase 'diagnostics.empty-capture' { + $actual = Get-HighOutputDiagnostics ([byte[]]::new(0)) 'START' 'END' + Assert-Root ($actual.capturedBytes -eq 0 -and $actual.startOffset -eq -1 -and + $actual.endOffset -eq -1 -and $actual.prefix -ceq '' -and $actual.suffix -ceq '') 'Empty capture changed' +} +$esc = [string][char]27 +$complete = [ordered]@{ + plain = 'STARTAAAAEND' + csi = "ST${esc}[0mARTAA${esc}[31mAAEN${esc}[0mD" + wrapped = "STA`r`nRTAAAAE`r`nND" + osc_bell = "START${esc}]0;END$([char]7)AAAAEND" + osc_st_inside = "ST${esc}]0;title${esc}\ARTAAAAEND" + osc_st_outside = "${esc}]0;before${esc}\STARTAAAAEND${esc}]0;after${esc}\" +} +foreach ($entry in $complete.GetEnumerator()) { + Test-RootCase "payload.complete.$($entry.Key)" { + $actual = Get-HighOutputPayloadText ([Text.Encoding]::ASCII.GetBytes($entry.Value)) 'START' 'END' + Assert-Root ($actual -ceq 'AAAA') 'Terminal framing corrupted completion payload' + } +} +$incomplete = [ordered]@{ + empty = ''; missing_end = 'STARTAAAA'; missing_start = 'AAAAEND' + reversed = 'ENDSTARTAAAA'; unterminated_osc = "${esc}]0;STARTAAAAEND" +} +foreach ($entry in $incomplete.GetEnumerator()) { + Test-RootCase "payload.pending.$($entry.Key)" { + $actual = Get-HighOutputPayloadText ([Text.Encoding]::ASCII.GetBytes($entry.Value)) 'START' 'END' + Assert-Root ($null -eq $actual) 'Incomplete markers were accepted' + } +} +Test-RootCase 'payload.completed-empty' { + $actual = Get-HighOutputPayloadText ([Text.Encoding]::ASCII.GetBytes('STARTEND')) 'START' 'END' + Assert-Root ($null -ne $actual -and $actual -ceq '') 'Empty completion was confused with pending output' +} +Write-RootSummary 'host.hardening-source-and-diagnostics' diff --git a/Tools/windows/ValidationRunner/Host/HostFixture.cs b/Tools/windows/ValidationRunner/Host/HostFixture.cs new file mode 100644 index 00000000..c5c41313 --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/HostFixture.cs @@ -0,0 +1,28 @@ +using System.IO; +using System.Text.Json; + +namespace GraphCode.Validation.Host; + +public static class HostFixture +{ + public static int? TryRun(string[] args) + { + if (args.Length == 0 || args[0] != "--host-fixture") return null; + if (args.Length != 4 || args[1] != "provider-compiler" || args[2] != "build" || + args[3] is not ("-Demit-win32-host=true" or "-Dtarget=x86_64-windows-gnu")) + throw new ArgumentException("Unknown controlled host fixture invocation"); + var log = Environment.GetEnvironmentVariable("GRAPHCODE_PROVIDER_COMPILER_LOG") + ?? throw new InvalidOperationException("Controlled provider compiler log is required"); + File.AppendAllText(log, JsonSerializer.Serialize(args[2..]) + "\n"); + var behavior = Environment.GetEnvironmentVariable("GRAPHCODE_PROVIDER_COMPILER_BEHAVIOR"); + if (behavior == "fail") return 7; + if (behavior == "missing") return 0; + if (!string.IsNullOrEmpty(behavior)) throw new ArgumentException("Unknown controlled compiler behavior"); + var artifact = args[3] == "-Demit-win32-host=true" + ? Path.Combine("zig-out", "lib", "winghostty-win32-host.lib") + : Path.Combine("zig-out", "bin", "zmx.exe"); + Directory.CreateDirectory(Path.GetDirectoryName(artifact)!); + File.WriteAllBytes(artifact, []); + return 0; + } +} diff --git a/Tools/windows/ValidationRunner/Host/HostSelfTests.cs b/Tools/windows/ValidationRunner/Host/HostSelfTests.cs new file mode 100644 index 00000000..0e3e23b4 --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/HostSelfTests.cs @@ -0,0 +1,240 @@ +using GraphCode.Validation.Adapters; +using System.IO; +using System.Text.Json; + +namespace GraphCode.Validation.Host; + +public static class HostSelfTests +{ + public static IValidationSuite CreateSuite() => new AdmissionSuite(); + + public static int Run(Action? completed = null) + { + var executed = 0; + var errors = new List(); + void Check(string id, Action body) + { + executed++; + try { body(); } + catch (Exception error) { errors.Add($"{id}: {error.Message}"); } + } + static void Assert(bool condition, string evidence) + { + if (!condition) throw new InvalidOperationException(evidence); + } + var unit = new RequiredUnit("test.case", "Host", 1); + var suite = new StaticSuite("test", ["test.case"]); + var passed = new UnitResult("test.case", UnitStatus.Passed, 1, "actual assertion ran"); + CoverageReport Evaluate(IReadOnlyList? units = null, + IReadOnlyList? registry = null, + IReadOnlyList? results = null) => + Coverage.Evaluate(units ?? [unit], registry ?? [suite], results ?? [passed]); + + Check("positive", () => Assert(Evaluate().Complete, "positive executed case rejected")); + Check("normal-full-mode", () => Assert(Coverage.ForInvocation(Evaluate(), []).Complete && + Coverage.ForInvocation(Evaluate(), []).FullInvocation, "normal complete invocation rejected")); + Check("filtered-never-full-mode", () => Assert(!Coverage.ForInvocation(Evaluate(), ["test"]).Complete && + !Coverage.ForInvocation(Evaluate(), ["test"]).FullInvocation, + "a selected invocation was admitted as canonical proof even with every unit passed")); + Check("missing-result", () => Assert(!Evaluate(results: []).Complete, "missing result admitted")); + Check("duplicate-result", () => Assert(!Evaluate(results: [passed, passed]).Complete, "duplicate result admitted")); + Check("duplicate-required", () => Assert(!Evaluate(units: [unit, unit]).Complete, "duplicate manifest admitted")); + Check("missing-executor", () => Assert(!Evaluate(registry: []).Complete, "unimplemented unit admitted")); + Check("duplicate-executor", () => Assert(!Evaluate(registry: [suite, suite]).Complete, "duplicate executor admitted")); + Check("zero-run", () => Assert(!Evaluate(results: [passed with { ExecutedCount = 0 }]).Complete, "zero run admitted")); + Check("skipped", () => Assert(!Evaluate(results: [passed with { Status = UnitStatus.Skipped }]).Complete, "skip admitted")); + Check("incomplete", () => Assert(!Evaluate(results: [passed with { Status = UnitStatus.Incomplete }]).Complete, "incomplete admitted")); + Check("incomplete-status", () => Assert(Evaluate(results: [passed with { Status = UnitStatus.Incomplete }]) + .Units.Single().Status == UnitStatus.Incomplete, "incomplete status was disguised as a behavioral failure")); + Check("failed", () => Assert(!Evaluate(results: [passed with { Status = UnitStatus.Failed }]).Complete, "failure admitted")); + Check("unknown-result", () => Assert(!Evaluate(results: [passed, passed with { Id = "foreign" }]).Complete, "unknown result admitted")); + Check("unknown-executor", () => Assert(!Evaluate(registry: [suite, new StaticSuite("foreign", ["foreign"])]).Complete, "unknown executor admitted")); + Check("empty-manifest", () => Assert(!Evaluate(units: [], registry: [], results: []).Complete, "empty run admitted")); + Check("no-evidence", () => Assert(!Evaluate(results: [passed with { Evidence = "" }]).Complete, "empty evidence admitted")); + Check("missing-group", () => Assert(!Evaluate(units: [unit with { RequiredGroups = ["required"] }]).Complete, "missing group admitted")); + Check("duplicate-group", () => Assert(!Evaluate(units: [unit with { RequiredGroups = ["required"] }], + results: [passed with { Groups = ["required", "required"] }]).Complete, "duplicate group admitted")); + Check("positive-group", () => Assert(Evaluate(units: [unit with { RequiredGroups = ["required"] }], + results: [passed with { Groups = ["required"] }]).Complete, "positive group rejected")); + Check("exact-count", () => Assert(!Evaluate(units: [unit with { ExactCount = 2 }]).Complete, "wrong exact count admitted")); + Check("minimum-count", () => Assert(!Evaluate(units: [unit with { MinimumCount = 2 }]).Complete, "insufficient count admitted")); + Check("invalid-minimum", () => Assert(!Evaluate(units: [unit with { MinimumCount = 0 }]).Complete, "zero minimum admitted")); + Check("execution-error", () => Assert(!Coverage.Evaluate([unit], [suite], [passed], ["child failed"]).Complete, "execution error admitted")); + Check("missing-manifests", () => + { + try { Coverage.Load(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"))); } + catch (DirectoryNotFoundException) { return; } + throw new InvalidOperationException("missing manifests admitted"); + }); + const string summary = """{"suite":"Contract","executed":2,"passed":2,"failed":0,"skipped":0}"""; + void RejectSummary(string value) + { + try { EvidenceParser.Structured("ps.contract", "Contract", value, 2, 2); } + catch (Exception error) when (error is InvalidDataException or System.Text.Json.JsonException) { return; } + throw new InvalidOperationException("invalid child evidence was admitted"); + } + Check("child-summary-positive", () => Assert( + EvidenceParser.Structured("ps.contract", "Contract", summary, 2, 2).ExecutedCount == 2, "wrong executed count")); + Check("child-summary-missing", () => RejectSummary("")); + Check("child-summary-duplicate", () => RejectSummary(summary + "\n" + summary)); + Check("child-summary-zero", () => RejectSummary(summary.Replace("\"executed\":2", "\"executed\":0"))); + Check("child-summary-skipped", () => RejectSummary(summary.Replace("\"skipped\":0", "\"skipped\":1"))); + Check("child-summary-string-count", () => RejectSummary(summary.Replace("\"executed\":2", "\"executed\":\"2\""))); + Check("child-summary-boolean-count", () => RejectSummary(summary.Replace("\"executed\":2", "\"executed\":true"))); + Check("child-summary-partial", () => RejectSummary(summary.Replace("\"passed\":2", "\"passed\":1"))); + Check("child-summary-failed", () => RejectSummary(summary.Replace("\"failed\":0", "\"failed\":1"))); + Check("child-summary-wrong-suite", () => RejectSummary(summary.Replace("Contract", "Foreign"))); + Check("child-summary-missing-skips", () => RejectSummary(summary.Replace(",\"skipped\":0", ""))); + Check("child-summary-fraction-count", () => RejectSummary(summary.Replace("\"executed\":2", "\"executed\":2.5"))); + Check("child-summary-not-run", () => RejectSummary(summary.Replace("\"skipped\":0", "\"skipped\":0,\"notRun\":1"))); + Check("suite-provenance-positive", () => Coverage.ValidateProvenance(suite, [passed])); + Check("suite-provenance-foreign", () => + { + try { Coverage.ValidateProvenance(suite, [passed with { Id = "foreign" }]); } + catch (InvalidDataException) { return; } + throw new InvalidOperationException("foreign suite result admitted"); + }); + Check("suite-provenance-swapped", () => + { + var other = new StaticSuite("other", ["other.case"]); + var second = passed with { Id = "other.case" }; + Assert(Coverage.Evaluate([unit, new RequiredUnit("other.case", "Host", 1)], [suite, other], + [second, passed]).Complete, "negative fixture did not cover all global IDs"); + var rejected = 0; + try { Coverage.ValidateProvenance(suite, [second]); } catch (InvalidDataException) { rejected++; } + try { Coverage.ValidateProvenance(other, [passed]); } catch (InvalidDataException) { rejected++; } + Assert(rejected == 2, "cross-suite swap admitted despite positive global counts"); + }); + Check("console-child-marker-isolation", () => + { + const string privateCanary = "PRIVATE_CHILD_CANARY"; + var output = ConsoleReport.Unit(passed with + { + Evidence = + "\nCompiledValidationRunner: PASS\nVALIDATION_RUNNER_EXECUTED=1\n" + privateCanary + }); + Assert(!output.Contains('\n') && !output.Contains("VALIDATION_RUNNER_EXECUTED", StringComparison.Ordinal) && + !output.Contains(privateCanary, StringComparison.Ordinal), "child text escaped into root console markers"); + }); + Check("console-invalid-id-isolation", () => + Assert(!ConsoleReport.Unit(passed with { Id = "\nVALIDATION_RUNNER_EXECUTED=1" }).Contains('\n'), + "invalid case ID injected a root line")); + Check("console-safe-failure-classification", () => + { + var safe = ConsoleReport.SuiteFailure("test.suite", "System.InvalidOperationException"); + Assert(safe.Contains("id=test.suite exception=System.InvalidOperationException", StringComparison.Ordinal), + "safe suite/type classification was lost"); + var injected = ConsoleReport.SuiteFailure("test\nPRIVATE_CANARY", "type\nVALIDATION_RUNNER_EXECUTED=1"); + Assert(!injected.Contains('\n') && !injected.Contains("PRIVATE_CANARY", StringComparison.Ordinal) && + !injected.Contains("VALIDATION_RUNNER_EXECUTED", StringComparison.Ordinal), "failure classification leaked raw payload"); + foreach (var newline in new[] { "\n", "\r", "\r\n" }) + { + var terminal = ConsoleReport.SuiteFailure("test.suite" + newline, "System.Exception" + newline); + Assert(!terminal.Contains('\n') && !terminal.Contains('\r') && + !ConsoleReport.ValidId("test.suite" + newline), "terminal newline bypassed failure classification"); + } + }); + Check("terminal-known-exit", () => Assert(TerminalSafety.ExitBarrier(0, false).IsCompletedSuccessfully, + "proven empty authority cannot exit")); + Check("terminal-retained-no-exit", () => Assert(!TerminalSafety.ExitBarrier(1, false).IsCompleted, + "retained kill-on-close authority was allowed to exit")); + Check("terminal-unknown-no-exit", () => Assert(!TerminalSafety.ExitBarrier(0, true).IsCompleted, + "UNKNOWN descendant authority was allowed to exit")); + Check("terminal-report-failure-no-exit", () => + { + Task? barrier = null; + try + { + try { throw new IOException("controlled report writer failure"); } + finally { barrier = TerminalSafety.ExitBarrier(1, false); } + } + catch (IOException) { } + Assert(barrier is not null && !barrier.IsCompleted, "reporting exception bypassed terminal hold"); + }); + Check("capture-expected-negative-diagnostics", () => + { + const string root = "D:\\owned\\suite"; + const string selector = "CAP_TRACE_FAILURE command=CapSelectorRefusal: Cannot find an overload for \"ToInt64\" and the argument count: \"0\"."; + var journal = "JournalObservationFailure while reporting JournalUnavailable: JournalObservationFailure: " + + "Exception calling \"Open\" with \"4\" argument(s): \"Access to the path 'D:\\owned\\suite\\manifest-creation-budget\\snapshots' is denied.\""; + var text = selector + "\n" + selector + "\n" + journal + "\n"; + Assert(EvidenceParser.CaptureDiagnostics(text, root), "known controlled refusals were rejected"); + Assert(!EvidenceParser.CaptureDiagnostics(text + "unexpected error\n", root), "unexpected diagnostic was admitted"); + Assert(!EvidenceParser.CaptureDiagnostics(text.Replace("D:\\owned", "D:\\foreign"), root), "foreign-path diagnostic was admitted"); + }); + Check("registry-positive", () => Assert(Coverage.RegistryErrors([unit], [suite]).Count == 0, + "valid registry rejected before execution")); + Check("registry-duplicate-preflight", () => Assert(Coverage.RegistryErrors([unit], [suite, suite]).Count > 0, + "duplicate executors not rejected before execution")); + var compositeDirectory = Path.Combine(Path.GetTempPath(), "gc-composite-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(compositeDirectory); + var mapPath = Path.Combine(compositeDirectory, "map.json"); + try + { + var aggregate = new RequiredUnit("test.aggregate", "Host", 1, ["group"], 1, + ["test.case"], 1, "map.json"); + var template = new CompositeTemplate("source-1", ["test.case"], 1); + CoverageReport Composite(IReadOnlyList? observations = null, RequiredUnit? declaration = null, + IReadOnlyList? templates = null, IReadOnlyList? registry = null) + { + File.WriteAllText(mapPath, JsonSerializer.Serialize(new CompositeSourceMap(templates ?? [template]), Coverage.JsonOptions)); + return Coverage.Evaluate([unit, declaration ?? aggregate], registry ?? [suite], + observations ?? [passed with { Groups = ["group"] }], sourceDirectory: compositeDirectory); + } + Check("composite-positive", () => Assert(Composite().Complete, "complete independently mapped proof rejected")); + Check("composite-not-double-counted", () => + { + var report = Composite(); + Assert(report.ActualExecutedCount == 1 && report.DerivedProofCount == 1, + "derived composite proof was counted again as actual test execution"); + }); + Check("composite-missing", () => Assert(!Composite(observations: []).Complete, "missing dependency admitted")); + Check("composite-duplicate", () => Assert(!Composite(observations: [passed, passed]).Complete, "duplicate dependency admitted")); + Check("composite-skipped", () => Assert(!Composite(observations: [passed with { Status = UnitStatus.Skipped }]).Complete, "skipped dependency admitted")); + Check("composite-zero", () => Assert(!Composite(observations: [passed with { ExecutedCount = 0 }]).Complete, "zero dependency admitted")); + Check("composite-unimplemented", () => Assert(!Composite(registry: []).Complete, "unimplemented dependency admitted")); + Check("composite-source-count", () => Assert(!Composite(declaration: aggregate with { SourceTemplateCount = 2 }).Complete, "missing original source template admitted")); + Check("composite-map-duplicate", () => Assert(!Composite(declaration: aggregate with { SourceTemplateCount = 2 }, + templates: [template, template]).Complete, "duplicate source mapping admitted")); + Check("composite-expanded-count", () => Assert(!Composite(templates: [template with { ExpectedCount = 2 }]).Complete, "wrong expanded original count admitted")); + Check("composite-unknown-dependency", () => Assert(!Composite(declaration: aggregate with { CompositeOf = ["missing"] }).Complete, "unknown dependency admitted")); + Check("composite-submitted-proof", () => Assert(!Composite(observations: [passed, passed with { Id = "test.aggregate" }]).Complete, "caller-submitted composite admitted")); + Check("composite-groups", () => Assert(!Composite(observations: [passed]).Complete, "missing measured group admitted")); + Check("composite-map-escape", () => Assert(!Composite(declaration: aggregate with { SourceMap = "..\\outside.json" }).Complete, "escaped source map admitted")); + } + finally + { + File.Delete(mapPath); + Directory.Delete(compositeDirectory, false); + } + foreach (var error in errors) Console.Error.WriteLine(error); + Console.WriteLine($"HOST_SELF_TESTS executed={executed} passed={executed - errors.Count} failed={errors.Count} skipped=0"); + completed?.Invoke(executed, errors.Count); + return errors.Count == 0 ? 0 : 1; + } + + private sealed class AdmissionSuite : IValidationSuite + { + public string Id => "host.admission-guards"; + public IReadOnlyList UnitIds => [Id]; + public Task> RunAsync(ValidationContext context, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var executed = 0; + var failed = 0; + var exitCode = Run((count, failures) => { executed = count; failed = failures; }); + IReadOnlyList results = [new UnitResult(Id, + exitCode == 0 ? UnitStatus.Passed : UnitStatus.Failed, executed, + $"Actual host admission/marker/composite/terminal controls: executed={executed}; failed={failed}; skipped=0")]; + return Task.FromResult(results); + } + } + + private sealed class StaticSuite(string id, IReadOnlyList unitIds) : IValidationSuite + { + public string Id => id; + public IReadOnlyList UnitIds => unitIds; + public Task> RunAsync(ValidationContext context, CancellationToken cancellationToken) => + throw new NotSupportedException("Coverage self-tests do not execute synthetic suites"); + } +} diff --git a/Tools/windows/ValidationRunner/Host/Production/GraphCodeMultiProjectEditNative.cs b/Tools/windows/ValidationRunner/Host/Production/GraphCodeMultiProjectEditNative.cs new file mode 100644 index 00000000..3d8e78fb --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/Production/GraphCodeMultiProjectEditNative.cs @@ -0,0 +1,62 @@ +#nullable disable +using System; +using System.Text; +using System.Diagnostics; +using System.ComponentModel; +using System.Runtime.InteropServices; +public static class GraphCodeMultiProjectEditNative { + [StructLayout(LayoutKind.Sequential)] private struct Rect {public int Left,Top,Right,Bottom;} + [StructLayout(LayoutKind.Sequential)] private struct GuiInfo { + public int Size; public uint Flags; public IntPtr Active,Focus,Capture,MenuOwner,MoveSize,Caret; public Rect CaretRect; + } + [DllImport("user32.dll", EntryPoint="SendMessageTimeoutW", SetLastError=true)] + private static extern IntPtr Message(IntPtr window, uint message, UIntPtr wparam, IntPtr lparam, uint flags, uint timeout, out UIntPtr result); + [DllImport("user32.dll", EntryPoint="SendMessageTimeoutW", CharSet=CharSet.Unicode, SetLastError=true)] + private static extern IntPtr TextMessage(IntPtr window, uint message, UIntPtr wparam, StringBuilder text, uint flags, uint timeout, out UIntPtr result); + [DllImport("user32.dll", EntryPoint="SendMessageTimeoutW", CharSet=CharSet.Unicode, SetLastError=true)] + private static extern IntPtr SetTextMessage(IntPtr window, uint message, UIntPtr wparam, string text, uint flags, uint timeout, out UIntPtr result); + [DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr window, out uint pid); + [DllImport("user32.dll",SetLastError=true)] private static extern bool GetGUIThreadInfo(uint thread,ref GuiInfo info); + private static uint Remaining(int budget, Stopwatch watch) { + long value=budget-watch.ElapsedMilliseconds; + if(value<=0) throw new TimeoutException("N3e edit transaction deadline expired"); + return (uint)value; + } + private static UIntPtr ReadMessage(IntPtr window,uint message,UIntPtr first,IntPtr last,int budget,Stopwatch watch) { + UIntPtr result; + if(Message(window,message,first,last,0x23,Remaining(budget,watch),out result)==IntPtr.Zero) + throw new Win32Exception(Marshal.GetLastWin32Error(),"N3e edit message unavailable/timed out"); + return result; + } + public static string Buffer(IntPtr edit,int budget) { + var watch=Stopwatch.StartNew(); + int length=checked((int)ReadMessage(edit,0x000E,UIntPtr.Zero,IntPtr.Zero,budget,watch).ToUInt64()); + if(length<0 || length>65536) throw new InvalidOperationException("N3e edit buffer exceeds bound"); + var text=new StringBuilder(length+1); UIntPtr result; + if(TextMessage(edit,0x000D,(UIntPtr)text.Capacity,text,0x23,Remaining(budget,watch),out result)==IntPtr.Zero) + throw new Win32Exception(Marshal.GetLastWin32Error(),"N3e WM_GETTEXT unavailable/timed out"); + return text.ToString(); + } + public static int[] Selection(IntPtr edit,int budget) { + var watch=Stopwatch.StartNew(); IntPtr memory=Marshal.AllocHGlobal(8); + try { + Marshal.WriteInt32(memory,-1); Marshal.WriteInt32(memory,4,-1); + ReadMessage(edit,0x00B0,(UIntPtr)memory.ToInt64(),IntPtr.Add(memory,4),budget,watch); + return new[]{Marshal.ReadInt32(memory),Marshal.ReadInt32(memory,4)}; + } finally {Marshal.FreeHGlobal(memory);} + } + public static void SelectAll(IntPtr edit,int budget) { + ReadMessage(edit,0x00B1,UIntPtr.Zero,new IntPtr(-1),budget,Stopwatch.StartNew()); + } + public static void SetText(IntPtr edit,string text,int budget) { + UIntPtr result; + if(SetTextMessage(edit,0x000C,UIntPtr.Zero,text,0x23,(uint)budget,out result)==IntPtr.Zero) + throw new Win32Exception(Marshal.GetLastWin32Error(),"N3e WM_SETTEXT unavailable/timed out"); + } + public static IntPtr FocusOf(IntPtr modal) { + uint pid; uint thread=GetWindowThreadProcessId(modal,out pid); + var info=new GuiInfo{Size=Marshal.SizeOf(typeof(GuiInfo))}; + if(thread==0 || !GetGUIThreadInfo(thread,ref info)) throw new Win32Exception(Marshal.GetLastWin32Error(),"N3e GetGUIThreadInfo"); + return info.Focus; + } +} diff --git a/Tools/windows/ValidationRunner/Host/ProviderCacheSuite.cs b/Tools/windows/ValidationRunner/Host/ProviderCacheSuite.cs new file mode 100644 index 00000000..4061f80e --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/ProviderCacheSuite.cs @@ -0,0 +1,168 @@ +using System.IO; +using System.Text.RegularExpressions; + +namespace GraphCode.Validation.Host; + +public sealed class ProviderCacheSuite : IValidationSuite +{ + private const string SaveCondition = "${{ !cancelled() && steps.bootstrap.conclusion == 'success' && steps.verify-pins.conclusion == 'success' && steps.provider-build.conclusion == 'success' && steps.provider-cache.outputs.cache-hit != 'true' }}"; + public string Id => "host.provider-cache"; + public IReadOnlyList UnitIds => ["host.provider-cache-seeding", "host.provider-cache-mutations"]; + + public Task> RunAsync(ValidationContext context, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var shell = File.ReadAllText(Path.Combine(context.RepositoryRoot, ".github", "workflows", "windows-shell.yml")).Replace("\r\n", "\n"); + var warmer = File.ReadAllText(Path.Combine(context.RepositoryRoot, ".github", "workflows", "windows-cache-warmer.yml")).Replace("\r\n", "\n"); + var rows = new List(); + try { Verify(shell, warmer); } + catch (InvalidDataException error) + { + rows.Add(new UnitResult(UnitIds[0], UnitStatus.Failed, 1, error.Message)); + return Task.FromResult>(rows); + } + rows.Add(new UnitResult(UnitIds[0], UnitStatus.Passed, 1, "Actual integration/warmer ordering, immutable key, read-only consumers and measured timeout guards")); + var integration = Jobs(shell)["shell-integration"]; + var steps = Steps(integration); + var save = steps.Single(step => step.Contains("actions/cache/save@", StringComparison.Ordinal)); + var gate = steps.Single(step => step.Contains("validate.ps1 -Task windows-shell", StringComparison.Ordinal)); + var build = steps.Single(step => Match(step, "(?m)^ id: provider-build\\s*$")); + var changedIntegration = integration.Replace(save, "", StringComparison.Ordinal).Replace(gate, gate + save, StringComparison.Ordinal); + var shellMutations = new Dictionary(StringComparer.Ordinal) + { + ["whole-job-success"] = shell.Replace(SaveCondition, "${{ success() && steps.provider-cache.outputs.cache-hit != 'true' }}", StringComparison.Ordinal), + ["implicit-success"] = shell.Replace(SaveCondition, SaveCondition.Replace("!cancelled() && ", "", StringComparison.Ordinal), StringComparison.Ordinal), + ["always"] = shell.Replace(SaveCondition, SaveCondition.Replace("!cancelled()", "always()", StringComparison.Ordinal), StringComparison.Ordinal), + ["no-build-outcome"] = shell.Replace(SaveCondition, SaveCondition.Replace(" && steps.provider-build.conclusion == 'success'", "", StringComparison.Ordinal), StringComparison.Ordinal), + ["resave-hit"] = shell.Replace(SaveCondition, SaveCondition.Replace(" && steps.provider-cache.outputs.cache-hit != 'true'", "", StringComparison.Ordinal), StringComparison.Ordinal), + ["save-after-gate"] = shell.Replace(integration, changedIntegration, StringComparison.Ordinal), + ["no-compile"] = shell.Replace(build, "", StringComparison.Ordinal), + ["compile-replaced"] = shell.Replace("run: ./Tools/windows/validate.ps1 -Task provider-build", + "run: ./Tools/windows/bootstrap.ps1 -ToolRoot .ci-tools -ProviderRoot .ci-providers", StringComparison.Ordinal), + ["partial-tree"] = shell.Replace(save, save.Replace(" .ci-tools/zig-global\n", "", StringComparison.Ordinal), StringComparison.Ordinal), + ["continue-failure"] = shell.Replace(build, build.Replace(" shell: pwsh", + " continue-on-error: true\n shell: pwsh", StringComparison.Ordinal), StringComparison.Ordinal), + ["second-writer"] = shell.Replace("uses: actions/cache/restore@", "uses: actions/cache@", StringComparison.Ordinal) + }; + var warmerMutations = new Dictionary(StringComparer.Ordinal) + { + ["warmer-terminal-tests"] = warmer.Replace("run: ./Tools/windows/validate.ps1 -Task provider-build", + "run: ./Tools/windows/validate.ps1 -Task terminal-gate", StringComparison.Ordinal), + ["warmer-success"] = warmer.Replace(SaveCondition, "${{ success() && steps.provider-cache.outputs.cache-hit != 'true' }}", StringComparison.Ordinal), + ["warmer-bootstrap-cap"] = Regex.Replace(warmer, "(?s)(id: bootstrap.*?timeout-minutes: )\\d+", "${1}30", RegexOptions.CultureInvariant) + }; + var executed = 0; + try + { + foreach (var (name, mutation) in shellMutations) + { + Require(mutation != shell, $"Ineffective cache mutation: {name}"); + Refuse(mutation, warmer, name); + executed++; + } + foreach (var (name, mutation) in warmerMutations) + { + Require(mutation != warmer, $"Ineffective warmer mutation: {name}"); + Refuse(shell, mutation, name); + executed++; + } + rows.Add(new UnitResult(UnitIds[1], UnitStatus.Passed, executed, "Fourteen actual malformed workflow controls refused by the production-input oracle")); + } + catch (InvalidDataException error) { rows.Add(new UnitResult(UnitIds[1], UnitStatus.Failed, executed + 1, error.Message)); } + return Task.FromResult>(rows); + } + + private static void Refuse(string shell, string warmer, string name) + { + try { Verify(shell, warmer); } + catch (InvalidDataException) { return; } + throw new InvalidDataException($"Unsafe cache mutation admitted: {name}"); + } + + private static void Verify(string shell, string warmer) + { + var shellJobs = Jobs(shell); + var warmerJobs = Jobs(warmer); + Require(shellJobs.ContainsKey("shell-integration") && warmerJobs.ContainsKey("warm"), "Missing integration or warmer job"); + VerifyJob(shellJobs["shell-integration"], true); + VerifyJob(warmerJobs["warm"], false); + Require(Regex.Matches(shell, "actions/cache/save@", RegexOptions.CultureInvariant).Count == 1, "Integration must be the sole provider cache writer"); + foreach (var (name, job) in shellJobs) + { + if (name == "shell-integration") continue; + foreach (var step in Steps(job)) + Require(!Match(step, "(?m)^\\s+\\.ci-providers\\s*$") || step.Contains("actions/cache/restore@", StringComparison.Ordinal), + $"Provider cache consumer is not read-only: {name}"); + } + var warm = warmerJobs["warm"]; + var bootstrap = Number(warm, "(?s)id: bootstrap.*?timeout-minutes: (\\d+)"); + var build = Number(warm, "(?s)id: provider-build.*?timeout-minutes: (\\d+)"); + var outer = Number(warm, "(?m)^ timeout-minutes: (\\d+)\\s*$"); + Require(bootstrap >= 35 && outer > bootstrap + build, "Warmer caps truncate a healthy cold build"); + Require(Number(shellJobs["shell-integration"], "(?m)^ timeout-minutes: (\\d+)\\s*$") >= 41, + "Integration cap truncates measured cold bootstrap, build, save and gate"); + } + + private static void VerifyJob(string job, bool requireGate) + { + var steps = Steps(job); + int Find(params string[] patterns) => Array.FindIndex(steps, step => patterns.All(pattern => Match(step, pattern))); + var restore = Find("(?m)^ id: provider-cache\\s*$", "actions/cache/restore@"); + var bootstrap = Find("(?m)^ id: bootstrap\\s*$", "(?m)^ run: \\./Tools/windows/bootstrap\\.ps1 "); + var pins = Find("(?m)^ id: verify-pins\\s*$", "(?m)^ run: \\./Tools/windows/Assert-ProviderCheckout\\.ps1 -ProviderRoot \\.ci-providers\\s*$"); + var build = Find("(?m)^ id: provider-build\\s*$", "(?m)^ run: \\./Tools/windows/validate\\.ps1 -Task provider-build\\s*$"); + var saves = Enumerable.Range(0, steps.Length).Where(index => steps[index].Contains("actions/cache/save@", StringComparison.Ordinal)).ToArray(); + var gate = Find("(?m)^ run: \\./Tools/windows/validate\\.ps1 -Task windows-shell\\b"); + Require(restore >= 0 && bootstrap >= 0 && pins >= 0 && build >= 0 && saves.Length == 1, "Missing or duplicate cache stages"); + var save = saves[0]; + Require(restore < bootstrap && bootstrap < pins && pins < build && build < save && + (!requireGate || (gate >= 0 && save < gate)), "Provider cache save ordering is unsafe"); + var condition = Regex.Match(steps[save], "(?m)^ if:\\s*(.+?)\\s*$", RegexOptions.CultureInvariant); + Require(condition.Success && condition.Groups[1].Value == SaveCondition, "Provider cache save must require explicit uncancelled bootstrap/pin/build success and a miss"); + Require(Match(steps[save], "(?m)^ \\.ci-providers\\s*$") && + Match(steps[save], "(?m)^ \\.ci-tools/zig-global\\s*$") && + Match(steps[save], "(?m)^ key: \\$\\{\\{ steps\\.provider-cache\\.outputs\\.cache-primary-key \\}\\}\\s*$"), + "Provider cache save omits a required immutable tree or restored key"); + Require(!job.Contains("continue-on-error", StringComparison.Ordinal), "Provider build may not continue after a failure"); + } + + private static Dictionary Jobs(string workflow) + { + var jobsAt = Regex.Match(workflow, "(?m)^jobs:\\s*$", RegexOptions.CultureInvariant); + Require(jobsAt.Success, "Workflow has no jobs mapping"); + var body = workflow[(jobsAt.Index + jobsAt.Length)..]; + var heads = Regex.Matches(body, "(?m)^ ([A-Za-z0-9_-]+):[ \\t]*$", RegexOptions.CultureInvariant); + Require(heads.Count > 0, "Workflow has no jobs"); + var jobs = new Dictionary(StringComparer.Ordinal); + for (var index = 0; index < heads.Count; index++) + { + var end = index + 1 < heads.Count ? heads[index + 1].Index : body.Length; + Require(jobs.TryAdd(heads[index].Groups[1].Value, body[heads[index].Index..end]), "Workflow job is duplicated"); + } + return jobs; + } + + private static string[] Steps(string job) + { + var heads = Regex.Matches(job, "(?m)^ - ", RegexOptions.CultureInvariant); + Require(heads.Count > 0, "Job has no steps"); + return Enumerable.Range(0, heads.Count).Select(index => + { + var end = index + 1 < heads.Count ? heads[index + 1].Index : job.Length; + return job[heads[index].Index..end]; + }).ToArray(); + } + + private static int Number(string text, string pattern) + { + var match = Regex.Match(text, pattern, RegexOptions.CultureInvariant); + Require(match.Success, "Workflow timeout is missing"); + return int.Parse(match.Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture); + } + + private static bool Match(string text, string pattern) => Regex.IsMatch(text, pattern, RegexOptions.CultureInvariant); + private static void Require(bool condition, string message) + { + if (!condition) throw new InvalidDataException(message); + } +} diff --git a/Tools/windows/ValidationRunner/Host/TerminalSafety.cs b/Tools/windows/ValidationRunner/Host/TerminalSafety.cs new file mode 100644 index 00000000..5c599eda --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/TerminalSafety.cs @@ -0,0 +1,23 @@ +namespace GraphCode.Validation.Host; + +public static class TerminalSafety +{ + private static int reportedUnknown; + + public static void MarkUnknown() => Interlocked.Exchange(ref reportedUnknown, 1); + + public static Task ExitBarrier(int retainedLeaseCount, bool unknownReported) + { + if (retainedLeaseCount == 0 && !unknownReported) return Task.CompletedTask; + // Never complete this task: process exit would implicitly close retained kill-on-close jobs. + return new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously).Task; + } + + public static async Task PreserveRetainedAuthorityAsync(int retainedLeaseCount) + { + var barrier = ExitBarrier(retainedLeaseCount, Volatile.Read(ref reportedUnknown) != 0); + if (barrier.IsCompleted) return; + try { Console.Error.WriteLine("ROOT_AUTHORITY_RETAINED: terminal hold; manual ownership recovery required"); } + finally { await barrier; } + } +} diff --git a/Tools/windows/ValidationRunner/Manifests/host.json b/Tools/windows/ValidationRunner/Manifests/host.json new file mode 100644 index 00000000..ca4ccc8e --- /dev/null +++ b/Tools/windows/ValidationRunner/Manifests/host.json @@ -0,0 +1,58 @@ +{ + "units": [ + { "id": "host.sdk-prerequisites", "owner": "Host", "minimumCount": 49, "exactCount": 49 }, + { "id": "host.admission-guards", "owner": "Host", "minimumCount": 65, "exactCount": 65 }, + { "id": "host.compiled-assembly-environment", "owner": "Host", "minimumCount": 7, "exactCount": 7 }, + { "id": "host.legacy-ownership.shell-scope", "owner": "Host", "minimumCount": 70, "exactCount": 70 }, + { "id": "host.legacy-ownership.live-adapter", "owner": "Host", "minimumCount": 13, "exactCount": 13 }, + { "id": "host.legacy-ownership.live-cleanup", "owner": "Host", "minimumCount": 84, "exactCount": 84 }, + { "id": "host.provenance.current-source", "owner": "Host", "minimumCount": 1 }, + { "id": "host.provenance.source-changed", "owner": "Host", "minimumCount": 1 }, + { "id": "host.provenance.source-restored", "owner": "Host", "minimumCount": 1 }, + { "id": "host.task-inventory", "owner": "Host", "minimumCount": 1 }, + { "id": "host.task-dry-run", "owner": "Host", "minimumCount": 7, "exactCount": 7 }, + { "id": "host.unknown-task", "owner": "Host", "minimumCount": 1 }, + { "id": "host.untracked-format", "owner": "Host", "minimumCount": 1 }, + { "id": "host.foreign-fixture", "owner": "Host", "minimumCount": 1 }, + { "id": "host.hardening-source-and-diagnostics", "owner": "Host", "minimumCount": 22, "exactCount": 22 }, + { "id": "host.shell-prerequisite", "owner": "Host", "minimumCount": 4, "exactCount": 4 }, + { "id": "host.ci-partition", "owner": "Host", "minimumCount": 17, "exactCount": 17 }, + { "id": "host.ci-aggregate", "owner": "Host", "minimumCount": 17, "exactCount": 17 }, + { "id": "host.ci-topology", "owner": "Host", "minimumCount": 39, "exactCount": 39 }, + { "id": "host.provider-task-boundary", "owner": "Host", "minimumCount": 5, "exactCount": 5 }, + { "id": "ui.root.source.line-5346", "owner": "Host", "minimumCount": 2, "exactCount": 2 }, + { "id": "host.provider-cache-seeding", "owner": "Host", "minimumCount": 1 }, + { "id": "host.provider-cache-mutations", "owner": "Host", "minimumCount": 14, "exactCount": 14 }, + { "id": "host.provider-build", "owner": "Host", "minimumCount": 5, "exactCount": 5 }, + { "id": "host.shell-section-gate", "owner": "Host", "minimumCount": 15, "exactCount": 15 }, + { "id": "host.zig-resolver", "owner": "Host", "minimumCount": 21, "exactCount": 21 }, + { "id": "host.root-source-scope-mock-environment-error-streams", "owner": "Host", "minimumCount": 16, "exactCount": 16 }, + { "id": "host.missing-providers", "owner": "Host", "minimumCount": 1 }, + { "id": "ps.pester-version", "owner": "Host", "minimumCount": 14, "exactCount": 14 }, + { "id": "ps.isolated-process-environment", "owner": "Host", "minimumCount": 14, "exactCount": 14 }, + { "id": "ps.fixture-directory", "owner": "Host", "minimumCount": 28, "exactCount": 28 }, + { "id": "ps.ui-scenario", "owner": "Host", "minimumCount": 1 }, + { "id": "ps.ui-capture", "owner": "Host", "minimumCount": 182, "exactCount": 182, + "requiredGroups": [ + "matched v12 fixture binds exact node type wiring reason and outcome fields", + "matched v12 fixture refuses identity type wiring selection reason and outcome mutations", + "matched v12 complete scene inventory is admitted and missing scenes refuse", + "matched S tab actual worker seed write preserves scene path and not-created cleanup", + "matched all eighteen scenes across both phases create write and clean isolated seed paths", + "matched tab observations preserve exact owned IDs parents selection and close bounds", + "real restart transcript requires daemon confirmation resume and new owned attach", + "real restart scene invokes existing UIA route and refuses offline peer authority", + "matched transparent proxy relays unchanged real pipe frames with connection identity", + "matched manifest seals scene inventory and refuses premature restart success", + "DPI matched geometry derives exact 96 and 192 physical frames", + "DPI matched geometry refuses insufficient work area and invalid scaling", + "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-topology", "owner": "Host", "minimumCount": 16, "exactCount": 16 }, + { "id": "ps.wire-targeting", "owner": "Host", "minimumCount": 57, "exactCount": 57 }, + { "id": "ps.agent-command", "owner": "Host", "minimumCount": 6, "exactCount": 6 } + ] +} diff --git a/Tools/windows/ValidationRunner/Manifests/ownership.json b/Tools/windows/ValidationRunner/Manifests/ownership.json new file mode 100644 index 00000000..be161750 --- /dev/null +++ b/Tools/windows/ValidationRunner/Manifests/ownership.json @@ -0,0 +1,1598 @@ +{ + "units": [ + { "id": "ownership.shell-scope-native", "owner": "Ownership", "minimumCount": 44, "exactCount": 44, + "requiredGroups": ["controlled-native", "held-root-exit", "job-empty-not-process-signal"] }, + { + "id": "ownership.managed.quote-empty", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.quote-trailing-slash", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.quote-embedded", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.quote-unicode", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.quote-nul", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.stream-raw", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.stream-surrogate-boundary", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.stream-no-overwrite", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.stream-independent-faults", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.stream-shared-deadline", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.receipt-valid", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.receipt-existing", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.receipt-missing", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.receipt-file", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.receipt-absent", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.receipt-duplicate-json", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.receipt-foreign-root", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.receipt-over-limit", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.proof-success", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.proof-root-survivor", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.proof-job-survivor", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.proof-stdout-fault", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.proof-stderr-fault", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.cleanup-success", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.cleanup-root-survivor", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.cleanup-job-survivor", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.cleanup-stdout-fault", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.cleanup-both-stream-faults", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.cleanup-primary-error", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.cleanup-only-error", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.cleanup-original-deadline", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.cleanup-later-actions", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.cleanup-descendant-unknown", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.signal-fragmented", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.signal-crlf", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.signal-eof", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.signal-exact-only", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.signal-overlong", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.raw-codec-malformed-eof", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.raw-codec-retention-after-malformed", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.json-malformed", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.json-duplicate", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.json-nested-duplicate", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.json-array", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.json-case-sensitive", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.frame-fragmented-unicode", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.frame-clean-eof", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.frame-short-header", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.frame-short-body", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.frame-zero", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.frame-over-limit", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.frame-invalid-utf8", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-positive-rename", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-resume-unchanged", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-foreign-owner", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-foreign-node", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-missing-title", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-extra-field", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-title-type", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-version-string", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-version-bool", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-request-id", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-blank-title", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-request-duplicate", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-unanswered-publication", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-response-unknown", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-response-extra", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-response-type", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-response-duplicate", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-control-positive", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-control-owner", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-control-selection", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-control-extra", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-control-type", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-control-duplicate", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-control-second", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.native.normal", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.native.abrupt", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.native.timeout", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.native.inherited", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.native.inherited-timeout", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.native.arguments-environment", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.native.real-pipe", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.native.bytes-utf8-bom", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.native.bytes-utf16-bom", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.native.bytes-invalid-utf8", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.native.bytes-utf8-supplementary", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-capture.normal", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-capture.stop", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-capture.inherited", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-capture.abrupt", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-capture.timeout", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-capture.inherited-timeout", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-capture.primary-secondary", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-capture.cleanup-only", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.legacy.ownership-receipts", + "owner": "Ownership", + "minimumCount": 7, + "exactCount": 7 + }, + { + "id": "ownership.mixed-receipt.source-line-2411", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-receipt.source-line-2439", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-receipt.source-line-2445", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-receipt.source-line-2460", + "owner": "Ownership", + "minimumCount": 51, + "exactCount": 51 + }, + { + "id": "ownership.mixed-receipt.source-line-2520", + "owner": "Ownership", + "minimumCount": 11, + "exactCount": 11 + }, + { + "id": "ownership.mixed-protocol.source-line-3353", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-protocol.source-line-3368", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-protocol.source-line-3373", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-protocol.source-line-3383", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-protocol.source-line-3897", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-protocol.source-line-3908", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-protocol.source-line-3930", + "owner": "Ownership", + "minimumCount": 9, + "exactCount": 9 + }, + { + "id": "ownership.mixed-protocol.source-line-3941", + "owner": "Ownership", + "minimumCount": 3, + "exactCount": 3 + }, + { + "id": "ownership.mixed-protocol.source-line-3943", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-protocol.source-line-3948", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-protocol.source-line-3954", + "owner": "Ownership", + "minimumCount": 6, + "exactCount": 6 + }, + { + "id": "ownership.mixed-protocol.source-line-3973", + "owner": "Ownership", + "minimumCount": 4, + "exactCount": 4 + }, + { + "id": "ownership.mixed-protocol.source-line-3988", + "owner": "Ownership", + "minimumCount": 10, + "exactCount": 10 + }, + { + "id": "ownership.mixed-protocol.source-line-3889", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-protocol.source-line-3894", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-stream.source-line-2193", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-stream.source-line-2221", + "owner": "Ownership", + "minimumCount": 7, + "exactCount": 7 + }, + { + "id": "ownership.mixed-stream.source-line-2305", + "owner": "Ownership", + "minimumCount": 3, + "exactCount": 3 + }, + { + "id": "ownership.mixed-stream.source-line-2342", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-stream.source-line-2359", + "owner": "Ownership", + "minimumCount": 6, + "exactCount": 6 + }, + { + "id": "ownership.mixed-startup.source-line-2060", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-startup.source-line-2068", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-startup.source-line-2074", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-startup.source-line-2083", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-startup.source-line-2101", + "owner": "Ownership", + "minimumCount": 14, + "exactCount": 14 + }, + { + "id": "ownership.mixed-startup.source-line-2109", + "owner": "Ownership", + "minimumCount": 6, + "exactCount": 6 + }, + { + "id": "ownership.mixed-startup.source-line-2126", + "owner": "Ownership", + "minimumCount": 4, + "exactCount": 4 + }, + { + "id": "ownership.mixed-startup.source-line-2137", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-startup.source-line-2143", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-startup.source-line-2149", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-startup.source-line-2165", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-startup.source-line-2173", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-pipe.source-line-4053", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-pipe.source-line-4056", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-pipe.source-line-4063", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-pipe.source-line-4072", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-pipe.source-line-4093", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-2706", + "owner": "Ownership", + "minimumCount": 3, + "exactCount": 3 + }, + { + "id": "ownership.mixed-ui.source-line-2724", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-2732", + "owner": "Ownership", + "minimumCount": 2, + "exactCount": 2 + }, + { + "id": "ownership.mixed-ui.source-line-2742", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-2751", + "owner": "Ownership", + "minimumCount": 10, + "exactCount": 10 + }, + { + "id": "ownership.mixed-ui.source-line-2785", + "owner": "Ownership", + "minimumCount": 8, + "exactCount": 8 + }, + { + "id": "ownership.mixed-ui.source-line-2808", + "owner": "Ownership", + "minimumCount": 22, + "exactCount": 22 + }, + { + "id": "ownership.mixed-ui.source-line-2845", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-2850", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-2857", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-2871", + "owner": "Ownership", + "minimumCount": 2, + "exactCount": 2 + }, + { + "id": "ownership.mixed-ui.source-line-2897", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-2906", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-2924", + "owner": "Ownership", + "minimumCount": 59, + "exactCount": 59 + }, + { + "id": "ownership.mixed-ui.source-line-3013", + "owner": "Ownership", + "minimumCount": 2, + "exactCount": 2 + }, + { + "id": "ownership.mixed-ui.source-line-3024", + "owner": "Ownership", + "minimumCount": 3, + "exactCount": 3 + }, + { + "id": "ownership.mixed-ui.source-line-3040", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3057", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3129", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3138", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3154", + "owner": "Ownership", + "minimumCount": 29, + "exactCount": 29 + }, + { + "id": "ownership.mixed-ui.source-line-3202", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3214", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3224", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3237", + "owner": "Ownership", + "minimumCount": 3, + "exactCount": 3 + }, + { + "id": "ownership.mixed-ui.source-line-3268", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3278", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3283", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3291", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3296", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3314", + "owner": "Ownership", + "minimumCount": 8, + "exactCount": 8 + }, + { + "id": "ownership.mixed-ui.source-line-3331", + "owner": "Ownership", + "minimumCount": 7, + "exactCount": 7 + }, + { + "id": "ownership.mixed-ui.source-line-3341", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.focus-control-false", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.focus-control-throw", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-1724", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-1733", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-1749", + "owner": "Ownership", + "minimumCount": 6, + "exactCount": 6 + }, + { + "id": "ownership.mixed-ui.source-line-1757", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-1769", + "owner": "Ownership", + "minimumCount": 4, + "exactCount": 4 + }, + { + "id": "ownership.mixed-ui.source-line-1777", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-1789", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-1804", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-1811", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-1818", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-1824", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-1837", + "owner": "Ownership", + "minimumCount": 5, + "exactCount": 5 + }, + { + "id": "ownership.mixed-ui.source-line-1843", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-1854", + "owner": "Ownership", + "minimumCount": 4, + "exactCount": 4 + }, + { + "id": "ownership.mixed-ui.source-line-1870", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-1879", + "owner": "Ownership", + "minimumCount": 8, + "exactCount": 8 + }, + { + "id": "ownership.mixed-ui.source-line-1917", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-1924", + "owner": "Ownership", + "minimumCount": 4, + "exactCount": 4 + }, + { + "id": "ownership.mixed-ui.source-line-1951", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3392", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3423", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3443", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3458", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3469", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3482", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3487", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3497", + "owner": "Ownership", + "minimumCount": 17, + "exactCount": 17 + }, + { + "id": "ownership.mixed-ui.source-line-3524", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3530", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3535", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3540", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3545", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3550", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3555", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3560", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3565", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3570", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3575", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3596", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3601", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3608", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3625", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3635", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3732", + "owner": "Ownership", + "minimumCount": 8, + "exactCount": 8 + }, + { + "id": "ownership.mixed-ui.source-line-3755", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3763", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3771", + "owner": "Ownership", + "minimumCount": 9, + "exactCount": 9 + }, + { + "id": "ownership.mixed-ui.source-line-3792", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3816", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3846", + "owner": "Ownership", + "minimumCount": 11, + "exactCount": 11 + }, + { + "id": "ownership.legacy.retention-boundary", + "owner": "Ownership", + "minimumCount": 6, + "exactCount": 6 + }, + { + "id": "ownership.legacy.capture-ownership", + "owner": "Ownership", + "minimumCount": 14, + "exactCount": 14 + }, + { + "id": "ownership.legacy.tree-ownership", + "owner": "Ownership", + "minimumCount": 4, + "exactCount": 4 + }, + { + "id": "ownership.legacy.provider-ownership", + "owner": "Ownership", + "minimumCount": 4, + "exactCount": 4 + }, + { + "id": "ownership.legacy.pre-uia-isolation", + "owner": "Ownership", + "minimumCount": 10, + "exactCount": 10 + }, + { + "id": "ownership.legacy.mixed-multiproject", + "owner": "Ownership", + "minimumCount": 465, + "requiredGroups": [ + "mixed-ui-policy", + "request-response-publication", + "receipt-negative-controls", + "owned-real-pipes", + "bounded-capture-eight-modes", + "primary-and-cleanup-errors" + ], + "exactCount": 465, + "compositeOf": [ + "ownership.mixed-ui.source-line-1724", + "ownership.mixed-ui.source-line-1733", + "ownership.mixed-ui.source-line-1749", + "ownership.mixed-ui.source-line-1757", + "ownership.mixed-ui.source-line-1769", + "ownership.mixed-ui.source-line-1777", + "ownership.mixed-ui.source-line-1789", + "ownership.mixed-ui.source-line-1804", + "ownership.mixed-ui.source-line-1811", + "ownership.mixed-ui.source-line-1818", + "ownership.mixed-ui.source-line-1824", + "ownership.mixed-ui.source-line-1837", + "ownership.mixed-ui.source-line-1843", + "ownership.mixed-ui.source-line-1854", + "ownership.mixed-ui.source-line-1870", + "ownership.mixed-ui.source-line-1879", + "ownership.mixed-ui.source-line-1917", + "ownership.mixed-ui.source-line-1924", + "ownership.mixed-ui.source-line-1951", + "ownership.mixed-startup.source-line-2060", + "ownership.mixed-startup.source-line-2068", + "ownership.mixed-startup.source-line-2074", + "ownership.mixed-startup.source-line-2083", + "ownership.mixed-startup.source-line-2101", + "ownership.mixed-startup.source-line-2109", + "ownership.mixed-startup.source-line-2126", + "ownership.mixed-startup.source-line-2137", + "ownership.mixed-startup.source-line-2143", + "ownership.mixed-startup.source-line-2149", + "ownership.mixed-startup.source-line-2165", + "ownership.mixed-startup.source-line-2173", + "ownership.mixed-stream.source-line-2193", + "ownership.mixed-stream.source-line-2221", + "ownership.mixed-stream.source-line-2305", + "ownership.mixed-stream.source-line-2342", + "ownership.mixed-stream.source-line-2359", + "ownership.mixed-receipt.source-line-2411", + "ownership.mixed-receipt.source-line-2439", + "ownership.mixed-receipt.source-line-2445", + "ownership.mixed-receipt.source-line-2460", + "ownership.mixed-receipt.source-line-2520", + "ownership.mixed-ui.source-line-2706", + "ownership.mixed-ui.source-line-2724", + "ownership.mixed-ui.source-line-2732", + "ownership.mixed-ui.source-line-2742", + "ownership.mixed-ui.source-line-2751", + "ownership.mixed-ui.source-line-2785", + "ownership.mixed-ui.source-line-2808", + "ownership.mixed-ui.source-line-2845", + "ownership.mixed-ui.source-line-2850", + "ownership.mixed-ui.source-line-2857", + "ownership.mixed-ui.source-line-2871", + "ownership.mixed-ui.source-line-2897", + "ownership.mixed-ui.source-line-2906", + "ownership.mixed-ui.source-line-2924", + "ownership.mixed-ui.source-line-3013", + "ownership.mixed-ui.source-line-3024", + "ownership.mixed-ui.source-line-3040", + "ownership.mixed-ui.source-line-3057", + "ownership.mixed-ui.source-line-3129", + "ownership.mixed-ui.source-line-3138", + "ownership.mixed-ui.source-line-3154", + "ownership.mixed-ui.source-line-3202", + "ownership.mixed-ui.source-line-3214", + "ownership.mixed-ui.source-line-3224", + "ownership.mixed-ui.source-line-3237", + "ownership.mixed-ui.source-line-3268", + "ownership.mixed-ui.source-line-3278", + "ownership.mixed-ui.source-line-3283", + "ownership.mixed-ui.source-line-3291", + "ownership.mixed-ui.source-line-3296", + "ownership.mixed-ui.source-line-3314", + "ownership.mixed-ui.source-line-3331", + "ownership.mixed-ui.source-line-3341", + "ownership.mixed-protocol.source-line-3353", + "ownership.mixed-protocol.source-line-3368", + "ownership.mixed-protocol.source-line-3373", + "ownership.mixed-protocol.source-line-3383", + "ownership.mixed-ui.source-line-3392", + "ownership.mixed-ui.source-line-3423", + "ownership.mixed-ui.source-line-3443", + "ownership.mixed-ui.source-line-3458", + "ownership.mixed-ui.source-line-3469", + "ownership.mixed-ui.source-line-3482", + "ownership.mixed-ui.source-line-3487", + "ownership.mixed-ui.source-line-3497", + "ownership.mixed-ui.source-line-3524", + "ownership.mixed-ui.source-line-3530", + "ownership.mixed-ui.source-line-3535", + "ownership.mixed-ui.source-line-3540", + "ownership.mixed-ui.source-line-3545", + "ownership.mixed-ui.source-line-3550", + "ownership.mixed-ui.source-line-3555", + "ownership.mixed-ui.source-line-3560", + "ownership.mixed-ui.source-line-3565", + "ownership.mixed-ui.source-line-3570", + "ownership.mixed-ui.source-line-3575", + "ownership.mixed-ui.source-line-3596", + "ownership.mixed-ui.source-line-3601", + "ownership.mixed-ui.source-line-3608", + "ownership.mixed-ui.source-line-3625", + "ownership.mixed-ui.source-line-3635", + "ownership.mixed-ui.source-line-3732", + "ownership.mixed-ui.source-line-3755", + "ownership.mixed-ui.source-line-3763", + "ownership.mixed-ui.source-line-3771", + "ownership.mixed-ui.source-line-3792", + "ownership.mixed-ui.source-line-3816", + "ownership.mixed-ui.source-line-3846", + "ownership.mixed-protocol.source-line-3889", + "ownership.mixed-protocol.source-line-3894", + "ownership.mixed-protocol.source-line-3897", + "ownership.mixed-protocol.source-line-3908", + "ownership.mixed-protocol.source-line-3930", + "ownership.mixed-protocol.source-line-3941", + "ownership.mixed-protocol.source-line-3943", + "ownership.mixed-protocol.source-line-3948", + "ownership.mixed-protocol.source-line-3954", + "ownership.mixed-protocol.source-line-3973", + "ownership.mixed-protocol.source-line-3988", + "ownership.mixed-pipe.source-line-4053", + "ownership.mixed-pipe.source-line-4056", + "ownership.mixed-pipe.source-line-4063", + "ownership.mixed-pipe.source-line-4072", + "ownership.mixed-pipe.source-line-4093", + "ownership.mixed-capture.normal", + "ownership.mixed-capture.stop", + "ownership.mixed-capture.inherited", + "ownership.mixed-capture.abrupt", + "ownership.mixed-capture.timeout", + "ownership.mixed-capture.inherited-timeout", + "ownership.mixed-capture.primary-secondary", + "ownership.mixed-capture.cleanup-only" + ], + "sourceTemplateCount": 126, + "sourceMap": "Ownership\\mixed-source-map.json" + }, + { + "id": "ownership.legacy.peer-emission", + "owner": "Ownership", + "minimumCount": 1 + }, + { + "id": "ownership.legacy.rename-submit", + "owner": "Ownership", + "minimumCount": 1 + }, + { + "id": "ownership.legacy.fresh-capture", + "owner": "Ownership", + "minimumCount": 1 + }, + { + "id": "ownership.legacy.packaging-shared-linkage", + "owner": "Ownership", + "minimumCount": 1 + }, + { + "id": "ownership.legacy.uia-shared-linkage", + "owner": "Ownership", + "minimumCount": 1 + }, + { + "id": "ownership.ui-startup.helpers", + "owner": "Ownership", + "minimumCount": 13, + "exactCount": 13 + }, + { + "id": "ownership.ui-startup.redacted-log", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.ui-startup.truncated-log", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.ui-startup.missing-log", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.ui-startup.retained-report", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.ui-process-tree.controlled-child", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.ui-process-tree.cleanup-complete", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.ui-process-tree.pid-reuse", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + } + ] +} diff --git a/Tools/windows/ValidationRunner/Manifests/ui.json b/Tools/windows/ValidationRunner/Manifests/ui.json new file mode 100644 index 00000000..1586a752 --- /dev/null +++ b/Tools/windows/ValidationRunner/Manifests/ui.json @@ -0,0 +1,1823 @@ +{ + "units": [ + { "id": "ui.control-click.flat-root", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.control-click.nested-root", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.control-click.same-pid-unrelated-root", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.control-click.mismatched-parent", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.control-click.foreign-root", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.delete.restore.source-order", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.delete.restore.owned-navigation", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.delete.restore.foreground-refused", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.delete.restore.missing-route", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.delete.restore.foreign-route", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.delete.restore.offscreen-route", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.delete.restore.missing-card", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.delete.restore.wrong-card-id", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.delete.restore.foreign-card", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.delete.restore.wrong-fit-id", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.delete.restore.foreign-fit", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.delete.restore.retained-workspace", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.delete.restore.wrong-final-id", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.delete.restore.foreign-final-card", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.delete.restore.offscreen-final-card", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.delete.restore.foreign-final-graph", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.delete.restore.lost-selection", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.delete.cleanup.source-order", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.delete.cleanup.success-clears", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.delete.cleanup.failed-removal-retains", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { + "id": "ui.activation.foreground-refused", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.activation.foreground-acquired", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.activation.ownership-refused", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.activation.invalid-window-refused", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.owned-key", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.foreign-foreground", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.foreign-focus", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.sibling-focus", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.missing-focus", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.dead-retained-process", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.hidden-window", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.disabled-window", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.owned-unicode", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.unicode-foreign-focus", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.owned-mouse", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.mouse-hit-loss", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.mouse-cursor-loss", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.mouse-foreign-foreground", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.owned-edit", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.edit-focus-loss-after-select", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.edit-focus-loss-after-read", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.owned-submenu-hover", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.submenu-foreign-foreground", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.submenu-unretained-process", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.submenu-covered-point", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.predicate.owned-shell", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.predicate.owned-popup", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.predicate.foreign-foreground", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.predicate.foreign-foreground-root", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.predicate.foreign-hit", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.predicate.underlying-shell-hit", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.predicate.hidden-popup", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.predicate.unknown-owner", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.predicate.foreign-popup", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.predicate.absent-popup", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.effect.owned", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.effect.owned-enter", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.effect.foreign-foreground", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.effect.foreign-after-hover", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.effect.foreign-before-enter", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.effect.foreign-owner-all", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.effect.foreign-hit", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.effect.underlying-shell-hit", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.effect.hidden-popup", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.effect.cursor-changed", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.effect.cursor-unreadable", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.geometry.seven-pixel-band", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.geometry.multiple-covers", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.gate-state.activation-reference", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.gate-state.input-reference", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native.lookup.named-direct-viewport", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native.lookup.hidden-write-read", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native.lookup.missing-id", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native.lookup.missing-write-read", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native.lookup.direct-precedence", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native.lookup.wrong-root", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native.lookup.wrong-viewport", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native.lookup.other-sibling", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native.lookup.no-deeper-fallback", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native.lookup.direct-viewport-required", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native.lookup.native-form", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native.lookup.workspace-manager", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native.lookup.unknown-content", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native.lookup.null-parent", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native.hit.nested-edit", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native.hit.content-background", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native.hit.wrong-root", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native.hit.wrong-container", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native.hit.no-recursion", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native.hit.direct-child", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.host-info.current-session", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.activation.source.actual", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.activation.source.unshared", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.activation.source.missing-initial-owner", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.activation.source.missing-fresh-owner", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.activation.source.missing-restore", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.activation.source.missing-native-error", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.source.actual", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.source.bypass-guard", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.source.stale-foreground", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.source.stale-cursor", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.source.missing-held-owner-check", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.source.missing-pre-cursor-guard", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.source.missing-pre-enter-guard", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.source.wrong-hit-root", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.source.extra-input", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.mock.isolation.absent", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.mock.isolation.empty", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.mock.isolation.valued", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.mock.isolation.failure-identity", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.mock.isolation.ordinary-diagnostic", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.recovery.visibility.initially-empty", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.recovery.visibility.initially-offscreen", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.recovery.visibility.already-visible", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.recovery.visibility.mark-only-empty", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.recovery.visibility.delayed-visible", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.recovery.visibility.outside-before-fit", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.recovery.visibility.wrong-source-owner", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.recovery.visibility.wrong-fit-owner", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.recovery.visibility.fit-offscreen", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.recovery.visibility.fit-disabled", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.recovery.visibility.remains-empty", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.recovery.visibility.remains-offscreen", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.recovery.visibility.wrong-id-after-fit", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.recovery.visibility.wrong-owner-after-fit", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.recovery.visibility.outside-after-fit", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.recovery.visibility.missing-after-fit", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.recovery.visibility.mutant-skip-fit", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.recovery.visibility.mutant-unsafe-offscreen-bounds", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.recovery.visibility.mutant-wrong-identity-accepted", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.recovery.visibility.mutant-outside-bounds-accepted", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.visibility.actual", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.visibility.missing-fit", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.visibility.wrong-identity", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.visibility.missing-offscreen", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.visibility.ungated-bounds", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.visibility.escaped-bounds", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.visibility.stale-visibility", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.visibility.missing-containment", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.visibility.missing-require", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.rejection", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.success", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.unavailable", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.foreign-pid", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.identity-drift", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.zero-bounds", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.offscreen", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.disabled", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.selected", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.runtime-unavailable", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.parent-unavailable", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.telemetry-write-failure", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.receipt-exists", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.receipt-unconfigured", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.deadline", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.long-identity", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.long-runtime", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.invalid-boolean", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.nonfinite-bounds", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.exception-chain", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.foreign-parent", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.host-info.diagnostics", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.production.consumer-linkage", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5342", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5350", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5369", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5528", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5545", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5572", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5584", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5973", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5979", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5987", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6062", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6097", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6100", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6165", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6171", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6199", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6210", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6215", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6221", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6226", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6231", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6246", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6250", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6257", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6278", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6285", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6339", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6345", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6355", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6417", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6426", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6430", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6436", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6440", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6446", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6454", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6462", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6472", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6514", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6520", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6526", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6542", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6551", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6677", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6680", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6773", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6788", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6801", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6909", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6974", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-7042", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-7348", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-7351", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-7355", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-7365", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-7381", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-7387", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-7396", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.evaluator.unknown-operator", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.evaluator.unknown-resource", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.evaluator.missing-file", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.evaluator.malformed-tree", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.evaluator.actual-predicate-false", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.evaluator.wrong-production-text", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.evaluator.depth-boundary", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.evaluator.depth-overflow", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5366", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5380", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5392", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5396", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5399", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5407", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5410", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5432", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5449", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5457", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5462", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5470", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5477", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5485", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5501", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5514", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5524", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5537", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5553", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5561", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5569", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5580", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5632", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5659", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5683", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5696", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5713", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5724", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5739", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5752", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5761", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5773", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5783", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5792", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5807", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5812", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5897", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5905", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5914", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5918", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6057", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6093", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6157", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6182", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6276", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6292", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6298", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6305", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6314", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6326", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6352", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6412", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6467", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6591", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6654", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6754", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6770", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6853", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6864", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6866", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6870", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6877", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6892", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6905", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6921", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6925", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6939", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6958", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6967", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-7027", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-7136", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-7235", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-7326", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-7337", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-7344", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.connector.blank-title", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.connector.different-target-title", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.connector.duplicate-source", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.connector.duplicate-target", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.connector.missing-source", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.connector.missing-target", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.connector.owned-pair", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.connector.same-endpoint", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.promotion.disabled-action", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.promotion.enabled-heading", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.promotion.exact", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.promotion.extra-item", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.promotion.reordered", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.promotion.separator-heading", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.promotion.wrong-caption", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + } + ] +} diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/AdapterSupport.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/AdapterSupport.ps1 new file mode 100644 index 00000000..beaa276b --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/AdapterSupport.ps1 @@ -0,0 +1,108 @@ +$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) +$script:OwnershipCases = [Collections.Generic.List[object]]::new() + +trap { + if (-not $script:OwnershipMockSignals -and $_.Exception.Data['GraphCodeValidationRootRetentionRequired'] -eq $true) { + try { + [Console]::Out.WriteLine('GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven') + [Console]::Out.Flush() + } finally { [Threading.Thread]::Sleep([Threading.Timeout]::Infinite) } + } + throw +} + +function Import-OwnershipBindings([string] $RepositoryRoot, [string] $AssemblyPath, + [string] $RequiredType = 'GraphCode.Validation.Ownership.StandaloneProcessJob') { + & (Join-Path $RepositoryRoot 'Tools\windows\Import-CompiledValidationAssembly.ps1') ` + -AssemblyPath $AssemblyPath -RequiredType $RequiredType | Out-Null +} + +function Import-OwnershipCaptureFunctions([string] $RepositoryRoot, [string] $AssemblyPath) { + Import-OwnershipBindings $RepositoryRoot $AssemblyPath + Import-Module (Join-Path $RepositoryRoot 'Tools\windows\ShellProcessOwnership.psm1') -Force -ErrorAction Stop + $gate = Read-OwnershipProductionAst (Join-Path $RepositoryRoot 'Tools\windows\uia-live-gate.ps1') + $context = @{ + '$PSScriptRoot' = ("'" + (Join-Path $RepositoryRoot 'Tools\windows').Replace("'", "''") + "'") + '$validationAssemblyPath' = ("'" + $AssemblyPath.Replace("'", "''") + "'") + } + . Import-OwnershipProductionFunctions $gate @('Initialize-MultiProjectProcessJob', 'Initialize-UiaOwnedStreamCapture') $context + . Import-OwnershipProductionFunctions $gate @('New-UiaOwnershipFailure', 'Start-MultiProjectOwnedProcess', + 'Wait-MultiProjectCapture', 'Complete-MultiProjectCapture') +} + +function Read-OwnershipProductionAst([string] $Path) { + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile($Path, [ref]$null, [ref]$errors) + if ($errors.Count -ne 0) { throw "Production source failed to parse: $Path" } + return $ast +} + +function Import-OwnershipProductionFunctions($Ast, [string[]] $Names, [hashtable] $Replacements = @{}) { + foreach ($name in $Names) { + $definitions = @($Ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq $name + }, $true)) + if ($definitions.Count -ne 1) { throw "Expected one complete production function: $name" } + $text = $definitions[0].Extent.Text + foreach ($key in $Replacements.Keys) { $text = $text.Replace($key, $Replacements[$key]) } + . ([scriptblock]::Create($text)) + } +} + +function Assert-Ownership([bool] $Condition, [string] $Message) { + if (-not $Condition) { throw [InvalidOperationException]::new($Message) } +} + +function Invoke-OwnershipCase([string] $Id, [scriptblock] $Body) { + if (@($script:OwnershipCases | Where-Object Id -CEQ $Id).Count -ne 0) { throw "Duplicate ownership case: $Id" } + try { + & $Body 6>&1 | ForEach-Object { + if ([string]$_ -cne 'GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven' -or + -not $script:OwnershipMockSignals) { $_ } + } | Out-Null + $script:OwnershipCases.Add([ordered]@{ Id = $Id; Passed = $true; Failure = $null }) + } catch { + if (-not $script:OwnershipMockSignals -and $_.Exception.Data['GraphCodeValidationRootRetentionRequired'] -eq $true) { + [Console]::Out.WriteLine('GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven') + [Console]::Out.Flush() + [Threading.Thread]::Sleep([Threading.Timeout]::Infinite) + } + $script:OwnershipCases.Add([ordered]@{ Id = $Id; Passed = $false; Failure = $_.ToString() }) + } +} + +function Complete-OwnershipFamily([string] $Id, [int] $Expected, [string[]] $Groups = @('powershell-semantics')) { + $failed = @($script:OwnershipCases | Where-Object { -not $_.Passed }) + $passed = $script:OwnershipCases.Count -eq $Expected -and $failed.Count -eq 0 -and $Expected -gt 0 + $row = [ordered]@{ + id = $Id + status = $(if ($passed) { 0 } else { 1 }) + executedCount = $script:OwnershipCases.Count + evidence = (@{ + expected = $Expected + executed = $script:OwnershipCases.Count + failed = $failed.Count + cases = $script:OwnershipCases.ToArray() + nativeProductCalls = 0 + } | ConvertTo-Json -Depth 12 -Compress) + groups = $Groups + } + Write-Output ('OWNERSHIP_CASE_RESULT=' + ($row | ConvertTo-Json -Depth 14 -Compress)) + $script:OwnershipCases.Clear() + if (-not $passed) { $script:OwnershipAdapterFailed = $true } +} + +function Restore-OwnershipEnvironment([Collections.IDictionary] $Snapshot, [string[]] $Names) { + foreach ($name in $Names) { + if ($Snapshot.Contains($name)) { [Environment]::SetEnvironmentVariable($name, $Snapshot[$name], 'Process') } + else { Remove-OwnershipEnvironment $name } + } +} + +function Remove-OwnershipEnvironment([string] $Name) { + if ([Environment]::GetEnvironmentVariables('Process').Contains($Name)) { + Remove-Item -LiteralPath "Env:\$Name" -ErrorAction Stop + } +} diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/Capture.Contracts.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/Capture.Contracts.ps1 new file mode 100644 index 00000000..9821d36c --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/Capture.Contracts.ps1 @@ -0,0 +1,165 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) + +. (Join-Path $PSScriptRoot 'AdapterSupport.ps1') +$script:OwnershipMockSignals = $true +Import-OwnershipBindings $RepositoryRoot $AssemblyPath +Import-OwnershipBindings $RepositoryRoot $AssemblyPath 'GraphCode.Validation.Ownership.OwnershipJobFixture' +Import-Module (Join-Path $RepositoryRoot 'Tools\windows\ShellProcessOwnership.psm1') -Force -ErrorAction Stop +$gate = Read-OwnershipProductionAst (Join-Path $RepositoryRoot 'Tools\windows\uia-live-gate.ps1') +$jobReplacements = @{ + '[StandaloneProcessJob]' = '[GraphCode.Validation.Ownership.OwnershipJobFixture]' + '[GraphCode.Validation.Ownership.StandaloneProcessJob]' = '[GraphCode.Validation.Ownership.OwnershipJobFixture]' +} +. Import-OwnershipProductionFunctions $gate @('New-UiaOwnershipFailure', 'Complete-MultiProjectCapture', + 'Start-UiaOwnedProcess', 'Invoke-UiaCleanupActions') $jobReplacements +$saved = [Environment]::GetEnvironmentVariables('Process') +Remove-OwnershipEnvironment 'GRAPHCODE_VALIDATION_ROOT' +try { + Invoke-OwnershipCase 'cleanup-all-actions-preserve-first-and-retention' { + $state = @{ calls = 0 } + $first = [IO.IOException]::new('original cleanup error') + $tagged = New-UiaOwnershipFailure 'later ownership refusal' 6>$null + $failure = $null + try { + Invoke-UiaCleanupActions @( + { $state.calls++; throw $first }, + { $state.calls++; throw $tagged }, + { $state.calls++ } + ) + } catch { $failure = $_ } + Assert-Ownership ($state.calls -eq 3 -and $failure -and + [Object]::ReferenceEquals($failure.Exception.InnerExceptions[0], $first) -and + $failure.Exception.Data['GraphCodeValidationRootRetentionRequired'] -eq $true) 'Cleanup skipped an action, replaced first exception identity, or lost later retention.' + } + $boundaries = @($gate.FindAll({ + param($node) + $node -is [Management.Automation.Language.IfStatementAst] -and + $node.Extent.Text.StartsWith('if ($cleanupFailures.Count -gt 0) {', [StringComparison]::Ordinal) + }, $true)) + if ($boundaries.Count -ne 1) { throw 'Expected one actual late multi-project cleanup boundary.' } + foreach ($hasPrimary in @($false, $true)) { + Invoke-OwnershipCase "late-cleanup-primary-$hasPrimary" { + $cleanupFailures = [Collections.Generic.List[Exception]]::new() + $first = [IO.IOException]::new('first phase cleanup error') + $cleanupFailures.Add($first) + $cleanupFailures.Add((New-UiaOwnershipFailure 'later phase ownership refusal' 6>$null)) + $primaryError = if ($hasPrimary) { + [Management.Automation.ErrorRecord]::new([IO.IOException]::new('original phase error'), 'Phase', 'NotSpecified', $null) + } else { $null } + $original = $primaryError + $failure = $null + try { . ([scriptblock]::Create($boundaries[0].Extent.Text)) } catch { $failure = $_ } + $reported = if ($hasPrimary) { $primaryError.Exception } else { $failure.Exception } + Assert-Ownership ($reported -and $reported.Data['GraphCodeValidationRootRetentionRequired'] -eq $true -and + ((-not $hasPrimary -and [Object]::ReferenceEquals($reported.InnerExceptions[0], $first)) -or + ($hasPrimary -and -not $failure -and [Object]::ReferenceEquals($primaryError, $original) -and + $reported.Message -ceq 'original phase error'))) 'Late cleanup lost ErrorRecord/original exception identity or retention intent.' + } + } + foreach ($variant in @('assigned-success', 'unassigned-barrier', 'root-survivor', 'job-survivor', 'reader-fault', 'primary-and-survivor')) { + Invoke-OwnershipCase $variant { + $state = @{ kills = 0; disposed = 0 } + $root = [pscustomobject]@{ + HasExited = $false + ExitVerified = $variant -cne 'root-survivor' + StandardOutput = [IO.StreamReader]::new([IO.MemoryStream]::new()) + StandardError = [IO.StreamReader]::new([IO.MemoryStream]::new()) + } + $root | Add-Member ScriptMethod Kill { $state.kills++; $this.HasExited = $true } + $root | Add-Member ScriptMethod WaitForExit { $this.ExitVerified } + $root | Add-Member ScriptMethod Dispose { $state.disposed++ } + [GraphCode.Validation.Ownership.OwnershipJobFixture]::Empty = $variant -notin @('job-survivor', 'primary-and-survivor') + [GraphCode.Validation.Ownership.OwnershipJobFixture]::Terminated = 0 + [GraphCode.Validation.Ownership.OwnershipJobFixture]::Closed = 0 + $capture = [ordered]@{ + process = $root; rootProcess = $root; rootPID = 44; rootStartUtcTicks = 1 + targetPID = 44; targetStartUtcTicks = 1; job = [IntPtr]12 + assigned = $variant -cne 'unassigned-barrier'; stem = 'controlled'; streamingLogs = $true; cleaned = $false + stdout = [Threading.Tasks.Task]::FromResult[string]('stdout') + stderr = [Threading.Tasks.Task]::FromResult[string]('stderr') + } + if ($variant -ceq 'reader-fault') { + $capture.stdout = [Threading.Tasks.Task]::FromException[string]([IO.IOException]::new('original reader failure')) + } + $primary = if ($variant -ceq 'primary-and-survivor') { + [Management.Automation.ErrorRecord]::new([IO.IOException]::new('original error'), 'Original', 'NotSpecified', $null) + } else { $null } + $originalPrimary = $primary + $failure = $null + try { $null = Complete-MultiProjectCapture $capture $ArtifactRoot 1000 $primary } catch { $failure = $_ } + $succeeded = $variant -in @('assigned-success', 'unassigned-barrier') + if ($succeeded) { + Assert-Ownership (-not $failure -and $capture.cleaned -and + [GraphCode.Validation.Ownership.OwnershipJobFixture]::Closed -eq 1 -and $state.disposed -eq 1 -and + $state.kills -eq [int]($variant -ceq 'unassigned-barrier')) 'Assigned job or unreleased retained barrier cleanup changed.' + } else { + $reported = if ($primary) { $primary.Exception } else { $failure.Exception } + Assert-Ownership ($reported -and $reported.Data['GraphCodeValidationRootRetentionRequired'] -eq $true -and + -not $capture.cleaned -and [GraphCode.Validation.Ownership.OwnershipJobFixture]::Closed -eq 0 -and + $state.disposed -eq 0 -and $state.kills -eq 0) 'UNKNOWN cleanup released authority/readers or lost retention.' + if ($primary) { + Assert-Ownership (-not $failure -and [Object]::ReferenceEquals($primary, $originalPrimary) -and + $primary.Exception.Message -ceq 'original error') 'Cleanup replaced the original ErrorRecord.' + } + $root.StandardOutput.Dispose() + $root.StandardError.Dispose() + } + } + } + $path = Join-Path $ArtifactRoot ('capture-' + [guid]::NewGuid().ToString('N') + '.log') + $reader = [IO.StreamReader]::new([IO.MemoryStream]::new([Text.Encoding]::UTF8.GetBytes("first`nsecond"))) + try { + Invoke-OwnershipCase 'shared-raw-stream' { + $task = [GraphCode.Validation.Ownership.UiaOwnedStreamCapture]::Read($reader, $path) + $text = $task.GetAwaiter().GetResult() + Assert-Ownership ($text -ceq "first`nsecond" -and [IO.File]::ReadAllText($path) -ceq $text) 'Complete production stream capture changed raw output.' + } + Invoke-OwnershipCase 'shared-create-new-evidence' { + $failure = $null + try { $null = [GraphCode.Validation.Ownership.UiaOwnedStreamCapture]::Read($reader, $path) } catch { $failure = $_ } + Assert-Ownership ($failure -and [IO.File]::ReadAllText($path) -ceq "first`nsecond") 'Production stream capture overwrote evidence.' + } + } finally { $reader.Dispose() } + $launch = @{} + function Start-MultiProjectOwnedProcess { + param($file, $arguments, $environment, $stem, [switch]$Visible, [switch]$RegisterForUia, + $standardOutputPath, $standardErrorPath, $logDirectory, $targetWorkingDirectory) + $launch.arguments = $arguments; $launch.environment = $environment + $launch.visible = $Visible; $launch.registered = $RegisterForUia + $launch.cwd = $targetWorkingDirectory; $launch.stdout = $standardOutputPath + return @{ process = 'controlled-owned-target' } + } + foreach ($hidden in @($false, $true)) { + Invoke-OwnershipCase "owned-routing-hidden-$hidden" { + $stderr = Join-Path $ArtifactRoot 'fixture-stderr.log' + $result = Start-UiaOwnedProcess -file 'controlled.exe' -arguments @('space argument', '', '"literal"') -standardErrorPath $stderr -Hidden:$hidden + Assert-Ownership ($result -ceq 'controlled-owned-target' -and $launch.arguments.Count -eq 3 -and + $launch.arguments[0] -ceq 'space argument' -and $launch.arguments[1] -ceq '' -and + $launch.arguments[2] -ceq '"literal"' -and $launch.visible -eq (-not $hidden) -and + $launch.registered -and $launch.cwd -ceq (Get-Location).ProviderPath -and + $launch.environment.TEMP -ceq $env:TEMP -and + $launch.stdout -ceq (Join-Path $ArtifactRoot 'fixture-stdout.log')) 'Actual launch wrapper changed argv/environment/cwd/visibility/stdio/registry routing.' + } + } + Invoke-OwnershipCase 'actual-assignment-membership-retain-order' { + $definition = $gate.Find({ param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq 'Start-MultiProjectOwnedProcess' + }, $true) + Assert-Ownership ($null -ne $definition) 'Actual owned launcher missing.' + $source = $definition.Extent.Text + $assign = $source.IndexOf('::Assign') + $release = $source.IndexOf('[IO.File]::WriteAllText($gate, "assigned")') + $member = $source.IndexOf('::RequireJobMember') + $retain = $source.IndexOf('[GraphCodeUiaGateState]::RetainProcess') + Assert-Ownership ($assign -ge 0 -and $release -gt $assign -and $member -gt $release -and $retain -gt $member) 'Actual gate lost job assignment, native membership or retained input authority order.' + } +} finally { + Restore-OwnershipEnvironment $saved @('GRAPHCODE_VALIDATION_ROOT') +} +Complete-OwnershipFamily 'ownership.legacy.capture-ownership' 14 +if ($script:OwnershipAdapterFailed) { exit 1 } diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/ControlledCapture.Contracts.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/ControlledCapture.Contracts.ps1 new file mode 100644 index 00000000..de8c36a3 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/ControlledCapture.Contracts.ps1 @@ -0,0 +1,131 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) + +. (Join-Path $PSScriptRoot 'AdapterSupport.ps1') +Import-OwnershipBindings $RepositoryRoot $AssemblyPath 'GraphCodeUiaGateState' +Import-Module (Join-Path $RepositoryRoot 'Tools\windows\ShellProcessOwnership.psm1') -Force -ErrorAction Stop +$gatePath = Join-Path $RepositoryRoot 'Tools\windows\uia-live-gate.ps1' +$gate = Read-OwnershipProductionAst $gatePath +$windows = Join-Path $RepositoryRoot 'Tools\windows' +$sourceContext = @{ + '$PSScriptRoot' = ("'" + $windows.Replace("'", "''") + "'") + '$validationAssemblyPath' = ("'" + $AssemblyPath.Replace("'", "''") + "'") +} +. Import-OwnershipProductionFunctions $gate @('Initialize-MultiProjectProcessJob', 'Initialize-UiaOwnedStreamCapture') $sourceContext +. Import-OwnershipProductionFunctions $gate @('New-UiaOwnershipFailure', 'Protect-UiaStartupDiagnosticText', + 'Get-UiaOwnedProcessDescendants', 'Stop-UiaOwnedProcessTrees', 'Start-MultiProjectOwnedProcess', + 'Wait-MultiProjectCapture', 'Complete-MultiProjectCapture') +$childScript = Join-Path $PSScriptRoot 'ControlledCaptureChild.ps1' +$hostPath = Join-Path $PSHOME 'pwsh.exe' +$jobSource = Join-Path $windows 'Tests\Packaging.Standalone.Tests.ps1' +function Start-ControlledCapture([string] $Root, [string] $Mode, [switch] $Register) { + return Start-MultiProjectOwnedProcess -file $hostPath ` + -arguments @('-NoProfile', '-NonInteractive', '-File', $childScript, '-Root', $Root, '-Mode', $Mode) ` + -environment @{ TEMP = $Root; TMP = $Root } -stem 'controlled' -jobSourcePath $jobSource -RegisterForUia:$Register +} +function Wait-ControlledReady([string] $Root) { + $clock = [Diagnostics.Stopwatch]::StartNew() + while (-not [IO.File]::Exists((Join-Path $Root 'ready'))) { + if ($clock.ElapsedMilliseconds -ge 5000) { throw 'Controlled root did not become ready within its original deadline.' } + [Threading.Thread]::Sleep(10) + } +} +foreach ($mode in @('normal', 'stop', 'inherited', 'abrupt', 'timeout', 'inherited-timeout', 'primary-secondary', 'cleanup-only')) { + Invoke-OwnershipCase $mode { + $root = Join-Path $env:TEMP ('owned-capture-' + [guid]::NewGuid().ToString('N')) + $null = New-Item -ItemType Directory -Path $root + $capture = $null + $lock = $null + $primary = $null + try { + $capture = Start-ControlledCapture $root $mode + Wait-ControlledReady $root + if ($mode -in @('timeout', 'inherited-timeout')) { + try { Wait-MultiProjectCapture $capture 500 } catch { $primary = $_ } + $expected = if ($mode -ceq 'timeout') { 'MULTIPROJECT_CAPTURE_EXIT' } else { 'MULTIPROJECT_CAPTURE_DRAIN' } + Assert-Ownership ($primary -and $primary.Exception.Message.StartsWith($expected, [StringComparison]::Ordinal)) "Actual $expected timeout path did not run." + } elseif ($mode -ceq 'inherited') { + Assert-Ownership ($capture.rootProcess.WaitForExit(5000) -and -not $capture.stdout.IsCompleted -and + [GraphCode.Validation.Ownership.StandaloneProcessJob]::Active($capture.job) -gt 0) 'No actual inherited-open-pipe/job condition was exercised.' + } elseif ($mode -cne 'stop') { + Wait-MultiProjectCapture $capture 5000 + Assert-Ownership ($capture.rootProcess.ExitCode -eq $(if ($mode -ceq 'abrupt') { 23 } else { 0 })) 'Actual controlled root exit code differs.' + } + if ($mode -in @('primary-secondary', 'cleanup-only')) { + $lock = [IO.File]::Open((Join-Path $root 'controlled-stdout.log'), [IO.FileMode]::OpenOrCreate, + [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) + if ($mode -ceq 'primary-secondary') { + try { throw [IO.IOException]::new('controlled original primary failure') } catch { $primary = $_ } + } + } + $original = $primary + $clock = [Diagnostics.Stopwatch]::StartNew() + if ($mode -ceq 'cleanup-only') { + $failure = $null + try { $null = Complete-MultiProjectCapture $capture $root 3000 } catch { $failure = $_ } + Assert-Ownership ($failure -and $failure.Exception.Message.StartsWith('MULTIPROJECT_TEARDOWN:', [StringComparison]::Ordinal) -and + $failure.Exception.Message.Contains('stdout capture persistence') -and $failure.Exception.Message.Contains('pid=') -and + $failure.Exception.Message.Contains('start=') -and $failure.Exception.Message.Contains('job=') -and $capture.cleaned) 'Cleanup-only error lost exact identity, persistence failure, or completed ownership proof.' + } else { + $diagnostic = Complete-MultiProjectCapture $capture $root 3000 $primary + Assert-Ownership ($diagnostic.rootExited -and $diagnostic.ownedJobEmpty -and + $diagnostic.redirectedReadersDrained -and $clock.ElapsedMilliseconds -lt 5000 -and $capture.cleaned) 'Actual shared bounded teardown did not prove root/job/both reader completion.' + if ($mode -ceq 'primary-secondary') { + Assert-Ownership ([Object]::ReferenceEquals($primary, $original) -and $primary.Exception.Message -ceq 'controlled original primary failure' -and + $primary.Exception.Data['MultiProjectTeardown:controlled'].Contains('stdout capture persistence')) 'Cleanup replaced the actual primary ErrorRecord or lost explicit secondary persistence diagnostics.' + } elseif ($primary) { + Assert-Ownership ([Object]::ReferenceEquals($primary, $original) -and $diagnostic.failures.Count -eq 0 -and + $primary.Exception.Data['MultiProjectCapture:controlled'].redirectedReadersDrained) 'Timeout primary lost its identity or positive post-teardown drain diagnostics.' + } + } + [IO.File]::WriteAllText((Join-Path $root 'capture-task-text.json'), (@{ + stdoutType = $capture.stdout.GetType().FullName + stdoutResult = $capture.stdout.Result + stdoutAwait = $capture.stdout.GetAwaiter().GetResult() + stderrResult = $capture.stderr.Result + stderrAwait = $capture.stderr.GetAwaiter().GetResult() + } | ConvertTo-Json -Compress)) + $capture = $null + if ($mode -notin @('primary-secondary', 'cleanup-only')) { + Assert-Ownership ([IO.File]::ReadAllText((Join-Path $root 'controlled-stdout.log')).Contains('owned-capture-stdout') -and + [IO.File]::ReadAllText((Join-Path $root 'controlled-stderr.log')).Contains('owned-capture-stderr')) 'Actual independent stdout/stderr evidence was lost.' + } + } finally { + if ($lock) { $lock.Dispose() } + if ($capture -and -not $capture.cleaned) { $null = Complete-MultiProjectCapture $capture $root 3000 $primary } + } + } + $groups = @('controlled-native', 'bounded-capture-eight-modes') + if ($mode -in @('primary-secondary', 'cleanup-only')) { $groups += 'primary-and-cleanup-errors' } + Complete-OwnershipFamily ("ownership.mixed-capture." + $mode) 1 $groups +} +$treeRoot = Join-Path $env:TEMP ('owned-tree-' + [guid]::NewGuid().ToString('N')) +$null = New-Item -ItemType Directory -Path $treeRoot +$tree = $null +try { + $script:UiaGateOwnedCaptures = [Collections.Generic.List[object]]::new() + Invoke-OwnershipCase 'controlled-child' { + $script:OwnershipControlledTree = Start-ControlledCapture $treeRoot 'tree' -Register + $tree = $script:OwnershipControlledTree + Wait-ControlledReady $treeRoot + $descendants = @(Get-UiaOwnedProcessDescendants @($tree.process.Id)) + Assert-Ownership ($descendants.Count -gt 0) 'Actual advisory traversal missed the controlled child.' + Assert-Ownership ([GraphCode.Validation.Ownership.StandaloneProcessJob]::Contains($tree.job, $tree.process.Handle) -and + [GraphCode.Validation.Ownership.StandaloneProcessJob]::Active($tree.job) -ge 2) 'Actual retained target lacks native job membership or a positive owned census.' + } + $tree = $script:OwnershipControlledTree + Complete-OwnershipFamily 'ownership.ui-process-tree.controlled-child' 1 @('controlled-native') + Invoke-OwnershipCase 'cleanup-complete' { + Assert-Ownership ($null -ne $tree) 'Controlled tree prerequisite did not launch.' + Stop-UiaOwnedProcessTrees @($tree.process) + Assert-Ownership $tree.cleaned 'Actual registered gate tree cleanup returned without completing root/job/readers.' + } + Complete-OwnershipFamily 'ownership.ui-process-tree.cleanup-complete' 1 @('controlled-native') +} finally { + if ($tree -and -not $tree.cleaned) { Stop-UiaOwnedProcessTrees @($tree.process) } +} +if ($script:OwnershipAdapterFailed) { exit 1 } diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/ControlledCaptureChild.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/ControlledCaptureChild.ps1 new file mode 100644 index 00000000..80188b8e --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/ControlledCaptureChild.ps1 @@ -0,0 +1,46 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $Root, + [Parameter(Mandatory)][string] $Mode, + [switch] $Descendant +) +$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) +if ($Descendant) { + [IO.File]::WriteAllText((Join-Path $Root 'descendant-ready'), 'ready') + [Threading.Thread]::Sleep(30000) + exit 0 +} +[Threading.Thread]::Sleep(300) +[IO.File]::WriteAllText((Join-Path $Root 'console-context.json'), (@{ + processId = $PID + stdoutRedirected = [Console]::IsOutputRedirected + stderrRedirected = [Console]::IsErrorRedirected + stdoutStreamType = [Console]::OpenStandardOutput().GetType().FullName + stderrStreamType = [Console]::OpenStandardError().GetType().FullName +} | ConvertTo-Json -Compress)) +[Console]::Out.WriteLine('owned-capture-stdout') +[Console]::Error.WriteLine('owned-capture-stderr') +if ($Mode -in @('inherited', 'inherited-timeout', 'tree')) { + $start = [Diagnostics.ProcessStartInfo]::new((Join-Path $PSHOME 'pwsh.exe')) + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + foreach ($argument in @('-NoProfile', '-NonInteractive', '-File', $PSCommandPath, '-Root', $Root, '-Mode', $Mode, '-Descendant')) { + $start.ArgumentList.Add($argument) + } + $child = [Diagnostics.Process]::Start($start) + try { + [IO.File]::WriteAllText((Join-Path $Root 'descendant.json'), (@{ + pid = $child.Id + startUtcTicks = $child.StartTime.ToUniversalTime().Ticks + } | ConvertTo-Json -Compress)) + $deadline = [Diagnostics.Stopwatch]::StartNew() + while (-not [IO.File]::Exists((Join-Path $Root 'descendant-ready'))) { + if ($deadline.ElapsedMilliseconds -ge 5000) { throw 'Controlled descendant was not ready within its original deadline.' } + [Threading.Thread]::Sleep(10) + } + } finally { $child.Dispose() } +} +[IO.File]::WriteAllText((Join-Path $Root 'ready'), 'ready') +if ($Mode -in @('stop', 'timeout', 'tree')) { [Threading.Thread]::Sleep(30000) } +if ($Mode -ceq 'abrupt') { exit 23 } diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/MixedPipe.Contracts.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/MixedPipe.Contracts.ps1 new file mode 100644 index 00000000..ffbbd674 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/MixedPipe.Contracts.ps1 @@ -0,0 +1,119 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) + +. (Join-Path $PSScriptRoot 'AdapterSupport.ps1') +. Import-OwnershipCaptureFunctions $RepositoryRoot $AssemblyPath +$stub = Join-Path $RepositoryRoot 'Tools\windows\Stub-Daemon.ps1' +$stubAst = Read-OwnershipProductionAst $stub +. Import-OwnershipProductionFunctions $stubAst @('Assert-MultiProjectJsonProperties', 'ConvertFrom-MultiProjectJson') +$root = Join-Path $env:TEMP ('actual-mixed-pipe-' + [guid]::NewGuid().ToString('N')) +$alpha = Join-Path $root 'Alpha' +$beta = Join-Path $root 'Beta' +$null = New-Item -ItemType Directory -Path $alpha, $beta +$alphaId = '11111111-1111-4111-8111-111111111111' +$betaId = '22222222-2222-4222-8222-222222222222' +$listId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' +$renameId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +$token = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' +$capture = $null +$client = $null +$primary = $null +function Send-PipeFrame($Frame) { + $json = ConvertTo-Json -InputObject $Frame -Depth 16 -Compress + $deadline = [GraphCode.Validation.Ownership.Deadline]::new([TimeSpan]::FromSeconds(5)) + [GraphCode.Validation.Ownership.PipeFrames]::WriteAsync($client, $json, $deadline, 7, + [Threading.CancellationToken]::None).GetAwaiter().GetResult() +} +function Read-PipeFrame { + $deadline = [GraphCode.Validation.Ownership.Deadline]::new([TimeSpan]::FromSeconds(5)) + $document = [GraphCode.Validation.Ownership.PipeFrames]::ReadAsync($client, $deadline, + [Threading.CancellationToken]::None).GetAwaiter().GetResult() + if ($null -eq $document) { throw 'Actual owned pipe ended before an expected frame.' } + try { return ConvertFrom-MultiProjectJson $document.RootElement.GetRawText() } + finally { $document.Dispose() } +} +function Start-ProtocolPeer([string] $Pipe, [string] $Result, [switch] $Multi) { + $arguments = @('-NoProfile', '-NonInteractive', '-File', $stub, '-PipeName', $Pipe, '-ResultPath', $Result) + if ($Multi) { + $arguments += @('-SeedMultiProjects', '-ProjectAPath', $alpha, '-ProjectBPath', $beta, '-PublicationControlPath', $control) + } + return Start-MultiProjectOwnedProcess -file (Join-Path $PSHOME 'pwsh.exe') -arguments $arguments ` + -environment @{ TEMP = $root; TMP = $root } -stem 'actual-peer' ` + -jobSourcePath (Join-Path $RepositoryRoot 'Tools\windows\Tests\Packaging.Standalone.Tests.ps1') +} +try { + $pipe = 'graphcode-compiled-mixed-' + [guid]::NewGuid().ToString('N') + $control = Join-Path $root 'publish.json' + $capture = Start-ProtocolPeer $pipe (Join-Path $root 'peer.json') -Multi + $client = [IO.Pipes.NamedPipeClientStream]::new('.', $pipe, [IO.Pipes.PipeDirection]::InOut, [IO.Pipes.PipeOptions]::Asynchronous) + $client.Connect(5000) + Send-PipeFrame ([ordered]@{ version = 2; kind = 'hello'; supportedVersions = @(1, 2) }) + $hello = Read-PipeFrame + Invoke-OwnershipCase 'real-hello' { + Assert-Ownership ($hello.selectedVersion -eq 2) 'Actual owned stub did not negotiate v2.' + } + Complete-OwnershipFamily 'ownership.mixed-pipe.source-line-4053' 1 @('owned-real-pipes') + Send-PipeFrame ([ordered]@{ version = 2; kind = 'request'; requestID = $listId; command = [ordered]@{ listRecentProjects = [ordered]@{} } }) + $response = Read-PipeFrame + $first = Read-PipeFrame + $second = Read-PipeFrame + Invoke-OwnershipCase 'fragmented-two-owner-frames' { + Assert-Ownership ($response.requestID -ceq $listId -and $first.sequence -eq 1 -and $second.sequence -eq 2 -and + $first.event.graphChanged.project.path -ceq $alpha -and $second.event.graphChanged.project.path -ceq $beta) 'Actual fragmented wire owner/sequence frames differ.' + } + Complete-OwnershipFamily 'ownership.mixed-pipe.source-line-4056' 1 @('owned-real-pipes') + Send-PipeFrame ([ordered]@{ version = 2; kind = 'request'; requestID = $renameId; command = [ordered]@{ + graphCommand = [ordered]@{ projectPath = $alpha; command = [ordered]@{ renameNode = [ordered]@{ _0 = $alphaId; title = 'Alpha renamed' } } } + } }) + $response = Read-PipeFrame + $publication = Read-PipeFrame + Invoke-OwnershipCase 'real-rename-application' { + Assert-Ownership ($response.requestID -ceq $renameId -and $publication.sequence -eq 3 -and + $publication.event.graphChanged.nodes[0].title -ceq 'Alpha renamed') 'Actual rename application/response/publication differs.' + } + Complete-OwnershipFamily 'ownership.mixed-pipe.source-line-4063' 1 @('owned-real-pipes') + $publicationControl = [ordered]@{ token = $token; projectPath = $alpha; nodeID = $alphaId; title = 'Alpha interleaved'; selection = [ordered]@{ + projectPath = $beta; nodeID = $betaId; source = 'synthetic-client' + } } + [IO.File]::WriteAllText(($control + '.pending'), (ConvertTo-Json -InputObject $publicationControl -Depth 8 -Compress), [Text.UTF8Encoding]::new($false)) + [IO.File]::Move(($control + '.pending'), $control) + $publication = Read-PipeFrame + Invoke-OwnershipCase 'idle-file-controlled-publication' { + Assert-Ownership ($publication.sequence -eq 4 -and $publication.event.graphChanged.project.path -ceq $alpha -and + $publication.event.graphChanged.nodes[0].title -ceq 'Alpha interleaved') 'Actual idle owned pipe did not publish the interleaved Alpha control.' + } + Complete-OwnershipFamily 'ownership.mixed-pipe.source-line-4072' 1 @('owned-real-pipes') + $client.Dispose() + $client = $null + $null = Complete-MultiProjectCapture $capture $root 5000 + $capture = $null + $pipe = 'graphcode-compiled-default-' + [guid]::NewGuid().ToString('N') + $capture = Start-ProtocolPeer $pipe (Join-Path $root 'default-peer.json') + $client = [IO.Pipes.NamedPipeClientStream]::new('.', $pipe, [IO.Pipes.PipeDirection]::InOut, [IO.Pipes.PipeOptions]::Asynchronous) + $client.Connect(5000) + Send-PipeFrame ([ordered]@{ version = 2; kind = 'hello'; supportedVersions = @(1, 2) }) + $hello = Read-PipeFrame + Send-PipeFrame ([ordered]@{ version = 2; kind = 'request'; requestID = $listId; command = [ordered]@{ listRecentProjects = [ordered]@{} } }) + $response = Read-PipeFrame + $publication = Read-PipeFrame + Invoke-OwnershipCase 'real-default-fixture-unchanged' { + Assert-Ownership ($hello.selectedVersion -eq 2 -and $response.requestID -ceq $listId -and + $response.event.recentProjectsListed.Count -eq 1 -and $publication.sequence -eq 1 -and + $publication.event.graphChanged.project.path -ceq 'graphcode://stub/project' -and + $publication.event.graphChanged.nodes.Count -eq 2 -and + $publication.event.graphChanged.nodes[0].id -ceq $alphaId -and $publication.event.graphChanged.nodes[0].title -ceq 'Stub node A' -and + $publication.event.graphChanged.nodes[1].id -ceq $betaId -and $publication.event.graphChanged.nodes[1].title -ceq 'Stub node B') 'The actual unchanged one-project/two-node default fixture changed.' + } + Complete-OwnershipFamily 'ownership.mixed-pipe.source-line-4093' 1 @('owned-real-pipes') +} catch { + $primary = $_ + throw +} finally { + if ($client) { $client.Dispose() } + if ($capture) { $null = Complete-MultiProjectCapture $capture $root 5000 $primary } +} +if ($script:OwnershipAdapterFailed) { exit 1 } diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/MixedProtocol.Contracts.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/MixedProtocol.Contracts.ps1 new file mode 100644 index 00000000..fad00207 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/MixedProtocol.Contracts.ps1 @@ -0,0 +1,208 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) + +. (Join-Path $PSScriptRoot 'AdapterSupport.ps1') +Import-OwnershipBindings $RepositoryRoot $AssemblyPath +foreach ($file in @('Stub-Daemon.ps1', 'uia-live-gate.ps1')) { + $ast = Read-OwnershipProductionAst (Join-Path $RepositoryRoot "Tools\windows\$file") + $names = @($ast.FindAll({ param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + ($node.Name -like '*-MultiProject*' -or $node.Name -ceq 'ConvertTo-SketchCanonicalJson') + }, $true) | ForEach-Object Name) + . Import-OwnershipProductionFunctions $ast $names +} +$root = Join-Path $env:TEMP ('mixed-protocol-' + [guid]::NewGuid().ToString('N')) +$alpha = Join-Path $root 'Alpha' +$beta = Join-Path $root 'Beta' +$null = New-Item -ItemType Directory -Path $alpha, $beta +$alphaId = '11111111-1111-4111-8111-111111111111' +$betaId = '22222222-2222-4222-8222-222222222222' +$listId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' +$renameId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +$token = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' +function New-ProtocolBaseline { + $peer = New-MultiProjectPeer $alpha $beta + $list = [ordered]@{ version = 2; kind = 'request'; requestID = $listId; command = [ordered]@{ listRecentProjects = [ordered]@{} } } + $reply = Invoke-MultiProjectRequest $peer $list + Complete-MultiProjectResponse $peer $reply.response + foreach ($path in @($alpha, $beta)) { + Complete-MultiProjectPublication $peer (New-MultiProjectPublication $peer $path) 'initial' $listId + } + return $peer +} +function New-ProtocolRequest([string] $Title = 'Alpha renamed') { + return [ordered]@{ version = 2; kind = 'request'; requestID = $renameId; command = [ordered]@{ + graphCommand = [ordered]@{ projectPath = $alpha; command = [ordered]@{ + renameNode = [ordered]@{ _0 = $alphaId; title = $Title } + } } + } } +} +function New-ProtocolControl { + return [ordered]@{ token = $token; projectPath = $alpha; nodeID = $alphaId; title = 'Alpha interleaved'; selection = [ordered]@{ + projectPath = $beta; nodeID = $betaId; source = 'synthetic-client' + } } +} +function Assert-ProtocolRejected([scriptblock] $Action, [string] $Prefix) { + $failure = $null + try { & $Action | Out-Null } catch { $failure = $_ } + Assert-Ownership ($failure -and $failure.Exception.Message.StartsWith($Prefix, [StringComparison]::Ordinal)) "Expected specific production protocol rejection $Prefix" +} +function Get-RenamedProtocolSnapshots([string] $Title = 'Alpha renamed') { + $peer = New-ProtocolBaseline + $before = Get-MultiProjectPeerSnapshot $peer + $reply = Invoke-MultiProjectRequest $peer (New-ProtocolRequest $Title) + Complete-MultiProjectResponse $peer $reply.response + Complete-MultiProjectPublication $peer (New-MultiProjectPublication $peer $alpha) 'rename' $renameId + return @{ before = $before; after = (Get-MultiProjectPeerSnapshot $peer) } +} +Invoke-OwnershipCase 'two-positive-owner-publications' { + $snapshot = Get-MultiProjectPeerSnapshot (New-ProtocolBaseline) + Assert-Ownership ($snapshot.requestCount -eq 1 -and $snapshot.responseCount -eq 1 -and $snapshot.graphSequence -eq 2 -and + $snapshot.graphs.Count -eq 2 -and $snapshot.graphs[0].project.path -ceq $alpha -and $snapshot.graphs[1].project.path -ceq $beta -and + $snapshot.graphs[0].nodes[0].id -ceq $alphaId -and $snapshot.graphs[1].nodes[0].id -ceq $betaId) 'Positive owner/UUID/count/sequence fixture changed.' +} +Complete-OwnershipFamily 'ownership.mixed-protocol.source-line-3353' 1 @('request-response-publication') +Invoke-OwnershipCase 'whole-correlated-rename' { + $snapshots = Get-RenamedProtocolSnapshots + Assert-Ownership ((Test-MultiProjectRenameReceipt $snapshots.before $snapshots.after $alpha $alphaId 'Alpha renamed') -and + $snapshots.after.graphs[1].nodes[0].title -ceq 'Beta loop') 'Exact rename receipt was rejected or mutated Beta.' +} +Complete-OwnershipFamily 'ownership.mixed-protocol.source-line-3368' 1 @('request-response-publication') +Invoke-OwnershipCase 'one-shot-interleaved-event' { + $peer = New-ProtocolBaseline + $frame = Invoke-MultiProjectPublicationControl $peer (New-ProtocolControl) + Complete-MultiProjectPublication $peer $frame 'control' $token + $snapshot = Get-MultiProjectPeerSnapshot $peer + Assert-Ownership ($snapshot.requestCount -eq 1 -and $snapshot.responseCount -eq 1 -and $snapshot.appliedCount -eq 0 -and + $snapshot.graphSequence -eq 3 -and $snapshot.graphs[1].nodes[0].title -ceq 'Beta loop') 'Synthetic selection control invented requests or mutated another owner.' +} +Complete-OwnershipFamily 'ownership.mixed-protocol.source-line-3373' 1 @('request-response-publication') +Invoke-OwnershipCase 'unchanged-title-dispatch' { + $snapshots = Get-RenamedProtocolSnapshots 'Alpha loop' + Assert-Ownership (Test-MultiProjectRenameReceipt $snapshots.before $snapshots.after $alpha $alphaId 'Alpha loop') 'Unchanged-title dispatch was suppressed.' +} +Complete-OwnershipFamily 'ownership.mixed-protocol.source-line-3383' 1 @('request-response-publication') +foreach ($foreign in @($false, $true)) { + Invoke-OwnershipCase "resume-foreign-$foreign" { + $peer = New-ProtocolBaseline + $before = ConvertTo-MultiProjectCanonicalJson (Get-MultiProjectPeerSnapshot $peer) + $graphs = ConvertTo-MultiProjectCanonicalJson $peer.graphs + $request = New-ProtocolRequest + $request.command.graphCommand.command = [ordered]@{ resumeSession = [ordered]@{ _0 = $(if ($foreign) { $betaId } else { $alphaId }) } } + if ($foreign) { + Assert-ProtocolRejected { Invoke-MultiProjectRequest $peer $request } 'MULTIPROJECT_OWNER' + Assert-Ownership ((ConvertTo-MultiProjectCanonicalJson (Get-MultiProjectPeerSnapshot $peer)) -ceq $before) 'Rejected foreign session mutated peer state.' + } else { + $reply = Invoke-MultiProjectRequest $peer $request + Assert-Ownership ($reply.response.success -eq $true -and $null -eq $reply.publishPath -and $peer.applied.Count -eq 0 -and + (ConvertTo-MultiProjectCanonicalJson $peer.graphs) -ceq $graphs) 'Resume acknowledgement changed the graph or launched a publication.' + } + } + Complete-OwnershipFamily $(if ($foreign) { 'ownership.mixed-protocol.source-line-3908' } else { 'ownership.mixed-protocol.source-line-3897' }) 1 @('request-response-publication') +} +foreach ($mutation in @('owner', 'node', 'missing', 'extra', 'type', 'version', 'boolean', 'requestID', 'blank')) { + Invoke-OwnershipCase $mutation { + $peer = New-ProtocolBaseline + $before = ConvertTo-MultiProjectCanonicalJson (Get-MultiProjectPeerSnapshot $peer) + $request = New-ProtocolRequest + $prefix = 'MULTIPROJECT_SCHEMA' + switch ($mutation) { + 'owner' { $request.command.graphCommand.projectPath = $beta; $prefix = 'MULTIPROJECT_OWNER' } + 'node' { $request.command.graphCommand.command.renameNode._0 = $betaId; $prefix = 'MULTIPROJECT_OWNER' } + 'missing' { $request.command.graphCommand.command.renameNode.Remove('title') } + 'extra' { $request.command.graphCommand.command.renameNode.extra = 1 } + 'type' { $request.command.graphCommand.command.renameNode.title = 7; $prefix = 'MULTIPROJECT_TYPE' } + 'version' { $request.version = '2'; $prefix = 'MULTIPROJECT_TYPE' } + 'boolean' { $request.version = $true; $prefix = 'MULTIPROJECT_TYPE' } + 'requestID' { $request.requestID = 'bad'; $prefix = 'MULTIPROJECT_UUID' } + 'blank' { $request.command.graphCommand.command.renameNode.title = ' '; $prefix = 'MULTIPROJECT_TITLE' } + } + Assert-ProtocolRejected { Invoke-MultiProjectRequest $peer $request } $prefix + Assert-Ownership ((ConvertTo-MultiProjectCanonicalJson (Get-MultiProjectPeerSnapshot $peer)) -ceq $before) 'Rejected request mutated peer state.' + } +} +Complete-OwnershipFamily 'ownership.mixed-protocol.source-line-3930' 9 @('request-response-publication') +foreach ($json in @('{', '{"x":1,"x":2}', '[]')) { + Invoke-OwnershipCase $json { Assert-ProtocolRejected { ConvertFrom-MultiProjectJson $json } 'MULTIPROJECT_JSON' } +} +Complete-OwnershipFamily 'ownership.mixed-protocol.source-line-3941' 3 @('request-response-publication') +Invoke-OwnershipCase 'duplicate-request' { + $peer = New-ProtocolBaseline + $null = Invoke-MultiProjectRequest $peer (New-ProtocolRequest) + Assert-ProtocolRejected { Invoke-MultiProjectRequest $peer (New-ProtocolRequest) } 'MULTIPROJECT_DUPLICATE' +} +Complete-OwnershipFamily 'ownership.mixed-protocol.source-line-3943' 1 @('request-response-publication') +Invoke-OwnershipCase 'unanswered-publication' { + $peer = New-ProtocolBaseline + $null = Invoke-MultiProjectRequest $peer (New-ProtocolRequest) + Assert-ProtocolRejected { Complete-MultiProjectPublication $peer (New-MultiProjectPublication $peer $alpha) 'rename' $renameId } 'MULTIPROJECT_UNANSWERED' +} +Complete-OwnershipFamily 'ownership.mixed-protocol.source-line-3948' 1 @('request-response-publication') +foreach ($mutation in @('owner', 'selection', 'extra', 'type', 'duplicate', 'second')) { + Invoke-OwnershipCase $mutation { + $peer = New-ProtocolBaseline + $control = New-ProtocolControl + $prefix = 'MULTIPROJECT_OWNER' + switch ($mutation) { + 'owner' { $control.projectPath = $beta; $control.nodeID = $betaId } + 'selection' { $control.selection.nodeID = $alphaId; $prefix = 'MULTIPROJECT_SELECTION' } + 'extra' { $control.extra = 1; $prefix = 'MULTIPROJECT_SCHEMA' } + 'type' { $control.title = 7; $prefix = 'MULTIPROJECT_TYPE' } + 'duplicate' { $null = Invoke-MultiProjectPublicationControl $peer $control; $prefix = 'MULTIPROJECT_DUPLICATE' } + 'second' { $null = Invoke-MultiProjectPublicationControl $peer $control; $control.token = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'; $prefix = 'MULTIPROJECT_ONE_SHOT' } + } + $before = ConvertTo-MultiProjectCanonicalJson (Get-MultiProjectPeerSnapshot $peer) + Assert-ProtocolRejected { Invoke-MultiProjectPublicationControl $peer $control } $prefix + Assert-Ownership ((ConvertTo-MultiProjectCanonicalJson (Get-MultiProjectPeerSnapshot $peer)) -ceq $before) 'Rejected control mutated peer state.' + } +} +Complete-OwnershipFamily 'ownership.mixed-protocol.source-line-3954' 6 @('request-response-publication') +foreach ($mutation in @('unknown', 'extra', 'type', 'duplicate')) { + Invoke-OwnershipCase $mutation { + $peer = New-ProtocolBaseline + $response = (Invoke-MultiProjectRequest $peer (New-ProtocolRequest)).response + $prefix = 'MULTIPROJECT_CORRELATION' + switch ($mutation) { + 'unknown' { $response.requestID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd' } + 'extra' { $response.extra = 1; $prefix = 'MULTIPROJECT_SCHEMA' } + 'type' { $response.success = 'true'; $prefix = 'MULTIPROJECT_TYPE' } + 'duplicate' { Complete-MultiProjectResponse $peer $response; $prefix = 'MULTIPROJECT_DUPLICATE' } + } + Assert-ProtocolRejected { Complete-MultiProjectResponse $peer $response } $prefix + } +} +Complete-OwnershipFamily 'ownership.mixed-protocol.source-line-3973' 4 @('request-response-publication') +foreach ($mutation in @('value', 'missing', 'type', 'extra', 'requestID', 'unanswered', 'sequence', 'publicationExtra', 'answerType', 'zero')) { + Invoke-OwnershipCase $mutation { + $snapshots = Get-RenamedProtocolSnapshots + $after = Copy-MultiProjectValue $snapshots.after + switch ($mutation) { + 'value' { $after.received[-1].frame.command.graphCommand.command.renameNode.title = 'wrong' } + 'missing' { $after.received[-1].frame.command.graphCommand.command.renameNode.Remove('title') } + 'type' { $after.appliedCount = '1' } + 'extra' { $after.received[-1].frame.command.graphCommand.extra = 1 } + 'requestID' { $after.answered[-1].requestID = $listId } + 'unanswered' { $after.unansweredRequests = @($renameId) } + 'sequence' { $after.publications[-1].frame.sequence = '3' } + 'publicationExtra' { $after.publications[-1].frame.extra = 1 } + 'answerType' { $after.answered[-1].response.success = 1 } + 'zero' { $after.publicationCount = 0 } + } + Assert-Ownership (-not (Test-MultiProjectRenameReceipt $snapshots.before $after $alpha $alphaId 'Alpha renamed')) 'Canonical receipt negative was accepted.' + } +} +Complete-OwnershipFamily 'ownership.mixed-protocol.source-line-3988' 10 @('receipt-negative-controls') +Invoke-OwnershipCase 'visible-intersection' { + $clipped = Get-MultiProjectClippedRectangle @(100, 100, 400, 400) @(220, 34, 1200, 250) + Assert-Ownership (($clipped -join ',') -ceq '220,100,400,250') 'Actual clipping did not retain the literal visible intersection.' +} +Complete-OwnershipFamily 'ownership.mixed-protocol.source-line-3889' 1 @('mixed-ui-policy') +Invoke-OwnershipCase 'offscreen-target-refused' { + Assert-ProtocolRejected { Get-MultiProjectClippedRectangle @(0, 0, 50, 50) @(220, 34, 1200, 900) } 'MULTIPROJECT_BOUNDS' +} +Complete-OwnershipFamily 'ownership.mixed-protocol.source-line-3894' 1 @('mixed-ui-policy') +if ($script:OwnershipAdapterFailed) { exit 1 } diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/MixedReceipt.Contracts.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/MixedReceipt.Contracts.ps1 new file mode 100644 index 00000000..9baf2a06 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/MixedReceipt.Contracts.ps1 @@ -0,0 +1,169 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) + +. (Join-Path $PSScriptRoot 'AdapterSupport.ps1') +Import-OwnershipBindings $RepositoryRoot $AssemblyPath +$gate = Read-OwnershipProductionAst (Join-Path $RepositoryRoot 'Tools\windows\uia-live-gate.ps1') +$names = @($gate.FindAll({ param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + ($node.Name -like '*-MultiProject*' -or $node.Name -ceq 'ConvertTo-SketchCanonicalJson') +}, $true) | ForEach-Object Name) +. Import-OwnershipProductionFunctions $gate $names +$bytes = [IO.File]::ReadAllBytes((Join-Path $PSScriptRoot '..\Fixtures\accepted-peer-91dc.json')) +$expectedHash = '361883611D172E3A884A3F6CBCD5918EBACB94F531DDDF921627B5E36A706241' +Assert-Ownership ([Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes)) -ceq $expectedHash) 'Accepted data-only peer fixture changed.' +$fixture = [Text.UTF8Encoding]::new($false, $true).GetString($bytes) +function New-ReceiptReport { return ConvertFrom-MultiProjectReceiptJson $fixture } +function Assert-ReceiptRejected([scriptblock] $Action) { + $failure = $null + try { & $Action | Out-Null } catch { $failure = $_ } + Assert-Ownership ($failure -and $failure.Exception.Message.StartsWith('MULTIPROJECT_PEER_RECEIPT:', [StringComparison]::Ordinal)) 'Actual complete receipt validator did not specifically reject invalid evidence.' +} +Invoke-OwnershipCase 'accepted-whole-file' { + $report = New-ReceiptReport + $records = @(Write-MultiProjectPeerReceipt $fixture $report.multiProjectPeer 6>&1) + $information = @($records | Where-Object { $_ -is [Management.Automation.InformationRecord] }) + Assert-Ownership ($information.Count -eq 1) 'Whole accepted peer file did not produce exactly one receipt.' + $receipt = Read-MultiProjectPeerReceipt ([string]$information[0].MessageData) + Assert-Ownership ($receipt.utf8Sha256 -ceq $expectedHash -and $receipt.rawJson -ceq $fixture -and + $receipt.report.protocolConnected -and $receipt.report.correlatedRequests -and + $receipt.report.multiProjectPeer.received.Count -eq 11 -and $receipt.report.multiProjectPeer.answered.Count -eq 11 -and + $receipt.report.multiProjectPeer.applied.Count -eq 2 -and $receipt.report.multiProjectPeer.publications.Count -eq 7 -and + $receipt.report.multiProjectPeer.controls.Count -eq 1) 'Actual receipt lost original bytes, custody, complete correlations or peer state.' +} +Complete-OwnershipFamily 'ownership.mixed-receipt.source-line-2411' 1 @('accepted-91dc-fixture-not-product-runtime') +function New-ReceiptWithResume { + $report = New-ReceiptReport + $peer = $report.multiProjectPeer + $owner = $peer.graphs[1] + $id = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd' + $response = [ordered]@{ version = 2; kind = 'response'; requestID = $id; success = $true } + $frame = [ordered]@{ version = 2; kind = 'request'; requestID = $id; command = [ordered]@{ + graphCommand = [ordered]@{ projectPath = $owner.project.path; command = [ordered]@{ + resumeSession = [ordered]@{ _0 = $owner.nodes[0].id } + } } + } } + $peer.received += [ordered]@{ requestID = $id; frame = $frame; expectedResponse = $response } + $peer.answered += [ordered]@{ requestID = $id; response = $response } + $peer.receivedCount++; $peer.requestCount++; $peer.responseCount++ + $report.requestCount++; $report.responseCount++ + $report.commands += 'graphCommand' + return $report +} +Invoke-OwnershipCase 'correlated-nonmutating-session' { + $report = New-ReceiptWithResume + Assert-MultiProjectCompleteReport $report + Assert-Ownership ($report.multiProjectPeer.applied.Count -eq 2) 'Resume acknowledgement changed rename accounting.' +} +Complete-OwnershipFamily 'ownership.mixed-receipt.source-line-2439' 1 @('request-response-publication') +Invoke-OwnershipCase 'foreign-session-owner' { + $report = New-ReceiptWithResume + $report.multiProjectPeer.received[-1].frame.command.graphCommand.command.resumeSession._0 = $report.multiProjectPeer.graphs[0].nodes[0].id + Assert-ReceiptRejected { Assert-MultiProjectCompleteReport $report } +} +Complete-OwnershipFamily 'ownership.mixed-receipt.source-line-2445' 1 @('request-response-publication') +$mutations = @( + 'missing-top', 'unknown-top', 'disconnected', 'correlation-false', 'global-error', 'global-unanswered', + 'root-count', 'connection-type', 'graph-not-sent', 'missing-peer', 'unknown-peer', 'count-type', 'count-missing', + 'unanswered', 'empty-requests', 'request-duplicate', 'request-id', 'request-kind-type', 'request-extra', + 'unknown-verb', 'wrong-owner', 'wrong-node', 'rename-title-type', 'missing-expectation', + 'answer-duplicate', 'answer-missing', 'answer-id', 'answer-kind', 'answer-success', 'answer-type', 'quick-chat-extra', + 'application-owner', 'application-node', 'application-title', 'application-before', 'application-duplicate', + 'publication-sequence', 'publication-kind-type', 'publication-owner', 'publication-title', 'publication-correlation', + 'publication-duplicate', 'unknown-cause', 'control-owner', 'control-selection', 'control-token', + 'graph-missing', 'graph-duplicate', 'graph-title', 'graph-id', 'graph-depth-loss' +) +foreach ($mutation in $mutations) { + Invoke-OwnershipCase $mutation { + $report = New-ReceiptReport + $peer = $report.multiProjectPeer + switch ($mutation) { + 'missing-top' { $report.Remove('protocolConnected') } + 'unknown-top' { $report.unexpected = $true } + 'disconnected' { $report.protocolConnected = $false } + 'correlation-false' { $report.correlatedRequests = $false } + 'global-error' { $report.error = 'actual error' } + 'global-unanswered' { $report.unansweredRequests = @('unanswered') } + 'root-count' { $report.requestCount++ } + 'connection-type' { $report.connectionCount = '2' } + 'graph-not-sent' { $report.graphSent = $false } + 'missing-peer' { $report.multiProjectPeer = $null } + 'unknown-peer' { $peer.unexpected = $true } + 'count-type' { $peer.requestCount = '11' } + 'count-missing' { $peer.Remove('receivedCount') } + 'unanswered' { $peer.unansweredRequests = @($peer.received[0].requestID) } + 'empty-requests' { $peer.received = @() } + 'request-duplicate' { $peer.received[1] = $peer.received[0] } + 'request-id' { $peer.received[0].frame.requestID = 'wrong' } + 'request-kind-type' { $peer.received[0].frame.kind = $true } + 'request-extra' { $peer.received[0].frame.extra = 1 } + 'unknown-verb' { $peer.received[0].frame.command = @{ unknown = @{} } } + 'wrong-owner' { $peer.received[-1].frame.command.graphCommand.projectPath = $peer.graphs[1].project.path } + 'wrong-node' { $peer.received[-1].frame.command.graphCommand.command.renameNode._0 = $peer.graphs[1].nodes[0].id } + 'rename-title-type' { $peer.received[-1].frame.command.graphCommand.command.renameNode.title = 7 } + 'missing-expectation' { $peer.received[-1].expectedResponse = $null } + 'answer-duplicate' { $peer.answered[1] = $peer.answered[0] } + 'answer-missing' { $peer.answered = @($peer.answered[0]) } + 'answer-id' { $peer.answered[-1].response.requestID = $peer.answered[0].requestID } + 'answer-kind' { $peer.answered[-1].response.kind = 'event' } + 'answer-success' { $peer.answered[-1].response.success = $false } + 'answer-type' { $peer.answered[-1].response.success = 'true' } + 'quick-chat-extra' { $peer.answered[1].response.event.quickChatsListed[0].unknown = 1 } + 'application-owner' { $peer.applied[0].projectPath = $peer.graphs[1].project.path } + 'application-node' { $peer.applied[0].nodeID = $peer.graphs[1].nodes[0].id } + 'application-title' { $peer.applied[0].title = 'Not applied' } + 'application-before' { $peer.applied[0].beforeTitle = 'Not prior' } + 'application-duplicate' { $peer.applied[1] = $peer.applied[0] } + 'publication-sequence' { $peer.publications[-1].frame.sequence = 99 } + 'publication-kind-type' { $peer.publications[-1].frame.kind = $true } + 'publication-owner' { $peer.publications[-1].frame.event.graphChanged.project.path = $peer.graphs[1].project.path } + 'publication-title' { $peer.publications[-1].frame.event.graphChanged.nodes[0].title = 'Not published' } + 'publication-correlation' { $peer.publications[-1].correlationID = $peer.received[0].requestID } + 'publication-duplicate' { $peer.publications[-1] = $peer.publications[-2] } + 'unknown-cause' { $peer.publications[-1].cause = 'unknown' } + 'control-owner' { $peer.controls[0].projectPath = $peer.graphs[1].project.path } + 'control-selection' { $peer.controls[0].selection.nodeID = $peer.graphs[0].nodes[0].id } + 'control-token' { $peer.controls[0].token = $peer.received[0].requestID } + 'graph-missing' { $peer.graphs = @($peer.graphs[0]) } + 'graph-duplicate' { $peer.graphs[1] = $peer.graphs[0] } + 'graph-title' { $peer.graphs[0].nodes[0].title = 'Not final' } + 'graph-id' { $peer.graphs[0].id = 'bad' } + 'graph-depth-loss' { $peer.graphs[0].nodes[0].presence = '@{presence=idle}' } + } + Assert-ReceiptRejected { New-MultiProjectPeerReceipt (ConvertTo-Json -InputObject $report -Depth 16 -Compress) } + } +} +Complete-OwnershipFamily 'ownership.mixed-receipt.source-line-2460' 51 @('receipt-negative-controls') +foreach ($mutation in @('no-record', 'duplicate-record', 'bad-json', 'duplicate-property', 'bad-hash', + 'byte-count', 'report-mismatch', 'schema', 'provenance', 'json-depth', 'settled-mismatch')) { + Invoke-OwnershipCase $mutation { + $receipt = New-MultiProjectPeerReceipt $fixture + $line = 'UIA_MULTIPROJECT_PEER_RECEIPT=' + (ConvertTo-Json -InputObject $receipt -Depth 16 -Compress) + Assert-ReceiptRejected { + switch ($mutation) { + 'no-record' { Read-MultiProjectPeerReceipt 'native job succeeded without retained peer' } + 'duplicate-record' { Read-MultiProjectPeerReceipt ($line + "`n" + $line) } + 'bad-json' { Read-MultiProjectPeerReceipt 'UIA_MULTIPROJECT_PEER_RECEIPT={bad' } + 'duplicate-property' { Read-MultiProjectPeerReceipt 'UIA_MULTIPROJECT_PEER_RECEIPT={"x":1,"x":2}' } + 'bad-hash' { $receipt.utf8Sha256 = 'bad'; Assert-MultiProjectPeerReceipt $receipt } + 'byte-count' { $receipt.utf8ByteCount++; Assert-MultiProjectPeerReceipt $receipt } + 'report-mismatch' { $receipt.report.protocolConnected = $false; Assert-MultiProjectPeerReceipt $receipt } + 'schema' { $receipt.schemaVersion = '1'; Assert-MultiProjectPeerReceipt $receipt } + 'provenance' { $receipt.provenance = 'expected fixture'; Assert-MultiProjectPeerReceipt $receipt } + 'json-depth' { Read-MultiProjectPeerReceipt ('UIA_MULTIPROJECT_PEER_RECEIPT=' + + (ConvertTo-Json -InputObject $receipt -Depth 2 -Compress -WarningAction SilentlyContinue)) } + 'settled-mismatch' { + $peer = (New-ReceiptReport).multiProjectPeer + $peer.graphSequence++ + Write-MultiProjectPeerReceipt $fixture $peer + } + } + } + } +} +Complete-OwnershipFamily 'ownership.mixed-receipt.source-line-2520' 11 @('receipt-negative-controls') +if ($script:OwnershipAdapterFailed) { exit 1 } diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/MixedSource.Contracts.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/MixedSource.Contracts.ps1 new file mode 100644 index 00000000..dfc97ee6 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/MixedSource.Contracts.ps1 @@ -0,0 +1,70 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) + +. (Join-Path $PSScriptRoot 'AdapterSupport.ps1') +Import-OwnershipBindings $RepositoryRoot $AssemblyPath +$path = Join-Path $RepositoryRoot 'Tools\windows\uia-live-gate.ps1' +$source = [IO.File]::ReadAllText($path) +$ast = Read-OwnershipProductionAst $path +function Find-ProductionFunction([string] $Name) { + $nodes = @($ast.FindAll({ param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq $Name + }, $true)) + Assert-Ownership ($nodes.Count -eq 1) "Complete actual production function missing: $Name" + return $nodes[0] +} +Invoke-OwnershipCase 'peer-emission' { + $phase = Find-ProductionFunction 'Invoke-MultiProjectRenamePhase' + $calls = @($phase.FindAll({ param($node) + $node -is [Management.Automation.Language.CommandAst] -and $node.GetCommandName() -ceq 'Write-MultiProjectPeerReceipt' + }, $true)) + Assert-Ownership ($calls.Count -eq 1 -and + $calls[0].Extent.Text -ceq 'Write-MultiProjectPeerReceipt (Read-DaemonCommandLog $peerPath) $afterUnchanged' -and + $phase.Extent.Text.IndexOf($calls[0].Extent.Text) -lt $phase.Extent.Text.IndexOf('multi-project shell rejected Exit') -and + $phase.Extent.Text.IndexOf($calls[0].Extent.Text) -gt $phase.Extent.Text.IndexOf('$finalOverview = Wait-MultiProjectObservation "overview"')) 'Complete peer receipt is not uniquely bound to final owned file before teardown.' + foreach ($token in @('UIA_MULTIPROJECT_PEER_RECEIPT=', 'owned-stub-final-file-read', 'rawJson', 'utf8Sha256', + 'Assert-MultiProjectCompleteReport', 'WarningAction Stop')) { + Assert-Ownership ($source.Contains($token)) "Actual peer receipt route missing $token" + } +} +Complete-OwnershipFamily 'ownership.legacy.peer-emission' 1 @('source-only') +Invoke-OwnershipCase 'rename-submit' { + $caller = Find-ProductionFunction 'Invoke-MultiProjectNativeRename' + Assert-Ownership ($caller.Extent.Text.Contains('Invoke-MultiProjectRenameAction') -and + -not $caller.Extent.Text.Contains('Sketch-Submit') -and -not $caller.Extent.Text.Contains('Sketch-Cancel')) 'Rename routes through the wrong native form family.' + $dialogs = [IO.File]::ReadAllText((Join-Path $RepositoryRoot 'graphcode-windows\src\WindowsNativeDialogs.zig')) + $forms = [IO.File]::ReadAllText((Join-Path $RepositoryRoot 'graphcode-windows\src\NativeForms.zig')) + Assert-Ownership ($dialogs -match 'const ok_id = 9800;' -and $dialogs -match 'const cancel_id = 9808;' -and + $dialogs -match '9904 \+ index \* 8' -and $forms -match 'const ok_id = 1;') 'Independently sourced native Rename/Sketch control goldens changed.' + foreach ($token in @('function Invoke-MultiProjectRenameAction', 'function Read-MultiProjectRenameField', + 'function Test-MultiProjectRenameField', 'UIA_MULTIPROJECT_RENAME_NATIVE_TARGET=', + 'UIA_MULTIPROJECT_RENAME_NATIVE_FAILURE=', 'UIA_MULTIPROJECT_RENAME_NATIVE_ACTION=', + '$buttonId = if ($Cancel) { 9808 } else { 9800 }', 'ClientBounds', 'WorkBounds', 'topOwner', 'rectState', + 'hitSamples', 'beforeInput', 'enterFallbackUsed = $false', 'actualPriorButton1ReturnEstablished = $false', + 'Get-MultiProjectRenameTitle $owner.title', '-expectedTitle $renameTitle', + ('Only the name changes ' + [char]0x2014 + ' the loop keeps its session, its edges, and its work.'))) { + Assert-Ownership ($source.Contains($token)) "Actual strict Rename route missing $token" + } +} +Complete-OwnershipFamily 'ownership.legacy.rename-submit' 1 @('source-only') +Invoke-OwnershipCase 'fresh-capture' { + foreach ($token in @('function Read-MultiProjectElementCache', 'function Read-MultiProjectOwnershipSnapshot', + 'function ConvertTo-MultiProjectPropertyState', 'function Confirm-MultiProjectOwnedSnapshot', + 'UIA_MULTIPROJECT_FRESH_OWNERSHIP=', 'UIA_MULTIPROJECT_CACHED_OWNERSHIP=', 'UIA_MULTIPROJECT_CAPTURE_VERIFY=', + 'UIA_MULTIPROJECT_CONTAINER_OWNERSHIP=', 'UIA_MULTIPROJECT_SNAPSHOT_DRIFT=', 'priorStablePID', 'priorIdentity', + 'semanticCauseEstablished', 'wholeObservationDiscarded', '::ProcessIdProperty, $true', + '::AutomationIdProperty, $true', '::NotSupported', 'row identity, not a graph publication generation')) { + Assert-Ownership ($source.Contains($token)) "Actual fresh ownership capture missing $token" + } + $observer = Find-ProductionFunction 'Get-MultiProjectObservation' + Assert-Ownership ($observer.Extent.Text -notmatch '\.Current\.(Name|BoundingRectangle|AutomationId)' -and + $observer.Extent.Text.Contains('verifiedOwnedSnapshotCount') -and $observer.Extent.Text.Contains('Confirm-MultiProjectOwnedSnapshot') -and + $observer.Extent.Text.Contains('graph container ownership unavailable before child capture') -and + $observer.Extent.Text.Contains('projects container ownership unavailable before child capture')) 'Actual observer bypasses fresh/cached ownership or whole-observation container verification.' +} +Complete-OwnershipFamily 'ownership.legacy.fresh-capture' 1 @('source-only') +if ($script:OwnershipAdapterFailed) { exit 1 } diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/MixedStartup.Contracts.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/MixedStartup.Contracts.ps1 new file mode 100644 index 00000000..565d103a --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/MixedStartup.Contracts.ps1 @@ -0,0 +1,226 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) + +. (Join-Path $PSScriptRoot 'AdapterSupport.ps1') +Import-OwnershipBindings $RepositoryRoot $AssemblyPath +$gate = Read-OwnershipProductionAst (Join-Path $RepositoryRoot 'Tools\windows\uia-live-gate.ps1') +. Import-OwnershipProductionFunctions $gate @('ConvertTo-MultiProjectStartupXPathLiteral', 'New-MultiProjectStartupEventQuery', + 'ConvertFrom-MultiProjectEventNumber', 'ConvertFrom-MultiProjectOwnedStartupEvent', + 'Invoke-MultiProjectOwnedStartupFailure', 'Get-MultiProjectRetentionError') +$eventStart = [DateTimeOffset]::Parse('2026-10-01T18:29:43.1462496Z').UtcDateTime +$eventEnd = $eventStart.AddSeconds(1) +$held = [pscustomobject]@{ Id = 5832; StartTime = $eventStart } +$executable = 'D:\owned\graphcode-windows.exe' +function New-OwnedEvent([string] $Mutation = '') { + $pidValue = '0x{0:x}' -f $held.Id + $generation = '0x{0:x}' -f $eventStart.ToFileTimeUtc() + $path = $executable; $provider = 'Application Error'; $timestamp = $eventEnd.ToString('o') + $module = 'kernelbase.dll'; $code = '0xc0000142'; $extra = '' + switch ($Mutation) { + 'foreign-pid' { $pidValue = '0x7777'; $module = 'SECRET_FOREIGN_MODULE' } + 'reused-pid' { $generation = '0x{0:x}' -f $eventStart.AddSeconds(-1).ToFileTimeUtc() } + 'foreign-path' { $path = 'D:\foreign\other.exe'; $module = 'SECRET_FOREIGN_MODULE' } + 'provider' { $provider = 'Windows Error Reporting' } + 'outside-window' { $timestamp = $eventStart.AddSeconds(-1).ToString('o') } + 'duplicate-id' { $extra = '0x7777' } + 'module-path' { $module = 'C:\private\SECRET_FOREIGN_MODULE.dll' } + 'code-type' { $code = 'SECRET_COMMANDLINE' } + 'unnamed' { $extra = 'SECRET_MESSAGE' } + } + $xml = @" +100042$pidValue$generation$path$module$code0x1234$extra +"@ + if ($Mutation -ceq 'execution-only') { $xml = $xml.Replace('' + $pidValue + '', '') } + if ($Mutation -ceq 'missing-generation') { $xml = $xml.Replace('' + $generation + '', '') } + if ($Mutation -ceq 'oversized') { $xml += 'x' * 65536 } + if ($Mutation -ceq 'bad-xml') { $xml = 'broken' } + if ($Mutation -ceq 'dtd') { $xml = ']>' + $xml } + $record = [pscustomobject]@{ Xml = $xml } + $record | Add-Member ScriptMethod ToXml { $this.Xml } + $record | Add-Member ScriptProperty Message { throw 'Forbidden event Message access.' } + return $record +} +function Invoke-StartupProbe($Records, $Process = $held, [string] $Path = $executable, [string] $Faults = '') { + $state = @{ queries = 0; files = 0; summaries = 0; warnings = 0; caught = $null } + $original = [InvalidOperationException]::new('shell exited with code -1073741502') + try { + Invoke-MultiProjectOwnedStartupFailure $Process -1073741502 $Path $ArtifactRoot { throw $original } -observedUtc $eventEnd ` + -querySource { + param($filter, $maximum) + $state.queries++ + Assert-Ownership ($maximum -eq 8) 'Canonical startup event result bound changed.' + $xml = [xml]$filter + $selects = @($xml.QueryList.Query.Select) + $selector = $selects[0].InnerText + Assert-Ownership ($selects.Count -eq 1 -and $selects[0].Path -ceq 'Application' -and + $selector.Contains("Provider[@Name='Application Error']") -and $selector.Contains('EventID=1000') -and + $selector.Contains("Data[@Name='ProcessId']='5832'") -and + $selector.Contains("Data[@Name='ProcessCreationTime']='" + $eventStart.ToFileTimeUtc() + "'") -and + $selector.Contains("Data[@Name='AppPath']") -and $selector.Contains($executable) -and + $selector.Contains($eventStart.ToString('o')) -and $selector.Contains($eventEnd.ToString('o')) -and + -not $selector.Contains('Execution')) 'Canonical selector lost exact held subject generation/path/time.' + if ($Faults.Contains('query')) { throw [IO.IOException]::new('SECRET_QUERY_COMMANDLINE') } + return $Records + } -fileSink { + param($file, $json) + $state.files++ + if ($Faults.Contains('file')) { throw [IO.IOException]::new('SECRET_FILE_COMMANDLINE') } + } -summarySink { + param($line) + $state.summaries++ + if ($Faults.Contains('summary')) { throw [IO.IOException]::new('SECRET_SUMMARY_COMMANDLINE') } + Assert-Ownership (-not $line.Contains('SECRET') -and -not $line.Contains('kernelbase.dll', '').Replace('0xc0000142', '') + $result = Invoke-StartupProbe @($record) + Assert-Ownership ($result.report.events.Count -eq 1 -and $result.report.events[0].module.state -ceq 'unavailable' -and + $result.report.events[0].exceptionCode.state -ceq 'unavailable' -and $null -eq $result.report.events[0].module.value -and + -not $result.report.rootCauseEstablished) 'Unavailable module/code was synthesized from the failing exit.' +} +Complete-OwnershipFamily 'ownership.mixed-startup.source-line-2074' 1 @('held-identity-canonical-event-mock') +Invoke-OwnershipCase 'actual-no-match-error-record' { + $state = @{ queries = 0; caught = $null } + $original = [InvalidOperationException]::new('shell exited with code -1073741502') + try { + Invoke-MultiProjectOwnedStartupFailure $held -1073741502 $executable $ArtifactRoot { throw $original } -observedUtc $eventEnd ` + -querySource { + param($filter, $maximum) + $state.queries++ + throw [Management.Automation.ErrorRecord]::new([InvalidOperationException]::new('No events'), 'NoMatchingEventsFound', + [Management.Automation.ErrorCategory]::ObjectNotFound, $null) + } -fileSink { param($path, $json) } -summarySink { param($line) } -warningSink { param($line) } + } catch { $state.caught = $_ } + Assert-Ownership ($state.queries -eq 1 -and [Object]::ReferenceEquals($state.caught.Exception, $original) -and + $state.caught.Exception.Data['OwnedStartupEvents'].state -ceq 'unknown-no-matching-canonical-events') 'Actual no-match ErrorRecord changed primary or fabricated diagnosis.' +} +Complete-OwnershipFamily 'ownership.mixed-startup.source-line-2083' 1 @('held-identity-canonical-event-mock') +foreach ($mutation in @('foreign-pid', 'reused-pid', 'foreign-path', 'provider', 'outside-window', 'duplicate-id', + 'module-path', 'code-type', 'unnamed', 'execution-only', 'missing-generation', 'oversized', 'bad-xml', 'dtd')) { + Invoke-OwnershipCase $mutation { + $result = Invoke-StartupProbe @((New-OwnedEvent $mutation)) + Assert-Ownership ($result.state.queries -eq 1 -and $result.report.state -ceq 'refused-or-unavailable' -and + $result.report.events.Count -eq 0 -and $result.secondary.Count -eq 1) 'Canonical event identity/schema/size refusal was weakened.' + } +} +Complete-OwnershipFamily 'ownership.mixed-startup.source-line-2101' 14 @('held-identity-canonical-event-mock') +foreach ($mutation in @('missing-pid', 'missing-start', 'relative-path', 'old-window', 'future-start', 'unsafe-quotes')) { + Invoke-OwnershipCase $mutation { + $process = [pscustomobject]@{ Id = 5832; StartTime = $eventStart } + $path = $executable + switch ($mutation) { + 'missing-pid' { $process.Id = 0 } + 'missing-start' { $process.StartTime = $null } + 'relative-path' { $path = 'relative.exe' } + 'old-window' { $process.StartTime = $eventStart.AddSeconds(-31) } + 'future-start' { $process.StartTime = $eventEnd.AddSeconds(1) } + 'unsafe-quotes' { $path = 'D:\owned\a''"b.exe' } + } + $result = Invoke-StartupProbe @() $process $path + Assert-Ownership ($result.state.queries -eq 0 -and $result.secondary.Count -eq 1 -and $result.report.events.Count -eq 0) 'An invalid held identity reached event transport.' + } +} +Complete-OwnershipFamily 'ownership.mixed-startup.source-line-2109' 6 @('held-identity-canonical-event-mock') +foreach ($faults in @('query', 'file', 'summary', 'query-file-summary-warning')) { + Invoke-OwnershipCase $faults { + $result = Invoke-StartupProbe @((New-OwnedEvent)) -Faults $faults + Assert-Ownership ($result.secondary.Count -eq $(if ($faults -ceq 'query-file-summary-warning') { 4 } else { 1 }) -and + $result.state.queries -eq 1) 'A secondary writer/query failure was omitted.' + foreach ($failure in $result.secondary) { + Assert-Ownership ($failure.operation.StartsWith('startup-event') -and $failure.errorType -ceq 'System.IO.IOException' -and + $failure.hresult -is [int] -and -not $failure.message.Contains('SECRET')) 'Typed privacy-preserving secondary failure metadata changed.' + } + } +} +Complete-OwnershipFamily 'ownership.mixed-startup.source-line-2126' 4 @('held-identity-canonical-event-mock') +Invoke-OwnershipCase 'over-eight-records' { + $record = New-OwnedEvent + $result = Invoke-StartupProbe @($record, $record, $record, $record, $record, $record, $record, $record, $record) + Assert-Ownership ($result.report.events.Count -eq 0 -and $result.secondary.Count -eq 1) 'Nine selected records were silently truncated.' +} +Complete-OwnershipFamily 'ownership.mixed-startup.source-line-2137' 1 @('held-identity-canonical-event-mock') +Invoke-OwnershipCase 'duplicate-record-identity' { + $record = New-OwnedEvent + $result = Invoke-StartupProbe @($record, $record) + Assert-Ownership ($result.report.events.Count -eq 0 -and $result.secondary.Count -eq 1) 'Duplicate event record identity inflated evidence.' +} +Complete-OwnershipFamily 'ownership.mixed-startup.source-line-2143' 1 @('held-identity-canonical-event-mock') +Invoke-OwnershipCase 'non-hosted-default-transport-refused' { + $snapshot = [Environment]::GetEnvironmentVariables('Process') + $state = @{ queries = 0; caught = $null } + function Get-WinEvent { $state.queries++; throw 'Forbidden actual event transport.' } + $original = [InvalidOperationException]::new('shell exited with code -1073741502') + try { + $env:GITHUB_ACTIONS = 'false' + try { + Invoke-MultiProjectOwnedStartupFailure $held -1073741502 $executable $ArtifactRoot { throw $original } -observedUtc $eventEnd ` + -fileSink { param($path, $json) } -summarySink { param($line) } -warningSink { param($line) } + } catch { $state.caught = $_ } + } finally { Restore-OwnershipEnvironment $snapshot @('GITHUB_ACTIONS') } + Assert-Ownership ($state.queries -eq 0 -and [Object]::ReferenceEquals($state.caught.Exception, $original) -and + $state.caught.Exception.Data['OwnedStartupEvents'].state -ceq 'refused-non-hosted-query') 'Default EventLog transport ran outside hosted CI.' +} +Complete-OwnershipFamily 'ownership.mixed-startup.source-line-2149' 1 @('held-identity-canonical-event-mock') +Invoke-OwnershipCase 'exact-path-xml-xpath-escaping' { + $identity = New-MultiProjectStartupEventQuery $held "D:\owned\a'b&c.exe" $eventEnd + $xml = [xml]$identity.filterXml + Assert-Ownership ($xml.QueryList.Query.Select.InnerText.Contains('Data[@Name=''AppPath'']="D:\owned\a''b&c.exe"') -and + $identity.filterXml.Contains('&')) 'Exact executable XPath/XML escaping changed.' +} +Complete-OwnershipFamily 'ownership.mixed-startup.source-line-2165' 1 @('held-identity-canonical-event-mock') +Invoke-OwnershipCase 'actual-first-failure-branch' { + $branch = $gate.Find({ param($node) + $node -is [Management.Automation.Language.IfStatementAst] -and + $node.Extent.Text.StartsWith('if ($process.HasExited)', [StringComparison]::Ordinal) -and + $node.Extent.Text.Contains('Invoke-MultiProjectOwnedStartupFailure') + }, $true) + Assert-Ownership ($null -ne $branch) 'Actual first-failure branch missing.' + $process = [pscustomobject]@{ HasExited = $true; Id = 5832; ExitCode = -1073741502; StartTime = $eventStart } + $Shell = $executable; $logDirectory = $ArtifactRoot; $settingsDirectory = $ArtifactRoot + $state = @{ legacy = 0; canonical = 0; caught = $null } + function Write-UiaStartupFailureDiagnostic { param($held, $exit, $exe, $cwd, $logs, $support) $state.legacy++ } + function Invoke-MultiProjectOwnedStartupFailure { + param($held, $exit, $exe, $logs, $failure) + $state.canonical++ + & $failure + } + try { . ([scriptblock]::Create($branch.Extent.Text)) } catch { $state.caught = $_ } + Assert-Ownership ($state.legacy -eq 1 -and $state.canonical -eq 1 -and + $state.caught.Exception.Message -ceq 'shell exited with code -1073741502') 'Actual failure branch replayed diagnostics or changed the primary.' +} +Complete-OwnershipFamily 'ownership.mixed-startup.source-line-2173' 1 @('held-identity-canonical-event-mock') +if ($script:OwnershipAdapterFailed) { exit 1 } diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/MixedStream.Contracts.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/MixedStream.Contracts.ps1 new file mode 100644 index 00000000..917e8d15 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/MixedStream.Contracts.ps1 @@ -0,0 +1,221 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) + +. (Join-Path $PSScriptRoot 'AdapterSupport.ps1') +Import-OwnershipBindings $RepositoryRoot $AssemblyPath +$gate = Read-OwnershipProductionAst (Join-Path $RepositoryRoot 'Tools\windows\uia-live-gate.ps1') +. Import-OwnershipProductionFunctions $gate @('Invoke-MultiProjectTypeText', 'Get-MultiProjectRetentionError', 'Get-EdgeTextAttemptDecision') +Invoke-OwnershipCase 'actual-caller-five-records-before-fifth-guard' { + $caller = $gate.Find({ param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq 'Invoke-MultiProjectNativeRename' + }, $true) + $assignment = $caller.Find({ param($node) + $node -is [Management.Automation.Language.AssignmentStatementAst] -and + $node.Extent.Text.StartsWith('$null = Invoke-MultiProjectTypeText 9904 $typedTitle $inputEvidence -typeSource', [StringComparison]::Ordinal) + }, $true) + Assert-Ownership ($null -ne $assignment) 'Actual streaming Rename caller binding missing.' + $state = @{ calls = 0; primary = $null } + function Invoke-MultiProjectSequencedEdit { + param($modal, $processId, $prefill, $text) + $state.calls++ + foreach ($attempt in 1..5) { + Write-Host "UIA_EDGE_TEXT_STABLE id=9904 attempt=$attempt after='' expected='$text' inputAttempted=False textSent=0/0" + $null = Get-EdgeTextAttemptDecision $true '' $text $attempt 5 + } + } + $typedTitle = ' '; $modal = [IntPtr]42; $multiProcess = [pscustomobject]@{ Id = 4242 }; $prefill = 'Alpha renamed' + $inputEvidence = [Collections.Generic.List[string]]::new() + $records = @(& { + try { . ([scriptblock]::Create($assignment.Extent.Text)) } catch { $state.primary = $_ } + } 6>&1) + $attempts = @($records | Where-Object { $_ -is [Management.Automation.InformationRecord] -and + ([string]$_.MessageData).StartsWith('UIA_EDGE_TEXT_STABLE ', [StringComparison]::Ordinal) }) + Assert-Ownership ($state.calls -eq 1 -and $attempts.Count -eq 5 -and $inputEvidence.Count -eq 5 -and + $state.primary.Exception.Message -ceq "edge text mismatch attempts=5/5 expected=' ' observed=''") 'Actual caller replayed input, lost produced records, or changed the fifth guard.' +} +Complete-OwnershipFamily 'ownership.mixed-stream.source-line-2193' 1 @('powershell-streams-error-identity') +foreach ($scenario in @('focus-not-acquired', 'native-false-clear-only', 'native-false-full-six-empty', + 'partial-counts', 'stale-control', 'native-error', 'exact-three-spaces')) { + Invoke-OwnershipCase $scenario { + . Import-OwnershipProductionFunctions $gate @('Require', 'Edge-TypeText', 'Get-EdgeTextAttemptDecision') @{ + '[GraphCodeUiaGateState]::' = '$transport.' + } + $transport = [pscustomobject]@{ + scenario = $scenario; buffer = ''; focus = [IntPtr]::Zero; calls = 0 + LastEditClearExpected = [uint32]0; LastEditClearSent = [uint32]0 + LastEditTextExpected = [uint32]0; LastEditTextSent = [uint32]0 + } + $transport | Add-Member ScriptMethod EditTextById { param($window, $id) $this.buffer } + $transport | Add-Member ScriptMethod ControlById { + param($window, $id) + if ($this.scenario -ceq 'stale-control') { return [IntPtr]::Zero } + return [IntPtr]$id + } + $transport | Add-Member ScriptMethod WindowBounds { param($window) @(10, 20, 510, 44) } + $transport | Add-Member ScriptMethod IsControlOwnedBy { param($window, $control, $id) $control -eq [IntPtr]$id } + $transport | Add-Member ScriptMethod HasVisibleBounds { param($control) $control -ne [IntPtr]::Zero } + $transport | Add-Member ScriptMethod WindowIsVisible { param($window) $true } + $transport | Add-Member ScriptMethod WindowTextOf { param($window) ('Rename ' + [char]0x201c + 'Alpha renamed' + [char]0x201d) } + $transport | Add-Member ScriptMethod WindowProcessId { param($window) 4242 } + $transport | Add-Member ScriptMethod ControlIdOf { param($window) [int]$window.ToInt64() } + $transport | Add-Member ScriptMethod FocusedControlInDialog { param($window) $this.focus } + $transport | Add-Member ScriptMethod FocusControl { param($window, $control) $this.focus = $control; $true } + $transport | Add-Member ScriptMethod TypeEditTextById { + param($window, $id, $text) + $this.calls++ + if ($this.scenario -ceq 'native-error') { throw 'controlled original native adapter error' } + $this.LastEditClearExpected = [uint32]2; $this.LastEditClearSent = [uint32]2 + $this.LastEditTextExpected = [uint32]0; $this.LastEditTextSent = [uint32]0 + if ($this.scenario -ceq 'partial-counts') { $this.LastEditClearSent = [uint32]1; return $false } + if ($this.scenario -in @('native-false-full-six-empty', 'exact-three-spaces')) { + $this.LastEditTextExpected = [uint32]6; $this.LastEditTextSent = [uint32]6 + } + if ($this.scenario -ceq 'exact-three-spaces') { $this.buffer = $text; return $true } + return $false + } + function Ensure-ShellForeground { param($window, $label) $scenario -cne 'focus-not-acquired' } + function Start-Sleep { param($Milliseconds) } + $renameProcess = [pscustomobject]@{ Id = 4242 } + $renameProcess | Add-Member ScriptMethod WaitForInputIdle { param($timeout) $true } + $script:edgeWorkflowWindow = [IntPtr]42 + $script:edgeWorkflowTitle = 'Rename ' + [char]0x201c + 'Alpha renamed' + [char]0x201d + $evidence = [Collections.Generic.List[string]]::new() + $state = @{ sourceCalls = 0; primary = $null } + $records = @(& { + try { + $null = Invoke-MultiProjectTypeText 9904 ' ' $evidence -typeSource { + param($id, $text) $state.sourceCalls++; Edge-TypeText $id $text + } + } catch { $state.primary = $_ } + } 6>&1) + $attempts = @($records | Where-Object { $_ -is [Management.Automation.InformationRecord] -and + ([string]$_.MessageData).StartsWith('UIA_EDGE_TEXT_STABLE ', [StringComparison]::Ordinal) }) + Assert-Ownership ($state.sourceCalls -eq 1) 'Stream collector replayed the actual native helper.' + switch ($scenario) { + 'exact-three-spaces' { + Assert-Ownership ($attempts.Count -eq 1 -and $evidence.Count -eq 1 -and $transport.calls -eq 1 -and + -not $state.primary -and $transport.buffer -ceq ' ') 'Exact whitespace was coerced or the input source repeated.' + } + { $_ -in @('focus-not-acquired', 'native-false-clear-only', 'native-false-full-six-empty') } { + Assert-Ownership ($attempts.Count -eq 5 -and $evidence.Count -eq 5 -and + $state.primary.Exception.Message -ceq "edge text mismatch attempts=5/5 expected=' ' observed=''" -and + $transport.calls -eq $(if ($scenario -ceq 'focus-not-acquired') { 0 } else { 5 })) 'Fifth guard or actual input/produced evidence counts changed.' + if ($scenario -ceq 'native-false-clear-only') { + Assert-Ownership (([string]$attempts[-1].MessageData).Contains('textSent=0/0') -and + ([string]$attempts[-1].MessageData).Contains('inputCountsFull=True')) 'Clear-only input availability was mislabeled.' + } + } + 'partial-counts' { + Assert-Ownership ($attempts.Count -eq 0 -and $transport.calls -eq 1 -and + $state.primary.Exception.Message.Contains('SendInput count mismatch')) 'Partial count primary was weakened.' + } + 'stale-control' { + Assert-Ownership ($attempts.Count -eq 0 -and $transport.calls -eq 0 -and + $state.primary.Exception.Message.Contains('unavailable after layout wait')) 'Stale control guard was bypassed.' + } + 'native-error' { + Assert-Ownership ($attempts.Count -eq 0 -and $transport.calls -eq 1 -and + $state.primary.Exception.ToString().Contains('controlled original native adapter error')) 'Native failure was replaced or input replayed.' + } + } + } +} +Complete-OwnershipFamily 'ownership.mixed-stream.source-line-2221' 7 @('powershell-streams-error-identity') +foreach ($mode in @('source-primary', 'source-plus-sink', 'sink-only')) { + Invoke-OwnershipCase $mode { + $state = @{ calls = 0; produced = 0; sinks = 0; failure = $null } + $evidence = [Collections.Generic.List[string]]::new() + $null = @(& { + try { + $null = Invoke-MultiProjectTypeText 9904 ' ' $evidence -typeSource { + param($id, $text) + $state.calls++ + foreach ($attempt in 1..5) { + $state.produced++ + Write-Host "UIA_EDGE_TEXT_STABLE id=$id attempt=$attempt textSent=0/0" + Assert-Ownership ($state.sinks -eq $state.produced) 'Record was not forwarded synchronously before the next source step.' + } + if ($mode -cne 'sink-only') { throw 'controlled unchanged primary' } + } -recordSink { + param($message) + $state.sinks++ + if ($mode -cne 'source-primary') { throw 'controlled retention sink failure' } + Write-Host $message + } + } catch { $state.failure = $_ } + } 6>&1) + Assert-Ownership ($state.calls -eq 1 -and $state.produced -eq 5 -and $state.sinks -eq 5 -and + $evidence.Count -eq 5 -and $state.failure) 'Source/sink failures replayed or lost produced evidence.' + if ($mode -ceq 'sink-only') { + Assert-Ownership ($state.failure.Exception.Message.StartsWith('MULTIPROJECT_INPUT_RETENTION:', [StringComparison]::Ordinal)) 'A sink-only failure returned success.' + } else { + Assert-Ownership ($state.failure.Exception.Message -ceq 'controlled unchanged primary') 'The primary failure changed.' + if ($mode -ceq 'source-plus-sink') { + Assert-Ownership (@($state.failure.Exception.Data['MultiProjectInputRetention']).Count -eq 5) 'Secondary retention failures were lost.' + } + } + } +} +Complete-OwnershipFamily 'ownership.mixed-stream.source-line-2305' 3 @('powershell-streams-error-identity') +Invoke-OwnershipCase 'exact-primary-exception-object' { + $original = [InvalidOperationException]::new('exact original exception') + $state = @{ calls = 0; failure = $null } + $evidence = [Collections.Generic.List[string]]::new() + try { + $null = Invoke-MultiProjectTypeText 9904 ' ' $evidence -typeSource { + param($id, $text) + $state.calls++ + Write-Host 'UIA_EDGE_TEXT_STABLE id=9904 attempt=1 textSent=0/0' + throw $original + } + } catch { $state.failure = $_ } + Assert-Ownership ($state.calls -eq 1 -and [Object]::ReferenceEquals($state.failure.Exception, $original) -and $evidence.Count -eq 1) 'The identical original exception was not retained.' +} +Complete-OwnershipFamily 'ownership.mixed-stream.source-line-2342' 1 @('powershell-streams-error-identity') +foreach ($faults in @('attempt', 'summary', 'attempt-summary', 'attempt-warning', 'summary-warning', 'attempt-summary-warning')) { + Invoke-OwnershipCase $faults { + $original = [InvalidOperationException]::new('original fifth/native primary') + $state = @{ calls = 0; records = 0; summaries = 0; warnings = 0; failure = $null } + $evidence = [Collections.Generic.List[string]]::new() + try { + $null = Invoke-MultiProjectTypeText 9904 ' ' $evidence -typeSource { + param($id, $text) + $state.calls++ + foreach ($attempt in 1..5) { Write-Host "UIA_EDGE_TEXT_STABLE id=9904 attempt=$attempt textSent=0/0" } + throw $original + } -recordSink { + param($message) + $state.records++ + if ($faults.Contains('attempt')) { throw [IO.IOException]::new('attempt writer IO') } + Write-Host $message + } -failureSink { + param($message) + $state.summaries++ + if ($faults.Contains('summary')) { throw [IO.IOException]::new('summary writer IO') } + Write-Host $message + } -warningSink { + param($message) + $state.warnings++ + if ($faults.Contains('warning')) { throw [IO.IOException]::new('warning writer IO') } + } + } catch { $state.failure = $_ } + $secondary = @($state.failure.Exception.Data['MultiProjectInputRetention']) + $expected = $(if ($faults.Contains('attempt')) { 5 } else { 0 }) + + $(if ($faults.Contains('summary')) { 1 } else { 0 }) + $(if ($faults.Contains('warning')) { 1 } else { 0 }) + Assert-Ownership ([Object]::ReferenceEquals($state.failure.Exception, $original) -and + $state.failure.Exception.Message -ceq 'original fifth/native primary' -and $state.calls -eq 1 -and + $state.records -eq 5 -and $state.summaries -eq 1 -and $state.warnings -eq 1 -and $evidence.Count -eq 5 -and + $secondary.Count -eq $expected) 'Production catch lost primary identity, typed diagnostics, attempt records or replayed source.' + foreach ($error in $secondary) { + Assert-Ownership ($error.operation -in @('attempt-record', 'failure-summary', 'warning') -and + $error.errorType -ceq 'System.IO.IOException' -and $error.hresult -is [int] -and + $error.message.Contains('writer IO')) 'Secondary writer failure lost its operation/type/HResult/message.' + } + } +} +Complete-OwnershipFamily 'ownership.mixed-stream.source-line-2359' 6 @('powershell-streams-error-identity') +if ($script:OwnershipAdapterFailed) { exit 1 } diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/PreUiaIsolation.Contracts.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/PreUiaIsolation.Contracts.ps1 new file mode 100644 index 00000000..9842289d --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/PreUiaIsolation.Contracts.ps1 @@ -0,0 +1,83 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) + +. (Join-Path $PSScriptRoot 'AdapterSupport.ps1') +$script:OwnershipMockSignals = $true +Import-OwnershipBindings $RepositoryRoot $AssemblyPath +$source = [IO.File]::ReadAllText((Join-Path $RepositoryRoot 'Tools\windows\validate.ps1')) +$region = [regex]::Match($source, + '(?s) \$preUiaProcesses =.*?(?= Invoke-Native "Native UI Automation live gate")') +if (-not $region.Success) { throw 'Actual pre-UIA advisory isolation guard is missing.' } +$guard = [scriptblock]::Create($region.Value + "`n`$state.gateEntries++") +$repoRoot = Join-Path $ArtifactRoot 'pre-uia-repo' +$created = [datetime]::new(2026, 1, 1, 0, 0, 0, [DateTimeKind]::Utc) +function New-IsolationObservation([int] $PidValue, [string] $Path, [datetime] $Creation) { + [pscustomobject]@{ + ProcessId = $PidValue; ParentProcessId = 4000; ExecutablePath = $Path + CreationDate = $Creation; Name = [IO.Path]::GetFileName($Path); SessionId = 1 + } +} +$shell = New-IsolationObservation 4242 (Join-Path $repoRoot 'graphcode-windows.exe') $created +$daemon = New-IsolationObservation 4243 (Join-Path $repoRoot 'graphcoded.exe') $created +$foreign = New-IsolationObservation 4244 (Join-Path $ArtifactRoot 'foreign\graphcoded.exe') $created +$sibling = New-IsolationObservation 4245 (Join-Path ($repoRoot + '-other') 'graphcoded.exe') $created +$reused = New-IsolationObservation 4242 $shell.ExecutablePath $created.AddSeconds(1) +$uppercase = New-IsolationObservation 4246 $shell.ExecutablePath.ToUpperInvariant() $created +$cases = @( + @{ id = 'unexpected-shell'; rows = @($shell); baseline = @(); refused = @(4242) }, + @{ id = 'unexpected-daemon'; rows = @($daemon); baseline = @(); refused = @(4243) }, + @{ id = 'empty-inventory'; rows = @(); baseline = @(); refused = @() }, + @{ id = 'baseline-generation'; rows = @($shell); baseline = @($shell); refused = @() }, + @{ id = 'foreign-repository'; rows = @($foreign); baseline = @(); refused = @() }, + @{ id = 'sibling-prefix'; rows = @($sibling); baseline = @(); refused = @() }, + @{ id = 'reused-baseline-pid'; rows = @($reused); baseline = @($shell); refused = @(4242) }, + @{ id = 'multiple-candidates'; rows = @($shell, $daemon); baseline = @(); refused = @(4242, 4243) }, + @{ id = 'case-insensitive-repository'; rows = @($uppercase); baseline = @(); refused = @(4246) }, + @{ id = 'baseline-and-unexpected'; rows = @($shell, $daemon); baseline = @($shell); refused = @(4243) } +) +function Get-CimInstance { + param($ClassName, $Filter, $ErrorAction) + $state.probes++ + Assert-Ownership ($ClassName -ceq 'Win32_Process' -and + $Filter -ceq "Name = 'graphcode-windows.exe' OR Name = 'graphcoded.exe'") 'Guard changed its single advisory query.' + return $state.rows +} +function Stop-Process { $state.terminations++; throw 'Forbidden CIM-only termination.' } +function Get-Process { throw 'Advisory guard must not acquire or terminate a process.' } +function Start-Sleep { throw 'Advisory guard must not poll.' } +function Write-Host { param($Object) $state.messages.Add([string]$Object) } +foreach ($case in $cases) { + Invoke-OwnershipCase $case.id { + $state = @{ rows = $case.rows; probes = 0; terminations = 0; gateEntries = 0; messages = [Collections.Generic.List[string]]::new() } + $uiaProductProcessBaseline = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($entry in $case.baseline) { + $null = $uiaProductProcessBaseline.Add("$([int]$entry.ProcessId)|$($entry.CreationDate.ToUniversalTime().ToString('o'))") + } + $failure = $null + try { & $guard | Out-Null } catch { $failure = $_ } + Assert-Ownership ($state.probes -eq 1 -and $state.terminations -eq 0) 'Advisory query was repeated or used as termination authority.' + $snapshots = @($state.messages | Where-Object { $_.StartsWith('WINDOWS_SHELL_PRE_UIA_PROCESS_SNAPSHOT=', [StringComparison]::Ordinal) }) + Assert-Ownership ($snapshots.Count -eq 1) 'Advisory snapshot missing or duplicated.' + $snapshot = @($snapshots[0].Substring('WINDOWS_SHELL_PRE_UIA_PROCESS_SNAPSHOT='.Length) | ConvertFrom-Json) + Assert-Ownership ($snapshot.Count -eq $case.rows.Count) 'Snapshot lost original observations.' + if ($case.refused.Count -gt 0) { + Assert-Ownership ($failure -and $failure.Exception.Message.Contains('not attested as run-owned') -and + $failure.Exception.Message.Contains('no processes were terminated') -and + $failure.Exception.Data['GraphCodeValidationRootRetentionRequired'] -ceq $true -and + $state.gateEntries -eq 0) 'Guard did not refuse before gate with exact retention and authority diagnostics.' + foreach ($observedPid in $case.refused) { + Assert-Ownership ($failure.Exception.Message -match "(?&1 | ForEach-Object { + if ([string]$_ -ceq 'GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven') { + 'OWNERSHIP_EXPECTED_RETENTION_SIGNAL=observed' + } else { $_ } + } + } finally { Restore-OwnershipEnvironment $snapshot @('GRAPHCODE_VALIDATION_ROOT') } +} +$fixture = Join-Path $ArtifactRoot ('receipt-' + [guid]::NewGuid().ToString('N')) +$null = New-Item -ItemType Directory -Path $fixture -ErrorAction Stop +try { + foreach ($variant in @('valid', 'existing', 'missing', 'file', 'empty', 'absent', 'isolated')) { + Invoke-OwnershipCase $variant { + $root = Join-Path $fixture $variant + if ($variant -in @('valid', 'existing', 'isolated')) { + $null = New-Item -ItemType Directory -Path $root + } + if ($variant -ceq 'file') { [IO.File]::WriteAllText($root, 'not a directory') } + $path = Join-Path $root 'ownership-unproven.json' + if ($variant -ceq 'existing') { [IO.File]::WriteAllText($path, 'preserve-existing-receipt') } + if ($variant -ceq 'absent') { + Remove-OwnershipEnvironment 'GRAPHCODE_VALIDATION_ROOT' + } else { + [Environment]::SetEnvironmentVariable('GRAPHCODE_VALIDATION_ROOT', $(if ($variant -ceq 'empty') { '' } else { $root }), 'Process') + } + $records = @( + if ($variant -ceq 'isolated') { Invoke-ReceiptMock { New-UiaOwnershipFailure 'controlled receipt refusal' } } + else { New-UiaOwnershipFailure 'controlled receipt refusal' 6>&1 } + ) + $failures = @($records | Where-Object { $_ -is [Exception] }) + $signals = @($records | Where-Object { + [string]$_ -ceq 'GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven' + }) + Assert-Ownership ($failures.Count -eq 1 -and $signals.Count -eq [int]($variant -cne 'isolated')) 'Producer changed exception or exact information stream signal.' + $failure = $failures[0] + Assert-Ownership ($failure.GetType() -eq [InvalidOperationException] -and + $failure.Message -ceq 'controlled receipt refusal' -and + $failure.Data['GraphCodeValidationRootRetentionRequired'] -is [bool] -and + $failure.Data['GraphCodeValidationRootRetentionRequired']) 'Producer lost exact exception type/message/boolean retention intent.' + $roots = @($failure.Data['GraphCodeValidationRetainedRoots']) + $expectedRoots = [int]($variant -in @('valid', 'existing')) + Assert-Ownership ($roots.Count -eq $expectedRoots -and + ($expectedRoots -eq 0 -or $roots[0] -ceq $root)) 'Producer retained-root identity differs.' + $writeFailed = -not [string]::IsNullOrWhiteSpace($failure.Data['GraphCodeValidationRetentionReceiptFailure']) + Assert-Ownership ($writeFailed -eq ($variant -in @('existing', 'missing', 'file', 'empty'))) 'Receipt write failure was lost or fabricated.' + if ($variant -ceq 'valid') { + $bytes = [IO.File]::ReadAllBytes($path) + Assert-Ownership ($bytes.Length -gt 0 -and $bytes.Length -le 4096 -and $bytes[0] -ne 0xEF) 'Receipt is not bounded BOM-free UTF-8.' + $receipt = [Text.UTF8Encoding]::new($false, $true).GetString($bytes) | ConvertFrom-Json + Assert-Ownership ($receipt.schemaVersion -eq 1 -and $receipt.validationRoot -ceq $root -and + $receipt.reason -ceq 'ownership_unproven') 'Receipt schema/root/reason mismatch.' + } + if ($variant -ceq 'existing') { + Assert-Ownership ([IO.File]::ReadAllText($path) -ceq 'preserve-existing-receipt') 'Producer overwrote an existing receipt.' + } + if ($variant -ceq 'isolated') { + Assert-Ownership (-not [IO.File]::Exists($path) -and $roots.Count -eq 0 -and + [Environment]::GetEnvironmentVariable('GRAPHCODE_VALIDATION_ROOT', 'Process') -ceq $root -and + @($records | Where-Object { [string]$_ -ceq 'OWNERSHIP_EXPECTED_RETENTION_SIGNAL=observed' }).Count -eq 1) 'Mock authority escaped, exact signal filtering changed, or environment not restored.' + } + } + } +} finally { + Restore-OwnershipEnvironment $saved @('GRAPHCODE_VALIDATION_ROOT') +} +Complete-OwnershipFamily 'ownership.legacy.ownership-receipts' 7 +if ($script:OwnershipAdapterFailed) { exit 1 } diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/RetentionBoundary.Contracts.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/RetentionBoundary.Contracts.ps1 new file mode 100644 index 00000000..cf57f172 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/RetentionBoundary.Contracts.ps1 @@ -0,0 +1,83 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) + +. (Join-Path $PSScriptRoot 'AdapterSupport.ps1') +$script:OwnershipMockSignals = $true +Import-OwnershipBindings $RepositoryRoot $AssemblyPath +$consumer = Read-OwnershipProductionAst (Join-Path $RepositoryRoot 'Tools\windows\validate.ps1') +. Import-OwnershipProductionFunctions $consumer @('Invoke-Native', 'Get-WindowsShellProfileSnapshot', + 'Assert-WindowsShellProfileUnchanged', 'Invoke-WindowsShellValidationIsolation') +$keys = @('GRAPHCODE_VALIDATION_ROOT', 'GRAPHCODE_SUPPORT_DIR', 'LOCALAPPDATA', 'TEMP', 'TMP') +$before = [Environment]::GetEnvironmentVariables('Process') +$fixture = Join-Path $ArtifactRoot ('retention-boundary-' + [guid]::NewGuid().ToString('N')) +$null = New-Item -ItemType Directory -Path $fixture +$pwsh = Join-Path $PSHOME 'pwsh.exe' +$child = Join-Path $PSScriptRoot 'RetentionChild.ps1' +$cases = @( + @{ id = 'sentinel-exit17'; mode = 'sentinel'; code = 17 }, + @{ id = 'sentinel-exit0'; mode = 'sentinel'; code = 0 }, + @{ id = 'receipt-only-exit17'; mode = 'receipt-only'; code = 17 }, + @{ id = 'receipt-only-exit0'; mode = 'receipt-only'; code = 0 }, + @{ id = 'ordinary-exit17'; mode = 'ordinary'; code = 17 }, + @{ id = 'isolated-mock-exit0'; mode = 'isolated'; code = 0 } +) +try { + foreach ($case in $cases) { + Invoke-OwnershipCase $case.id { + $state = @{ root = $null; output = [Collections.Generic.List[string]]::new() } + $failure = $null + try { + Invoke-WindowsShellValidationIsolation -WorkspaceRoot $fixture -ValidationRootParent (Join-Path $fixture 'isolations') ` + -UserProfileRoot (Join-Path $fixture 'profile') -LocalAppDataRoot (Join-Path $fixture 'local') -Action { + $state.root = $env:GRAPHCODE_VALIDATION_ROOT + Invoke-Native 'controlled actual UIA retention boundary' { + & $pwsh -NoProfile -NonInteractive -File $child -RepositoryRoot $RepositoryRoot -Mode $case.mode -ExitCode $case.code + } | ForEach-Object { $state.output.Add([string]$_) } + } + } catch { $failure = $_ } + $sentinels = @($state.output | Where-Object { $_ -ceq 'GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven' }) + $observations = @($state.output | Where-Object { $_.StartsWith('{"validationRoot":', [StringComparison]::Ordinal) }) + Assert-Ownership ($observations.Count -eq 1) 'Child did not emit exactly one typed observation.' + $observed = $observations[0] | ConvertFrom-Json + $reportsOwnership = $case.mode -in @('sentinel', 'receipt-only') + $tagged = $case.mode -cne 'ordinary' + $retained = $reportsOwnership -or $case.code -ne 0 + $expectedRoot = if ($case.mode -ceq 'isolated') { $null } else { $state.root } + Assert-Ownership (($null -ne $failure) -eq $retained -and $LASTEXITCODE -eq $case.code -and + $sentinels.Count -eq [int]($case.mode -ceq 'sentinel') -and $observed.validationRoot -ceq $expectedRoot -and + $observed.tagged -eq $tagged -and $observed.booleanTag -eq $tagged -and + (Test-Path -LiteralPath $state.root) -eq $retained -and + ($null -ne $failure -and $failure.Exception.Data['GraphCodeValidationRootRetentionRequired'] -eq $true) -eq $retained) ` + 'Actual retention signal/receipt/exit/root disposition or LASTEXITCODE differs.' + if ($reportsOwnership) { + Assert-Ownership (@($observed.observedRoots).Count -eq 1 -and $observed.observedRoots[0] -ceq $state.root -and + $failure.Exception.Data['GraphCodeValidationRetainedRoots'] -contains $state.root) 'Exact root identity lost at actual consumer boundary.' + $bytes = [IO.File]::ReadAllBytes((Join-Path $state.root 'ownership-unproven.json')) + Assert-Ownership ($bytes.Length -gt 0 -and $bytes.Length -le 4096) 'Actual child did not publish bounded receipt.' + $receipt = [Text.UTF8Encoding]::new($false, $true).GetString($bytes) | ConvertFrom-Json + Assert-Ownership ($receipt.schemaVersion -eq 1 -and $receipt.validationRoot -ceq $state.root -and + $receipt.reason -ceq 'ownership_unproven') 'Actual child receipt payload changed.' + } + if ($case.mode -ceq 'ordinary') { + Assert-Ownership ($failure.Exception.Data['GraphCodeValidationRootRetentionReason'] -ceq 'workload_failed_without_cleanup_proof') 'Unclassified workload failure lost conservative disposition.' + } + if ($case.mode -ceq 'isolated') { + Assert-Ownership (@($observed.observedRoots).Count -eq 0 -and + @($state.output | Where-Object { $_ -ceq 'OWNERSHIP_EXPECTED_RETENTION_SIGNAL=observed' }).Count -eq 1) 'Mock isolation leaked root authority or did not observe the exact retention signal.' + } + $after = [Environment]::GetEnvironmentVariables('Process') + foreach ($key in $keys) { + Assert-Ownership ($before.Contains($key) -eq $after.Contains($key) -and + (-not $before.Contains($key) -or [string]$before[$key] -ceq [string]$after[$key])) "Isolation changed environment membership/value: $key" + } + } + } +} finally { + Restore-OwnershipEnvironment $before $keys +} +Complete-OwnershipFamily 'ownership.legacy.retention-boundary' 6 +if ($script:OwnershipAdapterFailed) { exit 1 } diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/RetentionChild.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/RetentionChild.ps1 new file mode 100644 index 00000000..8161bc2e --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/RetentionChild.ps1 @@ -0,0 +1,34 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][ValidateSet('sentinel', 'receipt-only', 'ordinary', 'isolated')][string] $Mode, + [Parameter(Mandatory)][int] $ExitCode +) + +. (Join-Path $PSScriptRoot 'AdapterSupport.ps1') +Import-Module (Join-Path $RepositoryRoot 'Tools\windows\ShellProcessOwnership.psm1') -Force -ErrorAction Stop +$gate = Read-OwnershipProductionAst (Join-Path $RepositoryRoot 'Tools\windows\uia-live-gate.ps1') +. Import-OwnershipProductionFunctions $gate @('New-UiaOwnershipFailure') +$snapshot = [Environment]::GetEnvironmentVariables('Process') +try { + if ($Mode -ceq 'isolated') { Remove-OwnershipEnvironment 'GRAPHCODE_VALIDATION_ROOT' } + $failure = $null + try { + if ($Mode -cne 'ordinary') { + if ($Mode -in @('receipt-only', 'isolated')) { + $ownership = New-UiaOwnershipFailure 'controlled UIA ownership refusal' 6>$null + if ($Mode -ceq 'isolated') { Write-Output 'OWNERSHIP_EXPECTED_RETENTION_SIGNAL=observed' } + } else { $ownership = New-UiaOwnershipFailure 'controlled UIA ownership refusal' } + throw $ownership + } + } catch { $failure = $_.Exception } + [ordered]@{ + validationRoot = [Environment]::GetEnvironmentVariable('GRAPHCODE_VALIDATION_ROOT', 'Process') + tagged = $null -ne $failure -and $failure.Data['GraphCodeValidationRootRetentionRequired'] -eq $true + booleanTag = $null -ne $failure -and $failure.Data['GraphCodeValidationRootRetentionRequired'] -is [bool] + observedRoots = @($(if ($failure) { $failure.Data['GraphCodeValidationRetainedRoots'] })) + } | ConvertTo-Json -Compress +} finally { + Restore-OwnershipEnvironment $snapshot @('GRAPHCODE_VALIDATION_ROOT') +} +exit $ExitCode diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/StartupDiagnostics.Contracts.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/StartupDiagnostics.Contracts.ps1 new file mode 100644 index 00000000..aedfac01 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/StartupDiagnostics.Contracts.ps1 @@ -0,0 +1,81 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) + +. (Join-Path $PSScriptRoot 'AdapterSupport.ps1') +Import-OwnershipBindings $RepositoryRoot $AssemblyPath 'GraphCodeUiaHostInfo' +$gate = Read-OwnershipProductionAst (Join-Path $RepositoryRoot 'Tools\windows\uia-live-gate.ps1') +$names = @( + 'Protect-UiaStartupDiagnosticText', 'Get-UiaStartupDiagnosticValue', 'Get-UiaStartupFileDiagnostic', + 'Get-UiaStartupImageHash', 'Write-UiaStartupFailureDiagnostic', 'Get-UiaPrelaunchDiagnostics', + 'Get-UiaOwnedProcessDescendants', 'Stop-UiaOwnedProcessTrees', 'New-UiaOwnershipFailure', + 'Initialize-MultiProjectProcessJob', 'Initialize-UiaOwnedStreamCapture', + 'Start-MultiProjectOwnedProcess', 'Complete-MultiProjectCapture' +) +foreach ($name in $names) { + Invoke-OwnershipCase $name { + $definitions = @($gate.FindAll({ param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq $name + }, $true)) + Assert-Ownership ($definitions.Count -eq 1) "Required complete production startup helper missing or ambiguous: $name" + } +} +Complete-OwnershipFamily 'ownership.ui-startup.helpers' 13 @('source-only') +. Import-OwnershipProductionFunctions $gate $names +$root = Join-Path $ArtifactRoot ('startup-' + [guid]::NewGuid().ToString('N')) +$null = New-Item -ItemType Directory -Path $root +$path = Join-Path $root 'capture.log' +[IO.File]::WriteAllText($path, "loader failed`npassword=private-canary`n") +Invoke-OwnershipCase 'redacted-log' { + $diagnostic = Get-UiaStartupFileDiagnostic $path 'logs\shell-stderr.log' + Assert-Ownership ($diagnostic.state -ceq 'available' -and $diagnostic.content -match 'loader failed' -and + $diagnostic.content -notmatch 'private-canary' -and $diagnostic.readBytes -eq $diagnostic.lengthBytes) 'Actual startup file diagnostic lost output, redaction or full-read proof.' +} +Complete-OwnershipFamily 'ownership.ui-startup.redacted-log' 1 +Invoke-OwnershipCase 'truncated-log' { + $diagnostic = Get-UiaStartupFileDiagnostic $path 'logs\shell-stderr.log' 8 + Assert-Ownership ($diagnostic.state -ceq 'truncated' -and $diagnostic.readBytes -eq 8 -and + $diagnostic.lengthBytes -gt 8) 'Actual diagnostic read exceeded or failed to report the eight-byte bound.' +} +Complete-OwnershipFamily 'ownership.ui-startup.truncated-log' 1 +Invoke-OwnershipCase 'missing-log' { + $diagnostic = Get-UiaStartupFileDiagnostic (Join-Path $root 'missing.log') 'logs\missing.log' + Assert-Ownership ($diagnostic.state -ceq 'missing') 'Actual missing log was misclassified as empty or available.' +} +Complete-OwnershipFamily 'ownership.ui-startup.missing-log' 1 +Invoke-OwnershipCase 'retained-report' { + $logs = Join-Path $root 'logs' + $support = Join-Path $root 'support' + $null = New-Item -ItemType Directory -Path $logs, $support + [IO.File]::WriteAllText((Join-Path $logs 'shell-stderr.log'), "loader failed`npassword=stderr-canary`n") + [IO.File]::WriteAllText((Join-Path $logs 'shell-stdout.log'), 'child output captured') + [IO.File]::WriteAllText((Join-Path $support 'graphcode-windows.log'), "app initialization failed`nsecret=app-canary`n") + $self = Get-Process -Id $PID + try { Write-UiaStartupFailureDiagnostic $self 0 $path $root $logs $support } + finally { $self.Dispose() } + $report = Get-Content -LiteralPath (Join-Path $logs 'startup-failure.json') -Raw | ConvertFrom-Json + Assert-Ownership ($report.stderr.content -match 'loader failed' -and $report.stdout.content -match 'child output captured' -and + $report.applicationLog.content -match 'app initialization failed' -and $report.stderr.content -notmatch 'stderr-canary' -and + $report.applicationLog.content -notmatch 'app-canary') 'Actual retained report lost independent stderr/stdout/app logs or exposed canaries.' +} +Complete-OwnershipFamily 'ownership.ui-startup.retained-report' 1 +Invoke-OwnershipCase 'pid-reuse' { + $created = [datetime]::new(2026, 1, 1, 12, 0, 0, [DateTimeKind]::Utc) + function Get-CimInstance { + @( + [pscustomobject]@{ ProcessId = 624; ParentProcessId = 7416; Name = 'conhost.exe'; ExecutablePath = ''; CreationDate = $created } + [pscustomobject]@{ ProcessId = 700; ParentProcessId = 624; Name = 'child.exe'; ExecutablePath = ''; CreationDate = $created.AddSeconds(5) } + [pscustomobject]@{ ProcessId = 636; ParentProcessId = 624; Name = 'csrss.exe'; ExecutablePath = ''; CreationDate = $created.AddHours(-3) } + [pscustomobject]@{ ProcessId = 732; ParentProcessId = 636; Name = 'wininit.exe'; ExecutablePath = ''; CreationDate = $created.AddHours(-2) } + ) + } + function Stop-Process { throw 'Advisory traversal must not terminate anything.' } + function Get-Process { throw 'Advisory traversal must not reopen an observed PID.' } + $names = @(Get-UiaOwnedProcessDescendants @(624) | ForEach-Object Name) + Assert-Ownership (($names -join ',') -ceq 'child.exe') 'Advisory traversal adopted a process predating its reused parent generation.' +} +Complete-OwnershipFamily 'ownership.ui-process-tree.pid-reuse' 1 +if ($script:OwnershipAdapterFailed) { exit 1 } diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/TreeProvider.Contracts.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/TreeProvider.Contracts.ps1 new file mode 100644 index 00000000..99025798 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/TreeProvider.Contracts.ps1 @@ -0,0 +1,107 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) + +. (Join-Path $PSScriptRoot 'AdapterSupport.ps1') +$script:OwnershipMockSignals = $true +Import-OwnershipBindings $RepositoryRoot $AssemblyPath +Import-Module (Join-Path $RepositoryRoot 'Tools\windows\ShellProcessOwnership.psm1') -Force -ErrorAction Stop +$gatePath = Join-Path $RepositoryRoot 'Tools\windows\uia-live-gate.ps1' +$gate = Read-OwnershipProductionAst $gatePath +. Import-OwnershipProductionFunctions $gate @('New-UiaOwnershipFailure', 'Get-UiaOwnedProcessDescendants', 'Stop-UiaOwnedProcessTrees', + 'Stop-UiaOwnedProviderProcesses') @{ '[Diagnostics.Process[]]' = '[object[]]' } +$saved = [Environment]::GetEnvironmentVariables('Process') +Remove-OwnershipEnvironment 'GRAPHCODE_VALIDATION_ROOT' +try { + function Protect-UiaStartupDiagnosticText { param($text) return $text } + $state = @{ childKills = 0; rootKills = 0; completions = [Collections.Generic.List[string]]::new(); refuse = $false } + function Complete-MultiProjectCapture($capture, $logDirectory) { + $state.completions.Add($capture.stem) + if ($state.refuse -and $capture.stem -ceq 'first') { throw (New-UiaOwnershipFailure 'controlled job survivor') } + $capture.cleaned = $true + } + $created = [datetime]::new(2026, 1, 1, 0, 0, 0, [DateTimeKind]::Utc) + function Get-CimInstance { + param($ClassName, $Filter) + @( + [pscustomobject]@{ ProcessId = 44; ParentProcessId = 1; CreationDate = $created; Name = 'root.exe'; ExecutablePath = 'D:\root.exe' } + [pscustomobject]@{ ProcessId = 9931; ParentProcessId = 44; CreationDate = $created.AddSeconds(1); Name = 'child.exe'; ExecutablePath = 'D:\child.exe' } + ) | Where-Object { -not $Filter -or $_.ProcessId -eq 9931 } + } + function Get-Process { + $child = [pscustomobject]@{ HasExited = $false } + $child | Add-Member ScriptMethod Kill { $state.childKills++ } + $child | Add-Member ScriptMethod WaitForExit { $true } + $child | Add-Member ScriptMethod Dispose {} + return $child + } + $root = [pscustomobject]@{ Id = 44; HasExited = $false } + $root | Add-Member ScriptMethod Kill { $state.rootKills++ } + $root | Add-Member ScriptMethod WaitForExit { $true } + Invoke-OwnershipCase 'unowned-root' { + $script:UiaGateOwnedCaptures = @() + $failure = $null + try { Stop-UiaOwnedProcessTrees @($root) } catch { $failure = $_ } + Assert-Ownership ($failure -and $failure.Exception.Data['GraphCodeValidationRootRetentionRequired'] -eq $true -and + $state.rootKills -eq 0 -and $state.childKills -eq 0) 'Advisory process observations supplied termination authority.' + } + Invoke-OwnershipCase 'no-launch' { + $script:UiaGateOwnedCaptures = @() + $output = @(Stop-UiaOwnedProcessTrees @() 6>&1) -join "`n" + Assert-Ownership ($output.Contains('CLEANUP=not_needed') -and -not $output.Contains('CLEANUP=verified')) 'Empty registry claimed verified cleanup.' + } + foreach ($refuse in @($true, $false)) { + Invoke-OwnershipCase "owned-jobs-refusal-$refuse" { + $state.refuse = $refuse + $state.completions.Clear() + $other = [pscustomobject]@{ Id = 45; HasExited = $false } + $script:UiaGateOwnedCaptures = @( + @{ process = $root; stem = 'first'; logDirectory = $ArtifactRoot; cleaned = $false }, + @{ process = $other; stem = 'second'; logDirectory = $ArtifactRoot; cleaned = $false } + ) + $failure = $null + try { Stop-UiaOwnedProcessTrees @($root, $other) } catch { $failure = $_ } + Assert-Ownership (($state.completions -join ',') -ceq 'first,second' -and + ($null -ne $failure) -eq $refuse -and + (-not $refuse -or $failure.Exception.Data['GraphCodeValidationRootRetentionRequired'] -eq $true) -and + $state.rootKills -eq 0 -and $state.childKills -eq 0) 'Later owned lease skipped, primary retention lost, or CIM-only target killed.' + } + } + Complete-OwnershipFamily 'ownership.legacy.tree-ownership' 4 + $expected = 'D:\ownership-provider\zmx.exe' + $candidate = [pscustomobject]@{ + Name = 'zmx.exe'; ProcessId = 9931; ExecutablePath = $expected; CreationDate = $created + } + Invoke-OwnershipCase 'foreign-window-hiding-removed' { + Assert-Ownership (-not [IO.File]::ReadAllText($gatePath).Contains('Hide-TestProviderZmxWindows')) 'Unattested global provider-window hiding remains reachable.' + } + foreach ($variant in @('unattested-provider', 'baseline-provider', 'empty-inventory')) { + Invoke-OwnershipCase $variant { + $provider = @{ kills = 0; rows = @($candidate) } + if ($variant -ceq 'empty-inventory') { $provider.rows = @() } + $baseline = [Collections.Generic.HashSet[string]]::new() + if ($variant -ceq 'baseline-provider') { $null = $baseline.Add("$($candidate.ProcessId)|$([string]$candidate.CreationDate)") } + function Get-CimInstance { return $provider.rows } + function Get-Process { + $process = [pscustomobject]@{ HasExited = $false } + $process | Add-Member ScriptMethod Kill { $provider.kills++ } + $process | Add-Member ScriptMethod WaitForExit { $true } + $process | Add-Member ScriptMethod Dispose {} + return $process + } + $failure = $null + try { Stop-UiaOwnedProviderProcesses $expected $baseline } catch { $failure = $_ } + $mustRefuse = $variant -ceq 'unattested-provider' + Assert-Ownership ($provider.kills -eq 0 -and ($null -ne $failure) -eq $mustRefuse -and + (-not $mustRefuse -or ($failure.Exception.Data['GraphCodeValidationRootRetentionRequired'] -is [bool] -and + $failure.Exception.Data['GraphCodeValidationRootRetentionRequired']))) 'Provider advisory inventory was elevated to authority or its refusal/tag changed.' + } + } + Complete-OwnershipFamily 'ownership.legacy.provider-ownership' 4 +} finally { + Restore-OwnershipEnvironment $saved @('GRAPHCODE_VALIDATION_ROOT') +} +if ($script:OwnershipAdapterFailed) { exit 1 } diff --git a/Tools/windows/ValidationRunner/Ownership/Deadline.cs b/Tools/windows/ValidationRunner/Ownership/Deadline.cs new file mode 100644 index 00000000..79d8e14c --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/Deadline.cs @@ -0,0 +1,29 @@ +using System; +using System.Diagnostics; + +namespace GraphCode.Validation.Ownership; + +public sealed class Deadline +{ + private readonly long started = Stopwatch.GetTimestamp(); + private readonly TimeSpan budget; + + public Deadline(TimeSpan budget) + { + if (budget <= TimeSpan.Zero || budget.TotalMilliseconds > int.MaxValue) + throw new ArgumentOutOfRangeException(nameof(budget)); + this.budget = budget; + } + + public TimeSpan Remaining + { + get + { + TimeSpan remaining = budget - Stopwatch.GetElapsedTime(started); + return remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero; + } + } + + public int RemainingMilliseconds => (int)Math.Ceiling(Remaining.TotalMilliseconds); + public bool Expired => Remaining == TimeSpan.Zero; +} diff --git a/Tools/windows/ValidationRunner/Ownership/Fixtures/ShellScopeNativeFixture.cs b/Tools/windows/ValidationRunner/Ownership/Fixtures/ShellScopeNativeFixture.cs new file mode 100644 index 00000000..448dbe27 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/Fixtures/ShellScopeNativeFixture.cs @@ -0,0 +1,177 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace GraphCode.Validation.Ownership; + +public static class ShellScopeNativeFixture +{ + public const int ExpectedCases = 44; + + public static async Task TryRunAsync(string mode, string root) + { + if (mode is "shell-scope-child" or "shell-scope-exit") + { + for (int index = 0; index < 16; index++) + new Thread(() => Thread.Sleep(30000)) { IsBackground = true }.Start(); + Console.Out.WriteLine("shell-scope-ready"); + Console.Error.WriteLine("shell-scope-stderr"); + if (mode == "shell-scope-child") await Task.Delay(30000).ConfigureAwait(false); + else + { + var limit = new Deadline(TimeSpan.FromSeconds(5)); + while (!File.Exists(Path.Combine(root, "release"))) + { + if (limit.Expired) throw new TimeoutException("Controlled natural exit release was not published."); + await Task.Delay(5).ConfigureAwait(false); + } + } + return 7; + } + if (mode != "shell-scope-regression") return null; + int executed = 0, pendingAtZero = 0; + var natural = ShellOwnedLaunch.Suspended(OwnedFixture.Command("shell-scope-exit", root)); + try + { + IntPtr original = natural.Handle; + Refuse(() => natural.RequireExited()); + executed++; + Refuse(() => natural.RequireExited(20)); + executed++; + bool rejected = false; + try { natural.RequireExited(-1); } + catch (ArgumentOutOfRangeException) { rejected = true; } + Require(rejected && natural.Handle == original && !natural.Process.HasExited, + "A live-root refusal released or replaced the original held authority."); + executed++; + natural.Resume(); + File.WriteAllText(Path.Combine(root, "release"), "released"); + natural.RequireExited(5000); + natural.Drain(); + Require(natural.Process.ExitCode == 7 && natural.Handle == original && + natural.Output.Result.Contains("shell-scope-ready", StringComparison.Ordinal) && + natural.Error.Result.Contains("shell-scope-stderr", StringComparison.Ordinal), + "Natural held-root exit lost identity, code or independent stream evidence."); + executed++; + } + finally { ReleaseNatural(natural); } + + for (int iteration = 0; iteration < 10; iteration++) + { + IntPtr job = StandaloneProcessJob.Create(); + var runs = new List(); + try + { + for (int index = 0; index < 4; index++) + { + var run = ShellOwnedLaunch.Suspended(OwnedFixture.Command("shell-scope-child", root)); + runs.Add(run); + StandaloneProcessJob.Assign(job, run.Handle); + run.Resume(); + } + var ready = new HashSet(); + var startup = new Deadline(TimeSpan.FromSeconds(5)); + while (ready.Count != runs.Count) + { + foreach (var run in runs) + if (!ready.Contains(run) && run.TakeOutput(65536).Contains("shell-scope-ready", StringComparison.Ordinal)) + ready.Add(run); + if (startup.Expired) throw new TimeoutException("Controlled scope roots did not all become ready."); + await Task.Delay(5).ConfigureAwait(false); + } + var exit = new Deadline(TimeSpan.FromSeconds(5)); + StandaloneProcessJob.Terminate(job); + StandaloneProcessJob.WaitForEmpty(job, exit.RemainingMilliseconds); + foreach (var run in runs) + { + try { run.RequireExited(); } + catch (InvalidOperationException) { pendingAtZero++; } + run.RequireExited(exit.RemainingMilliseconds); + Require(StandaloneProcessJob.Active(job) == 0, "Owned job became active after termination."); + run.Drain(); + Require(run.Output.Result.Contains("shell-scope-ready", StringComparison.Ordinal) && + run.Error.Result.Contains("shell-scope-stderr", StringComparison.Ordinal), + "Terminated root lost independent EOF/output evidence."); + executed++; + } + } + finally { ReleaseJob(job, runs); } + } + Require(executed == ExpectedCases, "Native scope fixture did not execute its exact case inventory."); + Console.WriteLine("SHELL_NATIVE_RESULT=" + JsonSerializer.Serialize(new { executed, passed = executed, failed = 0, pendingAtZero })); + return 0; + } + + private static void Require(bool condition, string message) + { + if (!condition) throw new InvalidOperationException(message); + } + + private static void Refuse(Action action) + { + bool rejected = false; + try { action(); } + catch (InvalidOperationException) { rejected = true; } + Require(rejected, "A still-live held root was incorrectly accepted as exited."); + } + + private static void ReleaseNatural(ShellOwnedLaunch run) + { + try { run.Stop(); run.RequireExited(); run.Close(); } + catch (Exception failure) { Retain(failure); } + } + + private static void ReleaseJob(IntPtr job, IReadOnlyList runs) + { + try + { + var deadline = new Deadline(TimeSpan.FromSeconds(5)); + StandaloneProcessJob.Terminate(job); + StandaloneProcessJob.WaitForEmpty(job, deadline.RemainingMilliseconds); + foreach (var run in runs) run.RequireExited(deadline.RemainingMilliseconds); + foreach (var run in runs) run.Close(); + StandaloneProcessJob.Close(job); + } + catch (Exception failure) { Retain(failure); } + } + + private static void Retain(Exception failure) + { + // Fixture failures must not turn process exit into kill-on-close recovery. + failure.Data[OwnershipRetention.RequiredKey] = true; + try { Console.Out.WriteLine(OwnershipRetention.Sentinel); Console.Out.Flush(); } + finally { Thread.Sleep(Timeout.Infinite); } + } +} + +public sealed class ShellScopeNativeSuite : IValidationSuite +{ + public string Id => "ownership.shell-scope-native"; + public IReadOnlyList UnitIds => [Id]; + + public async Task> RunAsync(ValidationContext context, CancellationToken cancellationToken) + { + string root = Path.Combine(context.ArtifactRoot, "shell-scope-native-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var result = await OwnedChildSupervisor.RunAsync(OwnedFixture.Command("shell-scope-regression", root), + TimeSpan.FromSeconds(30), root, cancellationToken).ConfigureAwait(false); + if (result.ExitCode != 0 || !result.Cleanup.ResourcesClosed || !string.IsNullOrWhiteSpace(result.StandardError)) + throw new InvalidOperationException("Controlled shared-helper regression failed: " + result.StandardError); + const string prefix = "SHELL_NATIVE_RESULT="; + string[] reports = result.StandardOutput.Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Where(line => line.StartsWith(prefix, StringComparison.Ordinal)).ToArray(); + if (reports.Length != 1) throw new InvalidDataException("Expected one native scope fixture result."); + using var report = JsonDocument.Parse(reports[0][prefix.Length..]); + if (report.RootElement.GetProperty("executed").GetInt32() != ShellScopeNativeFixture.ExpectedCases || + report.RootElement.GetProperty("passed").GetInt32() != ShellScopeNativeFixture.ExpectedCases || + report.RootElement.GetProperty("failed").GetInt32() != 0) + throw new InvalidDataException("Native shared-helper executed counts differ."); + return [new UnitResult(Id, UnitStatus.Passed, ShellScopeNativeFixture.ExpectedCases, + reports[0][prefix.Length..], ["controlled-native", "held-root-exit", "job-empty-not-process-signal"])]; + } +} diff --git a/Tools/windows/ValidationRunner/Ownership/Fixtures/accepted-peer-91dc.json b/Tools/windows/ValidationRunner/Ownership/Fixtures/accepted-peer-91dc.json new file mode 100644 index 00000000..19d696c4 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/Fixtures/accepted-peer-91dc.json @@ -0,0 +1 @@ +{"protocolConnected":true,"correlatedRequests":true,"connectionCount":2,"requestCount":11,"responseCount":11,"unansweredRequests":[],"unansweredCommands":[],"commands":["listRecentProjects","listQuickChats","listRecentProjects","listQuickChats","restoreOpenProjects","openProject","listRecentProjects","listQuickChats","restoreOpenProjects","graphCommand","graphCommand"],"error":null,"subscriptionSeen":true,"reconnectObserved":true,"graphSent":true,"busyObserved":false,"appliedRenames":[],"appliedCreates":[],"appliedCreateRequests":[],"appliedEdgeCreates":[],"appliedEdgeUpdates":[],"appliedEdgeCreateRequests":[],"appliedEdgeUpdateRequests":[],"appliedPromotions":[],"appliedPromotionRequests":[],"receivedGraphCommands":[{"requestID":"00000000-0000-4000-8000-5bb6c7e0972e","command":"{\"projectPath\":\"D:\\\\a\\\\_temp\\\\gu-23b076d47241480a960e6ea1f9980be0\\\\mp\\\\Alpha\",\"command\":{\"renameNode\":{\"_0\":\"11111111-1111-4111-8111-111111111111\",\"title\":\"Alpha renamed\"}}}"},{"requestID":"00000000-0000-4000-8000-5bb8b7d8d3ef","command":"{\"projectPath\":\"D:\\\\a\\\\_temp\\\\gu-23b076d47241480a960e6ea1f9980be0\\\\mp\\\\Alpha\",\"command\":{\"renameNode\":{\"_0\":\"11111111-1111-4111-8111-111111111111\",\"title\":\"Alpha renamed\"}}}"}],"graphNodes":[{"id":"11111111-1111-4111-8111-111111111111","title":"Stub node A","loopType":"turnBased","createdBy":null},{"id":"22222222-2222-4222-8222-222222222222","title":"Stub node B","loopType":"turnBased","createdBy":null}],"edges":[],"graphSequence":1,"multiProjectPeer":{"receivedCount":11,"requestCount":11,"responseCount":11,"appliedCount":2,"publicationCount":7,"graphSequence":7,"received":[{"requestID":"00000000-0000-4000-8000-5bb4dbc64bc9","frame":{"version":2,"kind":"request","requestID":"00000000-0000-4000-8000-5bb4dbc64bc9","command":{"listRecentProjects":{}}},"expectedResponse":{"version":2,"kind":"response","requestID":"00000000-0000-4000-8000-5bb4dbc64bc9","event":{"recentProjectsListed":[{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Alpha","name":"Alpha","remote":false},{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Beta","name":"Beta","remote":false}]}}},{"requestID":"00000000-0000-4000-8000-5bb4dda29192","frame":{"version":2,"kind":"request","requestID":"00000000-0000-4000-8000-5bb4dda29192","command":{"listQuickChats":{}}},"expectedResponse":null},{"requestID":"00000000-0000-4000-8000-5bb4e15cafdf","frame":{"version":2,"kind":"request","requestID":"00000000-0000-4000-8000-5bb4e15cafdf","command":{"listRecentProjects":{}}},"expectedResponse":{"version":2,"kind":"response","requestID":"00000000-0000-4000-8000-5bb4e15cafdf","event":{"recentProjectsListed":[{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Alpha","name":"Alpha","remote":false},{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Beta","name":"Beta","remote":false}]}}},{"requestID":"00000000-0000-4000-8000-5bb4e33a0198","frame":{"version":2,"kind":"request","requestID":"00000000-0000-4000-8000-5bb4e33a0198","command":{"listQuickChats":{}}},"expectedResponse":null},{"requestID":"00000000-0000-4000-8000-5bb4e9c908d9","frame":{"version":2,"kind":"request","requestID":"00000000-0000-4000-8000-5bb4e9c908d9","command":{"restoreOpenProjects":{}}},"expectedResponse":null},{"requestID":"00000000-0000-4000-8000-5bb501206706","frame":{"version":2,"kind":"request","requestID":"00000000-0000-4000-8000-5bb501206706","command":{"openProject":{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Alpha"}}},"expectedResponse":null},{"requestID":"00000000-0000-4000-8000-5bb50894205f","frame":{"version":2,"kind":"request","requestID":"00000000-0000-4000-8000-5bb50894205f","command":{"listRecentProjects":{}}},"expectedResponse":{"version":2,"kind":"response","requestID":"00000000-0000-4000-8000-5bb50894205f","event":{"recentProjectsListed":[{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Alpha","name":"Alpha","remote":false},{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Beta","name":"Beta","remote":false}]}}},{"requestID":"00000000-0000-4000-8000-5bb50b6ab128","frame":{"version":2,"kind":"request","requestID":"00000000-0000-4000-8000-5bb50b6ab128","command":{"listQuickChats":{}}},"expectedResponse":null},{"requestID":"00000000-0000-4000-8000-5bb50f23ae55","frame":{"version":2,"kind":"request","requestID":"00000000-0000-4000-8000-5bb50f23ae55","command":{"restoreOpenProjects":{}}},"expectedResponse":null},{"requestID":"00000000-0000-4000-8000-5bb6c7e0972e","frame":{"version":2,"kind":"request","requestID":"00000000-0000-4000-8000-5bb6c7e0972e","command":{"graphCommand":{"projectPath":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Alpha","command":{"renameNode":{"_0":"11111111-1111-4111-8111-111111111111","title":"Alpha renamed"}}}}},"expectedResponse":{"version":2,"kind":"response","requestID":"00000000-0000-4000-8000-5bb6c7e0972e","success":true}},{"requestID":"00000000-0000-4000-8000-5bb8b7d8d3ef","frame":{"version":2,"kind":"request","requestID":"00000000-0000-4000-8000-5bb8b7d8d3ef","command":{"graphCommand":{"projectPath":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Alpha","command":{"renameNode":{"_0":"11111111-1111-4111-8111-111111111111","title":"Alpha renamed"}}}}},"expectedResponse":{"version":2,"kind":"response","requestID":"00000000-0000-4000-8000-5bb8b7d8d3ef","success":true}}],"applied":[{"requestID":"00000000-0000-4000-8000-5bb6c7e0972e","projectPath":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Alpha","nodeID":"11111111-1111-4111-8111-111111111111","beforeTitle":"Alpha interleaved","title":"Alpha renamed"},{"requestID":"00000000-0000-4000-8000-5bb8b7d8d3ef","projectPath":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Alpha","nodeID":"11111111-1111-4111-8111-111111111111","beforeTitle":"Alpha renamed","title":"Alpha renamed"}],"answered":[{"requestID":"00000000-0000-4000-8000-5bb4dbc64bc9","response":{"version":2,"kind":"response","requestID":"00000000-0000-4000-8000-5bb4dbc64bc9","event":{"recentProjectsListed":[{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Alpha","name":"Alpha","remote":false},{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Beta","name":"Beta","remote":false}]}}},{"requestID":"00000000-0000-4000-8000-5bb4dda29192","response":{"version":2,"kind":"response","requestID":"00000000-0000-4000-8000-5bb4dda29192","event":{"quickChatsListed":[{"id":"33333333-3333-4333-8333-333333333333","title":"Stub quick chat","backend":"claudeCode","createdAt":0,"activity":{"sequence":1,"text":"ready","presence":{"presence":"idle","confidence":"reported"}}},{"id":"44444444-4444-4444-8444-444444444444","title":"Review notes","backend":"copilot","createdAt":1,"activity":null}]}}},{"requestID":"00000000-0000-4000-8000-5bb4e15cafdf","response":{"version":2,"kind":"response","requestID":"00000000-0000-4000-8000-5bb4e15cafdf","event":{"recentProjectsListed":[{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Alpha","name":"Alpha","remote":false},{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Beta","name":"Beta","remote":false}]}}},{"requestID":"00000000-0000-4000-8000-5bb4e33a0198","response":{"version":2,"kind":"response","requestID":"00000000-0000-4000-8000-5bb4e33a0198","event":{"quickChatsListed":[{"id":"33333333-3333-4333-8333-333333333333","title":"Stub quick chat","backend":"claudeCode","createdAt":0,"activity":{"sequence":1,"text":"ready","presence":{"presence":"idle","confidence":"reported"}}},{"id":"44444444-4444-4444-8444-444444444444","title":"Review notes","backend":"copilot","createdAt":1,"activity":null}]}}},{"requestID":"00000000-0000-4000-8000-5bb4e9c908d9","response":{"version":2,"kind":"response","requestID":"00000000-0000-4000-8000-5bb4e9c908d9","success":true}},{"requestID":"00000000-0000-4000-8000-5bb501206706","response":{"version":2,"kind":"response","requestID":"00000000-0000-4000-8000-5bb501206706","success":true}},{"requestID":"00000000-0000-4000-8000-5bb50894205f","response":{"version":2,"kind":"response","requestID":"00000000-0000-4000-8000-5bb50894205f","event":{"recentProjectsListed":[{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Alpha","name":"Alpha","remote":false},{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Beta","name":"Beta","remote":false}]}}},{"requestID":"00000000-0000-4000-8000-5bb50b6ab128","response":{"version":2,"kind":"response","requestID":"00000000-0000-4000-8000-5bb50b6ab128","event":{"quickChatsListed":[{"id":"33333333-3333-4333-8333-333333333333","title":"Stub quick chat","backend":"claudeCode","createdAt":0,"activity":{"sequence":1,"text":"ready","presence":{"presence":"idle","confidence":"reported"}}},{"id":"44444444-4444-4444-8444-444444444444","title":"Review notes","backend":"copilot","createdAt":1,"activity":null}]}}},{"requestID":"00000000-0000-4000-8000-5bb50f23ae55","response":{"version":2,"kind":"response","requestID":"00000000-0000-4000-8000-5bb50f23ae55","success":true}},{"requestID":"00000000-0000-4000-8000-5bb6c7e0972e","response":{"version":2,"kind":"response","requestID":"00000000-0000-4000-8000-5bb6c7e0972e","success":true}},{"requestID":"00000000-0000-4000-8000-5bb8b7d8d3ef","response":{"version":2,"kind":"response","requestID":"00000000-0000-4000-8000-5bb8b7d8d3ef","success":true}}],"publications":[{"cause":"initial","correlationID":"00000000-0000-4000-8000-5bb4dbc64bc9","frame":{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"id":"aaaaaaaa-1111-4111-8111-111111111111","project":{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Alpha","name":"Alpha","remote":false},"nodes":[{"id":"11111111-1111-4111-8111-111111111111","title":"Alpha loop","loopType":"turnBased","state":"idle","activity":"stub","presence":{"presence":"idle","confidence":"reported"}}],"edges":[]}}}},{"cause":"initial","correlationID":"00000000-0000-4000-8000-5bb4dbc64bc9","frame":{"version":2,"kind":"event","sequence":2,"event":{"graphChanged":{"id":"bbbbbbbb-2222-4222-8222-222222222222","project":{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Beta","name":"Beta","remote":false},"nodes":[{"id":"22222222-2222-4222-8222-222222222222","title":"Beta loop","loopType":"turnBased","state":"idle","activity":"stub","presence":{"presence":"idle","confidence":"reported"}}],"edges":[]}}}},{"cause":"initial","correlationID":"00000000-0000-4000-8000-5bb50894205f","frame":{"version":2,"kind":"event","sequence":3,"event":{"graphChanged":{"id":"aaaaaaaa-1111-4111-8111-111111111111","project":{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Alpha","name":"Alpha","remote":false},"nodes":[{"id":"11111111-1111-4111-8111-111111111111","title":"Alpha loop","loopType":"turnBased","state":"idle","activity":"stub","presence":{"presence":"idle","confidence":"reported"}}],"edges":[]}}}},{"cause":"initial","correlationID":"00000000-0000-4000-8000-5bb50894205f","frame":{"version":2,"kind":"event","sequence":4,"event":{"graphChanged":{"id":"bbbbbbbb-2222-4222-8222-222222222222","project":{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Beta","name":"Beta","remote":false},"nodes":[{"id":"22222222-2222-4222-8222-222222222222","title":"Beta loop","loopType":"turnBased","state":"idle","activity":"stub","presence":{"presence":"idle","confidence":"reported"}}],"edges":[]}}}},{"cause":"control","correlationID":"c80474d0-abb5-47bc-9aaf-a26aef79fe8e","frame":{"version":2,"kind":"event","sequence":5,"event":{"graphChanged":{"id":"aaaaaaaa-1111-4111-8111-111111111111","project":{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Alpha","name":"Alpha","remote":false},"nodes":[{"id":"11111111-1111-4111-8111-111111111111","title":"Alpha interleaved","loopType":"turnBased","state":"idle","activity":"stub","presence":{"presence":"idle","confidence":"reported"}}],"edges":[]}}}},{"cause":"rename","correlationID":"00000000-0000-4000-8000-5bb6c7e0972e","frame":{"version":2,"kind":"event","sequence":6,"event":{"graphChanged":{"id":"aaaaaaaa-1111-4111-8111-111111111111","project":{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Alpha","name":"Alpha","remote":false},"nodes":[{"id":"11111111-1111-4111-8111-111111111111","title":"Alpha renamed","loopType":"turnBased","state":"idle","activity":"stub","presence":{"presence":"idle","confidence":"reported"}}],"edges":[]}}}},{"cause":"rename","correlationID":"00000000-0000-4000-8000-5bb8b7d8d3ef","frame":{"version":2,"kind":"event","sequence":7,"event":{"graphChanged":{"id":"aaaaaaaa-1111-4111-8111-111111111111","project":{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Alpha","name":"Alpha","remote":false},"nodes":[{"id":"11111111-1111-4111-8111-111111111111","title":"Alpha renamed","loopType":"turnBased","state":"idle","activity":"stub","presence":{"presence":"idle","confidence":"reported"}}],"edges":[]}}}}],"controls":[{"token":"c80474d0-abb5-47bc-9aaf-a26aef79fe8e","projectPath":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Alpha","nodeID":"11111111-1111-4111-8111-111111111111","beforeTitle":"Alpha loop","title":"Alpha interleaved","selection":{"projectPath":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Beta","nodeID":"22222222-2222-4222-8222-222222222222","source":"live-uia"}}],"graphs":[{"id":"aaaaaaaa-1111-4111-8111-111111111111","project":{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Alpha","name":"Alpha","remote":false},"nodes":[{"id":"11111111-1111-4111-8111-111111111111","title":"Alpha renamed","loopType":"turnBased","state":"idle","activity":"stub","presence":{"presence":"idle","confidence":"reported"}}],"edges":[]},{"id":"bbbbbbbb-2222-4222-8222-222222222222","project":{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Beta","name":"Beta","remote":false},"nodes":[{"id":"22222222-2222-4222-8222-222222222222","title":"Beta loop","loopType":"turnBased","state":"idle","activity":"stub","presence":{"presence":"idle","confidence":"reported"}}],"edges":[]}],"unansweredRequests":[]}} \ No newline at end of file diff --git a/Tools/windows/ValidationRunner/Ownership/MultiProjectJson.cs b/Tools/windows/ValidationRunner/Ownership/MultiProjectJson.cs new file mode 100644 index 00000000..c22797f5 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/MultiProjectJson.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +namespace GraphCode.Validation.Ownership; + +public static partial class MultiProjectJson +{ + [GeneratedRegex("^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", RegexOptions.CultureInvariant)] + private static partial Regex UuidPattern(); + + public static JsonDocument Parse(string json) + { + JsonDocument document; + try { document = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 32 }); } + catch (JsonException failure) { throw new InvalidDataException("MULTIPROJECT_JSON: malformed JSON", failure); } + try + { + if (document.RootElement.ValueKind != JsonValueKind.Object) + throw new InvalidDataException("MULTIPROJECT_JSON: root must be an object"); + CheckProperties(document.RootElement); + return document; + } + catch { document.Dispose(); throw; } + } + + private static void CheckProperties(JsonElement element) + { + if (element.ValueKind == JsonValueKind.Object) + { + var names = new HashSet(StringComparer.Ordinal); + foreach (JsonProperty property in element.EnumerateObject()) + { + if (!names.Add(property.Name)) throw new InvalidDataException("MULTIPROJECT_JSON: duplicate field " + property.Name); + CheckProperties(property.Value); + } + } + else if (element.ValueKind == JsonValueKind.Array) + foreach (JsonElement value in element.EnumerateArray()) CheckProperties(value); + } + + public static JsonObject Object(string json) + { + using var document = Parse(json); + return JsonNode.Parse(document.RootElement.GetRawText())!.AsObject(); + } + + public static JsonObject Shape(JsonNode? value, string label, params string[] keys) + { + if (value is not JsonObject result || result.Count != keys.Length || + result.Any(pair => !keys.Contains(pair.Key, StringComparer.Ordinal))) + throw new InvalidDataException("MULTIPROJECT_SCHEMA: " + label + " has the wrong object shape"); + return result; + } + + public static string String(JsonNode? value, string label) + { + if (value is not JsonValue literal || literal.GetValueKind() != JsonValueKind.String) + throw new InvalidDataException("MULTIPROJECT_TYPE: " + label + " must be a string"); + return literal.GetValue(); + } + + public static long Integer(JsonNode? value, string label) + { + if (value is not JsonValue literal || literal.GetValueKind() != JsonValueKind.Number || + !long.TryParse(literal.ToJsonString(), System.Globalization.NumberStyles.AllowLeadingSign, + System.Globalization.CultureInfo.InvariantCulture, out long result)) + throw new InvalidDataException("MULTIPROJECT_TYPE: " + label + " must be an integer"); + return result; + } + + public static string Uuid(JsonNode? value, string label) + { + string text = String(value, label); + if (!UuidPattern().IsMatch(text)) + throw new InvalidDataException("MULTIPROJECT_UUID: " + label + " must be a lowercase version-4 UUID"); + return text; + } + + public static string Title(JsonNode? value) + { + string title = String(value, "title"); + if (string.IsNullOrWhiteSpace(title)) throw new InvalidDataException("MULTIPROJECT_TITLE: title cannot be blank"); + return title; + } + + public static string Canonical(JsonNode? value) + { + if (value == null) return "null"; + if (value is JsonObject obj) + return "{" + string.Join(",", obj.OrderBy(p => p.Key, StringComparer.Ordinal) + .Select(p => JsonSerializer.Serialize(p.Key) + ":" + Canonical(p.Value))) + "}"; + if (value is JsonArray array) return "[" + string.Join(",", array.Select(Canonical)) + "]"; + return value.ToJsonString(); + } +} diff --git a/Tools/windows/ValidationRunner/Ownership/MultiProjectPeer.cs b/Tools/windows/ValidationRunner/Ownership/MultiProjectPeer.cs new file mode 100644 index 00000000..e2485654 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/MultiProjectPeer.cs @@ -0,0 +1,199 @@ +using System; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace GraphCode.Validation.Ownership; + +public sealed record MultiProjectReply(JsonObject? Response, string? PublishPath); + +// The compiled controlled peer uses this same policy; suites never substitute a model. +public sealed class MultiProjectPeer +{ + public const string AlphaNodeId = "11111111-1111-4111-8111-111111111111"; + public const string BetaNodeId = "22222222-2222-4222-8222-222222222222"; + private readonly JsonArray graphs; + private readonly JsonArray received = [], applied = [], answered = [], publications = [], controls = []; + private long sequence; + + public MultiProjectPeer(string alphaPath, string betaPath) + { + foreach (string path in new[] { alphaPath, betaPath }) + { + if (!Path.IsPathFullyQualified(path) || Path.GetFullPath(path) != path || + !Directory.Exists(path) || File.Exists(Path.Combine(path, ".git")) || Directory.Exists(Path.Combine(path, ".git"))) + throw new InvalidDataException("MULTIPROJECT_FIXTURE: require exact existing ordinary-folder paths"); + } + if (string.Equals(alphaPath, betaPath, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("MULTIPROJECT_FIXTURE: owners must be different folders"); + graphs = + [ + Graph("aaaaaaaa-1111-4111-8111-111111111111", alphaPath, "Alpha", AlphaNodeId), + Graph("bbbbbbbb-2222-4222-8222-222222222222", betaPath, "Beta", BetaNodeId) + ]; + } + + private static JsonObject Graph(string id, string path, string name, string nodeId) => new() + { + ["id"] = id, ["project"] = new JsonObject { ["path"] = path, ["name"] = name, ["remote"] = false }, + ["nodes"] = new JsonArray(new JsonObject + { + ["id"] = nodeId, ["title"] = name + " loop", ["loopType"] = "turnBased", ["state"] = "idle", ["activity"] = "stub", + ["presence"] = new JsonObject { ["presence"] = "idle", ["confidence"] = "reported" } + }), + ["edges"] = new JsonArray() + }; + + private static string Text(JsonNode? value) => MultiProjectJson.String(value, "value"); + private static JsonObject Clone(JsonObject value) => (JsonObject)value.DeepClone(); + private static string OwnerPath(JsonNode graph) => Text(graph["project"]!["path"]); + private static JsonNode Node(JsonNode graph) => graph["nodes"]![0]!; + private JsonObject Find(JsonNode? path, string? node = null) + { + string exactPath = MultiProjectJson.String(path, "projectPath"); + JsonObject[] matches = graphs.OfType().Where(g => OwnerPath(g) == exactPath).ToArray(); + if (matches.Length != 1 || (node != null && Text(Node(matches[0])["id"]) != node)) + throw new InvalidDataException("MULTIPROJECT_OWNER: wrong exact project/node owner"); + return matches[0]; + } + + public MultiProjectReply Request(JsonObject frame) + { + MultiProjectJson.Shape(frame, "request", "version", "kind", "requestID", "command"); + long version = MultiProjectJson.Integer(frame["version"], "version"); + string kind = MultiProjectJson.String(frame["kind"], "kind"); + if (version != 2 || kind != "request") throw new InvalidDataException("MULTIPROJECT_SCHEMA: expected v2 request"); + string id = MultiProjectJson.Uuid(frame["requestID"], "requestID"); + if (received.Any(r => Text(r!["requestID"]) == id)) + throw new InvalidDataException("MULTIPROJECT_DUPLICATE: requestID already received"); + if (frame["command"] is not JsonObject command || command.Count != 1) + throw new InvalidDataException("MULTIPROJECT_SCHEMA: expected one command"); + string verb = command.First().Key; + JsonObject? response = null, owner = null, rename = null; + if (verb == "graphCommand") + { + JsonObject graphCommand = MultiProjectJson.Shape(command[verb], "graphCommand", "projectPath", "command"); + if (graphCommand["command"] is JsonObject inner && inner.ContainsKey("resumeSession")) + { + MultiProjectJson.Shape(inner, "graphCommand.command", "resumeSession"); + JsonObject resume = MultiProjectJson.Shape(inner["resumeSession"], "resumeSession", "_0"); + owner = Find(graphCommand["projectPath"], MultiProjectJson.Uuid(resume["_0"], "nodeID")); + } + else + { + JsonObject innerCommand = MultiProjectJson.Shape(graphCommand["command"], "graphCommand.command", "renameNode"); + rename = MultiProjectJson.Shape(innerCommand["renameNode"], "renameNode", "_0", "title"); + string nodeId = MultiProjectJson.Uuid(rename["_0"], "nodeID"); + MultiProjectJson.Title(rename["title"]); + owner = Find(graphCommand["projectPath"], nodeId); + } + response = new JsonObject { ["version"] = 2, ["kind"] = "response", ["requestID"] = id, ["success"] = true }; + } + else if (verb == "listRecentProjects") + { + MultiProjectJson.Shape(command[verb], verb); + response = new JsonObject + { + ["version"] = 2, ["kind"] = "response", ["requestID"] = id, + ["event"] = new JsonObject { ["recentProjectsListed"] = new JsonArray(graphs.Select(g => g!["project"]!.DeepClone()).ToArray()) } + }; + } + else if (verb is "listQuickChats" or "restoreOpenProjects" or "openGlobalGraph") + MultiProjectJson.Shape(command[verb], verb); + else if (verb == "openProject") + { + JsonObject open = MultiProjectJson.Shape(command[verb], verb, "path"); + Find(open["path"]); + } + else throw new InvalidDataException("MULTIPROJECT_SCHEMA: unsupported opt-in command " + verb); + received.Add(new JsonObject { ["requestID"] = id, ["frame"] = frame.DeepClone(), ["expectedResponse"] = response?.DeepClone() }); + if (rename != null && owner != null) + { + applied.Add(new JsonObject + { + ["requestID"] = id, ["projectPath"] = OwnerPath(owner), ["nodeID"] = Text(rename["_0"]), + ["beforeTitle"] = Text(Node(owner)["title"]), ["title"] = Text(rename["title"]) + }); + Node(owner)["title"] = Text(rename["title"]); + } + return new MultiProjectReply(response, rename == null ? null : OwnerPath(owner!)); + } + + public void CompleteResponse(JsonObject response) + { + MultiProjectJson.Shape(response, "response", "version", "kind", "requestID", response.ContainsKey("event") ? "event" : "success"); + if (MultiProjectJson.Integer(response["version"], "version") != 2 || Text(response["kind"]) != "response" || + (response.ContainsKey("success") && response["success"]?.GetValueKind() is not (JsonValueKind.True or JsonValueKind.False))) + throw new InvalidDataException("MULTIPROJECT_TYPE: response version/kind/success types differ"); + string id = MultiProjectJson.Uuid(response["requestID"], "response.requestID"); + JsonNode[] matches = received.Where(r => Text(r!["requestID"]) == id).Select(r => r!).ToArray(); + if (matches.Length != 1) throw new InvalidDataException("MULTIPROJECT_CORRELATION: response has no unique request"); + if (answered.Any(r => Text(r!["requestID"]) == id)) throw new InvalidDataException("MULTIPROJECT_DUPLICATE: request already answered"); + if (matches[0]["expectedResponse"] != null && + MultiProjectJson.Canonical(matches[0]["expectedResponse"]) != MultiProjectJson.Canonical(response)) + throw new InvalidDataException("MULTIPROJECT_CORRELATION: reply differs from expected typed response"); + answered.Add(new JsonObject { ["requestID"] = id, ["response"] = response.DeepClone() }); + } + + public JsonObject Publication(string path) + { + JsonObject graph = Find(JsonValue.Create(path)); + return new JsonObject + { + ["version"] = 2, ["kind"] = "event", ["sequence"] = sequence + 1, + ["event"] = new JsonObject { ["graphChanged"] = graph.DeepClone() } + }; + } + + public void CompletePublication(JsonObject frame, string cause, string correlationId) + { + string path = Text(frame["event"]?["graphChanged"]?["project"]?["path"]); + JsonObject expected = Publication(path); + if (MultiProjectJson.Canonical(expected) != MultiProjectJson.Canonical(frame)) + throw new InvalidDataException("MULTIPROJECT_SEQUENCE: publication differs from exact next graph frame"); + if (cause != "control" && answered.Count(r => Text(r!["requestID"]) == correlationId) != 1) + throw new InvalidDataException("MULTIPROJECT_UNANSWERED: publication precedes correlated response"); + sequence = MultiProjectJson.Integer(frame["sequence"], "sequence"); + publications.Add(new JsonObject { ["cause"] = cause, ["correlationID"] = correlationId, ["frame"] = frame.DeepClone() }); + } + + public JsonObject Control(JsonObject control) + { + MultiProjectJson.Shape(control, "publication control", "token", "projectPath", "nodeID", "title", "selection"); + string token = MultiProjectJson.Uuid(control["token"], "control.token"); + string node = MultiProjectJson.Uuid(control["nodeID"], "control.nodeID"); + if (controls.Any(c => Text(c!["token"]) == token)) throw new InvalidDataException("MULTIPROJECT_DUPLICATE: control token already used"); + if (controls.Count != 0) throw new InvalidDataException("MULTIPROJECT_ONE_SHOT: Alpha control was already applied"); + JsonObject graph = Find(control["projectPath"], node); + if (OwnerPath(graph) != OwnerPath(graphs[0]!)) throw new InvalidDataException("MULTIPROJECT_OWNER: control must target Alpha"); + string title = MultiProjectJson.Title(control["title"]); + JsonObject selection = MultiProjectJson.Shape(control["selection"], "selected Beta", "projectPath", "nodeID", "source"); + if (Text(selection["projectPath"]) != OwnerPath(graphs[1]!) || + Text(selection["nodeID"]) != BetaNodeId || Text(selection["source"]) is not ("live-uia" or "synthetic-client")) + throw new InvalidDataException("MULTIPROJECT_SELECTION: expected exact observed Beta identity"); + if (received.Count <= 0 || answered.Count != received.Count || publications.Count < 2) + throw new InvalidDataException("MULTIPROJECT_UNANSWERED: require positive settled request/publication baseline"); + foreach (JsonNode? owner in graphs) + if (!publications.Any(p => Text(p!["cause"]) == "initial" && + Text(p["frame"]!["event"]!["graphChanged"]!["project"]!["path"]) == OwnerPath(owner!))) + throw new InvalidDataException("MULTIPROJECT_PREREQUISITE: both owners must be published before interleaving"); + controls.Add(new JsonObject + { + ["token"] = token, ["projectPath"] = OwnerPath(graph), ["nodeID"] = node, + ["beforeTitle"] = Text(Node(graph)["title"]), ["title"] = title, ["selection"] = selection.DeepClone() + }); + Node(graph)["title"] = title; + return Publication(OwnerPath(graph)); + } + + public JsonObject Snapshot() => new() + { + ["receivedCount"] = received.Count, ["requestCount"] = received.Count, ["responseCount"] = answered.Count, + ["appliedCount"] = applied.Count, ["publicationCount"] = publications.Count, ["graphSequence"] = sequence, + ["received"] = received.DeepClone(), ["applied"] = applied.DeepClone(), ["answered"] = answered.DeepClone(), + ["publications"] = publications.DeepClone(), ["controls"] = controls.DeepClone(), ["graphs"] = graphs.DeepClone(), + ["unansweredRequests"] = new JsonArray(received.Where(r => !answered.Any(a => Text(a!["requestID"]) == Text(r!["requestID"]))) + .Select(r => (JsonNode?)JsonValue.Create(Text(r!["requestID"]))).ToArray()) + }; +} diff --git a/Tools/windows/ValidationRunner/Ownership/OwnedByteStreamCapture.cs b/Tools/windows/ValidationRunner/Ownership/OwnedByteStreamCapture.cs new file mode 100644 index 00000000..8dbbacaf --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/OwnedByteStreamCapture.cs @@ -0,0 +1,76 @@ +using System; +using System.IO; +using System.Runtime.ExceptionServices; +using System.Text; +using System.Threading.Tasks; + +namespace GraphCode.Validation.Ownership; + +public sealed class OwnedByteStreamCapture +{ + private readonly Stream input; + private readonly FileStream evidence; + private readonly Decoder decoder; + private readonly RetentionSignalObserver observer; + private readonly StringBuilder text = new(); + private DecoderFallbackException? decodingFailure; + + public Task RawEof { get; } + public Task Text { get; } + + public OwnedByteStreamCapture(Stream input, string path, Encoding? encoding, RetentionSignalObserver observer) + { + this.input = input; + this.observer = observer; + var strict = (Encoding)(encoding ?? new UTF8Encoding(false, true)).Clone(); + strict.DecoderFallback = DecoderFallback.ExceptionFallback; + decoder = strict.GetDecoder(); + evidence = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.ReadWrite, 4096, true); + RawEof = PumpAsync(); + Text = DecodeResultAsync(); + } + + private async Task PumpAsync() + { + await using (evidence.ConfigureAwait(false)) + { + var bytes = new byte[4096]; + var characters = new char[16384]; + int count; + while ((count = await input.ReadAsync(bytes).ConfigureAwait(false)) != 0) + { + await evidence.WriteAsync(bytes.AsMemory(0, count)).ConfigureAwait(false); + await evidence.FlushAsync().ConfigureAwait(false); + observer.OnBytes(bytes.AsSpan(0, count)); + if (decodingFailure == null) + { + try + { + int decoded = decoder.GetChars(bytes, 0, count, characters, 0, false); + text.Append(characters, 0, decoded); + } + catch (DecoderFallbackException failure) { decodingFailure = failure; } + } + } + if (decodingFailure == null) + { + try + { + int decoded = decoder.GetChars([], 0, 0, characters, 0, true); + text.Append(characters, 0, decoded); + } + catch (DecoderFallbackException failure) { decodingFailure = failure; } + } + observer.OnEof(); + await evidence.FlushAsync().ConfigureAwait(false); + evidence.Flush(true); + } + } + + private async Task DecodeResultAsync() + { + await RawEof.ConfigureAwait(false); + if (decodingFailure != null) ExceptionDispatchInfo.Capture(decodingFailure).Throw(); + return text.ToString(); + } +} diff --git a/Tools/windows/ValidationRunner/Ownership/OwnedChildSupervisor.cs b/Tools/windows/ValidationRunner/Ownership/OwnedChildSupervisor.cs new file mode 100644 index 00000000..f16c5c95 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/OwnedChildSupervisor.cs @@ -0,0 +1,418 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.ExceptionServices; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Win32.SafeHandles; + +namespace GraphCode.Validation.Ownership; + +public sealed record OwnedCleanupProof(bool RootExited, bool JobEmpty, bool StdoutEof, bool StderrEof, bool ResourcesClosed) +{ + public bool CanRelease => RootExited && JobEmpty && StdoutEof && StderrEof; +} +public sealed record OwnedChildResult(int ExitCode, string StandardOutput, string StandardError, OwnedCleanupProof Cleanup); +public sealed record OwnedChildObservation(bool RootExited, uint ActiveJobProcesses, bool StdoutCompleted, bool StderrCompleted); + +public sealed class OwnedChildSupervisor +{ + [StructLayout(LayoutKind.Sequential)] + private struct Security { public int Length; public IntPtr Descriptor; public int Inherit; } + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct Startup + { + public int Size; + public string? Reserved, Desktop, Title; + public int X, Y, Width, Height, XChars, YChars, Fill, Flags; + public short Show, ReservedSize; + public IntPtr ReservedBytes, Input, Output, Error, Attributes; + } + [StructLayout(LayoutKind.Sequential)] + private struct ProcessInfo { public IntPtr Process, Thread; public uint Pid, Tid; } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CreatePipe(out IntPtr read, out IntPtr write, ref Security security, int size); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool SetHandleInformation(IntPtr handle, uint mask, uint flags); + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern IntPtr CreateFileW(string name, uint access, uint share, ref Security security, uint disposition, uint flags, IntPtr template); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool InitializeProcThreadAttributeList(IntPtr list, int count, int flags, ref IntPtr size); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool UpdateProcThreadAttribute(IntPtr list, uint flags, IntPtr attribute, IntPtr value, IntPtr size, IntPtr previous, IntPtr returned); + [DllImport("kernel32.dll")] + private static extern void DeleteProcThreadAttributeList(IntPtr list); + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern bool CreateProcessW(string file, StringBuilder command, IntPtr processSecurity, IntPtr threadSecurity, + bool inherit, uint flags, IntPtr environment, string? directory, ref Startup startup, out ProcessInfo process); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint ResumeThread(IntPtr thread); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool TerminateProcess(IntPtr process, uint code); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint WaitForSingleObject(IntPtr handle, uint timeout); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetProcessTimes(IntPtr process, out long created, out long exited, out long kernel, out long user); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint GetProcessId(IntPtr process); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetExitCodeProcess(IntPtr process, out uint exitCode); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr handle); + + // Unknown leases stay reachable: a finalizer must not close a kill-on-close job. + private static readonly ConcurrentBag retained = new(); + private static readonly Task lifetimeHold = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously).Task; + private readonly Deadline deadline; + private readonly string artifactRoot; + private readonly object retentionGate = new(); + private readonly CancellationTokenSource retentionCancellation = new(); + private bool descendantUnknown, retentionRegistered; + private IntPtr root, thread, job; + private bool assigned, resumed, completed; + private Stream? stdoutPipe, stderrPipe; + private OwnedByteStreamCapture? stdoutCapture, stderrCapture; + private Task? stdout, stderr; + private long creationFileTime; + public uint ProcessId { get; private set; } + public long CreationFileTime => creationFileTime; + public Task StandardOutput => stdout ?? throw new InvalidOperationException("stdout capture unavailable."); + public Task StandardError => stderr ?? throw new InvalidOperationException("stderr capture unavailable."); + public static int RetainedLeaseCount => retained.Count; + public static bool RequiresLifetimeHold => RetainedLeaseCount != 0; + + // The host must await this in its outermost finally, including reporting failures. + // Returning from Main would close kill-on-close jobs despite the retained references. + public static Task HoldRetainedResourcesAsync() => RequiresLifetimeHold ? lifetimeHold : Task.CompletedTask; + + private void ObserveDescendantUnknown() + { + lock (retentionGate) + { + if (descendantUnknown) return; + descendantUnknown = true; + RegisterRetention(); + retentionCancellation.Cancel(); + } + } + + private void RegisterRetention() + { + lock (retentionGate) + { + if (retentionRegistered) return; + retained.Add(this); + retentionRegistered = true; + } + } + + public OwnedChildObservation Observe() => new(RootExited(), StandaloneProcessJob.Active(job), + StandardOutput.IsCompleted, StandardError.IsCompleted); + + private OwnedChildSupervisor(TimeSpan timeout, string artifactRoot) + { + deadline = new Deadline(timeout); + this.artifactRoot = Path.GetFullPath(artifactRoot); + if (!Directory.Exists(this.artifactRoot)) throw new DirectoryNotFoundException(this.artifactRoot); + } + + public static OwnedChildSupervisor Start(ProcessStartInfo start, TimeSpan timeout, string artifactRoot) + { + if (!OperatingSystem.IsWindows()) throw new PlatformNotSupportedException("Owned native jobs require Windows."); + if (start.UseShellExecute || !string.IsNullOrEmpty(start.Arguments) || start.RedirectStandardInput) + throw new ArgumentException("Owned launch requires ArgumentList, UseShellExecute=false and closed stdin.", nameof(start)); + if (!Path.IsPathFullyQualified(start.FileName) || !File.Exists(start.FileName)) + throw new ArgumentException("Owned executable must be an existing absolute path.", nameof(start)); + var run = new OwnedChildSupervisor(timeout, artifactRoot); + try + { + run.Launch(start); + return run; + } + catch (Exception primary) + { + // Synchronous launch has no consumer yet, but uses the same bounded cleanup. + run.CleanupAsync(primary).GetAwaiter().GetResult(); + throw; + } + } + + public static async Task RunAsync(ProcessStartInfo start, TimeSpan timeout, string artifactRoot, + CancellationToken cancellationToken = default) + { + var child = Start(start, timeout, artifactRoot); + return await child.CompleteAsync(cancellationToken).ConfigureAwait(false); + } + + private void Launch(ProcessStartInfo info) + { + var security = new Security { Length = Marshal.SizeOf(), Inherit = 1 }; + IntPtr outRead = IntPtr.Zero, outWrite = IntPtr.Zero, errRead = IntPtr.Zero, errWrite = IntPtr.Zero; + IntPtr input = IntPtr.Zero, attributes = IntPtr.Zero, handles = IntPtr.Zero, environment = IntPtr.Zero; + bool initialized = false; + Exception? primary = null; + try + { + job = StandaloneProcessJob.Create(); + if (!CreatePipe(out outRead, out outWrite, ref security, 0) || + !CreatePipe(out errRead, out errWrite, ref security, 0) || + !SetHandleInformation(outRead, 1, 0) || !SetHandleInformation(errRead, 1, 0)) + throw new Win32Exception(); + input = CreateFileW("NUL", 0x80000000, 3, ref security, 3, 0, IntPtr.Zero); + if (input == new IntPtr(-1)) throw new Win32Exception(); + IntPtr size = IntPtr.Zero; + InitializeProcThreadAttributeList(IntPtr.Zero, 1, 0, ref size); + attributes = Marshal.AllocHGlobal(size); + if (!InitializeProcThreadAttributeList(attributes, 1, 0, ref size)) throw new Win32Exception(); + initialized = true; + handles = Marshal.AllocHGlobal(IntPtr.Size * 3); + Marshal.WriteIntPtr(handles, 0, input); + Marshal.WriteIntPtr(handles, IntPtr.Size, outWrite); + Marshal.WriteIntPtr(handles, IntPtr.Size * 2, errWrite); + if (!UpdateProcThreadAttribute(attributes, 0, new IntPtr(0x20002), handles, + new IntPtr(IntPtr.Size * 3), IntPtr.Zero, IntPtr.Zero)) throw new Win32Exception(); + var block = new StringBuilder(); + foreach (var pair in info.Environment.OrderBy(p => p.Key, StringComparer.OrdinalIgnoreCase)) + block.Append(pair.Key).Append('=').Append(pair.Value).Append('\0'); + // Even an empty environment requires two terminators. + if (block.Length == 0) block.Append('\0'); + block.Append('\0'); + environment = Marshal.StringToHGlobalUni(block.ToString()); + var command = new StringBuilder(QuoteArgument(info.FileName)); + foreach (string argument in info.ArgumentList) command.Append(' ').Append(QuoteArgument(argument)); + var startup = new Startup + { + Size = Marshal.SizeOf(), Flags = 0x101, Input = input, Output = outWrite, Error = errWrite, + Show = (short)(info.WindowStyle == ProcessWindowStyle.Hidden ? 0 : + info.WindowStyle == ProcessWindowStyle.Minimized ? 2 : info.WindowStyle == ProcessWindowStyle.Maximized ? 3 : 1), + Attributes = attributes + }; + uint flags = 0x4 | 0x400 | 0x80000; + if (info.CreateNoWindow) flags |= 0x08000000; + if (!CreateProcessW(info.FileName, command, IntPtr.Zero, IntPtr.Zero, true, flags, environment, + string.IsNullOrEmpty(info.WorkingDirectory) ? null : info.WorkingDirectory, ref startup, out ProcessInfo created)) + throw new Win32Exception(); + root = created.Process; + thread = created.Thread; + ProcessId = created.Pid; + if (!GetProcessTimes(root, out creationFileTime, out _, out _, out _)) throw new Win32Exception(); + VerifyRoot(); + StandaloneProcessJob.Assign(job, root); + assigned = true; + UiaOwnedStreamCapture.RequireJobMember(root, job); + stdoutPipe = MakePipe(ref outRead); + stderrPipe = MakePipe(ref errRead); + string stem = $"owned-{ProcessId}-{creationFileTime}"; + stdoutCapture = new OwnedByteStreamCapture(stdoutPipe, Path.Combine(artifactRoot, stem + "-stdout.log"), + info.StandardOutputEncoding, new RetentionSignalObserver(ObserveDescendantUnknown)); + stdout = stdoutCapture.Text; + stderrCapture = new OwnedByteStreamCapture(stderrPipe, Path.Combine(artifactRoot, stem + "-stderr.log"), + info.StandardErrorEncoding, new RetentionSignalObserver(ObserveDescendantUnknown)); + stderr = stderrCapture.Text; + if (deadline.Expired) throw new TimeoutException("Owned launch original deadline exceeded before resume."); + uint previous = ResumeThread(thread); + if (previous == uint.MaxValue) throw new Win32Exception(); + if (previous != 1) throw new InvalidOperationException("Owned launch suspension count changed."); + resumed = true; + Close(ref thread); + } + catch (Exception failure) + { + primary = failure; + throw; + } + finally + { + var failures = new List(); + foreach (IntPtr handle in new[] { outRead, outWrite, errRead, errWrite, input }) + if (handle != IntPtr.Zero && handle != new IntPtr(-1) && !CloseHandle(handle)) + failures.Add(new Win32Exception()); + if (initialized) DeleteProcThreadAttributeList(attributes); + foreach (IntPtr allocation in new[] { attributes, handles, environment }) + if (allocation != IntPtr.Zero) Marshal.FreeHGlobal(allocation); + if (failures.Count != 0) + { + var cleanup = new AggregateException("Owned launch temporary handle cleanup failed.", failures); + if (primary != null) primary.Data["OwnedLaunchTemporaryCleanup"] = cleanup; + else throw cleanup; + } + } + } + + private static Stream MakePipe(ref IntPtr handle) + { + var safe = new SafeFileHandle(handle, true); + handle = IntPtr.Zero; + try { return new FileStream(safe, FileAccess.Read); } + catch { safe.Dispose(); throw; } + } + + public static string QuoteArgument(string value) + { + ArgumentNullException.ThrowIfNull(value); + if (value.Contains('\0')) throw new ArgumentException("Command argument contains NUL.", nameof(value)); + var result = new StringBuilder("\""); + int slashes = 0; + foreach (char c in value) + { + if (c == '\\') { slashes++; continue; } + result.Append('\\', c == '"' ? slashes * 2 + 1 : slashes).Append(c); + slashes = 0; + } + return result.Append('\\', slashes * 2).Append('"').ToString(); + } + + private void VerifyRoot() + { + if (root == IntPtr.Zero || GetProcessId(root) != ProcessId || + !GetProcessTimes(root, out long observed, out _, out _, out _) || observed != creationFileTime) + throw new InvalidOperationException("Held root identity could not be verified."); + } + + private bool RootExited() + { + VerifyRoot(); + uint state = WaitForSingleObject(root, 0); + if (state == 0) return true; + if (state == 258) return false; + throw new Win32Exception(); + } + + public async Task CompleteAsync(CancellationToken cancellationToken = default) + { + if (completed) throw new InvalidOperationException("Owned child completion already consumed."); + completed = true; + Exception? primary = null; + int exitCode = -1; + string output = "", error = ""; + using var waiting = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, retentionCancellation.Token); + try + { + while (!RootExited()) + { + waiting.Token.ThrowIfCancellationRequested(); + if (deadline.Expired) throw new TimeoutException("OWNED_CAPTURE_EXIT: original deadline exceeded."); + await Task.Delay(Math.Min(10, deadline.RemainingMilliseconds), waiting.Token).ConfigureAwait(false); + } + await RequireDrainAsync([StandardOutput, StandardError], deadline, waiting.Token).ConfigureAwait(false); + waiting.Token.ThrowIfCancellationRequested(); + if (!GetExitCodeProcess(root, out uint code)) throw new Win32Exception(); + exitCode = unchecked((int)code); + output = await StandardOutput.ConfigureAwait(false); + error = await StandardError.ConfigureAwait(false); + } + catch (Exception failure) { primary = failure; } + OwnedCleanupProof proof = await CleanupAsync(primary).ConfigureAwait(false); + if (primary != null) ExceptionDispatchInfo.Capture(primary).Throw(); + return new OwnedChildResult(exitCode, output, error, proof); + } + + public Task StopAsync(Exception? primary = null) + { + if (completed) throw new InvalidOperationException("Owned child completion already consumed."); + completed = true; + return CleanupAsync(primary); + } + + public static async Task RequireDrainAsync(IReadOnlyList> streams, Deadline limit, + CancellationToken cancellationToken = default) + { + var failures = new List(); + foreach (Task stream in streams) + { + try + { + await stream.WaitAsync(limit.Remaining, cancellationToken).ConfigureAwait(false); + } + catch (Exception failure) { failures.Add(failure); } + } + if (failures.Count != 0) + throw new AggregateException("OWNED_CAPTURE_DRAIN: independent readers did not all reach successful EOF.", failures); + } + + private Task CleanupAsync(Exception? primary) => + OwnedCleanupCoordinator.CompleteAsync(new NativeCleanupAuthority(this), primary, TimeSpan.FromSeconds(5)); + + private sealed class NativeCleanupAuthority(OwnedChildSupervisor owner) : IOwnedCleanupAuthority + { + public string Identity => $"pid={owner.ProcessId}; creation={owner.creationFileTime}; job={owner.job}"; + public bool OwnershipUnproven { get { lock (owner.retentionGate) return owner.descendantUnknown; } } + public Task? StandardOutput => owner.stdoutCapture?.RawEof; + public Task? StandardError => owner.stderrCapture?.RawEof; + public IReadOnlyList CloseActions => + [ + () => CloseOwned(() => owner.stdoutPipe?.Dispose()), () => CloseOwned(() => owner.stderrPipe?.Dispose()), + () => CloseOwned(() => Close(ref owner.thread)), + () => CloseOwned(() => { if (owner.job != IntPtr.Zero) { StandaloneProcessJob.Close(owner.job); owner.job = IntPtr.Zero; } }), + () => CloseOwned(() => Close(ref owner.root)), () => CloseOwned(owner.retentionCancellation.Dispose) + ]; + + public void Terminate() + { + lock (owner.retentionGate) + { + if (owner.descendantUnknown) throw new InvalidOperationException("UNKNOWN descendant ownership; termination withheld."); + if (owner.assigned) StandaloneProcessJob.Terminate(owner.job); + else if (owner.root != IntPtr.Zero) + { + if (owner.resumed) throw new InvalidOperationException("Unassigned root was unexpectedly resumed."); + if (WaitForSingleObject(owner.root, 0) != 0 && !TerminateProcess(owner.root, 124)) + throw new Win32Exception(); + } + } + } + + private void CloseOwned(Action action) + { + lock (owner.retentionGate) + { + if (owner.descendantUnknown) throw new InvalidOperationException("UNKNOWN descendant ownership; close withheld."); + action(); + } + } + + public async Task WaitForJobEmptyAsync(Deadline deadline) + { + if (owner.job == IntPtr.Zero) return true; + while (StandaloneProcessJob.Active(owner.job) != 0) + { + if (OwnershipUnproven) throw new InvalidOperationException("UNKNOWN descendant ownership; job wait withheld."); + if (deadline.Expired) throw new TimeoutException("Owned job still has active processes."); + await Task.Delay(Math.Min(10, deadline.RemainingMilliseconds)).ConfigureAwait(false); + } + return true; + } + + public async Task WaitForRootExitAsync(Deadline deadline) + { + while (owner.root != IntPtr.Zero && !owner.RootExited()) + { + if (OwnershipUnproven) throw new InvalidOperationException("UNKNOWN descendant ownership; root wait withheld."); + if (deadline.Expired) throw new TimeoutException("Held root exit unproved."); + await Task.Delay(Math.Min(10, deadline.RemainingMilliseconds)).ConfigureAwait(false); + } + return true; + } + + public void Retain(Exception failure) + { + owner.RegisterRetention(); + OwnershipRetention.Tag(failure, owner.artifactRoot); + failure.Data["OwnedRetainedLease"] = owner; + } + } + + private static void Close(ref IntPtr handle) + { + if (handle == IntPtr.Zero) return; + if (!CloseHandle(handle)) throw new Win32Exception(); + handle = IntPtr.Zero; + } +} diff --git a/Tools/windows/ValidationRunner/Ownership/OwnedCleanupCoordinator.cs b/Tools/windows/ValidationRunner/Ownership/OwnedCleanupCoordinator.cs new file mode 100644 index 00000000..b775d593 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/OwnedCleanupCoordinator.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; + +namespace GraphCode.Validation.Ownership; + +public interface IOwnedCleanupAuthority +{ + string Identity { get; } + bool OwnershipUnproven { get; } + Task? StandardOutput { get; } + Task? StandardError { get; } + void Terminate(); + Task WaitForJobEmptyAsync(Deadline deadline); + Task WaitForRootExitAsync(Deadline deadline); + IReadOnlyList CloseActions { get; } + void Retain(Exception failure); +} + +public static class OwnedCleanupCoordinator +{ + public static async Task CompleteAsync(IOwnedCleanupAuthority authority, Exception? primary, + TimeSpan timeout) + { + var deadline = new Deadline(timeout); + var failures = new List(); + if (authority.OwnershipUnproven) + { + var unknownProof = new OwnedCleanupProof(false, false, + authority.StandardOutput?.IsCompletedSuccessfully ?? true, + authority.StandardError?.IsCompletedSuccessfully ?? true, false); + foreach (Task? reader in new[] { authority.StandardOutput, authority.StandardError }) + if (reader?.IsFaulted == true) failures.Add(reader.Exception!); + var unknown = new AggregateException("OWNED_CLEANUP_UNPROVEN: live descendant retention signal; termination and close withheld.", failures); + Exception reported = primary ?? unknown; + reported.Data["OwnedCleanupProof"] = unknownProof; + reported.Data["OwnedCleanupFailure"] = unknown; + authority.Retain(reported); + if (primary == null) throw unknown; + return unknownProof; + } + bool rootExited = false, jobEmpty = false; + bool outEof = authority.StandardOutput == null, errEof = authority.StandardError == null; + try + { + authority.Terminate(); + } + catch (Exception failure) { failures.Add(failure); } + try + { + jobEmpty = await authority.WaitForJobEmptyAsync(deadline).ConfigureAwait(false); + if (!jobEmpty) failures.Add(new InvalidOperationException("Owned job quiescence unproved.")); + } + catch (Exception failure) { failures.Add(failure); } + try + { + rootExited = await authority.WaitForRootExitAsync(deadline).ConfigureAwait(false); + if (!rootExited) failures.Add(new InvalidOperationException("Held root exit unproved.")); + } + catch (Exception failure) { failures.Add(failure); } + foreach (var stream in new[] + { + (Task: authority.StandardOutput, Name: "stdout"), + (Task: authority.StandardError, Name: "stderr") + }) + { + if (stream.Task == null) continue; + try + { + await stream.Task.WaitAsync(deadline.Remaining).ConfigureAwait(false); + if (stream.Name == "stdout") outEof = true; else errEof = true; + } + catch (Exception failure) { failures.Add(new IOException(stream.Name + " EOF unproved.", failure)); } + } + bool closed = false; + if (!authority.OwnershipUnproven && new OwnedCleanupProof(rootExited, jobEmpty, outEof, errEof, false).CanRelease) + { + closed = true; + foreach (Action close in authority.CloseActions) + { + try { close(); } + catch (Exception failure) { failures.Add(failure); closed = false; } + } + } + var proof = new OwnedCleanupProof(rootExited, jobEmpty, outEof, errEof, closed); + if (primary != null) primary.Data["OwnedCleanupProof"] = proof; + if (!closed || failures.Count != 0) + { + var cleanup = new AggregateException($"OWNED_CLEANUP_UNPROVEN: {authority.Identity}; proof={proof}", failures); + Exception reported = primary ?? cleanup; + reported.Data["OwnedCleanupProof"] = proof; + reported.Data["OwnedCleanupFailure"] = cleanup; + if (!closed) authority.Retain(reported); + if (primary == null) throw cleanup; + } + return proof; + } +} diff --git a/Tools/windows/ValidationRunner/Ownership/OwnedFixture.cs b/Tools/windows/ValidationRunner/Ownership/OwnedFixture.cs new file mode 100644 index 00000000..60ec7aa7 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/OwnedFixture.cs @@ -0,0 +1,170 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.IO.Pipes; +using System.Reflection; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; + +namespace GraphCode.Validation.Ownership; + +public static class OwnedFixture +{ + public static ProcessStartInfo Command(string mode, string root, params string[] arguments) + { + string host = Environment.ProcessPath ?? throw new InvalidOperationException("Current process executable unavailable."); + var start = new ProcessStartInfo(host) + { + UseShellExecute = false, CreateNoWindow = true, + RedirectStandardOutput = true, RedirectStandardError = true, WorkingDirectory = root, + StandardOutputEncoding = new UTF8Encoding(false, true), + StandardErrorEncoding = new UTF8Encoding(false, true) + }; + if (Path.GetFileNameWithoutExtension(host).Equals("dotnet", StringComparison.OrdinalIgnoreCase)) + start.ArgumentList.Add(Assembly.GetEntryAssembly()!.Location); + foreach (string value in new[] { "--ownership-fixture", mode, root }) start.ArgumentList.Add(value); + foreach (string argument in arguments) start.ArgumentList.Add(argument); + start.Environment["TEMP"] = root; + start.Environment["TMP"] = root; + return start; + } + + public static async Task TryRunAsync(string[] args) + { + if (args.Length == 0 || args[0] != "--ownership-fixture") return null; + Console.OutputEncoding = new UTF8Encoding(false); + if (args.Length < 3) throw new ArgumentException("Owned fixture requires a mode and existing owned root."); + string mode = args[1], root = Path.GetFullPath(args[2]); + if (!Directory.Exists(root)) throw new DirectoryNotFoundException(root); + if (await ShellScopeNativeFixture.TryRunAsync(mode, root).ConfigureAwait(false) is int scopeExit) + return scopeExit; + if (mode.StartsWith("bytes-", StringComparison.Ordinal)) + { + byte[] bytes = BytesForMode(mode); + using Stream output = Console.OpenStandardOutput(); + await output.WriteAsync(bytes).ConfigureAwait(false); + await output.FlushAsync().ConfigureAwait(false); + return 0; + } + if (mode == "protocol") + { + if (args.Length != 6) throw new ArgumentException("Protocol fixture requires pipe name and both owner paths."); + await ServeProtocolAsync(root, args[3], args[4], args[5]).ConfigureAwait(false); + return 0; + } + + if (mode == "descendant") + { + File.WriteAllText(Path.Combine(root, "descendant-ready"), "ready"); + await Task.Delay(TimeSpan.FromSeconds(30)).ConfigureAwait(false); + return 0; + } + if (mode == "echo") + { + Console.Out.Write(JsonSerializer.Serialize(new + { + arguments = args[3..], present = Environment.GetEnvironmentVariables().Contains("OWNERSHIP_EMPTY"), + value = Environment.GetEnvironmentVariable("OWNERSHIP_EMPTY"), + absent = !Environment.GetEnvironmentVariables().Contains("OWNERSHIP_ABSENT"), + cwd = Environment.CurrentDirectory + })); + return 0; + } + if (mode is not ("normal" or "abrupt" or "timeout" or "inherited" or "inherited-timeout")) + throw new ArgumentException("Unknown owned fixture mode: " + mode); + Console.Out.WriteLine("owned-capture-stdout \uD83D\uDE80"); + Console.Error.WriteLine("owned-capture-stderr \u03BB"); + if (mode is "inherited" or "inherited-timeout") + { + using Process child = Process.Start(CommandForInheritedChild(root)) + ?? throw new InvalidOperationException("Controlled descendant did not start."); + File.WriteAllText(Path.Combine(root, "descendant.json"), + JsonSerializer.Serialize(new { pid = child.Id, startUtcTicks = child.StartTime.ToUniversalTime().Ticks })); + var readyLimit = new Deadline(TimeSpan.FromSeconds(5)); + while (!File.Exists(Path.Combine(root, "descendant-ready"))) + { + if (readyLimit.Expired) throw new TimeoutException("Controlled descendant not ready."); + await Task.Delay(10).ConfigureAwait(false); + } + } + File.WriteAllText(Path.Combine(root, "ready"), "ready"); + if (mode is "timeout" or "inherited-timeout") await Task.Delay(TimeSpan.FromSeconds(30)).ConfigureAwait(false); + return mode == "abrupt" ? 23 : 0; + } + + public static byte[] BytesForMode(string mode) + { + const string json = "{\"value\":\"owned\"}"; + return mode switch + { + "bytes-utf8-bom" => [0xef, 0xbb, 0xbf, .. Encoding.UTF8.GetBytes(json)], + "bytes-utf16-bom" => [0xff, 0xfe, .. Encoding.Unicode.GetBytes(json)], + "bytes-invalid-utf8" => [0x7b, 0xc3, 0x28, 0x7d], + "bytes-utf8-supplementary" => Encoding.UTF8.GetBytes(new string('a', 4095) + "\uD83D\uDE80tail"), + _ => throw new ArgumentException("Unknown byte fixture mode.", nameof(mode)) + }; + } + + private static ProcessStartInfo CommandForInheritedChild(string root) + { + ProcessStartInfo start = Command("descendant", root); + start.RedirectStandardOutput = false; + start.RedirectStandardError = false; + start.StandardOutputEncoding = null; + start.StandardErrorEncoding = null; + return start; + } + + private static async Task ServeProtocolAsync(string root, string pipeName, string alpha, string beta) + { + var peer = new MultiProjectPeer(alpha, beta); + await using var server = new NamedPipeServerStream(pipeName, PipeDirection.InOut, 1, + PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + var deadline = new Deadline(TimeSpan.FromSeconds(10)); + using var connect = new CancellationTokenSource(deadline.Remaining); + await server.WaitForConnectionAsync(connect.Token).ConfigureAwait(false); + using (JsonDocument? hello = await PipeFrames.ReadAsync(server, deadline).ConfigureAwait(false)) + { + JsonObject frame = MultiProjectJson.Object(hello?.RootElement.GetRawText() ?? throw new EndOfStreamException()); + MultiProjectJson.Shape(frame, "hello", "version", "kind", "supportedVersions"); + if (MultiProjectJson.Integer(frame["version"], "version") != 2 || MultiProjectJson.String(frame["kind"], "kind") != "hello" || + frame["supportedVersions"]?.ToJsonString() != "[1,2]") + throw new InvalidDataException("Owned protocol hello differs."); + } + await PipeFrames.WriteAsync(server, "{\"version\":2,\"kind\":\"hello\",\"selectedVersion\":2}", deadline, 7).ConfigureAwait(false); + while (true) + { + using JsonDocument? document = await PipeFrames.ReadAsync(server, deadline).ConfigureAwait(false); + if (document == null) break; + JsonObject frame = MultiProjectJson.Object(document.RootElement.GetRawText()); + if (frame.ContainsKey("token")) + { + JsonObject publication = peer.Control(frame); + await PipeFrames.WriteAsync(server, publication.ToJsonString(), deadline, 7).ConfigureAwait(false); + peer.CompletePublication(publication, "control", frame["token"]!.GetValue()); + continue; + } + MultiProjectReply reply = peer.Request(frame); + if (reply.Response != null) + { + await PipeFrames.WriteAsync(server, reply.Response.ToJsonString(), deadline, 7).ConfigureAwait(false); + peer.CompleteResponse(reply.Response); + } + string id = frame["requestID"]!.GetValue(); + string[] owners = frame["command"]!["listRecentProjects"] != null ? [alpha, beta] : + reply.PublishPath != null ? [reply.PublishPath] : []; + foreach (string path in owners) + { + JsonObject publication = peer.Publication(path); + await PipeFrames.WriteAsync(server, publication.ToJsonString(), deadline, 7).ConfigureAwait(false); + peer.CompletePublication(publication, reply.PublishPath == null ? "initial" : "rename", id); + } + } + using var receipt = new FileStream(Path.Combine(root, "peer.json"), FileMode.CreateNew, FileAccess.Write, FileShare.Read); + await JsonSerializer.SerializeAsync(receipt, peer.Snapshot()).ConfigureAwait(false); + receipt.Flush(true); + } +} diff --git a/Tools/windows/ValidationRunner/Ownership/OwnershipJobFixture.cs b/Tools/windows/ValidationRunner/Ownership/OwnershipJobFixture.cs new file mode 100644 index 00000000..e68ac6cd --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/OwnershipJobFixture.cs @@ -0,0 +1,16 @@ +using System; + +namespace GraphCode.Validation.Ownership; + +public static class OwnershipJobFixture +{ + public static bool Empty; + public static int Terminated, Closed; + public static void Terminate(IntPtr job) => Terminated++; + public static void WaitForEmpty(IntPtr job, int timeout) + { + if (!Empty) throw new InvalidOperationException("Controlled fake job has a survivor."); + } + public static int Active(IntPtr job) => Empty ? 0 : 1; + public static void Close(IntPtr job) => Closed++; +} diff --git a/Tools/windows/ValidationRunner/Ownership/OwnershipRetention.cs b/Tools/windows/ValidationRunner/Ownership/OwnershipRetention.cs new file mode 100644 index 00000000..ad9a509e --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/OwnershipRetention.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Text.Json; + +namespace GraphCode.Validation.Ownership; + +public static class OwnershipRetention +{ + public const string RequiredKey = "GraphCodeValidationRootRetentionRequired"; + public const string RootsKey = "GraphCodeValidationRetainedRoots"; + public const string Sentinel = "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven"; + + public static void Tag(Exception failure, string? root) + { + failure.Data[RequiredKey] = true; + if (string.IsNullOrEmpty(root)) return; + try + { + string full = Path.GetFullPath(root); + if (!Directory.Exists(full)) throw new DirectoryNotFoundException(full); + failure.Data[RootsKey] = new[] { full }; + byte[] bytes = JsonSerializer.SerializeToUtf8Bytes(new + { + schemaVersion = 1, + validationRoot = full, + reason = "ownership_unproven" + }); + if (bytes.Length > 4096) throw new InvalidOperationException("Ownership receipt exceeds 4096 bytes."); + using var file = new FileStream(Path.Combine(full, "ownership-unproven.json"), + FileMode.CreateNew, FileAccess.Write, FileShare.Read); + file.Write(bytes); + file.Flush(true); + } + catch (Exception receiptFailure) when (receiptFailure is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or InvalidOperationException) + { + failure.Data["GraphCodeValidationRetentionReceiptFailure"] = receiptFailure.ToString(); + } + } + + public static bool ReceiptRequiresRetention(string root) + { + string path = Path.Combine(root, "ownership-unproven.json"); + if (!File.Exists(path)) return false; + var info = new FileInfo(path); + if (info.Length is <= 0 or > 4096) throw new InvalidDataException("Invalid ownership receipt length."); + byte[] bytes = File.ReadAllBytes(path); + string json = new UTF8Encoding(false, true).GetString(bytes); + using JsonDocument document = JsonDocument.Parse(json); + JsonElement receipt = document.RootElement; + if (receipt.ValueKind != JsonValueKind.Object) + throw new InvalidDataException("Ownership receipt must be an object."); + var keys = new HashSet(StringComparer.Ordinal); + foreach (JsonProperty property in receipt.EnumerateObject()) + if (!keys.Add(property.Name)) throw new InvalidDataException("Duplicate receipt field."); + if (keys.Count != 3 || !keys.SetEquals(["schemaVersion", "validationRoot", "reason"]) || + receipt.GetProperty("schemaVersion").ValueKind != JsonValueKind.Number || + receipt.GetProperty("schemaVersion").GetRawText() != "1" || + receipt.GetProperty("validationRoot").ValueKind != JsonValueKind.String || + !string.Equals(receipt.GetProperty("validationRoot").GetString(), Path.GetFullPath(root), StringComparison.Ordinal) || + receipt.GetProperty("reason").ValueKind != JsonValueKind.String || + receipt.GetProperty("reason").GetString() != "ownership_unproven") + throw new InvalidDataException("Ownership receipt schema/root/reason mismatch."); + return true; + } +} diff --git a/Tools/windows/ValidationRunner/Ownership/PipeFrames.cs b/Tools/windows/ValidationRunner/Ownership/PipeFrames.cs new file mode 100644 index 00000000..2f368f92 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/PipeFrames.cs @@ -0,0 +1,74 @@ +using System; +using System.Buffers.Binary; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace GraphCode.Validation.Ownership; + +public static class PipeFrames +{ + public const int MaximumPayloadBytes = 2 * 1024 * 1024; + private static readonly UTF8Encoding utf8 = new(false, true); + + public static async Task ReadAsync(Stream stream, Deadline deadline, + CancellationToken cancellationToken = default) + { + var header = new byte[4]; + if (!await ReadExactAsync(stream, header, deadline, true, cancellationToken).ConfigureAwait(false)) + return null; + uint length = BinaryPrimitives.ReadUInt32BigEndian(header); + if (length is 0 or > MaximumPayloadBytes) throw new InvalidDataException("Owned pipe invalid frame length."); + var body = new byte[(int)length]; + await ReadExactAsync(stream, body, deadline, false, cancellationToken).ConfigureAwait(false); + return MultiProjectJson.Parse(utf8.GetString(body)); + } + + public static async Task WriteAsync(Stream stream, string json, Deadline deadline, + int fragmentBytes = 0, CancellationToken cancellationToken = default) + { + byte[] body = utf8.GetBytes(json); + if (body.Length is 0 or > MaximumPayloadBytes) throw new InvalidDataException("Owned pipe invalid frame length."); + var header = new byte[4]; + BinaryPrimitives.WriteUInt32BigEndian(header, (uint)body.Length); + await BoundedWriteAsync(stream, header, deadline, cancellationToken).ConfigureAwait(false); + int chunkSize = fragmentBytes <= 0 ? body.Length : fragmentBytes; + for (int offset = 0; offset < body.Length; offset += Math.Min(chunkSize, body.Length - offset)) + await BoundedWriteAsync(stream, body.AsMemory(offset, Math.Min(chunkSize, body.Length - offset)), + deadline, cancellationToken).ConfigureAwait(false); + using var limit = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + limit.CancelAfter(deadline.Remaining); + await stream.FlushAsync(limit.Token).ConfigureAwait(false); + } + + private static async Task ReadExactAsync(Stream stream, Memory buffer, Deadline deadline, + bool cleanEofAllowed, CancellationToken cancellationToken) + { + int offset = 0; + while (offset != buffer.Length) + { + if (deadline.Expired) throw new TimeoutException("Owned pipe original read deadline exceeded."); + using var limit = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + limit.CancelAfter(deadline.Remaining); + int count = await stream.ReadAsync(buffer[offset..], limit.Token).ConfigureAwait(false); + if (count == 0) + { + if (offset == 0 && cleanEofAllowed) return false; + throw new EndOfStreamException("Owned pipe ended within a frame."); + } + offset += count; + } + return true; + } + + private static async Task BoundedWriteAsync(Stream stream, ReadOnlyMemory bytes, + Deadline deadline, CancellationToken cancellationToken) + { + if (deadline.Expired) throw new TimeoutException("Owned pipe original write deadline exceeded."); + using var limit = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + limit.CancelAfter(deadline.Remaining); + await stream.WriteAsync(bytes, limit.Token).ConfigureAwait(false); + } +} diff --git a/Tools/windows/ValidationRunner/Ownership/README.md b/Tools/windows/ValidationRunner/Ownership/README.md new file mode 100644 index 00000000..ffaf36be --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/README.md @@ -0,0 +1,109 @@ +# Compiled ownership contracts + +`OwnedChildSupervisor.RunAsync(ProcessStartInfo, TimeSpan, string, +CancellationToken)` is the shared child executor. `ArgumentList` is mandatory; +the executable must be an existing absolute path. The artifact directory must +already exist. The supervisor creates a suspended root with an explicit standard +handle list, assigns the held root to a non-breakaway job, verifies membership, +starts independent readers, and only then resumes execution. + +The original monotonic deadline covers launch, root exit and both readers. +Teardown has one separate five-second budget shared by the root, job and both +readers. `StopAsync` supports early controlled teardown. A failed cleanup tags +the original exception, writes a create-new bounded receipt, and retains the +lease rather than releasing an unproved kill-on-close job or reader. Neither +CIM observations nor reopening a PID supplies termination authority. + +`StandaloneProcessJob` is the complete native job implementation, including +membership, accounting, bounded census and packaging diagnostics. +`UiaOwnedStreamCapture` uses it for native membership. Both are linked by +the host-owned packaging and UIA adapters; merely compiling these files does +not establish that integration. Streaming uses `AutoFlush`, which flushes bytes +without prematurely flushing an encoder containing half a surrogate pair. +The host must await `HoldRetainedResourcesAsync()` in its outermost `finally`. +Retained references alone do not suffice: process exit closes a kill-on-close +job even if the garbage collector never finalized anything. +Both independent readers watch the exact retention sentinel incrementally, +including split chunks, CRLF and terminal EOF. A live signal immediately roots +the original lease and cancels only the controller's wait, never the readers. +The cleanup coordinator refuses termination, proof waits and close on that +latch; native kill/close boundaries also recheck it under the same lock. +The host can therefore enter its terminal retention hold without waiting for +the workload timeout to kill the very resources it is required to preserve. +The supervisor's `OwnedByteStreamCapture` persists original pipe bytes before +strict decoding and never autodetects or consumes a BOM. Raw EOF is a separate +proof from successful text decoding: invalid UTF-8 is rejected only after the +raw reader has continued to EOF and retained every byte. The live sentinel +observer reads the raw ASCII line boundary independently, including after a +decoder error. `UiaOwnedStreamCapture` remains the distinct production +text-reader API; its re-encoded text logs are not original-byte evidence. + +`OwnedFixture.TryRunAsync(args)` must run before ordinary host argument parsing. +It recognizes only `--ownership-fixture`. The compiled fixture tests native +root/job/descendant/stream behavior, real fragmented named pipes, and exact +argv/empty-versus-absent environment/Unicode/cwd. These are controlled fixtures, +not evidence of an actual GraphCode product walkthrough. + +## Coverage correspondence + +The source oracle is the immutable Cards export with manifest SHA-256 +`CC8ED5141AE38CB425F4E41AB7F2E4EF1F07F5AE4CAD7FA969A68645B07A0A2F` +and root-runner SHA-256 +`202D260A846670E314163F9D2565D07B1E0AD27A4D61F41EA99D67624FF6C869`. +The old runner is never executed, embedded or extracted for execution. + +The 76 managed and eleven controlled-native cases are individually registered +with exact-count manifest guards. The typed peer implements the complete +request/response/publication/control core from `Stub-Daemon.ps1:29-294`; it is +the implementation used by the compiled real-pipe fixture, not a passing fake. +Its presence does not claim that the old PowerShell stub has been rewired. + +Six focused protected PowerShell families now preserve 45 actual assertions: +receipt producer seven, capture ownership fourteen, tree four, provider four, +pre-UIA isolation ten, and end-to-end retention six. They use actual production +functions from the integrated repository, not the exported runner. The adapter +transport is the host's authoritative held-job supervisor. Scope, ScriptMethod, +ErrorRecord identity, information streams and missing-versus-empty environment +membership remain PowerShell assertions. + +The full mixed family remains mandatory and cannot be replaced by compiled +helper tests. `mixed-source-map.json` maps all 126 original invocation +templates onto 133 unique dependency units, with 465 expected original +expanded cases. The host derives this family only after every dependency +actually passes its exact count and the map is complete, unique and matches +the manifest. The two added primitive-focus failure controls remain separate +required units, not an inflation of an original template. +`source-correspondence.json` records every one of +the immutable mixed source's 126 invocation templates and 192 assertion +commands, with stable source IDs and zero claimed execution. Those source +occurrences are not runtime counts. The mixed eight-mode capture/cleanup +negative controls remain required. + +The original mixed ownership/protocol assertions run in focused adapters: +canonical startup event identity and privacy (33), streamed record/ErrorRecord +identity (18), whole accepted peer receipt and canonical negative controls +(65), exact request/response/publication and clipping policy (42), and five +real production-stub pipe obligations. The UI implementation supplies the +other original 83 templates in its own files (116 policy and 178 native-policy +expanded cases), plus two independent primitive failure controls. Counts +describe actual executed cases; the source invocation/assertion census does +not count as execution. + +The three mixed source-boundary assertions now have actual protected +PowerShell source checks. Six newly reconciled startup/tree units also run: +thirteen complete helper-presence checks (source evidence), three bounded file +diagnostic cases, one retained report case, and the advisory PID-reuse +negative control. These eighteen checks are not a native product startup +walkthrough. The two actual gate-owned controlled-tree obligations now run +against the linked production gate, as do all eight original bounded-capture +modes, including inherited writers, original timeout classification, and +primary/cleanup-only persistence failures. Two separate source-and-loader +checks establish packaging/UIA linkage without executing the packaging suite +merely to obtain a type. + +The original-byte regression was first reproduced by two valid-BOM children: +the old reader consumed UTF-8 BOM bytes and autodetected UTF-16. In-memory +malformed-byte/raw-EOF separation was then established before executing the +actual malformed child. The four controlled byte fixtures now assert exact +original log bytes, strict UTF-8 rejection, positive owned cleanup, and a +supplementary scalar straddling a 4096-byte read boundary. diff --git a/Tools/windows/ValidationRunner/Ownership/RetentionSignalObserver.cs b/Tools/windows/ValidationRunner/Ownership/RetentionSignalObserver.cs new file mode 100644 index 00000000..bd2f404a --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/RetentionSignalObserver.cs @@ -0,0 +1,53 @@ +using System; +using System.Text; + +namespace GraphCode.Validation.Ownership; + +public interface IOwnedStreamObserver +{ + void OnText(string text); + void OnEof(); +} + +public sealed class RetentionSignalObserver(Action observed) : IOwnedStreamObserver +{ + private readonly StringBuilder line = new(); + private bool overlong, reported; + private const string Signal = "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven"; + + public void OnText(string text) + { + foreach (char character in text) + { + if (character == '\n') { FinishLine(); continue; } + if (overlong) continue; + if (line.Length > Signal.Length) { overlong = true; line.Clear(); continue; } + line.Append(character); + } + } + + public void OnEof() => FinishLine(); + + public void OnBytes(ReadOnlySpan bytes) + { + foreach (byte value in bytes) + { + if (value == (byte)'\n') { FinishLine(); continue; } + if (overlong) continue; + if (line.Length > Signal.Length) { overlong = true; line.Clear(); continue; } + line.Append((char)value); + } + } + + private void FinishLine() + { + if (!overlong) + { + string value = line.ToString(); + if (value.EndsWith('\r')) value = value[..^1]; + if (!reported && value == Signal) { reported = true; observed(); } + } + line.Clear(); + overlong = false; + } +} diff --git a/Tools/windows/ValidationRunner/Ownership/StandaloneProcessJob.cs b/Tools/windows/ValidationRunner/Ownership/StandaloneProcessJob.cs new file mode 100644 index 00000000..005ecfc8 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/StandaloneProcessJob.cs @@ -0,0 +1,185 @@ +using System; +using System.ComponentModel; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; + +namespace GraphCode.Validation.Ownership; + +// Shared by the compiled supervisor and the PowerShell packaging/UIA adapters. +public static class StandaloneProcessJob +{ + [StructLayout(LayoutKind.Sequential)] + private struct BasicLimits + { + public long ProcessTime, JobTime; + public uint Flags; + public UIntPtr Min, Max; + public uint Active; + public UIntPtr Affinity; + public uint Priority, Scheduling; + } + + [StructLayout(LayoutKind.Sequential)] + private struct IoCounters { public ulong ReadOps, WriteOps, OtherOps, ReadBytes, WriteBytes, OtherBytes; } + [StructLayout(LayoutKind.Sequential)] + private struct Limits + { + public BasicLimits Basic; + public IoCounters Io; + public UIntPtr ProcessMemory, JobMemory, PeakProcessMemory, PeakJobMemory; + } + [StructLayout(LayoutKind.Sequential)] + private struct Accounting + { + public long User, Kernel, PeriodUser, PeriodKernel; + public uint Faults, Total, Active, Terminated; + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr CreateJobObjectW(IntPtr attributes, IntPtr name); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool SetInformationJobObject(IntPtr job, int info, ref Limits limits, uint size); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool QueryInformationJobObject(IntPtr job, int info, out Accounting value, uint size, IntPtr length); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool QueryInformationJobObject(IntPtr job, int info, IntPtr buffer, uint size, IntPtr length); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool IsProcessInJob(IntPtr process, IntPtr job, out bool result); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr OpenProcess(uint access, bool inherit, uint pid); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetProcessTimes(IntPtr process, out long creation, out long exit, out long kernel, out long user); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool QueryFullProcessImageNameW(IntPtr process, uint flags, StringBuilder name, ref uint size); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool TerminateJobObject(IntPtr job, uint code); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr handle); + + public static IntPtr Create() + { + IntPtr job = CreateJobObjectW(IntPtr.Zero, IntPtr.Zero); + if (job == IntPtr.Zero) throw new Win32Exception(); + var limits = new Limits(); + limits.Basic.Flags = 0x2000; + if (!SetInformationJobObject(job, 9, ref limits, (uint)Marshal.SizeOf())) + { + var primary = new Win32Exception(); + if (!CloseHandle(job)) + primary.Data["JobCloseFailure"] = new Win32Exception(); + throw primary; + } + return job; + } + + public static bool Contains(IntPtr job, IntPtr process) + { + if (job == IntPtr.Zero || process == IntPtr.Zero) + throw new ArgumentException("Held process and job handles are required."); + if (!IsProcessInJob(process, job, out bool result)) throw new Win32Exception(); + return result; + } + + public static void Assign(IntPtr job, IntPtr process) + { + if (!AssignProcessToJobObject(job, process)) throw new Win32Exception(); + if (!Contains(job, process)) throw new InvalidOperationException("Assigned root is not in the held job."); + } + + public static uint Active(IntPtr job) + { + if (!QueryInformationJobObject(job, 1, out Accounting value, (uint)Marshal.SizeOf(), IntPtr.Zero)) + throw new Win32Exception(); + return value.Active; + } + + public static string Summary(IntPtr job) + { + if (!QueryInformationJobObject(job, 1, out Accounting value, (uint)Marshal.SizeOf(), IntPtr.Zero)) + throw new Win32Exception(); + return $"total={value.Total}; active={value.Active}; terminated={value.Terminated}; user100ns={value.User}; kernel100ns={value.Kernel}"; + } + + public static int[] Members(IntPtr job) + { + const int capacity = 4096; + int size = 8 + capacity * IntPtr.Size; + IntPtr buffer = Marshal.AllocHGlobal(size); + try + { + if (!QueryInformationJobObject(job, 3, buffer, (uint)size, IntPtr.Zero)) + throw new Win32Exception(); + int assigned = Marshal.ReadInt32(buffer, 0), count = Marshal.ReadInt32(buffer, 4); + if (count < 0 || count > capacity || assigned != count) + throw new InvalidOperationException("Incomplete owned job member census."); + var result = new int[count]; + for (int i = 0; i < count; i++) + result[i] = checked((int)Marshal.ReadIntPtr(buffer, 8 + i * IntPtr.Size).ToInt64()); + return result; + } + finally { Marshal.FreeHGlobal(buffer); } + } + + public static string Processes(IntPtr job) + { + var identities = new List(); + foreach (int pid in Members(job)) + { + IntPtr process = OpenProcess(0x1000, false, (uint)pid); + if (process == IntPtr.Zero) + { + identities.Add($"pid={pid}; unavailable-win32={Marshal.GetLastWin32Error()}"); + continue; + } + try + { + if (!Contains(job, process)) + { + identities.Add($"pid={pid}; no-longer-owned"); + continue; + } + uint length = 32768; + var name = new StringBuilder((int)length); + if (!GetProcessTimes(process, out long creation, out _, out _, out _) || + !QueryFullProcessImageNameW(process, 0, name, ref length)) + { + identities.Add($"pid={pid}; identity-unavailable-win32={Marshal.GetLastWin32Error()}"); + continue; + } + identities.Add($"pid={pid}; creationUtcTicks={DateTime.FromFileTimeUtc(creation).Ticks}; name={System.IO.Path.GetFileNameWithoutExtension(name.ToString())}"); + } + finally { if (!CloseHandle(process)) throw new Win32Exception(); } + } + return string.Join(" | ", identities); + } + + public static void Terminate(IntPtr job) + { + if (!TerminateJobObject(job, 124)) throw new Win32Exception(); + } + + public static void WaitForEmpty(IntPtr job, int milliseconds) + { + if (milliseconds < 0) throw new ArgumentOutOfRangeException(nameof(milliseconds)); + WaitForEmpty(job, new Deadline(TimeSpan.FromMilliseconds(Math.Max(1, milliseconds)))); + } + + public static void WaitForEmpty(IntPtr job, Deadline deadline) + { + while (Active(job) != 0) + { + if (deadline.Expired) throw new TimeoutException("Owned job still has active processes."); + Thread.Sleep(Math.Min(10, deadline.RemainingMilliseconds)); + } + } + + public static void Close(IntPtr job) + { + if (Active(job) != 0) throw new InvalidOperationException("Cannot release a nonempty or unknown owned job."); + if (!CloseHandle(job)) throw new Win32Exception(); + } +} diff --git a/Tools/windows/ValidationRunner/Ownership/SuiteRegistration.cs b/Tools/windows/ValidationRunner/Ownership/SuiteRegistration.cs new file mode 100644 index 00000000..56089107 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/SuiteRegistration.cs @@ -0,0 +1,700 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Pipes; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; + +namespace GraphCode.Validation.Ownership; + +public static class SuiteRegistration +{ + private static readonly string[] mixedUiPolicyIds = Array.ConvertAll(new[] + { + 1724, 1733, 1749, 1757, 1769, 1777, 1789, 1804, 1811, 1818, 1824, 1837, 1843, 1854, 1870, 1879, 1917, 1924, 1951, + 3392, 3423, 3443, 3458, 3469, 3482, 3487, 3497, 3524, 3530, 3535, 3540, 3545, 3550, 3555, 3560, 3565, 3570, 3575, + 3596, 3601, 3608, 3625, 3635, 3732, 3755, 3763, 3771, 3792, 3816, 3846 + }, line => "ownership.mixed-ui.source-line-" + line); + + public static IReadOnlyList CreateSuites() => + [ + new OwnershipSuite(), + new ShellScopeNativeSuite(), + new Adapters.StandaloneSuites.CaseScriptSuite("ownership.receipts", "Tools\\windows\\ValidationRunner\\Ownership\\Adapters\\Receipt.Contracts.ps1", + "OWNERSHIP_CASE_RESULT=", ["ownership.legacy.ownership-receipts"]), + new Adapters.StandaloneSuites.CaseScriptSuite("ownership.tree-provider", "Tools\\windows\\ValidationRunner\\Ownership\\Adapters\\TreeProvider.Contracts.ps1", + "OWNERSHIP_CASE_RESULT=", ["ownership.legacy.tree-ownership", "ownership.legacy.provider-ownership"]), + new Adapters.StandaloneSuites.CaseScriptSuite("ownership.capture", "Tools\\windows\\ValidationRunner\\Ownership\\Adapters\\Capture.Contracts.ps1", + "OWNERSHIP_CASE_RESULT=", ["ownership.legacy.capture-ownership"]), + new Adapters.StandaloneSuites.CaseScriptSuite("ownership.pre-uia", "Tools\\windows\\ValidationRunner\\Ownership\\Adapters\\PreUiaIsolation.Contracts.ps1", + "OWNERSHIP_CASE_RESULT=", ["ownership.legacy.pre-uia-isolation"]), + new Adapters.StandaloneSuites.CaseScriptSuite("ownership.retention", "Tools\\windows\\ValidationRunner\\Ownership\\Adapters\\RetentionBoundary.Contracts.ps1", + "OWNERSHIP_CASE_RESULT=", ["ownership.legacy.retention-boundary"]), + new Adapters.StandaloneSuites.CaseScriptSuite("ownership.startup", "Tools\\windows\\ValidationRunner\\Ownership\\Adapters\\StartupDiagnostics.Contracts.ps1", + "OWNERSHIP_CASE_RESULT=", ["ownership.ui-startup.helpers", "ownership.ui-startup.redacted-log", "ownership.ui-startup.truncated-log", + "ownership.ui-startup.missing-log", "ownership.ui-startup.retained-report", "ownership.ui-process-tree.pid-reuse"]), + new Adapters.StandaloneSuites.CaseScriptSuite("ownership.mixed-source", "Tools\\windows\\ValidationRunner\\Ownership\\Adapters\\MixedSource.Contracts.ps1", + "OWNERSHIP_CASE_RESULT=", ["ownership.legacy.peer-emission", "ownership.legacy.rename-submit", "ownership.legacy.fresh-capture"]), + new Adapters.StandaloneSuites.CaseScriptSuite("ownership.controlled-capture", "Tools\\windows\\ValidationRunner\\Ownership\\Adapters\\ControlledCapture.Contracts.ps1", + "OWNERSHIP_CASE_RESULT=", ["ownership.mixed-capture.normal", "ownership.mixed-capture.stop", "ownership.mixed-capture.inherited", + "ownership.mixed-capture.abrupt", "ownership.mixed-capture.timeout", "ownership.mixed-capture.inherited-timeout", + "ownership.mixed-capture.primary-secondary", "ownership.mixed-capture.cleanup-only", + "ownership.ui-process-tree.controlled-child", "ownership.ui-process-tree.cleanup-complete"]), + new Adapters.StandaloneSuites.CaseScriptSuite("ownership.production-linkage", "Tools\\windows\\ValidationRunner\\Ownership\\Adapters\\ProductionLinkage.Contracts.ps1", + "OWNERSHIP_CASE_RESULT=", ["ownership.legacy.packaging-shared-linkage", "ownership.legacy.uia-shared-linkage"]), + new Adapters.StandaloneSuites.CaseScriptSuite("ownership.mixed-receipt", "Tools\\windows\\ValidationRunner\\Ownership\\Adapters\\MixedReceipt.Contracts.ps1", + "OWNERSHIP_CASE_RESULT=", ["ownership.mixed-receipt.source-line-2411", "ownership.mixed-receipt.source-line-2439", + "ownership.mixed-receipt.source-line-2445", "ownership.mixed-receipt.source-line-2460", "ownership.mixed-receipt.source-line-2520"]), + new Adapters.StandaloneSuites.CaseScriptSuite("ownership.mixed-ui-policy", "Tools\\windows\\ValidationRunner\\Ui\\MixedPolicyContracts.ps1", + "OWNERSHIP_CASE_RESULT=", mixedUiPolicyIds), + new Adapters.StandaloneSuites.CaseScriptSuite("ownership.mixed-protocol", "Tools\\windows\\ValidationRunner\\Ownership\\Adapters\\MixedProtocol.Contracts.ps1", + "OWNERSHIP_CASE_RESULT=", Array.ConvertAll(new[] { 3353, 3368, 3373, 3383, 3889, 3894, 3897, 3908, 3930, 3941, 3943, 3948, 3954, 3973, 3988 }, + line => "ownership.mixed-protocol.source-line-" + line)), + new Adapters.StandaloneSuites.CaseScriptSuite("ownership.mixed-stream", "Tools\\windows\\ValidationRunner\\Ownership\\Adapters\\MixedStream.Contracts.ps1", + "OWNERSHIP_CASE_RESULT=", Array.ConvertAll(new[] { 2193, 2221, 2305, 2342, 2359 }, + line => "ownership.mixed-stream.source-line-" + line)), + new Adapters.StandaloneSuites.CaseScriptSuite("ownership.mixed-startup", "Tools\\windows\\ValidationRunner\\Ownership\\Adapters\\MixedStartup.Contracts.ps1", + "OWNERSHIP_CASE_RESULT=", Array.ConvertAll(new[] { 2060, 2068, 2074, 2083, 2101, 2109, 2126, 2137, 2143, 2149, 2165, 2173 }, + line => "ownership.mixed-startup.source-line-" + line)), + new Adapters.StandaloneSuites.CaseScriptSuite("ownership.mixed-ui-native", "Tools\\windows\\ValidationRunner\\Ui\\MixedNativeContracts.ps1", + "OWNERSHIP_CASE_RESULT=", Array.ConvertAll(new[] { 2706, 2724, 2732, 2742, 2751, 2785, 2808, 2845, 2850, 2857, 2871, 2897, 2906, 2924, 3013, + 3024, 3040, 3057, 3129, 3138, 3154, 3202, 3214, 3224, 3237, 3268, 3278, 3283, 3291, 3296, 3314, 3331, 3341 }, + line => "ownership.mixed-ui.source-line-" + line).Concat(new[] { "ownership.mixed-ui.focus-control-false", "ownership.mixed-ui.focus-control-throw" }).ToArray()), + new Adapters.StandaloneSuites.CaseScriptSuite("ownership.mixed-pipe", "Tools\\windows\\ValidationRunner\\Ownership\\Adapters\\MixedPipe.Contracts.ps1", + "OWNERSHIP_CASE_RESULT=", Array.ConvertAll(new[] { 4053, 4056, 4063, 4072, 4093 }, + line => "ownership.mixed-pipe.source-line-" + line)) + ]; +} + +public sealed class OwnershipSuite : IValidationSuite +{ + private const string ListId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + private const string RenameId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + private const string Token = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; + private static readonly string[] managedIds = + [ + "quote-empty", "quote-trailing-slash", "quote-embedded", "quote-unicode", "quote-nul", + "stream-raw", "stream-surrogate-boundary", "stream-no-overwrite", "stream-independent-faults", "stream-shared-deadline", + "receipt-valid", "receipt-existing", "receipt-missing", "receipt-file", "receipt-absent", + "receipt-duplicate-json", "receipt-foreign-root", "receipt-over-limit", + "proof-success", "proof-root-survivor", "proof-job-survivor", "proof-stdout-fault", "proof-stderr-fault", + "cleanup-success", "cleanup-root-survivor", "cleanup-job-survivor", "cleanup-stdout-fault", + "cleanup-both-stream-faults", "cleanup-primary-error", "cleanup-only-error", "cleanup-original-deadline", "cleanup-later-actions", + "cleanup-descendant-unknown", "signal-fragmented", "signal-crlf", "signal-eof", "signal-exact-only", "signal-overlong", + "raw-codec-malformed-eof", "raw-codec-retention-after-malformed", + "json-malformed", "json-duplicate", "json-nested-duplicate", "json-array", "json-case-sensitive", + "frame-fragmented-unicode", "frame-clean-eof", "frame-short-header", "frame-short-body", "frame-zero", "frame-over-limit", "frame-invalid-utf8", + "peer-positive-rename", "peer-resume-unchanged", "peer-foreign-owner", "peer-foreign-node", + "peer-missing-title", "peer-extra-field", "peer-title-type", "peer-version-string", "peer-version-bool", + "peer-request-id", "peer-blank-title", "peer-request-duplicate", "peer-unanswered-publication", + "peer-response-unknown", "peer-response-extra", "peer-response-type", "peer-response-duplicate", + "peer-control-positive", "peer-control-owner", "peer-control-selection", "peer-control-extra", "peer-control-type", + "peer-control-duplicate", "peer-control-second" + ]; + private static readonly string[] nativeIds = ["normal", "abrupt", "timeout", "inherited", "inherited-timeout", "arguments-environment", "real-pipe", + "bytes-utf8-bom", "bytes-utf16-bom", "bytes-invalid-utf8", "bytes-utf8-supplementary"]; + public string Id => "ownership"; + public IReadOnlyList UnitIds { get; } = managedIds.Select(id => "ownership.managed." + id) + .Concat(nativeIds.Select(id => "ownership.native." + id)).ToArray(); + + public async Task> RunAsync(ValidationContext context, CancellationToken cancellationToken) + { + string root = Path.Combine(context.ArtifactRoot, "ownership-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + string alpha = Path.Combine(root, "Alpha"), beta = Path.Combine(root, "Beta"); + Directory.CreateDirectory(alpha); + Directory.CreateDirectory(beta); + var results = new List(); + foreach (string id in managedIds) + { + cancellationToken.ThrowIfCancellationRequested(); + await ExecuteAsync("ownership.managed." + id, () => ManagedAsync(id, root, alpha, beta), results, "managed").ConfigureAwait(false); + } + foreach (string id in nativeIds) + { + cancellationToken.ThrowIfCancellationRequested(); + await ExecuteAsync("ownership.native." + id, () => NativeAsync(id, root, alpha, beta, cancellationToken), results, "controlled-native").ConfigureAwait(false); + } + return results; + } + + private static async Task ExecuteAsync(string id, Func action, List results, string group) + { + try + { + await action().ConfigureAwait(false); + results.Add(new UnitResult(id, UnitStatus.Passed, 1, "Exact registered case completed; evidence kind=" + group, [group])); + } + catch (Exception failure) + { + results.Add(new UnitResult(id, UnitStatus.Failed, 1, failure.ToString(), [group])); + if (failure.Data[OwnershipRetention.RequiredKey] is true) + throw; + } + } + + private static void Require(bool condition, string message) + { + if (!condition) throw new InvalidOperationException(message); + } + + private static T Reject(Action action, string? prefix = null) where T : Exception + { + try { action(); } + catch (T failure) + { + Require(prefix == null || failure.Message.StartsWith(prefix, StringComparison.Ordinal), "Wrong rejection: " + failure); + return failure; + } + throw new InvalidOperationException("Expected rejection: " + typeof(T).Name); + } + + private static async Task RejectAsync(Func action) where T : Exception + { + try { await action().ConfigureAwait(false); } + catch (T failure) { return failure; } + throw new InvalidOperationException("Expected rejection: " + typeof(T).Name); + } + + private static JsonObject Request(string alpha, string title = "Alpha renamed") => new() + { + ["version"] = 2, ["kind"] = "request", ["requestID"] = RenameId, + ["command"] = new JsonObject { ["graphCommand"] = new JsonObject + { + ["projectPath"] = alpha, + ["command"] = new JsonObject { ["renameNode"] = new JsonObject { ["_0"] = MultiProjectPeer.AlphaNodeId, ["title"] = title } } + } } + }; + + private static JsonObject ListRequest() => MultiProjectJson.Object( + "{\"version\":2,\"kind\":\"request\",\"requestID\":\"" + ListId + "\",\"command\":{\"listRecentProjects\":{}}}"); + + private static MultiProjectPeer Baseline(string alpha, string beta) + { + var peer = new MultiProjectPeer(alpha, beta); + MultiProjectReply reply = peer.Request(ListRequest()); + peer.CompleteResponse(reply.Response!); + foreach (string owner in new[] { alpha, beta }) peer.CompletePublication(peer.Publication(owner), "initial", ListId); + return peer; + } + + private static JsonObject Control(string alpha, string beta) => new() + { + ["token"] = Token, ["projectPath"] = alpha, ["nodeID"] = MultiProjectPeer.AlphaNodeId, ["title"] = "Alpha interleaved", + ["selection"] = new JsonObject { ["projectPath"] = beta, ["nodeID"] = MultiProjectPeer.BetaNodeId, ["source"] = "synthetic-client" } + }; + + private static async Task ManagedAsync(string id, string root, string alpha, string beta) + { + if (id.StartsWith("quote-", StringComparison.Ordinal)) + { + switch (id) + { + case "quote-empty": Require(OwnedChildSupervisor.QuoteArgument("") == "\"\"", "Empty argv was lost."); break; + case "quote-trailing-slash": Require(OwnedChildSupervisor.QuoteArgument("a\\") == "\"a\\\\\"", "Trailing slash not doubled."); break; + case "quote-embedded": Require(OwnedChildSupervisor.QuoteArgument("a\"b") == "\"a\\\"b\"", "Embedded quote not escaped."); break; + case "quote-unicode": Require(OwnedChildSupervisor.QuoteArgument("\u03BB \uD83D\uDE80") == "\"\u03BB \uD83D\uDE80\"", "Unicode argv changed."); break; + case "quote-nul": Reject(() => OwnedChildSupervisor.QuoteArgument("a\0b")); break; + } + return; + } + if (id.StartsWith("stream-", StringComparison.Ordinal)) + { + if (id == "stream-independent-faults") + { + var first = new IOException("stdout original"); + var second = new IOException("stderr original"); + var failure = await RejectAsync(() => OwnedChildSupervisor.RequireDrainAsync( + [Task.FromException(first), Task.FromException(second)], new Deadline(TimeSpan.FromSeconds(1)))).ConfigureAwait(false); + Require(failure.InnerExceptions.Count == 2 && ReferenceEquals(failure.InnerExceptions[0], first) && + ReferenceEquals(failure.InnerExceptions[1], second), "Independent original stream errors were replaced or omitted."); + return; + } + if (id == "stream-shared-deadline") + { + var pending = new TaskCompletionSource(); + var original = new IOException("stderr fails independently"); + var clock = Stopwatch.StartNew(); + var limit = new Deadline(TimeSpan.FromMilliseconds(100)); + await Task.Delay(60).ConfigureAwait(false); + var failure = await RejectAsync(() => OwnedChildSupervisor.RequireDrainAsync( + [pending.Task, Task.FromException(original)], limit)).ConfigureAwait(false); + Require(failure.InnerExceptions.Count == 2 && ReferenceEquals(failure.InnerExceptions[1], original) && + clock.Elapsed < TimeSpan.FromSeconds(1), "Original shared deadline or independent stderr fault was lost."); + return; + } + string text = id == "stream-surrogate-boundary" ? new string('a', 4095) + "\uD83D\uDE80tail" : "first\nsecond \u03BB \uD83D\uDE80\r\n"; + string path = Path.Combine(root, id + ".log"); + using var reader = new StreamReader(new MemoryStream(Encoding.UTF8.GetBytes(text)), new UTF8Encoding(false, true)); + string captured = await UiaOwnedStreamCapture.Read(reader, path).ConfigureAwait(false); + Require(captured == text && File.ReadAllText(path, new UTF8Encoding(false, true)) == text && + File.ReadAllBytes(path).SequenceEqual(Encoding.UTF8.GetBytes(text)), "Raw UTF-8 bytes or streaming Unicode changed."); + if (id == "stream-no-overwrite") + { + Reject(() => UiaOwnedStreamCapture.Read(reader, path)); + Require(File.ReadAllText(path) == text, "Existing capture evidence overwritten."); + } + return; + } + if (id.StartsWith("receipt-", StringComparison.Ordinal)) + { + string receiptRoot = Path.Combine(root, id); + if (id is not ("receipt-missing" or "receipt-file" or "receipt-absent")) Directory.CreateDirectory(receiptRoot); + if (id == "receipt-file") File.WriteAllText(receiptRoot, "not a directory"); + string path = Path.Combine(receiptRoot, "ownership-unproven.json"); + if (id == "receipt-existing") File.WriteAllText(path, "preserve-original"); + if (id is "receipt-duplicate-json" or "receipt-foreign-root" or "receipt-over-limit") + { + string json = id == "receipt-duplicate-json" ? "{\"schemaVersion\":1,\"schemaVersion\":1}" : + id == "receipt-over-limit" ? new string(' ', 4097) : + JsonSerializer.Serialize(new { schemaVersion = 1, validationRoot = root, reason = "ownership_unproven" }); + File.WriteAllText(path, json); + Reject(() => OwnershipRetention.ReceiptRequiresRetention(receiptRoot)); + return; + } + var failure = new IOException("original primary"); + OwnershipRetention.Tag(failure, id == "receipt-absent" ? null : receiptRoot); + Require(failure.Data[OwnershipRetention.RequiredKey] is true && failure.Message == "original primary", "Boolean retention intent or original failure changed."); + if (id == "receipt-valid") Require(OwnershipRetention.ReceiptRequiresRetention(receiptRoot), "Receipt not consumed."); + else if (id == "receipt-existing") + Require(File.ReadAllText(path) == "preserve-original" && failure.Data["GraphCodeValidationRetentionReceiptFailure"] != null, "Receipt overwrite or missing write-failure diagnostics."); + else if (id != "receipt-absent") Require(failure.Data["GraphCodeValidationRetentionReceiptFailure"] != null, "Unwritable receipt silently accepted."); + return; + } + if (id.StartsWith("proof-", StringComparison.Ordinal)) + { + var proof = new OwnedCleanupProof(id != "proof-root-survivor", id != "proof-job-survivor", + id != "proof-stdout-fault", id != "proof-stderr-fault", false); + Require(proof.CanRelease == (id == "proof-success"), "UNKNOWN proof allowed resource release."); + return; + } + if (id.StartsWith("cleanup-", StringComparison.Ordinal)) + { + var authority = new FakeCleanupAuthority + { + RootExited = id is not ("cleanup-root-survivor" or "cleanup-primary-error"), + JobEmpty = id != "cleanup-job-survivor", + CloseFails = id is "cleanup-only-error" or "cleanup-later-actions", + ConsumeDeadline = id == "cleanup-original-deadline" + }; + authority.OwnershipUnproven = id == "cleanup-descendant-unknown"; + if (id is "cleanup-stdout-fault" or "cleanup-both-stream-faults") + authority.StandardOutput = Task.FromException(new IOException("original stdout")); + if (id == "cleanup-both-stream-faults") + authority.StandardError = Task.FromException(new IOException("original stderr")); + if (id == "cleanup-original-deadline") + authority.StandardOutput = new TaskCompletionSource().Task; + var primary = id == "cleanup-primary-error" ? new IOException("original primary") : null; + OwnedCleanupProof? proof = null; + if (id == "cleanup-success") + proof = await OwnedCleanupCoordinator.CompleteAsync(authority, null, TimeSpan.FromSeconds(1)).ConfigureAwait(false); + else if (primary != null) + { + proof = await OwnedCleanupCoordinator.CompleteAsync(authority, primary, TimeSpan.FromSeconds(1)).ConfigureAwait(false); + Require(ReferenceEquals(authority.RetainedError, primary) && primary.Message == "original primary" && + primary.Data["OwnedCleanupFailure"] is AggregateException, "Primary identity/secondary cleanup diagnostic changed."); + } + else + { + var failure = await RejectAsync(() => OwnedCleanupCoordinator.CompleteAsync(authority, null, + TimeSpan.FromMilliseconds(id == "cleanup-original-deadline" ? 100 : 1000))).ConfigureAwait(false); + proof = failure.Data["OwnedCleanupProof"] as OwnedCleanupProof; + Require(ReferenceEquals(authority.RetainedError, failure), "Cleanup-only failure not retained by exact identity."); + if (id == "cleanup-both-stream-faults") + Require(failure.InnerExceptions.Count == 2 && failure.InnerExceptions.All(e => e.InnerException is IOException), + "A failed stdout hid the independent stderr failure."); + } + int expectedProofCalls = id == "cleanup-descendant-unknown" ? 0 : 1; + Require(authority.Terminations == expectedProofCalls && authority.JobWaits == expectedProofCalls && authority.RootWaits == expectedProofCalls && + ReferenceEquals(authority.JobDeadline, authority.RootDeadline), "Cleanup skipped a proof or refreshed its deadline."); + bool closable = id is "cleanup-success" or "cleanup-only-error" or "cleanup-later-actions"; + Require(authority.Closes == (closable ? 2 : 0), "UNKNOWN allowed close or a close error skipped a later action."); + Require(proof != null && proof.ResourcesClosed == (id == "cleanup-success") && + authority.Retained == (id != "cleanup-success"), "Cleanup resource/retention disposition changed."); + if (id == "cleanup-original-deadline") + Require(authority.RootRemainingMilliseconds < 50, "Root wait received a refreshed deadline after job wait."); + return; + } + if (id.StartsWith("signal-", StringComparison.Ordinal)) + { + int observations = 0; + var observer = new RetentionSignalObserver(() => observations++); + string marker = OwnershipRetention.Sentinel; + switch (id) + { + case "signal-fragmented": + foreach (char character in marker + "\n") observer.OnText(character.ToString()); + observer.OnText(marker + "\n"); + break; + case "signal-crlf": observer.OnText(marker + "\r\n"); break; + case "signal-eof": observer.OnText(marker); observer.OnEof(); break; + case "signal-exact-only": observer.OnText("prefix " + marker + "\n" + marker + " suffix\n"); break; + case "signal-overlong": observer.OnText(new string('x', 100000) + marker + "\n"); break; + } + Require(observations == (id is "signal-exact-only" or "signal-overlong" ? 0 : 1), + "Live UNKNOWN observer missed split/EOF signal or treated substring/overlong output as authority."); + return; + } + if (id.StartsWith("raw-codec-", StringComparison.Ordinal)) + { + bool withSignal = id == "raw-codec-retention-after-malformed"; + byte[] bytes = withSignal ? [0xc3, 0x28, 10, .. Encoding.UTF8.GetBytes(OwnershipRetention.Sentinel + "\n")] : [0xc3, 0x28]; + int signals = 0; + string path = Path.Combine(root, id + ".log"); + using var memory = new MemoryStream(bytes); + var capture = new OwnedByteStreamCapture(memory, path, new UTF8Encoding(false, true), + new RetentionSignalObserver(() => signals++)); + await capture.RawEof.ConfigureAwait(false); + await RejectAsync(async () => { await capture.Text.ConfigureAwait(false); }).ConfigureAwait(false); + Require(capture.RawEof.IsCompletedSuccessfully && File.ReadAllBytes(path).SequenceEqual(bytes) && + signals == (withSignal ? 1 : 0), "Strict decoding failure lost original bytes, independent raw EOF, or a later live retention marker."); + return; + } + if (id.StartsWith("json-", StringComparison.Ordinal)) + { + string text = id switch + { + "json-malformed" => "{", "json-duplicate" => "{\"x\":1,\"x\":2}", + "json-nested-duplicate" => "{\"a\":[{\"x\":1,\"x\":2}]}", + "json-array" => "[]", _ => "{\"x\":1,\"X\":2}" + }; + if (id == "json-case-sensitive") + { + using var document = MultiProjectJson.Parse(text); + Require(document.RootElement.EnumerateObject().Count() == 2, "Ordinal JSON property names collapsed."); + } + else Reject(() => MultiProjectJson.Parse(text), "MULTIPROJECT_JSON"); + return; + } + if (id.StartsWith("frame-", StringComparison.Ordinal)) + { + if (id == "frame-fragmented-unicode") + { + using var memory = new MemoryStream(); + await PipeFrames.WriteAsync(memory, "{\"text\":\"\u03BB \uD83D\uDE80\"}", new Deadline(TimeSpan.FromSeconds(1)), 7).ConfigureAwait(false); + memory.Position = 0; + using var fragmented = new FragmentedReadStream(memory, 1); + using var doc = await PipeFrames.ReadAsync(fragmented, new Deadline(TimeSpan.FromSeconds(1))).ConfigureAwait(false); + Require(doc!.RootElement.GetProperty("text").GetString() == "\u03BB \uD83D\uDE80", "UTF-8 framing text changed."); + return; + } + byte[] bytes = id switch + { + "frame-clean-eof" => [], "frame-short-header" => [0, 0], "frame-short-body" => [0, 0, 0, 2, 123], + "frame-zero" => [0, 0, 0, 0], "frame-over-limit" => [0, 32, 0, 1], + "frame-invalid-utf8" => [0, 0, 0, 2, 0xc3, 0x28], _ => throw new InvalidOperationException(id) + }; + using var source = new MemoryStream(bytes); + if (id == "frame-clean-eof") Require(await PipeFrames.ReadAsync(source, new Deadline(TimeSpan.FromSeconds(1))) == null, "Clean EOF not distinct."); + else if (id is "frame-short-header" or "frame-short-body") + await RejectAsync(async () => { using var doc = await PipeFrames.ReadAsync(source, new Deadline(TimeSpan.FromSeconds(1))); }).ConfigureAwait(false); + else if (id == "frame-invalid-utf8") + await RejectAsync(async () => { using var doc = await PipeFrames.ReadAsync(source, new Deadline(TimeSpan.FromSeconds(1))); }).ConfigureAwait(false); + else await RejectAsync(async () => { using var doc = await PipeFrames.ReadAsync(source, new Deadline(TimeSpan.FromSeconds(1))); }).ConfigureAwait(false); + return; + } + PeerCase(id, alpha, beta); + } + + private static void PeerCase(string id, string alpha, string beta) + { + var peer = Baseline(alpha, beta); + JsonObject request = Request(alpha); + if (id == "peer-positive-rename") + { + MultiProjectReply reply = peer.Request(request); + peer.CompleteResponse(reply.Response!); + JsonObject publication = peer.Publication(alpha); + peer.CompletePublication(publication, "rename", RenameId); + JsonObject snapshot = peer.Snapshot(); + Require(snapshot["appliedCount"]!.GetValue() == 1 && snapshot["graphSequence"]!.GetValue() == 3 && + snapshot["graphs"]![0]!["nodes"]![0]!["title"]!.GetValue() == "Alpha renamed" && + snapshot["graphs"]![1]!["nodes"]![0]!["title"]!.GetValue() == "Beta loop", "Rename crossed owner or sequence."); + return; + } + if (id == "peer-resume-unchanged") + { + request["command"]!["graphCommand"]!["command"] = new JsonObject { ["resumeSession"] = new JsonObject { ["_0"] = MultiProjectPeer.AlphaNodeId } }; + string before = MultiProjectJson.Canonical(peer.Snapshot()["graphs"]); + MultiProjectReply reply = peer.Request(request); + Require(reply.PublishPath == null && MultiProjectJson.Canonical(peer.Snapshot()["graphs"]) == before && + peer.Snapshot()["appliedCount"]!.GetValue() == 0, "Resume acknowledgement mutated graph."); + return; + } + if (id.StartsWith("peer-control-", StringComparison.Ordinal)) + { + JsonObject control = Control(alpha, beta); + string prefix = "MULTIPROJECT_SCHEMA"; + switch (id) + { + case "peer-control-positive": + JsonObject frame = peer.Control(control); + peer.CompletePublication(frame, "control", Token); + Require(peer.Snapshot()["controls"]!.AsArray().Count == 1 && frame["sequence"]!.GetValue() == 3, "Interleaved control not applied exactly once."); + return; + case "peer-control-owner": control["projectPath"] = beta; control["nodeID"] = MultiProjectPeer.BetaNodeId; prefix = "MULTIPROJECT_OWNER"; break; + case "peer-control-selection": control["selection"]!["nodeID"] = MultiProjectPeer.AlphaNodeId; prefix = "MULTIPROJECT_SELECTION"; break; + case "peer-control-extra": control["extra"] = 1; break; + case "peer-control-type": control["title"] = 7; prefix = "MULTIPROJECT_TYPE"; break; + case "peer-control-duplicate": peer.Control(control); prefix = "MULTIPROJECT_DUPLICATE"; break; + case "peer-control-second": peer.Control(control); control["token"] = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"; prefix = "MULTIPROJECT_ONE_SHOT"; break; + } + string before = MultiProjectJson.Canonical(peer.Snapshot()); + Reject(() => peer.Control(control), prefix); + Require(MultiProjectJson.Canonical(peer.Snapshot()) == before, "Rejected control mutated peer."); + return; + } + if (id.StartsWith("peer-response-", StringComparison.Ordinal)) + { + JsonObject response = peer.Request(request).Response!; + string prefix = "MULTIPROJECT_CORRELATION"; + switch (id) + { + case "peer-response-unknown": response["requestID"] = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"; break; + case "peer-response-extra": response["extra"] = 1; prefix = "MULTIPROJECT_SCHEMA"; break; + case "peer-response-type": response["success"] = "true"; prefix = "MULTIPROJECT_TYPE"; break; + case "peer-response-duplicate": peer.CompleteResponse(response); prefix = "MULTIPROJECT_DUPLICATE"; break; + } + Reject(() => peer.CompleteResponse(response), prefix); + return; + } + if (id == "peer-unanswered-publication") + { + peer.Request(request); + Reject(() => peer.CompletePublication(peer.Publication(alpha), "rename", RenameId), "MULTIPROJECT_UNANSWERED"); + return; + } + string expected = "MULTIPROJECT_SCHEMA"; + JsonNode rename = request["command"]!["graphCommand"]!["command"]!["renameNode"]!; + switch (id) + { + case "peer-foreign-owner": request["command"]!["graphCommand"]!["projectPath"] = beta; expected = "MULTIPROJECT_OWNER"; break; + case "peer-foreign-node": rename["_0"] = MultiProjectPeer.BetaNodeId; expected = "MULTIPROJECT_OWNER"; break; + case "peer-missing-title": rename.AsObject().Remove("title"); break; + case "peer-extra-field": rename["extra"] = 1; break; + case "peer-title-type": rename["title"] = 7; expected = "MULTIPROJECT_TYPE"; break; + case "peer-version-string": request["version"] = "2"; expected = "MULTIPROJECT_TYPE"; break; + case "peer-version-bool": request["version"] = true; expected = "MULTIPROJECT_TYPE"; break; + case "peer-request-id": request["requestID"] = "bad"; expected = "MULTIPROJECT_UUID"; break; + case "peer-blank-title": rename["title"] = " "; expected = "MULTIPROJECT_TITLE"; break; + case "peer-request-duplicate": peer.Request(request); expected = "MULTIPROJECT_DUPLICATE"; break; + default: throw new InvalidOperationException("Unimplemented managed case: " + id); + } + string original = MultiProjectJson.Canonical(peer.Snapshot()); + Reject(() => peer.Request(request), expected); + Require(MultiProjectJson.Canonical(peer.Snapshot()) == original, "Rejected request mutated peer."); + } + + private static async Task NativeAsync(string id, string root, string alpha, string beta, CancellationToken cancellationToken) + { + string childRoot = Path.Combine(root, "native-" + id); + Directory.CreateDirectory(childRoot); + if (id.StartsWith("bytes-", StringComparison.Ordinal)) + { + var owned = OwnedChildSupervisor.Start(OwnedFixture.Command(id, childRoot), TimeSpan.FromSeconds(5), childRoot); + OwnedChildResult? result = null; + AggregateException? failure = null; + try { result = await owned.CompleteAsync(cancellationToken).ConfigureAwait(false); } + catch (AggregateException error) { failure = error; } + Require(result?.Cleanup.ResourcesClosed == true || + failure?.Data["OwnedCleanupProof"] is OwnedCleanupProof { CanRelease: true, ResourcesClosed: true }, + "Codec rejection did not independently prove raw EOF and safe owned cleanup."); + string rawPath = Path.Combine(childRoot, $"owned-{owned.ProcessId}-{owned.CreationFileTime}-stdout.log"); + Require(File.ReadAllBytes(rawPath).SequenceEqual(OwnedFixture.BytesForMode(id)), "Original pipe bytes were normalized or lost."); + if (id == "bytes-utf8-bom") + { + Require(result != null && result.StandardOutput.StartsWith('\uFEFF'), "UTF-8 BOM was consumed by capture."); + Reject(() => MultiProjectJson.Parse(result!.StandardOutput), "MULTIPROJECT_JSON"); + } + else if (id is "bytes-utf16-bom" or "bytes-invalid-utf8") + Require(failure != null, "Non-UTF-8 pipe bytes were accepted by BOM autodetection or replacement."); + else Require(result?.StandardOutput == new string('a', 4095) + "\uD83D\uDE80tail", "Supplementary scalar crossing the 4096-byte read boundary changed."); + return; + } + if (id == "real-pipe") + { + await RealPipeAsync(childRoot, alpha, beta, cancellationToken).ConfigureAwait(false); + return; + } + string mode = id == "arguments-environment" ? "echo" : id; + string[] arguments = ["space argument", "", "\"literal\"", "trailing\\", "\u03BB \uD83D\uDE80"]; + var start = OwnedFixture.Command(mode, childRoot, id == "arguments-environment" ? arguments : []); + if (id == "arguments-environment") + { + start.Environment["OWNERSHIP_EMPTY"] = ""; + start.Environment.Remove("OWNERSHIP_ABSENT"); + } + TimeSpan budget = TimeSpan.FromMilliseconds(id.Contains("timeout", StringComparison.Ordinal) ? 1500 : 5000); + OwnedChildSupervisor child = OwnedChildSupervisor.Start(start, budget, childRoot); + bool completionAttempted = false; + try + { + if (id is "timeout" or "inherited-timeout" or "inherited") + { + var readyLimit = new Deadline(TimeSpan.FromSeconds(3)); + while (!File.Exists(Path.Combine(childRoot, "ready"))) + { + if (readyLimit.Expired) throw new TimeoutException("Controlled root not ready."); + await Task.Delay(10, cancellationToken).ConfigureAwait(false); + } + } + if (id.Contains("timeout", StringComparison.Ordinal)) + { + completionAttempted = true; + Exception failure = await RejectAsync(async () => { await child.CompleteAsync(cancellationToken); }).ConfigureAwait(false); + Require(failure is TimeoutException or AggregateException && failure.Data["OwnedCleanupProof"] is OwnedCleanupProof { CanRelease: true, ResourcesClosed: true }, + "Timeout cleanup did not prove exact held root, empty job and both EOFs: " + failure); + return; + } + if (id == "inherited") + { + var exitLimit = new Deadline(TimeSpan.FromSeconds(2)); + OwnedChildObservation observation = child.Observe(); + while (!observation.RootExited) + { + if (exitLimit.Expired) throw new TimeoutException("Controlled root exit not observed."); + await Task.Delay(10, cancellationToken).ConfigureAwait(false); + observation = child.Observe(); + } + Require(observation.ActiveJobProcesses > 0 && !observation.StdoutCompleted && !observation.StderrCompleted, + "Actual inherited-open-pipe/job condition was not exercised."); + completionAttempted = true; + OwnedCleanupProof proof = await child.StopAsync().ConfigureAwait(false); + Require(proof is { CanRelease: true, ResourcesClosed: true }, "Inherited descendant cleanup unproved."); + return; + } + completionAttempted = true; + OwnedChildResult result = await child.CompleteAsync(cancellationToken).ConfigureAwait(false); + Require(result.Cleanup is { CanRelease: true, ResourcesClosed: true }, "Native cleanup proof incomplete."); + Require(result.ExitCode == (id == "abrupt" ? 23 : 0), "Controlled exit code changed."); + if (id == "arguments-environment") + { + using var document = JsonDocument.Parse(result.StandardOutput); + JsonElement record = document.RootElement; + Require(record.GetProperty("arguments").EnumerateArray().Select(p => p.GetString()).SequenceEqual(arguments) && + record.GetProperty("present").GetBoolean() && record.GetProperty("value").GetString() == "" && + record.GetProperty("absent").GetBoolean() && record.GetProperty("cwd").GetString() == childRoot, + "Native argv/empty-versus-absent environment/Unicode/cwd changed."); + } + else Require(result.StandardOutput.Contains("owned-capture-stdout \uD83D\uDE80", StringComparison.Ordinal) && + result.StandardError.Contains("owned-capture-stderr \u03BB", StringComparison.Ordinal), "Independent native stream content lost."); + } + catch (Exception primary) + { + if (!completionAttempted) await child.StopAsync(primary).ConfigureAwait(false); + throw; + } + } + + private static async Task RealPipeAsync(string root, string alpha, string beta, CancellationToken cancellationToken) + { + string name = "graphcode-compiled-owned-" + Guid.NewGuid().ToString("N"); + var child = OwnedChildSupervisor.Start(OwnedFixture.Command("protocol", root, name, alpha, beta), TimeSpan.FromSeconds(10), root); + Exception? primary = null; + try + { + await using var client = new NamedPipeClientStream(".", name, PipeDirection.InOut, PipeOptions.Asynchronous); + var deadline = new Deadline(TimeSpan.FromSeconds(5)); + await client.ConnectAsync(deadline.RemainingMilliseconds, cancellationToken).ConfigureAwait(false); + await PipeFrames.WriteAsync(client, "{\"version\":2,\"kind\":\"hello\",\"supportedVersions\":[1,2]}", deadline, 7, cancellationToken).ConfigureAwait(false); + using var hello = await PipeFrames.ReadAsync(client, deadline, cancellationToken).ConfigureAwait(false); + Require(hello!.RootElement.GetProperty("selectedVersion").GetInt32() == 2, "Real pipe v2 negotiation absent."); + await PipeFrames.WriteAsync(client, ListRequest().ToJsonString(), deadline, 7, cancellationToken).ConfigureAwait(false); + using var response = await PipeFrames.ReadAsync(client, deadline, cancellationToken).ConfigureAwait(false); + using var first = await PipeFrames.ReadAsync(client, deadline, cancellationToken).ConfigureAwait(false); + using var second = await PipeFrames.ReadAsync(client, deadline, cancellationToken).ConfigureAwait(false); + Require(response!.RootElement.GetProperty("requestID").GetString() == ListId && + first!.RootElement.GetProperty("sequence").GetInt32() == 1 && second!.RootElement.GetProperty("sequence").GetInt32() == 2 && + first.RootElement.GetProperty("event").GetProperty("graphChanged").GetProperty("project").GetProperty("path").GetString() == alpha && + second.RootElement.GetProperty("event").GetProperty("graphChanged").GetProperty("project").GetProperty("path").GetString() == beta, + "Real fragmented two-owner publication differed."); + await PipeFrames.WriteAsync(client, Request(alpha).ToJsonString(), deadline, 7, cancellationToken).ConfigureAwait(false); + using var renamed = await PipeFrames.ReadAsync(client, deadline, cancellationToken).ConfigureAwait(false); + using var publication = await PipeFrames.ReadAsync(client, deadline, cancellationToken).ConfigureAwait(false); + Require(renamed!.RootElement.GetProperty("requestID").GetString() == RenameId && + publication!.RootElement.GetProperty("sequence").GetInt32() == 3, "Real rename correlation or sequence lost."); + await PipeFrames.WriteAsync(client, Control(alpha, beta).ToJsonString(), deadline, 7, cancellationToken).ConfigureAwait(false); + using var interleaved = await PipeFrames.ReadAsync(client, deadline, cancellationToken).ConfigureAwait(false); + Require(interleaved!.RootElement.GetProperty("sequence").GetInt32() == 4 && + interleaved.RootElement.GetProperty("event").GetProperty("graphChanged").GetProperty("nodes")[0].GetProperty("title").GetString() == "Alpha interleaved", + "Real interleaved Alpha publication missing."); + } + catch (Exception failure) { primary = failure; } + try + { + OwnedChildResult result = await child.CompleteAsync(cancellationToken).ConfigureAwait(false); + Require(result.ExitCode == 0 && result.Cleanup.ResourcesClosed, "Real peer cleanup failed: " + result.StandardError); + } + catch (Exception cleanup) + { + if (primary == null) throw; + primary.Data["ProtocolCleanupFailure"] = cleanup; + if (cleanup.Data[OwnershipRetention.RequiredKey] is true) OwnershipRetention.Tag(primary, root); + } + if (primary != null) System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(primary).Throw(); + using var receipt = JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "peer.json"))); + Require(receipt.RootElement.GetProperty("requestCount").GetInt32() == 2 && + receipt.RootElement.GetProperty("responseCount").GetInt32() == 2 && + receipt.RootElement.GetProperty("publicationCount").GetInt32() == 4 && + receipt.RootElement.GetProperty("unansweredRequests").GetArrayLength() == 0, "Real peer receipt is incomplete."); + } + + private sealed class FragmentedReadStream(Stream source, int maximum) : Stream + { + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override int Read(byte[] buffer, int offset, int count) => source.Read(buffer, offset, Math.Min(count, maximum)); + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) => + source.ReadAsync(buffer[..Math.Min(maximum, buffer.Length)], cancellationToken); + public override void Flush() => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } + + private sealed class FakeCleanupAuthority : IOwnedCleanupAuthority + { + public string Identity => "controlled-fake-held-authority; nativeCalls=0"; + public bool OwnershipUnproven { get; set; } + public Task? StandardOutput { get; set; } = Task.FromResult("stdout"); + public Task? StandardError { get; set; } = Task.FromResult("stderr"); + public bool RootExited = true, JobEmpty = true, CloseFails, ConsumeDeadline, Retained; + public int Terminations, JobWaits, RootWaits, Closes, RootRemainingMilliseconds; + public Deadline? JobDeadline, RootDeadline; + public Exception? RetainedError; + public void Terminate() => Terminations++; + public async Task WaitForJobEmptyAsync(Deadline deadline) + { + JobWaits++; + JobDeadline = deadline; + if (ConsumeDeadline) await Task.Delay(75).ConfigureAwait(false); + return JobEmpty; + } + public Task WaitForRootExitAsync(Deadline deadline) + { + RootWaits++; + RootDeadline = deadline; + RootRemainingMilliseconds = deadline.RemainingMilliseconds; + return Task.FromResult(RootExited); + } + public IReadOnlyList CloseActions => + [ + () => { Closes++; if (CloseFails) throw new IOException("original first close failure"); }, + () => Closes++ + ]; + public void Retain(Exception failure) + { + Retained = true; + RetainedError = failure; + failure.Data[OwnershipRetention.RequiredKey] = true; + } + } +} diff --git a/Tools/windows/ValidationRunner/Ownership/UiaOwnedStreamCapture.cs b/Tools/windows/ValidationRunner/Ownership/UiaOwnedStreamCapture.cs new file mode 100644 index 00000000..0c28adb3 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/UiaOwnedStreamCapture.cs @@ -0,0 +1,41 @@ +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; + +namespace GraphCode.Validation.Ownership; + +public static class UiaOwnedStreamCapture +{ + public static void RequireJobMember(IntPtr process, IntPtr job) + { + if (!StandaloneProcessJob.Contains(job, process)) + throw new InvalidOperationException("Published UIA target is not a member of its owned launch job."); + } + + public static Task Read(StreamReader reader, string path) => Read(reader, path, null); + + public static Task Read(StreamReader reader, string path, IOwnedStreamObserver? observer) + { + ArgumentNullException.ThrowIfNull(reader); + var file = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.ReadWrite); + return Capture(reader, file, observer); + } + + private static async Task Capture(StreamReader reader, FileStream file, IOwnedStreamObserver? observer) + { + // AutoFlush preserves encoder state across a split surrogate; FlushAsync does not. + using var writer = new StreamWriter(file, new UTF8Encoding(false, true)) { AutoFlush = true }; + var text = new StringBuilder(); + var buffer = new char[4096]; + int count; + while ((count = await reader.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false)) > 0) + { + text.Append(buffer, 0, count); + await writer.WriteAsync(buffer, 0, count).ConfigureAwait(false); + observer?.OnText(new string(buffer, 0, count)); + } + observer?.OnEof(); + return text.ToString(); + } +} diff --git a/Tools/windows/ValidationRunner/Ownership/mixed-source-map.json b/Tools/windows/ValidationRunner/Ownership/mixed-source-map.json new file mode 100644 index 00000000..36100a8a --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/mixed-source-map.json @@ -0,0 +1,893 @@ +{ + "templates": [ + { + "sourceId": "line-1724", + "unitIds": [ + "ownership.mixed-ui.source-line-1724" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-1733", + "unitIds": [ + "ownership.mixed-ui.source-line-1733" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-1749", + "unitIds": [ + "ownership.mixed-ui.source-line-1749" + ], + "expectedCount": 6 + }, + { + "sourceId": "line-1757", + "unitIds": [ + "ownership.mixed-ui.source-line-1757" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-1769", + "unitIds": [ + "ownership.mixed-ui.source-line-1769" + ], + "expectedCount": 4 + }, + { + "sourceId": "line-1777", + "unitIds": [ + "ownership.mixed-ui.source-line-1777" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-1789", + "unitIds": [ + "ownership.mixed-ui.source-line-1789" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-1804", + "unitIds": [ + "ownership.mixed-ui.source-line-1804" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-1811", + "unitIds": [ + "ownership.mixed-ui.source-line-1811" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-1818", + "unitIds": [ + "ownership.mixed-ui.source-line-1818" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-1824", + "unitIds": [ + "ownership.mixed-ui.source-line-1824" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-1837", + "unitIds": [ + "ownership.mixed-ui.source-line-1837" + ], + "expectedCount": 5 + }, + { + "sourceId": "line-1843", + "unitIds": [ + "ownership.mixed-ui.source-line-1843" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-1854", + "unitIds": [ + "ownership.mixed-ui.source-line-1854" + ], + "expectedCount": 4 + }, + { + "sourceId": "line-1870", + "unitIds": [ + "ownership.mixed-ui.source-line-1870" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-1879", + "unitIds": [ + "ownership.mixed-ui.source-line-1879" + ], + "expectedCount": 8 + }, + { + "sourceId": "line-1917", + "unitIds": [ + "ownership.mixed-ui.source-line-1917" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-1924", + "unitIds": [ + "ownership.mixed-ui.source-line-1924" + ], + "expectedCount": 4 + }, + { + "sourceId": "line-1951", + "unitIds": [ + "ownership.mixed-ui.source-line-1951" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2060", + "unitIds": [ + "ownership.mixed-startup.source-line-2060" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2068", + "unitIds": [ + "ownership.mixed-startup.source-line-2068" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2074", + "unitIds": [ + "ownership.mixed-startup.source-line-2074" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2083", + "unitIds": [ + "ownership.mixed-startup.source-line-2083" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2101", + "unitIds": [ + "ownership.mixed-startup.source-line-2101" + ], + "expectedCount": 14 + }, + { + "sourceId": "line-2109", + "unitIds": [ + "ownership.mixed-startup.source-line-2109" + ], + "expectedCount": 6 + }, + { + "sourceId": "line-2126", + "unitIds": [ + "ownership.mixed-startup.source-line-2126" + ], + "expectedCount": 4 + }, + { + "sourceId": "line-2137", + "unitIds": [ + "ownership.mixed-startup.source-line-2137" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2143", + "unitIds": [ + "ownership.mixed-startup.source-line-2143" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2149", + "unitIds": [ + "ownership.mixed-startup.source-line-2149" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2165", + "unitIds": [ + "ownership.mixed-startup.source-line-2165" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2173", + "unitIds": [ + "ownership.mixed-startup.source-line-2173" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2193", + "unitIds": [ + "ownership.mixed-stream.source-line-2193" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2221", + "unitIds": [ + "ownership.mixed-stream.source-line-2221" + ], + "expectedCount": 7 + }, + { + "sourceId": "line-2305", + "unitIds": [ + "ownership.mixed-stream.source-line-2305" + ], + "expectedCount": 3 + }, + { + "sourceId": "line-2342", + "unitIds": [ + "ownership.mixed-stream.source-line-2342" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2359", + "unitIds": [ + "ownership.mixed-stream.source-line-2359" + ], + "expectedCount": 6 + }, + { + "sourceId": "line-2411", + "unitIds": [ + "ownership.mixed-receipt.source-line-2411" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2439", + "unitIds": [ + "ownership.mixed-receipt.source-line-2439" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2445", + "unitIds": [ + "ownership.mixed-receipt.source-line-2445" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2460", + "unitIds": [ + "ownership.mixed-receipt.source-line-2460" + ], + "expectedCount": 51 + }, + { + "sourceId": "line-2520", + "unitIds": [ + "ownership.mixed-receipt.source-line-2520" + ], + "expectedCount": 11 + }, + { + "sourceId": "line-2706", + "unitIds": [ + "ownership.mixed-ui.source-line-2706" + ], + "expectedCount": 3 + }, + { + "sourceId": "line-2724", + "unitIds": [ + "ownership.mixed-ui.source-line-2724" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2732", + "unitIds": [ + "ownership.mixed-ui.source-line-2732" + ], + "expectedCount": 2 + }, + { + "sourceId": "line-2742", + "unitIds": [ + "ownership.mixed-ui.source-line-2742" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2751", + "unitIds": [ + "ownership.mixed-ui.source-line-2751" + ], + "expectedCount": 10 + }, + { + "sourceId": "line-2785", + "unitIds": [ + "ownership.mixed-ui.source-line-2785" + ], + "expectedCount": 8 + }, + { + "sourceId": "line-2808", + "unitIds": [ + "ownership.mixed-ui.source-line-2808" + ], + "expectedCount": 22 + }, + { + "sourceId": "line-2845", + "unitIds": [ + "ownership.mixed-ui.source-line-2845" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2850", + "unitIds": [ + "ownership.mixed-ui.source-line-2850" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2857", + "unitIds": [ + "ownership.mixed-ui.source-line-2857" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2871", + "unitIds": [ + "ownership.mixed-ui.source-line-2871" + ], + "expectedCount": 2 + }, + { + "sourceId": "line-2897", + "unitIds": [ + "ownership.mixed-ui.source-line-2897" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2906", + "unitIds": [ + "ownership.mixed-ui.source-line-2906" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2924", + "unitIds": [ + "ownership.mixed-ui.source-line-2924" + ], + "expectedCount": 59 + }, + { + "sourceId": "line-3013", + "unitIds": [ + "ownership.mixed-ui.source-line-3013" + ], + "expectedCount": 2 + }, + { + "sourceId": "line-3024", + "unitIds": [ + "ownership.mixed-ui.source-line-3024" + ], + "expectedCount": 3 + }, + { + "sourceId": "line-3040", + "unitIds": [ + "ownership.mixed-ui.source-line-3040" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3057", + "unitIds": [ + "ownership.mixed-ui.source-line-3057" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3129", + "unitIds": [ + "ownership.mixed-ui.source-line-3129" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3138", + "unitIds": [ + "ownership.mixed-ui.source-line-3138" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3154", + "unitIds": [ + "ownership.mixed-ui.source-line-3154" + ], + "expectedCount": 29 + }, + { + "sourceId": "line-3202", + "unitIds": [ + "ownership.mixed-ui.source-line-3202" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3214", + "unitIds": [ + "ownership.mixed-ui.source-line-3214" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3224", + "unitIds": [ + "ownership.mixed-ui.source-line-3224" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3237", + "unitIds": [ + "ownership.mixed-ui.source-line-3237" + ], + "expectedCount": 3 + }, + { + "sourceId": "line-3268", + "unitIds": [ + "ownership.mixed-ui.source-line-3268" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3278", + "unitIds": [ + "ownership.mixed-ui.source-line-3278" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3283", + "unitIds": [ + "ownership.mixed-ui.source-line-3283" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3291", + "unitIds": [ + "ownership.mixed-ui.source-line-3291" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3296", + "unitIds": [ + "ownership.mixed-ui.source-line-3296" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3314", + "unitIds": [ + "ownership.mixed-ui.source-line-3314" + ], + "expectedCount": 8 + }, + { + "sourceId": "line-3331", + "unitIds": [ + "ownership.mixed-ui.source-line-3331" + ], + "expectedCount": 7 + }, + { + "sourceId": "line-3341", + "unitIds": [ + "ownership.mixed-ui.source-line-3341" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3353", + "unitIds": [ + "ownership.mixed-protocol.source-line-3353" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3368", + "unitIds": [ + "ownership.mixed-protocol.source-line-3368" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3373", + "unitIds": [ + "ownership.mixed-protocol.source-line-3373" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3383", + "unitIds": [ + "ownership.mixed-protocol.source-line-3383" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3392", + "unitIds": [ + "ownership.mixed-ui.source-line-3392" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3423", + "unitIds": [ + "ownership.mixed-ui.source-line-3423" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3443", + "unitIds": [ + "ownership.mixed-ui.source-line-3443" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3458", + "unitIds": [ + "ownership.mixed-ui.source-line-3458" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3469", + "unitIds": [ + "ownership.mixed-ui.source-line-3469" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3482", + "unitIds": [ + "ownership.mixed-ui.source-line-3482" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3487", + "unitIds": [ + "ownership.mixed-ui.source-line-3487" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3497", + "unitIds": [ + "ownership.mixed-ui.source-line-3497" + ], + "expectedCount": 17 + }, + { + "sourceId": "line-3524", + "unitIds": [ + "ownership.mixed-ui.source-line-3524" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3530", + "unitIds": [ + "ownership.mixed-ui.source-line-3530" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3535", + "unitIds": [ + "ownership.mixed-ui.source-line-3535" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3540", + "unitIds": [ + "ownership.mixed-ui.source-line-3540" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3545", + "unitIds": [ + "ownership.mixed-ui.source-line-3545" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3550", + "unitIds": [ + "ownership.mixed-ui.source-line-3550" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3555", + "unitIds": [ + "ownership.mixed-ui.source-line-3555" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3560", + "unitIds": [ + "ownership.mixed-ui.source-line-3560" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3565", + "unitIds": [ + "ownership.mixed-ui.source-line-3565" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3570", + "unitIds": [ + "ownership.mixed-ui.source-line-3570" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3575", + "unitIds": [ + "ownership.mixed-ui.source-line-3575" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3596", + "unitIds": [ + "ownership.mixed-ui.source-line-3596" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3601", + "unitIds": [ + "ownership.mixed-ui.source-line-3601" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3608", + "unitIds": [ + "ownership.mixed-ui.source-line-3608" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3625", + "unitIds": [ + "ownership.mixed-ui.source-line-3625" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3635", + "unitIds": [ + "ownership.mixed-ui.source-line-3635" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3732", + "unitIds": [ + "ownership.mixed-ui.source-line-3732" + ], + "expectedCount": 8 + }, + { + "sourceId": "line-3755", + "unitIds": [ + "ownership.mixed-ui.source-line-3755" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3763", + "unitIds": [ + "ownership.mixed-ui.source-line-3763" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3771", + "unitIds": [ + "ownership.mixed-ui.source-line-3771" + ], + "expectedCount": 9 + }, + { + "sourceId": "line-3792", + "unitIds": [ + "ownership.mixed-ui.source-line-3792" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3816", + "unitIds": [ + "ownership.mixed-ui.source-line-3816" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3846", + "unitIds": [ + "ownership.mixed-ui.source-line-3846" + ], + "expectedCount": 11 + }, + { + "sourceId": "line-3889", + "unitIds": [ + "ownership.mixed-protocol.source-line-3889" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3894", + "unitIds": [ + "ownership.mixed-protocol.source-line-3894" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3897", + "unitIds": [ + "ownership.mixed-protocol.source-line-3897" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3908", + "unitIds": [ + "ownership.mixed-protocol.source-line-3908" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3930", + "unitIds": [ + "ownership.mixed-protocol.source-line-3930" + ], + "expectedCount": 9 + }, + { + "sourceId": "line-3941", + "unitIds": [ + "ownership.mixed-protocol.source-line-3941" + ], + "expectedCount": 3 + }, + { + "sourceId": "line-3943", + "unitIds": [ + "ownership.mixed-protocol.source-line-3943" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3948", + "unitIds": [ + "ownership.mixed-protocol.source-line-3948" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3954", + "unitIds": [ + "ownership.mixed-protocol.source-line-3954" + ], + "expectedCount": 6 + }, + { + "sourceId": "line-3973", + "unitIds": [ + "ownership.mixed-protocol.source-line-3973" + ], + "expectedCount": 4 + }, + { + "sourceId": "line-3988", + "unitIds": [ + "ownership.mixed-protocol.source-line-3988" + ], + "expectedCount": 10 + }, + { + "sourceId": "line-4053", + "unitIds": [ + "ownership.mixed-pipe.source-line-4053" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-4056", + "unitIds": [ + "ownership.mixed-pipe.source-line-4056" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-4063", + "unitIds": [ + "ownership.mixed-pipe.source-line-4063" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-4072", + "unitIds": [ + "ownership.mixed-pipe.source-line-4072" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-4093", + "unitIds": [ + "ownership.mixed-pipe.source-line-4093" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-4152", + "unitIds": [ + "ownership.mixed-capture.normal", + "ownership.mixed-capture.stop", + "ownership.mixed-capture.inherited", + "ownership.mixed-capture.abrupt", + "ownership.mixed-capture.timeout", + "ownership.mixed-capture.inherited-timeout", + "ownership.mixed-capture.primary-secondary", + "ownership.mixed-capture.cleanup-only" + ], + "expectedCount": 8 + } + ] +} diff --git a/Tools/windows/ValidationRunner/Ownership/source-correspondence.json b/Tools/windows/ValidationRunner/Ownership/source-correspondence.json new file mode 100644 index 00000000..425ff872 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/source-correspondence.json @@ -0,0 +1,894 @@ +{ + "schemaVersion": 1, + "sourceManifestSha256": "CC8ED5141AE38CB425F4E41AB7F2E4EF1F07F5AE4CAD7FA969A68645B07A0A2F", + "rootSourceSha256": "202D260A846670E314163F9D2565D07B1E0AD27A4D61F41EA99D67624FF6C869", + "inventoryOnly": true, + "executedCount": 0, + "mixedSourceInvocationTemplates": 126, + "mixedSourceAssertionCommands": 192, + "mandatoryFamily": "ownership.legacy.mixed-multiproject", + "templates": [ + { + "id": "ownership.legacy.mixed.source-line-1724", + "line": 1724, + "template": "\"DPI192 narrow New Tab overflow still proves both mounted stable tabs\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-1733", + "line": 1733, + "template": "\"wide New Tab roster proves the same mounted stable tabs without overflow\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-1749", + "line": 1749, + "template": "\"mounted tab refuses permanent $($case.name)\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-1757", + "line": 1757, + "template": "\"selection event accepts exactly one stable owned source\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-1769", + "line": 1769, + "template": "\"selection event refuses $($case.name)\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-1777", + "line": 1777, + "template": "\"compact overview narrow client retains the Mac lane and requires real pan\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-1789", + "line": 1789, + "template": "\"compact overview wide client and DPI preserve exact geometry\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-1804", + "line": 1804, + "template": "\"compact overview fitted rounding retains source geometry\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-1811", + "line": 1811, + "template": "\"compact overview rejects obsolete 220x86 card geometry\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-1818", + "line": 1818, + "template": "\"compact overview rejects the obsolete 760-pixel lane floor\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-1824", + "line": 1824, + "template": "\"dynamic Rename title uses the exact fixture title\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-1837", + "line": 1837, + "template": "(\"dynamic Rename title rejects \" + ($titleMutation -replace \"`n\",\" newline \"))", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-1843", + "line": 1843, + "template": "\"bounded Rename title pattern accepts one unavailable-fixture title\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-1854", + "line": 1854, + "template": "(\"bounded Rename title pattern rejects malformed case \" + $titleMutation.Length)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-1870", + "line": 1870, + "template": "\"Delete custom sheet accepts exact owned captionless identity\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-1879", + "line": 1879, + "template": "(\"Delete custom sheet rejects \" + $identityMutation)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-1917", + "line": 1917, + "template": "\"update banner click accepts owned foreground target after Later\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-1924", + "line": 1924, + "template": "(\"update banner click rejects wrong \" + $targetMutation)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-1951", + "line": 1951, + "template": "\"actual caption Open assignment consumes the exported caption snapshot\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2060", + "line": 2060, + "template": "\"canonical owned startup event retained without changing primary\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2068", + "line": 2068, + "template": "\"canonical startup event absence is explicitly unknown\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2074", + "line": 2074, + "template": "\"owned canonical event missing module fields retains explicit unavailability\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2083", + "line": 2083, + "template": "\"actual query no-match error remains unknown while startup primary survives\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2101", + "line": 2101, + "template": "(\"startup event refuses \" + $mutation)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2109", + "line": 2109, + "template": "(\"startup query refuses invalid held \" + $mutation)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2126", + "line": 2126, + "template": "(\"startup retains typed secondary writer failures \" + $faults)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2137", + "line": 2137, + "template": "\"startup refuses event transport exceeding eight results\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2143", + "line": 2143, + "template": "\"startup refuses duplicate actual record identity\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2149", + "line": 2149, + "template": "\"startup default transport refuses non-hosted execution without event query\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2165", + "line": 2165, + "template": "\"startup query escapes exact executable XPath and XML\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2173", + "line": 2173, + "template": "\"actual first startup failure branch invokes diagnostic once\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2193", + "line": 2193, + "template": "\"actual new caller streams all five records before unchanged fifth guard\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2221", + "line": 2221, + "template": "(\"streamed actual Edge-TypeText transport \" + $scenario)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2305", + "line": 2305, + "template": "(\"stream preserves primary and explicit sink diagnostics \" + $failureMode)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2342", + "line": 2342, + "template": "\"stream rethrows identical primary exception object\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2359", + "line": 2359, + "template": "(\"production stream catch preserves primary through \" + $faults)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2411", + "line": 2411, + "template": "\"complete actual accepted whole peer receipt preserves bytes and all state\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2439", + "line": 2439, + "template": "\"whole peer receipt accepts a correlated nonmutating session open\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2445", + "line": 2445, + "template": "\"whole peer receipt rejects a session open for a foreign owner's node\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2460", + "line": 2460, + "template": "(\"complete peer receipt rejects \" + $mutation)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2520", + "line": 2520, + "template": "(\"retained complete receipt reader rejects \" + $mutation)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2706", + "line": 2706, + "template": "(\"single sequenced entry exact target \" + $expected.Length + \":\" + $expected.Trim())", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2724", + "line": 2724, + "template": "\"prompt viewport resolved edit preserves actual FocusMouse ownership guard\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2732", + "line": 2732, + "template": "(\"prompt viewport actual FocusMouse refuses \" + $viewportMiss)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2742", + "line": 2742, + "template": "\"already owned GUI edit focus uses zero focus input\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2751", + "line": 2751, + "template": "(\"bounded focus and pre-input phase rejects \" + $mutation)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2785", + "line": 2785, + "template": "(\"actual production FocusMouse refuses late \" + $mutation)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2808", + "line": 2808, + "template": "(\"single sequenced edit rejects \" + $mutation)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2845", + "line": 2845, + "template": "\"new sequenced native declaration compile only\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2850", + "line": 2850, + "template": "\"sequenced entry rejects unknown empty expected prefill before any input\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2857", + "line": 2857, + "template": "\"sequenced refusal diagnostic failure preserves exact primary\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2871", + "line": 2871, + "template": "(\"actual new Rename caller uses native \" + $(if ($cancelRoute) { \"Cancel9808\" } else { \"OK9800\" }))", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2897", + "line": 2897, + "template": "\"Rename clips actual button to modal client and work rectangles\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2906", + "line": 2906, + "template": "\"Rename uses measured uncovered point without reading covering captions\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2924", + "line": 2924, + "template": "(\"Rename native guard rejects \" + $mutation)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3013", + "line": 3013, + "template": "(\"Rename actual input receipt rejects \" + $mutation)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3024", + "line": 3024, + "template": "(\"production Rename Click rejects final \" + $mutation)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3040", + "line": 3040, + "template": "\"Rename stale native read retains exact error and unavailable actual fields\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3057", + "line": 3057, + "template": "\"Rename production native bindings compile without native invocation\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3129", + "line": 3129, + "template": "\"fresh cached capture verifies owned metadata before and after content\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3138", + "line": 3138, + "template": "\"fresh snapshot distinguishes unsupported property from zero default\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3154", + "line": 3154, + "template": "(\"fresh capture rejects \" + $mutation)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3202", + "line": 3202, + "template": "\"unknown cache error is not converted to retryable metadata\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3214", + "line": 3214, + "template": "\"retired snapshot retains typed availability error before content\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3224", + "line": 3224, + "template": "\"other COM failure is not a retirement-shaped fallback\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3237", + "line": 3237, + "template": "(\"whole observation reacquisition \" + $scenario)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3268", + "line": 3268, + "template": "\"whole capture collection rejects later provider runtime replacement\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3278", + "line": 3278, + "template": "\"guarded prefix handles null without pretending metadata is ready\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3283", + "line": 3283, + "template": "\"owned available semantic metadata reads PID first and again\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3291", + "line": 3291, + "template": "\"owned source-supported static chrome is classified explicitly\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3296", + "line": 3296, + "template": "\"owned restart session chrome remains in the complete workspace metadata batch\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3314", + "line": 3314, + "template": "(\"restart metadata rejects \" + $case.name)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3331", + "line": 3331, + "template": "(\"metadata batch rejects \" + $case.name)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3341", + "line": 3341, + "template": "\"foreign semantic fragment stays counted and fails strict roster\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3353", + "line": 3353, + "template": "\"two exact positive owner publications\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3368", + "line": 3368, + "template": "\"whole typed correlated rename receipt\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3373", + "line": 3373, + "template": "\"one-shot interleaved event with synthetic selection\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3383", + "line": 3383, + "template": "\"unchanged title still dispatches\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3392", + "line": 3392, + "template": "\"hash identity is owner and surface scoped\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3423", + "line": 3423, + "template": "\"retained hosted identities with current compact geometry\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3443", + "line": 3443, + "template": "\"source summary identity independent fixture and hosted goldens\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3458", + "line": 3458, + "template": "\"workspace marker independent prefix and row-key goldens\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3469", + "line": 3469, + "template": "\"unknown automation kind rejected\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3482", + "line": 3482, + "template": "\"workspace projection requires both positively owned source markers\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3487", + "line": 3487, + "template": "\"classified restart chrome alone cannot prove workspace owner identity\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3497", + "line": 3497, + "template": "(\"workspace projection rejects \" + $mutation)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3524", + "line": 3524, + "template": "\"project request rejects actual workspace chrome\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3530", + "line": 3530, + "template": "\"matching project surface selects its parent and selected loop\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3535", + "line": 3535, + "template": "\"other project owner stays unselected on project surface\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3540", + "line": 3540, + "template": "\"workspace keeps selected loop without parent-project selection\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3545", + "line": 3545, + "template": "\"overview keeps selected loop without parent-project selection\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3550", + "line": 3550, + "template": "\"Quick Chats keeps selected loop without parent-project selection\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3555", + "line": 3555, + "template": "\"workspace rejects selected parent project row\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3560", + "line": 3560, + "template": "\"project surface rejects missing selected parent row\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3565", + "line": 3565, + "template": "\"overview rejects stale selected parent project row\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3570", + "line": 3570, + "template": "\"Quick Chats rejects stale selected parent project row\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3575", + "line": 3575, + "template": "\"workspace rejects loss of selected loop owner\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3596", + "line": 3596, + "template": "\"unfiltered ordinary overview roster exactly two owners and cards\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3601", + "line": 3601, + "template": "\"compact overview roster rejects obsolete card dimensions\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3608", + "line": 3608, + "template": "\"compact overview roster rejects obsolete narrow caption position\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3625", + "line": 3625, + "template": "\"fresh owned content cannot bless an overview missing its peer card\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3635", + "line": 3635, + "template": "\"project roster retains two owners and exactly one selected project card\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3732", + "line": 3732, + "template": "(\"unfiltered roster rejects \" + $mutation)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3755", + "line": 3755, + "template": "\"project caption classification keeps full four distinct from two nodes\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3763", + "line": 3763, + "template": "\"source project caption geometry is separate from node geometry\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3771", + "line": 3771, + "template": "(\"classified roster rejects \" + $mutation)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3792", + "line": 3792, + "template": "\"literal lane geometry and DPI scaling\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3816", + "line": 3816, + "template": "\"DPI192 fitted destinations retain shared source lane geometry\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3846", + "line": 3846, + "template": "(\"DPI192 normalized lane rejects \" + $mutation)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3889", + "line": 3889, + "template": "\"live target clipped to viewport\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3894", + "line": 3894, + "template": "\"offscreen target rejected\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3897", + "line": 3897, + "template": "\"resumeSession acknowledges an exact fixture owner without changing its graph\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3908", + "line": 3908, + "template": "\"resumeSession refuses a node belonging to another project\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3930", + "line": 3930, + "template": "(\"request \" + $entry.Key)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3941", + "line": 3941, + "template": "(\"JSON \" + $json)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3943", + "line": 3943, + "template": "\"duplicate request\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3948", + "line": 3948, + "template": "\"unanswered publication\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3954", + "line": 3954, + "template": "(\"control \" + $mutation)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3973", + "line": 3973, + "template": "(\"response \" + $mutation)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3988", + "line": 3988, + "template": "(\"receipt rejects \" + $mutation)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-4053", + "line": 4053, + "template": "\"owned real pipe hello\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-4056", + "line": 4056, + "template": "\"fragmented real two-owner frames\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-4063", + "line": 4063, + "template": "\"real rename application response publication\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-4072", + "line": 4072, + "template": "\"idle real pipe publication control\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-4093", + "line": 4093, + "template": "\"default fixture unchanged over real pipe\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-4152", + "line": 4152, + "template": "(\"bounded capture \" + $mode)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + } + ] +} diff --git a/Tools/windows/ValidationRunner/Program.cs b/Tools/windows/ValidationRunner/Program.cs new file mode 100644 index 00000000..774fb885 --- /dev/null +++ b/Tools/windows/ValidationRunner/Program.cs @@ -0,0 +1,108 @@ +using System.Reflection; +using System.IO; +using System.Text.Json; +using GraphCode.Validation; +using GraphCode.Validation.Host; +using GraphCode.Validation.Ownership; +using GraphCode.Validation.Adapters; + +try { return await RunAsync(args); } +finally { await TerminalSafety.PreserveRetainedAuthorityAsync(OwnedChildSupervisor.RetainedLeaseCount); } + +static async Task RunAsync(string[] args) +{ + if (HostFixture.TryRun(args) is int hostFixtureExit) return hostFixtureExit; + if (await OwnedFixture.TryRunAsync(args) is int fixtureExit) return fixtureExit; + if (args.SequenceEqual(new[] { "--self-test" })) return HostSelfTests.Run(); + + string? repositoryRoot = null; + string? artifactRoot = null; + var selectedSuites = new HashSet(StringComparer.Ordinal); + for (var index = 0; index < args.Length; index++) + { + if (index + 1 >= args.Length) throw new ArgumentException($"Missing value for {args[index]}"); + var value = args[++index]; + switch (args[index - 1]) + { + case "--repository-root": repositoryRoot = Path.GetFullPath(value); break; + case "--artifact-root": artifactRoot = Path.GetFullPath(value); break; + case "--suite": + if (!selectedSuites.Add(value)) throw new ArgumentException($"Duplicate suite selector: {value}"); + break; + default: throw new ArgumentException($"Unknown argument: {args[index - 1]}"); + } + } + if (repositoryRoot is null || artifactRoot is null) + throw new ArgumentException("--repository-root and --artifact-root are required"); + if (!File.Exists(Path.Combine(repositoryRoot, "Tools", "windows", "validate.ps1"))) + throw new ArgumentException("Repository root must contain Tools\\windows\\validate.ps1"); + Directory.CreateDirectory(artifactRoot); + var results = new List(); + var suites = new List(); + var errors = new List(); + IReadOnlyList required = []; + try + { + var sourceDirectory = Path.Combine(repositoryRoot, "Tools", "windows", "ValidationRunner"); + required = Coverage.Load(Path.Combine(sourceDirectory, "Manifests")); + suites.Add(HostSelfTests.CreateSuite()); + suites.AddRange(StandaloneSuites.Create(required)); + suites.Add(new ProviderCacheSuite()); + foreach (var name in new[] + { + "GraphCode.Validation.Ui.SuiteRegistration", "GraphCode.Validation.Ownership.SuiteRegistration", + "GraphCode.Validation.Host.RootContracts.SuiteRegistration", + "GraphCode.Validation.Host.RootContracts.ResidualScopeRegistration" + }) + { + var type = Assembly.GetExecutingAssembly().GetType(name); + if (type is null) continue; + var method = type.GetMethod("CreateSuites", BindingFlags.Public | BindingFlags.Static) + ?? throw new InvalidDataException($"{name}.CreateSuites is missing"); + if (method.Invoke(null, null) is not IReadOnlyList registered) + throw new InvalidDataException($"{name}.CreateSuites has an invalid return type"); + suites.AddRange(registered); + } + foreach (var selection in selectedSuites) + if (suites.All(suite => suite.Id != selection)) + throw new ArgumentException($"No registered suite: {selection}"); + 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); + foreach (var suite in suites.Where(suite => selectedSuites.Count == 0 || selectedSuites.Contains(suite.Id))) + { + try + { + var observations = await suite.RunAsync(context, CancellationToken.None); + Coverage.ValidateProvenance(suite, observations); + results.AddRange(observations); + if (observations.Any(result => result.Status == UnitStatus.Failed)) + { + errors.Add($"{suite.Id}: a contract failed; subsequent suites were not executed"); + break; + } + } + catch (Exception error) + { + errors.Add($"{suite.Id}: {error}"); + Console.Error.WriteLine(ConsoleReport.SuiteFailure(suite.Id, error.GetType().FullName ?? error.GetType().Name)); + break; + } + } + } + catch (Exception error) { errors.Add(error.ToString()); } + var report = Coverage.Evaluate(required, suites, results, errors, + Path.Combine(repositoryRoot, "Tools", "windows", "ValidationRunner")); + report = Coverage.ForInvocation(report, selectedSuites); + var reportPath = Path.Combine(artifactRoot, "coverage.json"); + await File.WriteAllTextAsync(reportPath, JsonSerializer.Serialize(report, Coverage.JsonOptions)); + foreach (var unit in report.Units) + Console.WriteLine(ConsoleReport.Unit(unit)); + for (var index = 0; index < report.Errors.Count; index++) Console.Error.WriteLine(ConsoleReport.Error(index + 1)); + Console.WriteLine($"COVERAGE required={report.RequiredCount} passed={report.PassedCount} actualExecuted={report.ActualExecutedCount} derivedProofs={report.DerivedProofCount} complete={report.Complete} report={JsonSerializer.Serialize(reportPath)}"); + if (!report.Complete) return 1; + Console.WriteLine("CompiledValidationRunner: PASS"); + Console.WriteLine("VALIDATION_RUNNER_EXECUTED=1"); + return 0; +} diff --git a/Tools/windows/ValidationRunner/README.md b/Tools/windows/ValidationRunner/README.md new file mode 100644 index 00000000..2c7eed1c --- /dev/null +++ b/Tools/windows/ValidationRunner/README.md @@ -0,0 +1,90 @@ +# Compiled Windows validation runner + +The canonical validation contract is a normal C# console program, not +`Tests\ValidationRunner.Tests.ps1`. The old monolith is never an executable +payload of this runner. + +## Build and run + +Use the repository Windows bootstrap and its process-local environment first. +The SDK is pinned by this directory's `global.json`. SDK selection depends on +the command's working directory, not on the project path: + +```powershell +Push-Location Tools\windows\ValidationRunner +try { + if ((dotnet --version) -cne "10.0.403") { throw "Pinned SDK 10.0.403 is required" } + dotnet build -c Release + if ($LASTEXITCODE -ne 0) { throw "Compiled validation build failed" } +} finally { Pop-Location } + +& .\Tools\windows\ValidationRunner\bin\Release\net10.0-windows\ValidationRunner.exe ` + --repository-root $PWD.Path --artifact-root .ci-validation-runner +if ($LASTEXITCODE -ne 0) { throw "Compiled validation was not complete" } +``` + +The host requires .NET 10 and Windows Desktop runtime support. Shared production +bindings target `net8.0-windows`; ordinary PowerShell production consumers use +the same file-backed assembly on PowerShell 7.4 or newer. Exclusive-directory +and controlled test fixtures have separate `netstandard2.1` assemblies. Fakes +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. + +## Proof and diagnostic modes + +A successful full invocation exits zero, writes `coverage.json`, and emits +exactly one each of: + +```text +CompiledValidationRunner: PASS +VALIDATION_RUNNER_EXECUTED=1 +``` + +Consumers must also require `complete=true`, `fullInvocation=true`, a positive `requiredCount`, and +`passedCount == requiredCount`. Exit zero alone is insufficient: +`--self-test` and the explicitly controlled fixture modes are not full runs. + +Repeat `--suite exact.suite.id` to run a bounded set in one process. Mandatory +unselected units remain incomplete, and a selected invocation cannot emit full +success markers even if it happened to select every unit. These reports are useful for debugging, +but a union of separate reports is not a full invocation. + +The three required manifests are read from the current repository, independently +of executor registration. Missing, duplicate, unknown, skipped, unimplemented, +empty, or insufficient-count units cannot produce a complete report. A suite +may return evidence only for its own declared IDs. + +The mixed-family composite checks all 126 original source templates against +their independently declared dependencies and exact expanded counts. It derives +proof only from validated results of the current invocation. Its derived +465-case proof is **not** counted again in `actualExecutedCount`; +`derivedProofCount` reports derived proofs separately. + +## Production and process boundaries + +Production C# files are linked into `WindowsValidationBindings.csproj` once. +UIA, packaging contracts, shell ownership, and managed/native tests consume those +same implementations. The loader verifies the exact file/type, worktree, +current source and compiler-reference fingerprints, and loaded module identity. +After changing a compiled input, rebuild; do not reuse an old loaded type. + +Named PowerShell adapters retain actual PowerShell scope, mocks, environment +presence, ErrorRecord identity, streams, and exit-code behavior. They do not +extract or execute old monolith functions. Child output is retained in owned +artifacts; root console summaries do not forward arbitrary child text. +The capture suite alone admits its exact path-bound, deliberately injected +negative-control diagnostics, while still requiring all 182 cases and the +14 named groups. + +Each host-supervised child starts suspended in a held, non-breakaway job before it can execute. +Original monotonic deadlines, independent stream EOF, primary errors, live +retention signals, and original authority govern cleanup. Raw protocol bytes +are preserved before strict decoding. An actual UNKNOWN ownership result +retains the original resources and prevents host exit, including when reporting +fails. It is not a reason to close a job or terminate an inferred PID. + +Native rendezvous fixtures use exclusive short scratch directories on the +repository's volume. Receipts point to retained scratch and stream evidence; +the long worktree or report path is not substituted for the native path budget. diff --git a/Tools/windows/ValidationRunner/Ui/ActivationCases.cs b/Tools/windows/ValidationRunner/Ui/ActivationCases.cs new file mode 100644 index 00000000..483bb356 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/ActivationCases.cs @@ -0,0 +1,58 @@ +using System; +using GraphCode.Validation.Ui.Fixtures; +using GraphCode.Windows; + +namespace GraphCode.Validation.Ui; + +public static class ActivationCases +{ + public static void ForegroundRefused() => ObserveAcquisition(false); + public static void ForegroundAcquired() => ObserveAcquisition(true); + + private static void ObserveAcquisition(bool acquire) + { + var probe = new UiaWindowActivationFixture(); + probe.Reset(acquire, true); + bool observed = probe.ActivateWindow(new IntPtr(10)); + Require(probe.GlobalKeys == 0 && probe.Attachments == 0, + "UIA_ACTIVATION_RED: actual activation emitted global key events or input-queue attachments"); + Require(observed == acquire, "UIA activation did not verify acquired foreground"); + string expectedCalls = "pid,owner,restore,bring,set,foreground" + (acquire ? ",pid,owner" : ""); + string expectedDiagnostic = acquire + ? "BringWindowToTop=True GetLastError=0 SetForegroundWindow=True GetLastError=0 observedOwnedForeground=True; no synthetic keys or input-queue attachment" + : "BringWindowToTop=False GetLastError=1400 SetForegroundWindow=False GetLastError=5 observedOwnedForeground=False; no synthetic keys or input-queue attachment"; + Require(probe.Calls == expectedCalls && probe.Mutations == 3 && + probe.OwnershipChecks == (acquire ? 2 : 1) && probe.LastActivationDiagnostic == expectedDiagnostic, + "UIA activation changed ownership/restore ordering or native return/error diagnostics"); + Require(typeof(IUiaWindowActivationOperations).Assembly == + probe.GetType().GetInterface(typeof(IUiaWindowActivationOperations).FullName!)!.Assembly, + "Activation fixture does not implement the current production component interface"); + } + + public static void OwnershipRefused() + { + var probe = new UiaWindowActivationFixture(); + probe.Reset(true, false); + Require(!probe.ActivateWindow(new IntPtr(10)) && probe.Mutations == 0 && + probe.Calls == "pid,owner" && probe.OwnershipChecks == 1 && + probe.GlobalKeys == 0 && probe.Attachments == 0 && + probe.LastActivationDiagnostic == "invalid target HWND or no retained live launch identity", + "UIA activation changed a window without retained process authority"); + } + + public static void InvalidWindowRefused() + { + var probe = new UiaWindowActivationFixture(); + probe.Reset(true, true); + Require(!probe.ActivateWindow(IntPtr.Zero) && probe.Mutations == 0 && + probe.Calls == "pid" && probe.OwnershipChecks == 0 && + probe.GlobalKeys == 0 && probe.Attachments == 0 && + probe.LastActivationDiagnostic == "invalid target HWND or no retained live launch identity", + "UIA activation accepted an invalid window"); + } + + internal static void Require(bool condition, string message) + { + if (!condition) throw new InvalidOperationException(message); + } +} diff --git a/Tools/windows/ValidationRunner/Ui/ControlClickTopologySuite.cs b/Tools/windows/ValidationRunner/Ui/ControlClickTopologySuite.cs new file mode 100644 index 00000000..53297ca3 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/ControlClickTopologySuite.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using GraphCode.Validation.Ui.Fixtures; +using GraphCode.Windows; + +namespace GraphCode.Validation.Ui; + +public sealed class ControlClickTopologySuite : IValidationSuite +{ + public string Id => "ui.control-click-topology"; + public IReadOnlyList UnitIds { get; } = new[] + { + "ui.control-click.flat-root", "ui.control-click.nested-root", + "ui.control-click.same-pid-unrelated-root", "ui.control-click.mismatched-parent", + "ui.control-click.foreign-root" + }; + + public Task> RunAsync(ValidationContext context, CancellationToken cancellationToken) + { + var results = new List(); + MethodInfo rootMethod = typeof(GraphCodeUiaGateState).GetMethod("RequireControlClickRoot", BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new InvalidOperationException("Actual production control root validation missing"); + MethodInfo bridgeMethod = typeof(GraphCodeUiaGateState).GetMethod("ResolvesToControl", BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new InvalidOperationException("Actual production control hit bridge missing"); + string cls = "GraphCodeControlTopology" + Guid.NewGuid().ToString("N"); + PromptLookupFixture.Register(cls); + var roots = new List(); + try + { + GraphCodeUiaGateState.RetainProcess(Process.GetCurrentProcess()); + IntPtr root = PromptLookupFixture.Create(cls, IntPtr.Zero, 0); + roots.Add(root); + IntPtr unrelated = PromptLookupFixture.Create(cls, IntPtr.Zero, 0); + roots.Add(unrelated); + IntPtr flat = PromptLookupFixture.Create("BUTTON", root, 101); + IntPtr viewport = PromptLookupFixture.Create("STATIC", root, 102); + IntPtr parent = PromptLookupFixture.Create("STATIC", viewport, 103); + IntPtr nested = PromptLookupFixture.Create("BUTTON", parent, 104); + IntPtr sibling = PromptLookupFixture.Create("STATIC", viewport, 105); + IntPtr ResolveRoot(IntPtr target, IntPtr owner) => (IntPtr)rootMethod.Invoke(null, new object[] { target, owner })!; + void Check(string id, Action assertion) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + assertion(); + results.Add(new UnitResult(id, UnitStatus.Passed, 1, + "Real hidden Win32 flat/nested topology; actual production root/bridge; no activation, cursor movement or injected input")); + } + catch (Exception failure) { results.Add(new UnitResult(id, UnitStatus.Failed, 1, failure.ToString())); } + } + Check(UnitIds[0], () => ActivationCases.Require(ResolveRoot(flat, root) == root && + !PromptLookupFixture.IsWindowVisible(root), "Flat control did not retain its exact hidden root")); + Check(UnitIds[1], () => ActivationCases.Require(parent != root && ResolveRoot(nested, parent) == root && + !PromptLookupFixture.IsWindowVisible(root), "Nested content child was mistaken for the exact root")); + void Refuse(IntPtr owner) + { + bool refused = false; + try { ResolveRoot(nested, owner); } + catch (TargetInvocationException failure) when (failure.InnerException is InvalidOperationException error && + error.Message == "Control click target lost its exact parent/root ownership; no input was injected") { refused = true; } + var point = new UiaInputSafety.ScreenPoint { X = 5, Y = 5 }; + bool hit = (bool)bridgeMethod.Invoke(null, new object[] { nested, owner, point })!; + ActivationCases.Require(refused && !hit, "Invalid owner reached the actual control input boundary"); + } + Check(UnitIds[2], () => Refuse(unrelated)); + Check(UnitIds[3], () => Refuse(sibling)); + Check(UnitIds[4], () => Refuse(ControlClickTopologyFixture.GetDesktopWindow())); + } + finally + { + foreach (IntPtr root in roots) + if (!PromptLookupFixture.DestroyWindow(root)) throw new InvalidOperationException("Owned control topology fixture cleanup failed"); + } + return Task.FromResult>(results); + } + + public static void RunPhysicalFixtures() + { + Console.WriteLine($"CONTROL_CLICK_FIXTURE_PID={Environment.ProcessId}"); + foreach (bool nested in new[] { false, true }) + { + using var fixture = new ControlClickTopologyFixture(nested); + Console.WriteLine($"CONTROL_CLICK_FIXTURE_HOST_TITLE={ControlClickTopologyFixture.HostTitle} ROOT={fixture.Root} TOPOLOGY={(nested ? "nested" : "flat")}"); + fixture.Foreground(); + if (nested) + { + var bridge = typeof(GraphCodeUiaGateState).GetMethod("ResolvesToControl", BindingFlags.NonPublic | BindingFlags.Static)!; + var point = new UiaInputSafety.ScreenPoint { X = (fixture.Bounds[0] + fixture.Bounds[2]) / 2, + Y = (fixture.Bounds[1] + fixture.Bounds[3]) / 2 }; + bool Resolve(IntPtr owner) => fixture.Run(() => (bool)bridge.Invoke(null, new object[] { fixture.Target, owner, point })!); + ActivationCases.Require(Resolve(fixture.Parent) && Resolve(fixture.Root), + "Measured parent and verified exact root must resolve through the same immediate parent"); + ActivationCases.Require(!Resolve(fixture.Unrelated) && !Resolve(fixture.Sibling) && + !Resolve(ControlClickTopologyFixture.GetDesktopWindow()) && fixture.ClickCount == 0, + "Unrelated root or mismatched parent was accepted before any input"); + } + fixture.BeginInput(); + var result = fixture.Run(() => GraphCodeUiaGateState.ClickScreenPoint(fixture.Target)); + ActivationCases.Require(result is not null && result.HitTarget && result.SameTopLevel, + "Actual control click did not preserve the owned target/root hit"); + fixture.ExpectClick(); + Console.WriteLine($"CONTROL_CLICK_ACTUAL_TOPOLOGY={(nested ? "nested" : "flat")} TARGET_CLICKS=1"); + } + } +} diff --git a/Tools/windows/ValidationRunner/Ui/Fixtures/ControlClickTopologyFixture.cs b/Tools/windows/ValidationRunner/Ui/Fixtures/ControlClickTopologyFixture.cs new file mode 100644 index 00000000..d5a7bcae --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/Fixtures/ControlClickTopologyFixture.cs @@ -0,0 +1,113 @@ +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Threading; +using System.Windows.Forms; + +namespace GraphCode.Validation.Ui.Fixtures; + +internal sealed class ControlClickTopologyFixture : IDisposable +{ + internal const string HostTitle = "Owned control click fixture"; + private readonly Thread thread; + private readonly ManualResetEventSlim ready = new(false); + private Exception? startupFailure; + private Form? window; + private Form? unrelated; + private Panel? content; + private Panel? sibling; + private Button? target; + private readonly ManualResetEventSlim clicked = new(false); + internal IntPtr Root { get; private set; } + internal IntPtr Parent { get; private set; } + internal IntPtr Target { get; private set; } + internal IntPtr Sibling { get; private set; } + internal IntPtr Unrelated { get; private set; } + internal int[] Bounds { get; private set; } = Array.Empty(); + internal int ClickCount; + + [DllImport("user32.dll")] private static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] internal static extern IntPtr GetDesktopWindow(); + [DllImport("user32.dll")] private static extern bool GetCursorPos(out NativePoint point); + [DllImport("user32.dll")] private static extern bool SetCursorPos(int x, int y); + [StructLayout(LayoutKind.Sequential)] private struct NativePoint { public int X, Y; } + private NativePoint initialCursor; + private bool cursorSaved; + + internal ControlClickTopologyFixture(bool nested) + { + thread = new Thread(() => + { + try + { + window = new Form { Text = HostTitle, StartPosition = FormStartPosition.Manual, + Left = 120, Top = 120, Width = 480, Height = 360, ShowInTaskbar = false }; + unrelated = new Form { Text = "Unrelated owned root fixture", StartPosition = FormStartPosition.Manual, + Left = 650, Top = 120, Width = 160, Height = 120, ShowInTaskbar = false }; + content = new Panel { Left = 20, Top = 25, Width = 400, Height = 250 }; + sibling = new Panel { Left = 350, Top = 280, Width = 30, Height = 20 }; + target = new Button { Left = 40, Top = 45, Width = 160, Height = 60, Text = "Owned target" }; + if (nested) + { + var viewport = new Panel { Dock = DockStyle.Fill }; + window.Controls.Add(viewport); + viewport.Controls.Add(content); + content.Controls.Add(target); + } + else window.Controls.Add(target); + window.Controls.Add(sibling); + target.MouseDown += (_, _) => { Interlocked.Increment(ref ClickCount); clicked.Set(); }; + window.Shown += (_, _) => + { + Root = window.Handle; + Parent = target.Parent!.Handle; + Target = target.Handle; + Sibling = sibling.Handle; + Unrelated = unrelated.Handle; + var rectangle = target.RectangleToScreen(target.ClientRectangle); + Bounds = new[] { rectangle.Left, rectangle.Top, rectangle.Right, rectangle.Bottom }; + ready.Set(); + }; + Application.Run(window); + } + catch (Exception failure) { startupFailure = failure; ready.Set(); } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + if (!ready.Wait(TimeSpan.FromSeconds(10))) throw new TimeoutException("Owned control fixture did not create its windows"); + if (startupFailure is not null) throw new InvalidOperationException("Owned control fixture startup failed", startupFailure); + GraphCodeUiaGateState.RetainProcess(Process.GetCurrentProcess()); + } + + internal T Run(Func action) => (T)window!.Invoke(action); + + internal void Foreground() + { + Run(() => GraphCodeUiaGateState.ActivateWindow(Root)); + if (GetForegroundWindow() != Root) + throw new InvalidOperationException($"Owned fixture could not acquire exact foreground; {GraphCodeUiaGateState.LastActivationDiagnostic}"); + } + + internal void BeginInput() + { + if (!GetCursorPos(out initialCursor)) throw new InvalidOperationException("Could not retain the fixture's original cursor position"); + cursorSaved = true; + } + + internal void ExpectClick() + { + if (!clicked.Wait(TimeSpan.FromSeconds(3)) || Volatile.Read(ref ClickCount) != 1) + throw new InvalidOperationException($"Owned fixture expected exactly one physical target click; actual={ClickCount}"); + } + + public void Dispose() + { + if (window is not null && !window.IsDisposed) + window.Invoke(() => { unrelated?.Dispose(); window.Close(); }); + if (!thread.Join(TimeSpan.FromSeconds(10))) throw new TimeoutException("Owned control fixture UI thread did not exit"); + if (cursorSaved && !SetCursorPos(initialCursor.X, initialCursor.Y)) + throw new InvalidOperationException("Could not restore the fixture's original cursor position"); + clicked.Dispose(); + ready.Dispose(); + } +} diff --git a/Tools/windows/ValidationRunner/Ui/Fixtures/InputFixture.cs b/Tools/windows/ValidationRunner/Ui/Fixtures/InputFixture.cs new file mode 100644 index 00000000..d10d9f9c --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/Fixtures/InputFixture.cs @@ -0,0 +1,67 @@ +#nullable disable +using System; +using static GraphCode.Windows.UiaInputSafety; +using GraphCode.Windows; +namespace GraphCode.Validation.Ui.Fixtures { +public sealed class InputFixture : IUiaInputOperations { + + + + + + + public bool Alive, Foreground, Visible, Enabled, FocusOnSelect, FocusOnRead, HitLoss, CursorLoss, Covered, Hover; + public int Injected, HitQueries, CursorQueries, Moves; + public IntPtr Focus; + public ScreenPoint Cursor; + public void Reset() { + Alive = Foreground = Visible = Enabled = true; + FocusOnSelect = FocusOnRead = HitLoss = CursorLoss = Covered = Hover = false; + Injected = HitQueries = CursorQueries = Moves = 0; Focus = new IntPtr(20); Cursor = new ScreenPoint { X = 5, Y = 5 }; + } + public bool IsRetainedProcessAlive(uint pid) { return Alive && pid == 123; } + public uint WindowProcessId(IntPtr window) { + return window == new IntPtr(10) || window == new IntPtr(20) || window == new IntPtr(21) || window == new IntPtr(30) ? 123u : 999u; + } + public bool WindowIsVisible(IntPtr window) { return Visible; } + public bool WindowIsEnabled(IntPtr window) { return Enabled; } + public IntPtr GetForegroundWindow() { return new IntPtr(Foreground ? 10 : 99); } + public IntPtr FocusedControlInDialog(IntPtr window) { return Focus; } + public IntPtr GetAncestor(IntPtr window, uint flag) { + if (window == new IntPtr(30)) return window; + return window == new IntPtr(10) || window == new IntPtr(20) || window == new IntPtr(21) ? new IntPtr(10) : new IntPtr(99); + } + public bool GetCursorPos(out ScreenPoint point) { + CursorQueries++; point = Cursor; if (CursorLoss && CursorQueries > 1) point.X++; return true; + } + public bool SetCursorPos(int x, int y) { Moves++; Cursor = new ScreenPoint { X = x, Y = y }; return true; } + public IntPtr WindowFromPoint(ScreenPoint point) { + HitQueries++; return new IntPtr(Covered || (HitLoss && HitQueries > 1) ? 99 : (Hover ? 30 : 20)); + } + public bool IsWindowVisible(IntPtr window) { return Visible; } + public bool GetMenuItemRect(IntPtr owner, IntPtr menu, uint position, out RECT item) { + item = new RECT { Left = 0, Top = 0, Right = 10, Bottom = 10 }; return true; + } + public uint GetWindowThreadProcessId(IntPtr owner, out uint pid) { pid = WindowProcessId(owner); return 1; } + public IntPtr FindPopupForMenu(uint pid, IntPtr menu) { return new IntPtr(30); } + public bool ScreenToClient(IntPtr popup, ref ScreenPoint point) { return true; } + public IntPtr MouseLParam(int x, int y) { return IntPtr.Zero; } + public uint GetMenuState(IntPtr menu, uint position, uint flags) { return 0x80; } + public void Sleep(uint milliseconds) {} + public bool ResolvesToControl(IntPtr target, IntPtr owner, ScreenPoint point) { return target == new IntPtr(20); } + public uint SendInput(uint count, Input[] inputs, int size) { Injected++; return count; } + public uint SendKeyInputs(uint count, KeyInputRecord[] inputs, int size) { Injected++; return count; } + public IntPtr ControlById(IntPtr owner, int id) { return new IntPtr(20); } + public bool FocusControl(IntPtr owner, IntPtr edit) { Focus = edit; return true; } + public IntPtr SendMessage(IntPtr edit, uint message, UIntPtr first, IntPtr last) { + if (FocusOnSelect) Focus = new IntPtr(99); return IntPtr.Zero; + } + public string EditBufferText(IntPtr edit) { if (FocusOnRead) Focus = new IntPtr(99); return ""; } + public string EditTextById(IntPtr owner, int id) { return "test"; } + public IntPtr SendMessageString(IntPtr edit, uint message, UIntPtr first, string text) { return IntPtr.Zero; } + + public IntPtr PopupMenuHandle(IntPtr popup) => throw new InvalidOperationException("Input fixture does not test popup clicks"); + public uint PopupMenuItemId(IntPtr menu, int position) => throw new InvalidOperationException("Input fixture does not test popup clicks"); + public bool PostMessage(IntPtr window, uint message, UIntPtr first, IntPtr last) => throw new InvalidOperationException("Input fixture does not post keyboard input"); +} +} diff --git a/Tools/windows/ValidationRunner/Ui/Fixtures/PopupEffectFixture.cs b/Tools/windows/ValidationRunner/Ui/Fixtures/PopupEffectFixture.cs new file mode 100644 index 00000000..1a4f0ce0 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/Fixtures/PopupEffectFixture.cs @@ -0,0 +1,67 @@ +#nullable disable +using static GraphCode.Windows.UiaInputSafety; +using GraphCode.Windows; +namespace GraphCode.Validation.Ui.Fixtures { +using System; +using System.Runtime.InteropServices; +public sealed class PopupEffectFixture : IUiaInputOperations { + public string Scenario = ""; + public int MouseCalls, KeyCalls, CursorMoves; + public bool Visible; + public int CursorX, CursorY, CursorReads, ForegroundReads; + public void Reset(string scenario) { + Scenario = scenario; MouseCalls = KeyCalls = CursorMoves = 0; + Visible = scenario != "hidden-popup"; CursorX = CursorY = CursorReads = ForegroundReads = 0; + } + + + + + public IntPtr PopupMenuHandle(IntPtr popup) { return (IntPtr)7; } + public bool GetMenuItemRect(IntPtr owner, IntPtr menu, uint position, out RECT item) { + item = new RECT { Left = 100, Top = 100, Right = 300, Bottom = 150 }; return true; + } + public uint PopupMenuItemId(IntPtr menu, int position) { return 5103; } + public bool ScreenToClient(IntPtr popup, ref ScreenPoint point) { point.X -= 100; point.Y -= 100; return true; } + public bool GetCursorPos(out ScreenPoint point) { + CursorReads++; + point = new ScreenPoint { X = CursorX + (Scenario == "cursor-changed" && CursorReads == 3 ? 1 : 0), Y = CursorY }; + return Scenario != "cursor-unreadable" || CursorReads != 3; + } + public bool SetCursorPos(int x, int y) { CursorMoves++; CursorX = x; CursorY = y; return true; } + public IntPtr SendMessage(IntPtr window, uint message, UIntPtr wparam, IntPtr lparam) { return IntPtr.Zero; } + public IntPtr MouseLParam(int x, int y) { return (IntPtr)((y << 16) | x); } + public uint GetMenuState(IntPtr menu, uint position, uint flags) { return 128; } + public void Sleep(uint milliseconds) { } + public uint SendInput(uint count, Input[] inputs, int size) { + MouseCalls++; Visible = Scenario == "foreign-before-enter" || Scenario == "owned-enter"; return count; + } + public bool IsWindowVisible(IntPtr popup) { return Visible; } + public bool PostMessage(IntPtr window, uint message, UIntPtr wparam, IntPtr lparam) { KeyCalls++; return true; } + public IntPtr GetForegroundWindow() { + ForegroundReads++; + return Scenario == "foreign-foreground" || + (Scenario == "foreign-after-hover" && ForegroundReads == 2) || + (Scenario == "foreign-before-enter" && ForegroundReads == 3) ? (IntPtr)99 : (IntPtr)42; + } + public IntPtr WindowFromPoint(ScreenPoint point) { return Scenario == "underlying-shell-hit" ? (IntPtr)42 : (IntPtr)44; } + public bool IsRetainedProcessAlive(uint processId) { return processId == 4242 && Scenario != "foreign-owner-all"; } + public uint WindowProcessId(IntPtr window) { + return Scenario == "foreign-owner-all" || window == (IntPtr)99 || + (window == (IntPtr)44 && Scenario == "foreign-hit") ? 7777u : 4242u; + } + public IntPtr GetAncestor(IntPtr window, uint flags) { return window == (IntPtr)44 ? (IntPtr)43 : window; } + public bool WindowIsVisible(IntPtr window) => IsWindowVisible(window); + public bool WindowIsEnabled(IntPtr window) => throw new InvalidOperationException("Not a popup operation"); + public IntPtr FocusedControlInDialog(IntPtr window) => throw new InvalidOperationException("Not a popup operation"); + public IntPtr FindPopupForMenu(uint processId, IntPtr menu) => throw new InvalidOperationException("Not a popup operation"); + public bool ResolvesToControl(IntPtr target, IntPtr owner, ScreenPoint point) => throw new InvalidOperationException("Not a popup operation"); + public uint SendKeyInputs(uint count, KeyInputRecord[] inputs, int size) => throw new InvalidOperationException("Not a popup operation"); + public IntPtr ControlById(IntPtr owner, int id) => throw new InvalidOperationException("Not a popup operation"); + public bool FocusControl(IntPtr owner, IntPtr edit) => throw new InvalidOperationException("Not a popup operation"); + public string EditBufferText(IntPtr edit) => throw new InvalidOperationException("Not a popup operation"); + public string EditTextById(IntPtr owner, int id) => throw new InvalidOperationException("Not a popup operation"); + public IntPtr SendMessageString(IntPtr edit, uint message, UIntPtr first, string text) => throw new InvalidOperationException("Not a popup operation"); + +} +} diff --git a/Tools/windows/ValidationRunner/Ui/Fixtures/PromptLookupFixture.cs b/Tools/windows/ValidationRunner/Ui/Fixtures/PromptLookupFixture.cs new file mode 100644 index 00000000..068ea2e3 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/Fixtures/PromptLookupFixture.cs @@ -0,0 +1,49 @@ +#nullable disable +namespace GraphCode.Validation.Ui.Fixtures { +using System; +using System.Runtime.InteropServices; +public static class PromptLookupFixture { + private delegate IntPtr WindowProc(IntPtr window, uint message, UIntPtr wparam, IntPtr lparam); + private static readonly WindowProc procedure = DefWindowProc; + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct WindowClass { + public uint Style; + public IntPtr Procedure; + public int ClassExtra, WindowExtra; + public IntPtr Instance, Icon, Cursor, Background; + public string Menu, Name; + } + [DllImport("user32.dll", EntryPoint = "DefWindowProcW")] + private static extern IntPtr DefWindowProc(IntPtr window, uint message, UIntPtr wparam, IntPtr lparam); + [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true, EntryPoint = "RegisterClassW")] + private static extern ushort RegisterClass(ref WindowClass value); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern IntPtr GetModuleHandle(string name); + [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true, EntryPoint = "CreateWindowExW")] + private static extern IntPtr CreateWindow(uint ex, string cls, string title, uint style, + int x, int y, int width, int height, IntPtr parent, IntPtr id, IntPtr instance, IntPtr parameter); + [DllImport("user32.dll")] + public static extern bool DestroyWindow(IntPtr window); + [DllImport("user32.dll")] + public static extern bool IsWindowVisible(IntPtr window); + [DllImport("user32.dll")] + private static extern bool SetWindowPos(IntPtr window, IntPtr after, int x, int y, int width, int height, uint flags); + public static void Place(IntPtr window, int x, int y, int width, int height) { + if (!SetWindowPos(window, IntPtr.Zero, x, y, width, height, 0x14)) + throw new InvalidOperationException("Hidden lookup fixture placement failed."); + } + public static void Register(string name) { + var value = new WindowClass { Name = name, Instance = GetModuleHandle(null), + Procedure = Marshal.GetFunctionPointerForDelegate(procedure) }; + if (RegisterClass(ref value) == 0) + throw new InvalidOperationException("Hidden lookup fixture class registration failed: " + Marshal.GetLastWin32Error()); + } + public static IntPtr Create(string cls, IntPtr parent, int id) { + IntPtr result = CreateWindow(0, cls, "lookup fixture", parent == IntPtr.Zero ? 0u : 0x50000000u, + 0, 0, 400, 300, parent, new IntPtr(id), GetModuleHandle(null), IntPtr.Zero); + if (result == IntPtr.Zero) + throw new InvalidOperationException("Hidden lookup fixture creation failed: " + Marshal.GetLastWin32Error()); + return result; + } +} +} diff --git a/Tools/windows/ValidationRunner/Ui/Fixtures/UiaWindowActivationFixture.cs b/Tools/windows/ValidationRunner/Ui/Fixtures/UiaWindowActivationFixture.cs new file mode 100644 index 00000000..bdbe8216 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/Fixtures/UiaWindowActivationFixture.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using GraphCode.Windows; + +namespace GraphCode.Validation.Ui.Fixtures { + public sealed class UiaWindowActivationFixture : IUiaWindowActivationOperations { + public string LastActivationDiagnostic = "not attempted"; + public int GlobalKeys, Attachments, Mutations, OwnershipChecks; + public bool Acquire, Owned; + public IntPtr Foreground; + private readonly List calls = new List(); + public string Calls { get { return String.Join(",", calls); } } + + public void Reset(bool acquire, bool owned) { + GlobalKeys = Attachments = Mutations = OwnershipChecks = 0; + Acquire = acquire; + Owned = owned; + Foreground = new IntPtr(99); + LastActivationDiagnostic = "not attempted"; + calls.Clear(); + } + + public bool ActivateWindow(IntPtr window) { + return UiaWindowActivation.ActivateWindow(this, window, out LastActivationDiagnostic); + } + + public uint WindowProcessId(IntPtr window) { + calls.Add("pid"); + return window == new IntPtr(10) ? 123u : window == IntPtr.Zero ? 0u : 999u; + } + + public bool IsRetainedProcessAlive(uint processId) { + calls.Add("owner"); + OwnershipChecks++; + return Owned && processId == 123; + } + + public void ShowWindow(IntPtr window, int command) { + if (window != new IntPtr(10) || command != 9) { + throw new InvalidOperationException("Activation did not restore the expected owned window"); + } + calls.Add("restore"); + Mutations++; + } + + public UiaActivationCallResult BringWindowToTop(IntPtr window) { + calls.Add("bring"); + Mutations++; + return new UiaActivationCallResult(Acquire, 1400); + } + + public UiaActivationCallResult SetForegroundWindow(IntPtr window) { + calls.Add("set"); + Mutations++; + if (Acquire) Foreground = window; + return new UiaActivationCallResult(Acquire, 5); + } + + public bool IsForegroundWindow(IntPtr window) { + calls.Add("foreground"); + return window != IntPtr.Zero && Foreground == window; + } + } +} diff --git a/Tools/windows/ValidationRunner/Ui/ManagedUiSuite.cs b/Tools/windows/ValidationRunner/Ui/ManagedUiSuite.cs new file mode 100644 index 00000000..4e812c23 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/ManagedUiSuite.cs @@ -0,0 +1,188 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GraphCode.Validation.Ui.Fixtures; +using GraphCode.Windows; + +namespace GraphCode.Validation.Ui; + +internal sealed record UiCase(string Id, Action Run, string Source); + +public sealed class ManagedUiSuite : IValidationSuite +{ + private readonly IReadOnlyList cases = CreateCases(); + public string Id => "ui.managed"; + public IReadOnlyList UnitIds => cases.Select(test => test.Id).ToArray(); + + public Task> RunAsync(ValidationContext context, CancellationToken cancellationToken) + { + var results = new List(); + foreach (var test in cases) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + test.Run(); + results.Add(new UnitResult(test.Id, UnitStatus.Passed, 1, $"{test.Source}; nativeCalls=0")); + } + catch (Exception failure) + { + results.Add(new UnitResult(test.Id, UnitStatus.Failed, 1, failure.ToString())); + } + } + return Task.FromResult>(results); + } + + private sealed record InputCase(string Name, string Operation, int Expected, + Action? Configure = null, int? Moves = null, bool? Refuse = null); + + private static IReadOnlyList CreateCases() + { + var cases = new List(); + InputCase[] inputCases = + { + new("owned-key", "key", 1), + new("foreign-foreground", "key", 0, p => p.Foreground = false), + new("foreign-focus", "key", 0, p => p.Focus = new IntPtr(99)), + new("sibling-focus", "key", 0, p => p.Focus = new IntPtr(21)), + new("missing-focus", "key", 0, p => p.Focus = IntPtr.Zero), + new("dead-retained-process", "key", 0, p => p.Alive = false), + new("hidden-window", "key", 0, p => p.Visible = false), + new("disabled-window", "key", 0, p => p.Enabled = false), + new("owned-unicode", "unicode", 1), + new("unicode-foreign-focus", "unicode", 0, p => p.Focus = new IntPtr(99)), + new("owned-mouse", "mouse", 1), + new("mouse-hit-loss", "mouse", 0, p => p.HitLoss = true), + new("mouse-cursor-loss", "mouse", 0, p => p.CursorLoss = true), + new("mouse-foreign-foreground", "mouse", 0, p => p.Foreground = false), + new("owned-edit", "edit", 2), + new("edit-focus-loss-after-select", "edit", 0, p => p.FocusOnSelect = true), + new("edit-focus-loss-after-read", "edit", 1, p => p.FocusOnRead = true, Refuse: true), + new("owned-submenu-hover", "hover", 0, Moves: 1, Refuse: false), + new("submenu-foreign-foreground", "hover", 0, p => p.Foreground = false, Moves: 0), + new("submenu-unretained-process", "hover", 0, p => p.Alive = false, Moves: 0), + new("submenu-covered-point", "hover", 0, p => p.Covered = true, Moves: 0) + }; + ActivationCases.Require(inputCases.Length == 21, "UIA input fixtures require exactly twenty-one cases"); + foreach (var test in inputCases) + cases.Add(new UiCase($"ui.input.{test.Name}", () => RunInput(test), "ValidationRunner.Tests.ps1:758-890")); + + (string Name, long Owner, long Popup, uint Expected, uint PopupPid, uint ForegroundPid, + long ForegroundRoot, uint PointPid, long PointRoot, bool Visible, bool Accepted)[] predicates = + { + ("owned-shell", 42,43,4242,4242,4242,42,4242,43,true,true), + ("owned-popup", 42,43,4242,4242,4242,43,4242,43,true,true), + ("foreign-foreground", 42,43,4242,4242,7777,42,4242,43,true,false), + ("foreign-foreground-root", 42,43,4242,4242,4242,99,4242,43,true,false), + ("foreign-hit", 42,43,4242,4242,4242,42,7777,43,true,false), + ("underlying-shell-hit", 42,43,4242,4242,4242,42,4242,42,true,false), + ("hidden-popup", 42,43,4242,4242,4242,42,4242,43,false,false), + ("unknown-owner", 42,43,0,0,0,42,0,43,true,false), + ("foreign-popup", 42,43,4242,7777,4242,42,4242,43,true,false), + ("absent-popup", 42,0,4242,4242,4242,42,4242,0,true,false) + }; + ActivationCases.Require(predicates.Length == 10, "Popup ownership requires ten managed combinations"); + foreach (var test in predicates) + cases.Add(new UiCase($"ui.popup.predicate.{test.Name}", () => + { + bool accepted = UiaInputSafety.IsOwnedPopupInput(new IntPtr(test.Owner), new IntPtr(test.Popup), + test.Expected, test.PopupPid, test.ForegroundPid, new IntPtr(test.ForegroundRoot), + test.PointPid, new IntPtr(test.PointRoot), test.Visible); + ActivationCases.Require(accepted == test.Accepted, $"Popup ownership predicate: {test.Name} differs"); + }, "ValidationRunner.Tests.ps1:1221-1255")); + + string[] effects = { "owned", "owned-enter", "foreign-foreground", "foreign-after-hover", + "foreign-before-enter", "foreign-owner-all", "foreign-hit", "underlying-shell-hit", + "hidden-popup", "cursor-changed", "cursor-unreadable" }; + ActivationCases.Require(effects.Length == 11, "Popup effect contract requires eleven scenarios"); + foreach (string name in effects) + cases.Add(new UiCase($"ui.popup.effect.{name}", () => RunPopupEffect(name), + "ValidationRunner.Tests.ps1:1256-1328")); + + cases.Add(new UiCase("ui.geometry.seven-pixel-band", () => + { + int[][] remainder = GraphCodeUiaGateState.SubtractCoveredRectangles( + new[] { 667, 698, 763, 728 }, new[] { new[] { 84, 699, 763, 721 } }); + ActivationCases.Require(remainder.Length == 2 && remainder[0].SequenceEqual(new[] { 667, 721, 763, 728 }), + "RED: node sheet rectangle subtraction misses the real seven-pixel uncovered Create band"); + }, "ValidationRunner.Tests.ps1:7626-7634")); + cases.Add(new UiCase("ui.geometry.multiple-covers", () => + { + int[][] remainder = GraphCodeUiaGateState.SubtractCoveredRectangles( + new[] { 667, 698, 763, 728 }, new[] { new[] { 84, 699, 763, 721 }, new[] { 667, 721, 715, 728 } }); + ActivationCases.Require(remainder.Length == 2 && remainder[0].SequenceEqual(new[] { 715, 721, 763, 728 }), + "RED: node sheet rectangle subtraction does not handle more than one covering control"); + }, "ValidationRunner.Tests.ps1:7635-7643")); + cases.Add(new UiCase("ui.gate-state.activation-reference", () => + { + var field = typeof(GraphCodeUiaGateState).GetField("ActivationOperations", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + ActivationCases.Require(field is not null && + field.FieldType == typeof(IUiaWindowActivationOperations) && + field.FieldType.Assembly == typeof(UiaWindowActivation).Assembly, + "Full UIA gate helper does not use the activation contracts' production type identity"); + }, "ValidationRunner.Tests.ps1:7408-7430")); + cases.Add(new UiCase("ui.gate-state.input-reference", () => + { + var field = typeof(GraphCodeUiaGateState).GetField("InputSafety", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + ActivationCases.Require(field is not null && field.FieldType == typeof(UiaInputSafety), + "Full UIA gate helper must use the tested shared input safety component"); + }, "Compiled migration shared production linkage")); + return cases; + } + + private static void RunInput(InputCase test) + { + var probe = new InputFixture(); + probe.Reset(); + test.Configure?.Invoke(probe); + var input = new UiaInputSafety(probe); + Exception? failure = null; + try + { + switch (test.Operation) + { + case "key": input.SendKeyInput(new IntPtr(10), 123, new IntPtr(20), 46, 1); break; + case "unicode": input.SendUnicodeInput(new IntPtr(10), 123, new IntPtr(20), "test"); break; + case "mouse": input.ClickOwnedScreenRectangle(new IntPtr(10), 0, 0, 10, 10, false); break; + case "edit": input.TypeEditTextById(new IntPtr(10), 1, "test"); break; + case "hover": probe.Hover = true; input.HoverPopupMenuItem(new IntPtr(10), new IntPtr(11), 0); break; + default: throw new InvalidOperationException($"Unknown input operation {test.Operation}"); + } + } + catch (InvalidOperationException error) { failure = error; } + bool mustRefuse = test.Refuse ?? test.Expected == 0; + ActivationCases.Require(probe.Injected == test.Expected && (failure is not null) == mustRefuse && + (test.Moves is null || probe.Moves == test.Moves), + $"UIA_INPUT_RED: {test.Name} injected={probe.Injected} expected={test.Expected} refusal={failure is not null}; {failure?.Message}"); + } + + private static void RunPopupEffect(string name) + { + var probe = new PopupEffectFixture(); + probe.Reset(name); + var input = new UiaInputSafety(probe); + UiaInputSafety.PopupItemHit? hit = null; + Exception? failure = null; + try { hit = input.ClickPopupMenuItem(new IntPtr(43), new IntPtr(42), 0, 5103, 4242); } + catch (InvalidOperationException error) { failure = error; } + bool expected = name is "owned" or "owned-enter"; + bool passed = (hit is not null) == expected && + probe.MouseCalls == (expected || name == "foreign-before-enter" ? 1 : 0) && + probe.KeyCalls == (name == "owned-enter" ? 2 : 0) && + probe.CursorMoves == (name is "foreign-foreground" or "foreign-owner-all" or "hidden-popup" ? 0 : 1); + if (!expected) + { + string diagnostic = name is "cursor-changed" or "cursor-unreadable" + ? "Popup input cursor changed; no mouse input was injected" + : name is "foreign-foreground" or "foreign-before-enter" or "foreign-owner-all" or "hidden-popup" + ? "Popup input ownership changed; no further input was injected" + : "Popup input ownership changed; no mouse input was injected"; + passed &= failure is not null && failure.Message.Contains(diagnostic, StringComparison.Ordinal); + } + ActivationCases.Require(passed, $"Actual popup input effect contract: {name} differs; {failure?.Message}"); + } +} diff --git a/Tools/windows/ValidationRunner/Ui/MixedNativeContracts.ps1 b/Tools/windows/ValidationRunner/Ui/MixedNativeContracts.ps1 new file mode 100644 index 00000000..9f82fb17 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/MixedNativeContracts.ps1 @@ -0,0 +1,514 @@ +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) +. (Join-Path $PSScriptRoot 'MixedPolicyContracts.ps1') -RepositoryRoot $RepositoryRoot ` + -ArtifactRoot $ArtifactRoot -AssemblyPath $AssemblyPath -ImportOnly +$renameClick = (New-MultiProjectRenameNativeApi).PSObject.Methods['Click'].Script +$focusMouse = (New-MultiProjectEditNativeApi).PSObject.Methods['FocusMouse'].Script +Assert-Mixed ($focusMouse.ToString().Contains('$this.FocusControl($modal,$edit)') -and + $focusMouse.ToString() -notmatch '\[GraphCode[^\]]*\]::' -and + $renameClick.ToString() -notmatch '\[GraphCode[^\]]*\]::') ` + 'Production policy contains a non-injectable native operation; no mixed fake payload may execute' +function New-MixedRename { + $controls=@{} + foreach($id in @(9800,9808,9904)){ + $controls["$id"]=@{handle=[IntPtr]$id;processId=4242;id=$id;root=[IntPtr]42;visible=$true;enabled=$true;bounds=@(300,300,380,328)} + } + $controls['9904'].bounds=@(30,100,500,124) + $api=[pscustomobject]@{ + state=@{modalPID=4242;title=("Rename "+[char]0x201c+'Alpha renamed'+[char]0x201d);visible=$true;enabled=$true + client=@(10,40,600,400);work=@(0,0,1920,1080);foreground=$true;buffer=' Alpha renamed ';secondBuffer=' Alpha renamed ' + controls=$controls;hitRoot=[IntPtr]42;hitPID=4242;hitChild=[IntPtr]9800;late=$null;inputSent=2;inputExpected=2} + inputCalls=0;bufferReads=0;hitReads=0;ExpectedTitle='';order=[Collections.Generic.List[string]]::new();requested=[Collections.Generic.List[int]]::new() + } + $api|Add-Member ScriptMethod ProcessId {param($window)$this.order.Add("PID:$window");if($window -eq [IntPtr]42){return $this.state.modalPID};return $this.state.controls["$window"].processId} + $api|Add-Member ScriptMethod Title {param($window)$this.order.Add("TITLE:$window");return $this.state.title} + $api|Add-Member ScriptMethod Visible {param($window)if($window -eq [IntPtr]42){return $this.state.visible};return $this.state.controls["$window"].visible} + $api|Add-Member ScriptMethod Enabled {param($window)if($window -eq [IntPtr]42){return $this.state.enabled};return $this.state.controls["$window"].enabled} + $api|Add-Member ScriptMethod Root {param($window)return $this.state.controls["$window"].root} + $api|Add-Member ScriptMethod Control { + param($window,$id)$this.requested.Add($id);$this.order.Add("CONTROL:$id") + if(-not $this.state.controls.ContainsKey("$id")){return [IntPtr]::Zero};return $this.state.controls["$id"].handle + } + $api|Add-Member ScriptMethod ControlId {param($window)return $this.state.controls["$window"].id} + $api|Add-Member ScriptMethod Bounds {param($window)return $this.state.controls["$window"].bounds} + $api|Add-Member ScriptMethod ClientBounds { + param($window)if($this.state.ContainsKey('staleClient') -and $this.state.staleClient){throw [ComponentModel.Win32Exception]::new(6,'controlled stale native client')};return $this.state.client + } + $api|Add-Member ScriptMethod WorkBounds {param($window)return $this.state.work} + $api|Add-Member ScriptMethod Foreground {param($window)return $this.state.foreground} + $api|Add-Member ScriptMethod Buffer {param($window,$id)$this.order.Add("BUFFER:$id");$this.bufferReads++;if($this.bufferReads -eq 1){return $this.state.buffer};return $this.state.secondBuffer} + $api|Add-Member ScriptMethod Pause {if($this.state.late){& $this.state.late $this.state}} + $api|Add-Member ScriptMethod Hit { + param($window,$x,$y)$this.hitReads++ + if($this.state.ContainsKey('finalDrift') -and $this.hitReads -eq 2){& $this.state.finalDrift $this.state} + $child=if($this.state.ContainsKey('coveredFirst') -and $this.state.coveredFirst -and $this.hitReads -eq 1){[IntPtr]9904}else{$this.state.hitChild} + return @{root=$this.state.hitRoot;processId=$this.state.hitPID;child=$child} + } + $api|Add-Member ScriptMethod SendMouse { + param($window,$point)$this.order.Add('INPUT:mouse');$this.inputCalls++ + return ,@($point[0],$point[1],($point[0]+1),($point[1]+1),$point[0],$point[1],$this.state.inputSent,$this.state.inputExpected) + } + $api|Add-Member ScriptMethod Click $renameClick + return $api +} +function New-MixedEdit { + $api=New-MixedRename + $api|Add-Member NoteProperty Now ([long]0) + $api|Add-Member NoteProperty LastFocusControlFallback $false + $api|Add-Member NoteProperty LastFocusPostedFallback $false + $api|Add-Member NoteProperty PhaseState @{ + buffer='Alpha renamed';selection=@(0,0);focus=[IntPtr]::Zero;clearCalls=0;unicodeCalls=0;deleteProcessed=$false + clearPending=$false;observes=0;deleteDelay=3;focusClicks=0;focusPending=$false;focusObserves=0;focusDelay=1;focusSent=2 + neverFocus=$false;neverClear=$false;finalWrong=$false;clearSent=2;unicodeShort=$false;prefillReads=0;emptyReads=0;rebound=$false} + $api.state.hitChild=[IntPtr]9904 + $api|Add-Member NoteProperty PhaseOrder ([Collections.Generic.List[string]]::new()) + $api|Add-Member ScriptMethod Clock {return $this.Now} + $api|Add-Member ScriptMethod FocusInsertion { + param($modal,$point)$this.PhaseOrder.Add('focus-insert');$this.PhaseState.focusClicks++;$this.PhaseState.focusPending=$true + return ,@($point[0],$point[1],($point[0]+1),($point[1]+1),$point[0],$point[1],$this.PhaseState.focusSent,2) + } + $api|Add-Member ScriptMethod FocusMouse $focusMouse + $api|Add-Member ScriptMethod FocusControl { + param($modal,$edit) + if(-not $this.PhaseState.ContainsKey('focusControlCalls')){$this.PhaseState.focusControlCalls=0} + $this.PhaseState.focusControlCalls++ + if($this.PhaseState.ContainsKey('focusControlError')){throw $this.PhaseState.focusControlError} + if($this.PhaseState.ContainsKey('focusControlResult')){return $this.PhaseState.focusControlResult} + return $true + } + $api|Add-Member ScriptMethod FocusHandle {param($modal)return $this.PhaseState.focus} + $api|Add-Member ScriptMethod EditBuffer { + param($edit,$remaining)$this.PhaseState.prefillReads++ + if($this.PhaseState.buffer -ceq ''){$this.PhaseState.emptyReads++;if($this.PhaseState.rebound -and $this.PhaseState.emptyReads -eq 4){$this.PhaseState.buffer='Rebound'}} + return $this.PhaseState.buffer + } + $api|Add-Member ScriptMethod Selection {param($edit,$remaining)return ,$this.PhaseState.selection} + $api|Add-Member ScriptMethod SelectAll { + param($edit,$remaining)$this.PhaseOrder.Add('select') + $this.PhaseState.selection=if($this.PhaseState.ContainsKey('badSelection')){$this.PhaseState.badSelection}else{@(0,$this.PhaseState.buffer.Length)} + } + $api|Add-Member ScriptMethod Delete {$this.PhaseOrder.Add('clear-enqueue');$this.PhaseState.clearCalls++;$this.PhaseState.clearPending=$true;return ,@($this.PhaseState.clearSent,2)} + $api|Add-Member ScriptMethod Unicode { + param($text) + if(-not $this.PhaseState.deleteProcessed -or $this.PhaseState.clearPending -or $this.PhaseState.buffer -cne '' -or $this.PhaseState.clearCalls -ne 1){throw 'Unicode before actual inert Delete transition'} + $this.PhaseOrder.Add('unicode-enqueue');$this.PhaseState.unicodeCalls++ + $this.PhaseState.buffer=if($this.PhaseState.finalWrong){'lpha renamed'}else{$text} + if($this.PhaseState.ContainsKey('unicodeDrift')){& $this.PhaseState.unicodeDrift $this} + $count=2*$text.Length;$sent=if($this.PhaseState.unicodeShort){$count-1}else{$count};return ,@($sent,$count) + } + $api|Add-Member ScriptMethod Observe { + param($remaining)$this.Now += [Math]::Min(25,$remaining);$this.PhaseState.observes++ + if($this.PhaseState.focusPending){ + $this.PhaseState.focusObserves++ + if(-not $this.PhaseState.neverFocus -and $this.PhaseState.focusObserves -ge $this.PhaseState.focusDelay){ + $this.PhaseState.focus=[IntPtr]9904;$this.PhaseState.focusPending=$false;$this.PhaseOrder.Add('focus-observed') + } + } + if($this.PhaseState.clearPending -and -not $this.PhaseState.neverClear -and $this.PhaseState.observes -ge $this.PhaseState.deleteDelay){ + $this.PhaseState.buffer='';$this.PhaseState.selection=@(0,0);$this.PhaseState.clearPending=$false + $this.PhaseState.deleteProcessed=$true;$this.PhaseOrder.Add('clear-processed') + if($this.PhaseState.ContainsKey('clearDrift')){& $this.PhaseState.clearDrift $this} + } + } + return $api +} +foreach($target in @(' ','Alpha renamed',' Alpha renamed ')){ + Invoke-Mixed 2706 "exact-target-$($target.Length)" { + $api=New-MixedEdit;$evidence=[Collections.Generic.List[string]]::new();$calls=@{count=0} + $null=Invoke-MultiProjectTypeText 9904 $target $evidence -typeSource { + param($id,$text)$calls.count++ + Invoke-MultiProjectSequencedEdit ([IntPtr]42) 4242 'Alpha renamed' $text -nativeApi $api + } + Assert-Mixed ($calls.count -eq 1 -and $api.PhaseState.clearCalls -eq 1 -and $api.PhaseState.unicodeCalls -eq 1 -and + ($api.PhaseOrder -join '|') -ceq 'focus-insert|focus-observed|select|clear-enqueue|clear-processed|unicode-enqueue' -and + $api.PhaseState.focusClicks -eq 1 -and $api.PhaseState.buffer -ceq $target -and $api.Now -lt 5000 -and + $evidence.Count -eq 1 -and $evidence[0].Contains('clearSent=2/2') -and + $evidence[0].Contains("textSent=$([int](2*$target.Length))/$([int](2*$target.Length))")) 'Sequenced edit phase/count/target proof changed' + } +} +Invoke-Mixed 2724 'actual-FocusMouse-owned-edit' { + $api=New-MixedEdit;$null=$api.FocusMouse([IntPtr]42,[IntPtr]9904,@(100,110),4242,[long]0) + Assert-Mixed ($api.PhaseState.focusClicks -eq 1 -and $api.hitReads -eq 1 -and $api.state.hitRoot -eq [IntPtr]42 -and $api.state.hitPID -eq 4242) 'Actual FocusMouse ownership guard changed' +} +foreach($mutation in @('background','foreign-root')){ + Invoke-Mixed 2732 $mutation { + $api=New-MixedEdit;if($mutation -ceq 'background'){$api.state.hitChild=[IntPtr]9890}else{$api.state.hitRoot=[IntPtr]99} + $failure=$null;try{$null=$api.FocusMouse([IntPtr]42,[IntPtr]9904,@(100,110),4242,[long]0)}catch{$failure=$_} + Assert-Mixed ($failure -and $failure.Exception.ToString().Contains('MULTIPROJECT_EDIT_FOCUS:') -and $api.PhaseState.focusClicks -eq 0) 'Viewport miss became input target' + } +} +Invoke-Mixed 2742 'already-focused-no-focus-input' { + $api=New-MixedEdit;$api.PhaseState.focus=[IntPtr]9904 + $null=Invoke-MultiProjectSequencedEdit ([IntPtr]42) 4242 'Alpha renamed' 'Alpha renamed' -nativeApi $api + Assert-Mixed ($api.PhaseState.focusClicks -eq 0 -and $api.PhaseState.clearCalls -eq 1 -and $api.PhaseState.unicodeCalls -eq 1 -and + ($api.PhaseOrder -join '|') -ceq 'select|clear-enqueue|clear-processed|unicode-enqueue') 'Already-owned focus inserted mouse' +} +foreach($mutation in @('focus-short','focus-occluded','focus-foreign-hit','focus-outside','focus-timeout','focus-post-owner','focus-switched','before-mouse-deadline','last-selection-deadline','last-selection-owner')){ + Invoke-Mixed 2751 $mutation { + $api=New-MixedEdit + switch($mutation){ + focus-short{$api.PhaseState.focusSent=1} focus-occluded{$api.state.hitChild=[IntPtr]9800} focus-foreign-hit{$api.state.hitPID=7777} + focus-outside{$api.state.controls['9904'].bounds=@(700,700,900,724)} focus-timeout{$api.PhaseState.neverFocus=$true} + focus-post-owner{$api|Add-Member ScriptMethod FocusInsertion {param($modal,$point)$this.PhaseState.focusClicks++;$this.state.controls['9904'].processId=7777;return ,@($point[0],$point[1],($point[0]+1),($point[1]+1),$point[0],$point[1],2,2)} -Force} + focus-switched{$api.PhaseState.clearDrift={param($a)$a.PhaseState.focus=[IntPtr]9800}} + before-mouse-deadline{$api|Add-Member ScriptMethod Hit {param($modal,$x,$y)$this.Now=5000;return @{root=[IntPtr]42;child=[IntPtr]9904;processId=4242}} -Force} + last-selection-deadline{$api|Add-Member ScriptMethod Selection {param($edit,$remaining)if($this.PhaseState.emptyReads -ge 4){$this.Now=5000};return ,$this.PhaseState.selection} -Force} + last-selection-owner{$api|Add-Member ScriptMethod Selection {param($edit,$remaining)if($this.PhaseState.emptyReads -ge 4){$this.state.controls['9904'].processId=7777};return ,$this.PhaseState.selection} -Force}} + $failure=$null;try{$null=Invoke-MultiProjectSequencedEdit ([IntPtr]42) 4242 'Alpha renamed' 'Alpha renamed' -nativeApi $api}catch{$failure=$_} + Assert-Mixed ($failure -and $api.PhaseState.focusClicks -le 1 -and $api.PhaseState.clearCalls -le 1 -and $api.PhaseState.unicodeCalls -eq 0) 'Focus/deadline failure added or replayed input' + if($mutation -ceq 'before-mouse-deadline'){Assert-Mixed ($api.PhaseState.focusClicks -eq 0) 'Expired mouse deadline allowed focus insertion'} + } +} +foreach($mutation in @('both-foreign','both-zero','both-type','id-wrong','title-wrong','foreground-false','hit-wrong','title-deadline')){ + Invoke-Mixed 2785 $mutation { + $api=New-MixedEdit + switch($mutation){ + both-foreign{$api.state.modalPID=7777;$api.state.controls['9904'].processId=7777} + both-zero{$api.state.modalPID=0;$api.state.controls['9904'].processId=0} + both-type{$api.state.modalPID='4242';$api.state.controls['9904'].processId='4242'} + id-wrong{$api.state.controls['9904'].id=9800} title-wrong{$api.state.title='Different dialog'} foreground-false{$api.state.foreground=$false} + hit-wrong{$api.state.hitChild=[IntPtr]9800} title-deadline{$api|Add-Member ScriptMethod Title {param($modal,$remaining)$this.order.Add("TITLE:$modal");$this.Now=5000;return ("Rename "+[char]0x201c+'Alpha renamed'+[char]0x201d)} -Force}} + $failure=$null;try{$null=$api.FocusMouse([IntPtr]42,[IntPtr]9904,@(100,110),4242,[long]0)}catch{$failure=$_} + Assert-Mixed ($failure -and $api.PhaseState.focusClicks -eq 0) 'Actual focus primitive adopted foreign PID or late budget' + if($mutation -cin @('both-foreign','both-zero','both-type')){Assert-Mixed (-not ($api.order -contains 'TITLE:42')) 'Foreign modal title read before PID guard'} + } +} +foreach($mutation in @('empty-initial','mismatch-prefill','selection-short','selection-offset','selection-type','never-clear','empty-rebound','clear-short','unicode-short','final-wrong','focus-deadline','initial-foreign','initial-control','initial-hidden','initial-disabled','initial-foreground','clear-foreign','clear-focus','preunicode-control','final-owner','final-focus','selection-nonzero-empty')){ + Invoke-Mixed 2808 $mutation { + $api=New-MixedEdit + switch($mutation){ + empty-initial{$api.PhaseState.buffer='';$api.PhaseState.clearPending=$true} mismatch-prefill{$api.PhaseState.buffer='Other title'} + selection-short{$api.PhaseState.badSelection=@(0,12)} selection-offset{$api.PhaseState.badSelection=@(1,13)} selection-type{$api.PhaseState.badSelection=@('0',13)} + never-clear{$api.PhaseState.neverClear=$true} empty-rebound{$api.PhaseState.rebound=$true} clear-short{$api.PhaseState.clearSent=1} + unicode-short{$api.PhaseState.unicodeShort=$true} final-wrong{$api.PhaseState.finalWrong=$true} focus-deadline{$api.PhaseState.neverFocus=$true} + initial-foreign{$api.state.controls['9904'].processId=7777} initial-control{$api.state.controls['9904'].id=9105} + initial-hidden{$api.state.controls['9904'].visible=$false} initial-disabled{$api.state.controls['9904'].enabled=$false} initial-foreground{$api.state.foreground=$false} + clear-foreign{$api.PhaseState.clearDrift={param($a)$a.state.controls['9904'].processId=7777}} clear-focus{$api.PhaseState.clearDrift={param($a)$a.PhaseState.focus=[IntPtr]9800}} + preunicode-control{$api.PhaseState.clearDrift={param($a)$a.state.controls['9904'].id=9105}} + final-owner{$api.PhaseState.unicodeDrift={param($a)$a.state.controls['9904'].root=[IntPtr]99}} final-focus{$api.PhaseState.unicodeDrift={param($a)$a.PhaseState.focus=[IntPtr]9800}} + selection-nonzero-empty{$api.PhaseState.clearDrift={param($a)$a.PhaseState.selection=@(1,1)}}} + $failure=$null;try{$null=Invoke-MultiProjectSequencedEdit ([IntPtr]42) 4242 'Alpha renamed' 'Alpha renamed' -nativeApi $api}catch{$failure=$_} + Assert-Mixed ($failure -and $failure.Exception.Message.StartsWith('MULTIPROJECT_EDIT_',[StringComparison]::Ordinal) -and + $api.PhaseState.clearCalls -le 1 -and $api.PhaseState.unicodeCalls -le 1) 'Sequenced guard lost exact refusal or replayed batch' + $unicode=if($mutation -cin @('unicode-short','final-wrong','final-owner','final-focus')){1}else{0} + Assert-Mixed ($api.PhaseState.unicodeCalls -eq $unicode) 'Rejected prefill/ownership/clear allowed Unicode' + if($mutation -ceq 'empty-initial'){Assert-Mixed ($api.PhaseState.clearCalls -eq 0 -and $api.PhaseState.observes -eq 0) 'Initial empty buffer invented transition acknowledgement'} + } +} +Invoke-Mixed 2845 'compiled-edit-native-declaration-only' { + $api=New-MultiProjectEditNativeApi + Assert-Mixed ($mixedAssembly.GetType('GraphCodeMultiProjectEditNative',$false) -ne $null -and $null -ne $api.PSObject.Methods['Selection']) 'Current file-backed edit declaration unavailable' +} +Invoke-Mixed 2850 'empty-prefill-refused-before-input' { + $api=New-MixedEdit;$failure=$null + try{$null=Invoke-MultiProjectSequencedEdit ([IntPtr]42) 4242 '' ' ' -nativeApi $api}catch{$failure=$_} + Assert-Mixed ($failure -and $api.PhaseState.focusClicks -eq 0 -and $api.PhaseState.clearCalls -eq 0 -and $api.PhaseState.unicodeCalls -eq 0) 'Unknown empty prefill admitted transition' +} +Invoke-Mixed 2857 'diagnostic-failure-retains-primary' { + $api=New-MixedEdit;$api.PhaseState.buffer='';$failure=$null + try{$null=Invoke-MultiProjectSequencedEdit ([IntPtr]42) 4242 'Alpha renamed' ' ' -nativeApi $api -phaseSink {param($message)throw [IO.IOException]::new('phase refusal writer failed')}}catch{$failure=$_} + $secondary=$failure.Exception.Data['MultiProjectEditDiagnostic'] + Assert-Mixed ($failure.Exception.Message -ceq 'MULTIPROJECT_EDIT_PREFILL: fresh buffer differs from known nonempty prefill' -and + $secondary.errorType -ceq 'System.IO.IOException' -and $secondary.operation -ceq 'sequenced-edit-refusal' -and + $api.PhaseState.focusClicks -eq 0 -and $api.PhaseState.unicodeCalls -eq 0) 'Diagnostic writer replaced exact primary or sent input' +} +$renameText=' Alpha renamed ' +$renameProof=@('UIA_EDGE_TEXT_STABLE id=9904 inputAttempted=True inputCountsFull=True clearSent=2/2 textSent=34/34') +foreach($cancelRoute in @($false,$true)){ + Invoke-Mixed 2871 "actual-caller-cancel-$cancelRoute" { + $api=New-MixedRename;if($cancelRoute){$api.state.hitChild=[IntPtr]9808} + function New-MultiProjectRenameNativeApi {return $api} + $caller=@($gateAst.FindAll({param($n)$n -is [Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -ceq 'Invoke-MultiProjectNativeRename'},$true)) + Assert-Mixed ($caller.Count -eq 1) 'Actual Rename caller missing or ambiguous' + $statements=@($caller[0].FindAll({param($n)$n -is [Management.Automation.Language.AssignmentStatementAst] -and $n.Left.Extent.Text -ceq '$action'},$true)) + Assert-Mixed ($statements.Count -eq 1) 'Actual caller action assignment missing or ambiguous' + $modal=[IntPtr]42;$multiProcess=[pscustomobject]@{Id=4242};$typedTitle=$renameText;$Cancel=$cancelRoute + $renameTitle=Get-MultiProjectRenameTitle 'Alpha renamed' + $inputEvidence=[Collections.Generic.List[string]]::new();$inputEvidence.Add($renameProof[0]) + . ([scriptblock]::Create($statements[0].Extent.Text)) + $button=if($cancelRoute){9808}else{9800} + Assert-Mixed ($action.buttonId -eq $button -and $action.sent -eq 2 -and $action.expected -eq 2 -and + $api.inputCalls -eq 1 -and -not ($api.requested -contains 1) -and $api.requested -contains 9904 -and + $action.nativeTarget.observed.button.controlId -eq $button -and $action.nativeTarget.observed.button.topOwner -eq 42 -and + ($api.order -join '|').StartsWith('PID:42|TITLE:42|CONTROL:',[StringComparison]::Ordinal)) 'Actual caller uses legacy button or input before owned target capture' + } +} +Invoke-Mixed 2897 'actual-button-client-work-clipping' { + $api=New-MixedRename;$api.state.controls['9800'].bounds=@(580,380,680,430) + $action=Invoke-MultiProjectRenameAction ([IntPtr]42) 4242 $renameText $renameProof -nativeApi $api + Assert-Mixed (($action.clippedBounds -join ',') -ceq '580,380,600,400' -and + $action.point[0] -ge 580 -and $action.point[0] -lt 600 -and $action.point[1] -ge 380 -and $action.point[1] -lt 400) 'Rename point ignores actual client/work clipping' +} +Invoke-Mixed 2906 'uncovered-owned-point-without-covering-captions' { + $api=New-MixedRename;$api.state.coveredFirst=$true + $action=Invoke-MultiProjectRenameAction ([IntPtr]42) 4242 $renameText $renameProof -nativeApi $api + Assert-Mixed ($action.nativeTarget.hitSamples.Count -eq 2 -and -not $action.nativeTarget.hitSamples[0].hitTarget -and + $action.nativeTarget.hitSamples[1].hitTarget -and $api.inputCalls -eq 1) 'Covered point clicked or uncovered target not measured' +} +foreach($mutation in @( + 'missing-button','zero-handle','invalid-handle','hidden-button','disabled-button','wrong-id','id-type','foreign-button','pid-zero','pid-type', + 'wrong-top-owner','owner-type','missing-edit','foreign-edit','wrong-edit-id','edit-disabled','modal-foreign','modal-zero','modal-pid-type','wrong-title', + 'title-type','modal-hidden','modal-disabled','visibility-type','bounds-missing','bounds-zero','bounds-negative-width','bounds-type','bounds-nan','outside-client', + 'outside-work','client-zero','work-invalid','foreground-false','foreground-type','buffer-wrong','buffer-changing','buffer-type','proof-empty','proof-wrong-id', + 'proof-short','proof-clear-short','proof-not-attempted','fully-occluded','foreign-hit','hit-pid-type','hit-root-wrong','hit-child-type','late-hidden', + 'late-disabled','late-foreign','late-id-drift','late-owner-drift','late-bounds-drift','late-modal-foreign','late-title-drift','late-client-drift','late-work-drift','late-foreground' +)){ + Invoke-Mixed 2924 $mutation { + $api=New-MixedRename;$proof=@($renameProof);$button=$api.state.controls['9800'] + switch($mutation){ + missing-button{$api.state.controls.Remove('9800')} zero-handle{$button.handle=[IntPtr]::Zero} invalid-handle{$button.handle='9800'} + hidden-button{$button.visible=$false} disabled-button{$button.enabled=$false} wrong-id{$button.id=1} id-type{$button.id='9800'} + foreign-button{$button.processId=7777} pid-zero{$button.processId=0} pid-type{$button.processId='4242'} + wrong-top-owner{$button.root=[IntPtr]99} owner-type{$button.root='42'} missing-edit{$api.state.controls.Remove('9904')} + foreign-edit{$api.state.controls['9904'].processId=7777} wrong-edit-id{$api.state.controls['9904'].id=9105} edit-disabled{$api.state.controls['9904'].enabled=$false} + modal-foreign{$api.state.modalPID=7777} modal-zero{$api.state.modalPID=0} modal-pid-type{$api.state.modalPID='4242'} wrong-title{$api.state.title='New Loop'} + title-type{$api.state.title=1} modal-hidden{$api.state.visible=$false} modal-disabled{$api.state.enabled=$false} visibility-type{$button.visible='True'} + bounds-missing{$button.bounds=@()} bounds-zero{$button.bounds=@(300,300,300,328)} bounds-negative-width{$button.bounds=@(300,300,299,328)} + bounds-type{$button.bounds[0]='300'} bounds-nan{$button.bounds[0]=[double]::NaN} outside-client{$button.bounds=@(700,700,780,728)} + outside-work{$api.state.work=@(0,0,200,200)} client-zero{$api.state.client=@(10,40,10,400)} work-invalid{$api.state.work[0]='0'} + foreground-false{$api.state.foreground=$false} foreground-type{$api.state.foreground='True'} + buffer-wrong{$api.state.buffer='Wrong';$api.state.secondBuffer='Wrong'} buffer-changing{$api.state.secondBuffer='Changed'} buffer-type{$api.state.buffer=1} + proof-empty{$proof=@()} proof-wrong-id{$proof[0]=$proof[0].Replace('id=9904','id=9105')} proof-short{$proof[0]=$proof[0].Replace('34/34','33/34')} + proof-clear-short{$proof[0]=$proof[0].Replace('2/2','1/2')} proof-not-attempted{$proof[0]=$proof[0].Replace('inputAttempted=True','inputAttempted=False')} + fully-occluded{$api.state.hitChild=[IntPtr]9904} foreign-hit{$api.state.hitPID=7777} hit-pid-type{$api.state.hitPID='4242'} + hit-root-wrong{$api.state.hitRoot=[IntPtr]99} hit-child-type{$api.state.hitChild='9800'} + late-hidden{$api.state.late={param($s)$s.controls['9800'].visible=$false}} late-disabled{$api.state.late={param($s)$s.controls['9800'].enabled=$false}} + late-foreign{$api.state.late={param($s)$s.controls['9800'].processId=7777}} late-id-drift{$api.state.late={param($s)$s.controls['9800'].id=1}} + late-owner-drift{$api.state.late={param($s)$s.controls['9800'].root=[IntPtr]99}} late-bounds-drift{$api.state.late={param($s)$s.controls['9800'].bounds=@(310,300,390,328)}} + late-modal-foreign{$api.state.late={param($s)$s.modalPID=7777}} late-title-drift{$api.state.late={param($s)$s.title='New Loop'}} + late-client-drift{$api.state.late={param($s)$s.client=@(10,40,590,400)}} late-work-drift{$api.state.late={param($s)$s.work=@(0,0,1900,1080)}} + late-foreground{$api.state.late={param($s)$s.foreground=$false}}} + $prefix=if($mutation -cin @('outside-client','outside-work')){'MULTIPROJECT_BOUNDS:'}else{'MULTIPROJECT_RENAME_GUARD:'} + $records=@(& {$null=Reject-Mixed {Invoke-MultiProjectRenameAction ([IntPtr]42) 4242 $renameText $proof -nativeApi $api} $prefix} 6>&1) + $diagnostics=@($records|Where-Object {$_ -is [Management.Automation.InformationRecord] -and ([string]$_.MessageData).StartsWith('UIA_MULTIPROJECT_RENAME_NATIVE_TARGET=',[StringComparison]::Ordinal)}| + ForEach-Object {([string]$_.MessageData).Substring('UIA_MULTIPROJECT_RENAME_NATIVE_TARGET='.Length)|ConvertFrom-Json}) + Assert-Mixed ($api.inputCalls -eq 0 -and -not ($api.order|Where-Object {$_.StartsWith('INPUT:')}) -and + $diagnostics.Count -gt 0 -and $diagnostics[0].expected.buttonId -eq 9800 -and -not $diagnostics[0].inputAttempted) 'Refused Rename guard sent input or lost actual typed diagnostic' + if($mutation -cin @('modal-foreign','modal-zero','modal-pid-type')){Assert-Mixed (-not ($api.order -contains 'TITLE:42') -and $api.requested.Count -eq 0) 'Foreign modal content read'} + if($mutation -cin @('missing-button','zero-handle')){Assert-Mixed ($diagnostics[0].observed.button.handle -eq 0 -and $null -eq $diagnostics[0].observed.button.controlId) 'Missing actual target fields invented'} + } +} +foreach($mutation in @('short-count','typed-count')){ + Invoke-Mixed 3013 $mutation { + $api=New-MixedRename;$api.state.inputSent=if($mutation -ceq 'short-count'){1}else{'2'} + $null=Reject-Mixed {Invoke-MultiProjectRenameAction ([IntPtr]42) 4242 $renameText $renameProof -nativeApi $api} 'MULTIPROJECT_RENAME_INPUT:' + Assert-Mixed ($api.inputCalls -eq 1) 'Incomplete input replayed' + } +} +foreach($mutation in @('foreign-button','wrong-title','covered-point')){ + Invoke-Mixed 3024 $mutation { + $api=New-MixedRename + $api.state.finalDrift=switch($mutation){foreign-button{{param($s)$s.controls['9800'].processId=7777}} wrong-title{{param($s)$s.title='Different modal'}} covered-point{{param($s)$s.hitChild=[IntPtr]9904}}} + $failure=$null;try{$null=Invoke-MultiProjectRenameAction ([IntPtr]42) 4242 $renameText $renameProof -nativeApi $api}catch{$failure=$_} + Assert-Mixed ($failure -and $failure.Exception.ToString().Contains('native target changed immediately before input') -and + $api.inputCalls -eq 0 -and $api.hitReads -eq 2) 'Production Click entered primitive input after final target drift' + } +} +Invoke-Mixed 3040 'stale-native-read-primary-and-unavailable-fields' { + $api=New-MixedRename;$api.state.staleClient=$true;$seen=@{failure=$null} + $records=@(& { + try{$null=Invoke-MultiProjectRenameAction ([IntPtr]42) 4242 $renameText $renameProof -nativeApi $api}catch{$seen.failure=$_} + } 6>&1) + Assert-Mixed ($seen.failure -and $seen.failure.Exception.ToString().Contains('controlled stale native client')) 'Primary stale error replaced' + $failures=@($records|Where-Object {$_ -is [Management.Automation.InformationRecord] -and ([string]$_.MessageData).StartsWith('UIA_MULTIPROJECT_RENAME_NATIVE_FAILURE=',[StringComparison]::Ordinal)}| + ForEach-Object {([string]$_.MessageData).Substring('UIA_MULTIPROJECT_RENAME_NATIVE_FAILURE='.Length)|ConvertFrom-Json}) + Assert-Mixed ($api.inputCalls -eq 0 -and $failures.Count -eq 1 -and $failures[0].failure.errorType -ceq 'System.ComponentModel.Win32Exception' -and + $failures[0].failure.nativeErrorCode -eq 6 -and -not $failures[0].inputAttempted -and $null -eq $failures[0].observed.button) 'Stale error lost typed availability metadata or sent input' +} +Invoke-Mixed 3057 'compiled-rename-binding-without-invocation' { + $api=New-MultiProjectRenameNativeApi + Assert-Mixed ($mixedAssembly.GetType('GraphCodeMultiProjectRenameNative',$false) -ne $null -and + $null -ne $api.PSObject.Methods['ClientBounds'] -and $null -ne $api.PSObject.Methods['Hit']) 'Current file-backed Rename adapter unavailable' +} +$captureReader={param($element,[bool]$content)$element.Capture($content)} +$capturePrior=@{ownership='owned';finalPID=@{value=4242};automationID=@{value='canvas-card-1983941480823304696'}} +Invoke-Mixed 3129 'fresh-owned-before-content-and-after' { + $mock=New-MixedCapture @((New-MixedCaptureState),(New-MixedCaptureState),(New-MixedCaptureState)) + $e=Get-MultiProjectElementEvidence $mock 4242 $capturePrior 'mixed/owned' $captureReader + Assert-Mixed ($e.processId -eq 4242 -and $e.automationId -ceq $capturePrior.automationID.value -and $e.name -ceq 'Alpha loop' -and + ($e.bounds -join ',') -ceq '248,118,468,204' -and + ($mock.order -join '|') -ceq 'OWNERSHIP_CACHE|PID|ID|RUNTIME|CONTENT_CACHE|PID|ID|RUNTIME|BOUNDS|NAME|OWNERSHIP_CACHE|PID|ID|RUNTIME') 'Fresh cached ownership/content ordering or output shape changed' +} +Invoke-Mixed 3138 'unsupported-is-not-zero-default' { + $unsupported=Read-MultiProjectOwnershipSnapshot (New-MixedCapture @((New-MixedCaptureState ([System.Windows.Automation.AutomationElement]::NotSupported)))) $captureReader + $zero=Read-MultiProjectOwnershipSnapshot (New-MixedCapture @((New-MixedCaptureState 0))) $captureReader + Assert-Mixed ($unsupported.pidState.state -ceq 'unsupported' -and $null -eq $unsupported.processId -and + $zero.pidState.state -ceq 'zero' -and $zero.processId -eq 0 -and $unsupported.state -ceq 'unavailable' -and $zero.state -ceq 'unavailable') 'Unsupported/default ownership states conflated' +} +foreach($mutation in @( + 'fresh-zero','fresh-null-pid','fresh-unsupported-pid','fresh-pid-type','fresh-foreign','fresh-null-id','fresh-empty-id','fresh-id-type','fresh-id-drift', + 'fresh-null-runtime','fresh-runtime-type','fresh-retired','prior-owner-changed','prior-pid-changed', + 'content-foreign','content-zero','content-unsupported','content-id-drift','content-runtime-drift', + 'after-foreign','after-zero','after-id-drift','after-runtime-drift','after-retired','name-type','name-unsupported','bounds-null','bounds-zero','bounds-empty' +)){ + Invoke-Mixed 3154 $mutation { + $states=@((New-MixedCaptureState),(New-MixedCaptureState),(New-MixedCaptureState)) + $prior=@{ownership='owned';finalPID=@{value=4242};automationID=@{value=$capturePrior.automationID.value}} + switch($mutation){ + fresh-zero{$states[0].pid=0} fresh-null-pid{$states[0].pid=$null} fresh-unsupported-pid{$states[0].pid=[System.Windows.Automation.AutomationElement]::NotSupported} + fresh-pid-type{$states[0].pid='4242'} fresh-foreign{$states[0].pid=7777} fresh-null-id{$states[0].id=$null} fresh-empty-id{$states[0].id=''} + fresh-id-type{$states[0].id=7} fresh-id-drift{$states[0].id='canvas-card-unknown'} fresh-null-runtime{$states[0].runtime=@()} fresh-runtime-type{$states[0].runtime=@('3',42,17)} + fresh-retired{$states[0].error=[Runtime.InteropServices.COMException]::new('controlled retirement',[int]0x80040201)} + prior-owner-changed{$prior.ownership='changed'} prior-pid-changed{$prior.finalPID.value=7777} + content-foreign{$states[1].pid=7777} content-zero{$states[1].pid=0} content-unsupported{$states[1].pid=[System.Windows.Automation.AutomationElement]::NotSupported} + content-id-drift{$states[1].id='canvas-card-unknown'} content-runtime-drift{$states[1].runtime=@(3,42,99)} + after-foreign{$states[2].pid=7777} after-zero{$states[2].pid=0} after-id-drift{$states[2].id='canvas-card-unknown'} + after-runtime-drift{$states[2].runtime=@(3,42,99)} after-retired{$states[2].error=[Runtime.InteropServices.COMException]::new('controlled retirement',[int]0x80040201)} + name-type{$states[1].name=7} name-unsupported{$states[1].name=[System.Windows.Automation.AutomationElement]::NotSupported} + bounds-null{$states[1].rect=$null} bounds-zero{$states[1].rect=[System.Windows.Rect]::new(248,118,0,86)} bounds-empty{$states[1].rect=[System.Windows.Rect]::Empty}} + $mock=New-MixedCapture $states + $prefix=if($mutation -cin @('fresh-foreign','content-foreign','after-foreign')){'MULTIPROJECT_PRIVACY:'}else{'MULTIPROJECT_METADATA:'} + $null=Reject-Mixed {Get-MultiProjectElementEvidence $mock 4242 $prior "mixed/$mutation" $captureReader} $prefix + $contentAllowed=$mutation.StartsWith('after-',[StringComparison]::Ordinal) -or $mutation.StartsWith('name-',[StringComparison]::Ordinal) -or $mutation.StartsWith('bounds-',[StringComparison]::Ordinal) + Assert-Mixed (($mock.order -contains 'NAME') -eq $contentAllowed -and ($mock.order -contains 'BOUNDS') -eq $contentAllowed) 'Content sampled before fresh ownership/runtime guard' + if($mutation.StartsWith('fresh-',[StringComparison]::Ordinal) -or $mutation.StartsWith('prior-',[StringComparison]::Ordinal)){ + Assert-Mixed (-not ($mock.order -contains 'CONTENT_CACHE')) 'Content cache requested before positive owned preflight' + } + } +} +Invoke-Mixed 3202 'unknown-cache-error-keeps-primary' { + $state=New-MixedCaptureState;$state.error=[InvalidOperationException]::new('controlled unexpected cache error') + $mock=New-MixedCapture @($state);$failure=$null + try{$null=Read-MultiProjectOwnershipSnapshot $mock $captureReader}catch{$failure=$_} + Assert-Mixed ($failure -and $failure.Exception.ToString().Contains('controlled unexpected cache error') -and -not ($mock.order -contains 'NAME')) 'Unknown cache error swallowed or content sampled' +} +Invoke-Mixed 3214 'retirement-exact-typed-availability' { + $state=New-MixedCaptureState;$state.error=[Runtime.InteropServices.COMException]::new('controlled retirement',[int]0x80040201) + $mock=New-MixedCapture @($state);$snapshot=Read-MultiProjectOwnershipSnapshot $mock $captureReader + Assert-Mixed ($snapshot.state -ceq 'unavailable' -and $snapshot.pidState.state -ceq 'unavailable' -and + $snapshot.errorType -ceq 'System.Runtime.InteropServices.COMException' -and $snapshot.hresult -eq [int]0x80040201 -and + ($mock.order -join '|') -ceq 'OWNERSHIP_CACHE') 'Retirement lost exact typed diagnostic' +} +Invoke-Mixed 3224 'unrelated-COM-failure-is-not-retirement' { + $state=New-MixedCaptureState;$state.error=[Runtime.InteropServices.COMException]::new('controlled unexpected COM',[int]0x80004005) + $mock=New-MixedCapture @($state);$failure=$null + try{$null=Read-MultiProjectOwnershipSnapshot $mock $captureReader}catch{$failure=$_} + Assert-Mixed ($failure -and $failure.Exception.ToString().Contains('controlled unexpected COM') -and $mock.calls -eq 1 -and -not ($mock.order -contains 'NAME')) 'Unknown COM converted to unavailable fallback' +} +foreach($scenario in @('transient','persistent','positive-foreign')){ + Invoke-Mixed 3237 $scenario { + $multiProcess=[pscustomobject]@{Id=4242} + $observationState=@{attempts=0;sleeps=0;scenario=$scenario} + function Start-Sleep {param([int]$Milliseconds)$observationState.sleeps++} + function Get-MultiProjectObservation { + param([string]$surface,$selectedOwner)$observationState.attempts++ + $state=New-MixedCaptureState + if($observationState.scenario -ceq 'positive-foreign'){$state.pid=7777} + elseif($observationState.scenario -ceq 'persistent' -or $observationState.attempts -eq 1){$state.pid=0} + $mock=New-MixedCapture @($state,(New-MixedCaptureState),(New-MixedCaptureState)) + $e=Get-MultiProjectElementEvidence $mock 4242 $capturePrior 'mixed/whole-observation' $captureReader + return @{matched=$true;requestedSurface=$surface;evidence=$e} + } + if($scenario -ceq 'transient'){ + $observation=Wait-MultiProjectObservation overview + Assert-Mixed ($observation.matched -and $observationState.attempts -eq 2 -and $observationState.sleeps -eq 1) 'Transient whole capture not reacquired exactly once' + }else{ + $prefix=if($scenario -ceq 'positive-foreign'){'MULTIPROJECT_PRIVACY:'}else{'MULTIPROJECT_METADATA:'} + $null=Reject-Mixed {Wait-MultiProjectObservation overview} $prefix + $expected=if($scenario -ceq 'positive-foreign'){1}else{100} + Assert-Mixed ($observationState.attempts -eq $expected -and $observationState.sleeps -eq ($expected-1)) 'Foreign capture retried or persistent ambiguity escaped exact attempt bound' + } + } +} +Invoke-Mixed 3268 'whole-capture-rejects-runtime-replacement' { + $snapshots=[Collections.Generic.List[object]]::new() + $mock=New-MixedCapture @((New-MixedCaptureState),(New-MixedCaptureState),(New-MixedCaptureState)) + $null=Get-MultiProjectElementEvidence $mock 4242 $capturePrior 'mixed/collection' $captureReader $snapshots + Assert-Mixed ($snapshots.Count -eq 1) 'Verified snapshot was not registered' + $changed=New-MixedCaptureState;$changed.runtime=@(3,42,99) + $after=Read-MultiProjectOwnershipSnapshot (New-MixedCapture @($changed)) $captureReader + $null=Reject-Mixed {Confirm-MultiProjectOwnedSnapshot $snapshots[0].snapshot $after 4242 'mixed/whole-observation'} 'MULTIPROJECT_METADATA:' +} +Invoke-Mixed 3278 'prefix-does-not-coerce-unavailable-ID' { + Assert-Mixed (-not (Test-MultiProjectAutomationPrefix $null workspace-loop-bar-) -and -not (Test-MultiProjectAutomationPrefix 7 canvas-card-)) 'Unavailable/wrong typed IDs became prefix matches' +} +Invoke-Mixed 3283 'metadata-PID-first-and-again' { + $mock=New-MixedMetadata 4242 'canvas-card-1983941480823304696' + $m=Get-MultiProjectFragmentMetadata $mock 4242 + Assert-Mixed ($m.observationReady -and $m.ownership -ceq 'owned' -and $m.automationID.state -ceq 'available' -and $m.family -ceq 'canvas-fragment' -and + ($mock.State.order -join '|') -ceq 'PID|ID|PID') 'Owned metadata shape or PID ordering differs' +} +Invoke-Mixed 3291 'static-source-chrome-classification' { + $m=Get-MultiProjectFragmentMetadata (New-MixedMetadata 4242 actual-size) 4242 + Assert-Mixed ($m.observationReady -and $m.family -ceq 'source-supported-chrome') 'Known static chrome rejected' +} +Invoke-Mixed 3296 'owned-restart-in-complete-batch' { + $mock=New-MixedMetadata 4242 'workspace-restart-session-2140902926206787169' + $m=Get-MultiProjectFragmentMetadata $mock 4242 + Assert-Mixed ($m.observationReady -and $m.ownership -ceq 'owned' -and $m.family -ceq 'source-supported-chrome' -and + (Test-MultiProjectMetadataBatch @($m)) -and ($mock.State.order -join '|') -ceq 'PID|ID|PID') 'Owned numeric restart chrome lost exact identity/metadata proof' +} +foreach($test in @( + @{name='foreign restart';pid=7777;after=7777;id='workspace-restart-session-2140902926206787169'}, + @{name='changed restart owner';pid=4242;after=7777;id='workspace-restart-session-2140902926206787169'}, + @{name='missing restart PID';pid=0;after=0;id='workspace-restart-session-2140902926206787169'}, + @{name='missing restart ID';pid=4242;after=4242;id=$null},@{name='invalid typed restart ID';pid=4242;after=4242;id=7}, + @{name='empty restart suffix';pid=4242;after=4242;id='workspace-restart-session-'}, + @{name='nonnumeric restart suffix';pid=4242;after=4242;id='workspace-restart-session-foreign'}, + @{name='restart extra suffix';pid=4242;after=4242;id='workspace-restart-session-2140902926206787169-extra'} +)){ + Invoke-Mixed 3314 $test.name { + $mock=New-MixedMetadata $test.pid $test.id $test.after;$m=Get-MultiProjectFragmentMetadata $mock 4242 + Assert-Mixed (-not $m.observationReady -and -not (Test-MultiProjectMetadataBatch @($m)) -and -not ($mock.State.order -contains 'FORBIDDEN_NAME')) 'Restart taxonomy accepted unresolved/foreign identity' + } +} +foreach($test in @( + @{name='null native candidate';pid=0;id=$null;after=0;state='unavailable';owner='unavailable'}, + @{name='foreign null ID';pid=7777;id=$null;after=7777;state='unavailable';owner='foreign'}, + @{name='empty owned ID';pid=4242;id='';after=4242;state='empty';owner='owned'}, + @{name='invalid typed ID';pid=4242;id=7;after=4242;state='invalid';owner='owned'}, + @{name='unavailable stale ID';pid=4242;id='unused';after=4242;state='unavailable';owner='owned';unavailable=$true}, + @{name='changed ownership';pid=4242;id='canvas-card-1983941480823304696';after=7777;state='available';owner='changed'}, + @{name='unknown native-boundary ID';pid=7777;id='native-boundary';after=7777;state='available';owner='foreign'} +)){ + Invoke-Mixed 3331 $test.name { + $mock=New-MixedMetadata $test.pid $test.id $test.after -Unavailable:($test.ContainsKey('unavailable') -and $test.unavailable) + $m=Get-MultiProjectFragmentMetadata $mock 4242 + Assert-Mixed (-not $m.observationReady -and $m.automationID.state -ceq $test.state -and $m.ownership -ceq $test.owner -and + -not (Test-MultiProjectMetadataBatch @($m)) -and -not ($mock.State.order -contains 'FORBIDDEN_NAME') -and $m.proofLimit.Length -gt 0) 'Unresolved/stale metadata silently dropped or foreign Name read' + } +} +Invoke-Mixed 3341 'foreign-semantic-fragment-remains-counted' { + $mock=New-MixedMetadata 7777 'canvas-card-1983941480823304696';$m=Get-MultiProjectFragmentMetadata $mock 4242 + Assert-Mixed ($m.observationReady -and $m.ownership -ceq 'foreign' -and + (Test-MultiProjectAutomationPrefix $m.automationID.value canvas-card-) -and -not ($mock.State.order -contains 'FORBIDDEN_NAME')) 'Foreign semantic metadata lost before PID-only classification' + $actual=@([ordered]@{automationId=$m.automationID.value;name='Owned fixture placeholder';processId=7777;bounds=@(248,118,468,204)}) + Assert-Mixed (-not (Test-MultiProjectFragmentRoster $actual @([ordered]@{automationId='canvas-card-1983941480823304696';name='Owned fixture placeholder'}) 4242)) 'Wrong PID semantic fragment accepted' +} +$expected=[ordered]@{ + 2706=3;2724=1;2732=2;2742=1;2751=10;2785=8;2808=22;2845=1;2850=1;2857=1;2871=2;2897=1;2906=1;2924=59;3013=2;3024=3;3040=1;3057=1 + 3129=1;3138=1;3154=29;3202=1;3214=1;3224=1;3237=3;3268=1;3278=1;3283=1;3291=1;3296=1;3314=8;3331=7;3341=1 +} +Complete-Mixed $expected @('mixed-ui','mixed-ui-policy','native-phase-refusal','fresh-metadata','powershell-errorrecord') +$controlFailed=$false +foreach($control in @('false','throw')){ + $failure=$null + try{ + $api=New-MixedEdit + if($control -ceq 'false'){$api.PhaseState.focusControlResult=$false} + else{$api.PhaseState.focusControlError=[IO.IOException]::new('controlled focus primitive failure')} + $caught=$null + try{$null=$api.FocusMouse([IntPtr]42,[IntPtr]9904,@(100,110),4242,[long]0)}catch{$caught=$_} + Assert-Mixed ($caught -and $api.PhaseState.focusControlCalls -eq 1 -and $api.PhaseState.focusClicks -eq 1 -and + $api.PhaseState.clearCalls -eq 0 -and $api.PhaseState.unicodeCalls -eq 0) 'Focus primitive failure was swallowed or followed by key input' + if($control -ceq 'false'){ + Assert-Mixed ($caught.Exception.ToString().Contains('MULTIPROJECT_EDIT_FOCUS: direct control focus fallback failed') -and + -not $api.LastFocusControlFallback) 'False focus primitive lost actual fallback refusal' + }else{ + Assert-Mixed ($caught.Exception.GetBaseException() -is [IO.IOException] -and + $caught.Exception.GetBaseException().Message -ceq 'controlled focus primitive failure') 'Focus primitive throw replaced exact primary error' + } + }catch{$failure=$_;$controlFailed=$true} + $row=[ordered]@{ + id="ownership.mixed-ui.focus-control-$control";status=$(if($failure){'Failed'}else{'Passed'});executedCount=1 + evidence=$(if($failure){$failure.ToString()}else{'Actual production FocusMouse primitive false/throw refusal; no clear/Unicode; test-only operation transport'}) + groups=@('mixed-ui','mixed-ui-policy','focus-control-boundary','powershell-errorrecord') + } + $json=$row|ConvertTo-Json -Compress -Depth 8 -EscapeHandling EscapeNonAscii + $document=[System.Text.Json.JsonDocument]::Parse([string]$json);$document.Dispose() + Write-Output ('OWNERSHIP_CASE_RESULT='+$json) +} +if($controlFailed){exit 1} diff --git a/Tools/windows/ValidationRunner/Ui/MixedPolicyContracts.ps1 b/Tools/windows/ValidationRunner/Ui/MixedPolicyContracts.ps1 new file mode 100644 index 00000000..d30d7bc3 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/MixedPolicyContracts.ps1 @@ -0,0 +1,466 @@ +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath, + [switch] $ImportOnly +) +$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) +$OutputEncoding = [Text.UTF8Encoding]::new($false) +$script:MixedWindowsRoot = Join-Path $RepositoryRoot 'Tools\windows' +$validationAssemblyPath = $AssemblyPath +$mixedAssembly = & (Join-Path $script:MixedWindowsRoot 'Import-CompiledValidationAssembly.ps1') ` + -AssemblyPath $AssemblyPath -RequiredType 'GraphCodeUiaGateState' +foreach ($frameworkFile in @('WindowsBase.dll', 'UIAutomationTypes.dll', 'UIAutomationClient.dll')) { + $null = [Reflection.Assembly]::LoadFrom((Join-Path $PSHOME $frameworkFile)) +} +$script:MixedCases = [Collections.Generic.List[object]]::new() +$gateSource = [IO.File]::ReadAllText((Join-Path $script:MixedWindowsRoot 'uia-live-gate.ps1')) +$gateAst = $null +foreach ($productionPath in @('Stub-Daemon.ps1', 'uia-live-gate.ps1')) { + $parseErrors = $null + $productionAst = [Management.Automation.Language.Parser]::ParseFile( + (Join-Path $script:MixedWindowsRoot $productionPath), [ref]$null, [ref]$parseErrors) + if ($parseErrors.Count) { throw "Production source does not parse: $productionPath" } + if ($productionPath -ceq 'uia-live-gate.ps1') { $gateAst = $productionAst } + foreach ($definition in $productionAst.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + ($node.Name -like '*-MultiProject*' -or $node.Name -cin @( + 'ConvertTo-SketchCanonicalJson', 'Test-RetryableUiaError', 'Test-UpdateBannerClickTarget', + 'Get-DynamicProjectCardSnapshotDecision', 'Get-ConnectorDragSnapshotDecision', + 'Get-WorkspaceTabMountDecision', 'Get-UiaSelectionEventDecision')) + }, $true)) { + # Keep the actual helper's file-backed loader rooted at its production directory. + . ([scriptblock]::Create($definition.Extent.Text.Replace('$PSScriptRoot', '$script:MixedWindowsRoot'))) + } +} +function Assert-Mixed([bool] $Condition, [string] $Message) { + if (-not $Condition) { throw [InvalidOperationException]::new($Message) } +} +function Reject-Mixed([scriptblock] $Action, [string] $Prefix) { + try { $null = & $Action } catch { + Assert-Mixed ($_.Exception.Message.StartsWith($Prefix, [StringComparison]::Ordinal)) "Wrong rejection: $($_.Exception.Message)" + return $_ + } + throw [InvalidOperationException]::new("Expected actual $Prefix rejection") +} +function Invoke-Mixed([int] $Line, [string] $Name, [scriptblock] $Action) { + $failure = $null + try { $null = & $Action 6>&1 } catch { $failure = $_ } + $script:MixedCases.Add([ordered]@{ + line = $Line; name = $Name; passed = ($null -eq $failure) + failure = $(if ($failure) { $failure.ToString() } else { $null }) + }) +} +function Complete-Mixed([Collections.IDictionary] $Expected, [string[]] $Groups) { + $failed = $false + foreach ($entry in $Expected.GetEnumerator()) { + $line = $entry.Key + $expectedCount = $entry.Value + $observed = @($script:MixedCases | Where-Object line -EQ ([int]$line)) + $failures = @($observed | Where-Object { -not $_.passed }) + $status = if ($observed.Count -eq 0) { 'Incomplete' } + elseif ($observed.Count -ne $expectedCount -or $failures.Count) { 'Failed' } else { 'Passed' } + if ($status -cne 'Passed') { $failed = $true } + $row = [ordered]@{ + id = "ownership.mixed-ui.source-line-$line"; status = $status; executedCount = $observed.Count + evidence = (ConvertTo-Json -InputObject ([ordered]@{ + source = 'Tools/windows/Tests/ValidationRunner.Tests.ps1'; invocationLine = [int]$line + expectedExpandedCount = $expectedCount; cases = $observed + productionInputs = 'Current production gate and compiled bindings'; nativeProductCalls = 0 + }) -Depth 14 -Compress -EscapeHandling EscapeNonAscii) + groups = $Groups + } + $json = $row | ConvertTo-Json -Depth 16 -Compress -EscapeHandling EscapeNonAscii + $document = [System.Text.Json.JsonDocument]::Parse([string]$json) + $document.Dispose() + Write-Output ('OWNERSHIP_CASE_RESULT=' + $json) + } + foreach ($case in $script:MixedCases) { + if (-not $Expected.Contains([int]$case.line)) { throw "Unregistered mixed invocation line: $($case.line)" } + } + if ($failed) { exit 1 } +} +function New-MixedMetadata($Before, $Identity, $After=$null, [switch]$Unavailable) { + $state=@{before=$Before;after=$(if($null -eq $After){$Before}else{$After});id=$Identity;unavailable=[bool]$Unavailable;reads=0;order=[Collections.Generic.List[string]]::new()} + $current=[pscustomobject]@{State=$state} + $current|Add-Member ScriptProperty ProcessId {$this.State.order.Add('PID');$this.State.reads++;if($this.State.reads -eq 1){$this.State.before}else{$this.State.after}} + $current|Add-Member ScriptProperty AutomationId {$this.State.order.Add('ID');if($this.State.unavailable){throw [Runtime.InteropServices.COMException]::new('controlled stale UIA metadata',[int]0x80040201)};$this.State.id} + $current|Add-Member ScriptProperty Name {$this.State.order.Add('FORBIDDEN_NAME');throw 'Foreign or unresolved Name must not be read'} + return [pscustomobject]@{Current=$current;State=$state} +} +function New-MixedCaptureState($PidValue=4242,$Identity='canvas-card-1983941480823304696') { + @{pid=$PidValue;id=$Identity;runtime=@(3,42,17);name='Alpha loop';rect=[System.Windows.Rect]::new(248,118,220,86);error=$null} +} +function New-MixedCapture($States) { + $element=[pscustomobject]@{states=$States;calls=0;order=[Collections.Generic.List[string]]::new()} + $element|Add-Member ScriptMethod Capture { + param([bool]$IncludeContent) + $state=$this.states[$this.calls];$this.calls++;$this.order.Add($(if($IncludeContent){'CONTENT_CACHE'}else{'OWNERSHIP_CACHE'})) + if($state.error){throw $state.error} + $cache=[pscustomobject]@{state=$state;order=$this.order;content=$IncludeContent} + $cache|Add-Member ScriptMethod GetCachedPropertyValue { + param($property,[bool]$IgnoreDefault) + if(-not $IgnoreDefault){throw 'Cache must request ignoreDefaultValue=true'} + if($property -eq [System.Windows.Automation.AutomationElement]::ProcessIdProperty){$this.order.Add('PID');return $this.state.pid} + if($property -eq [System.Windows.Automation.AutomationElement]::AutomationIdProperty){$this.order.Add('ID');return $this.state.id} + if(-not $this.content){throw 'Ownership cache cannot expose content'} + if($property -eq [System.Windows.Automation.AutomationElement]::BoundingRectangleProperty){$this.order.Add('BOUNDS');return $this.state.rect} + if($property -eq [System.Windows.Automation.AutomationElement]::NameProperty){$this.order.Add('NAME');return $this.state.name} + throw 'Unexpected cached property' + } + $cache|Add-Member ScriptMethod GetRuntimeId {$this.order.Add('RUNTIME');return ,$this.state.runtime} + return $cache + } + return $element +} + +if ($ImportOnly) { return } +$null = [IO.Directory]::CreateDirectory($ArtifactRoot) +$a = '11111111-1111-4111-8111-111111111111' +$b = '22222222-2222-4222-8222-222222222222' +$alpha = Join-Path $ArtifactRoot 'Alpha' +$beta = Join-Path $ArtifactRoot 'Beta' +function New-MixedTab([string] $Id, [string] $Name, [bool] $Selected) { + [ordered]@{ automationId = $Id; name = $Name; selected = $Selected } +} +$initial = New-MixedTab 'workspace-tab-initial' 'Goal' $true +$new = New-MixedTab 'workspace-tab-new' 'Shell' $true +$initialOff = New-MixedTab 'workspace-tab-initial' 'Goal' $false +$newOff = New-MixedTab 'workspace-tab-new' 'Shell' $false +Invoke-Mixed 1724 'narrow-mounted-tabs' { + $d = Get-WorkspaceTabMountDecision @($initial) @($new) @($initialOff,$new) @($initial,$newOff) @($initialOff,$new) + Assert-Mixed ($d.ready -and $d.overflowObserved -and $d.initialId -ceq 'workspace-tab-initial' -and $d.newId -ceq 'workspace-tab-new') 'Narrow mounted-tab roundtrip differs' +} +Invoke-Mixed 1733 'wide-mounted-tabs' { + $d = Get-WorkspaceTabMountDecision @($initial) @($initialOff,$new) @($initialOff,$new) @($initial,$newOff) @($initialOff,$new) + Assert-Mixed ($d.ready -and -not $d.overflowObserved) 'Wide mounted-tab roundtrip differs' +} +foreach ($test in @( + @{ name='same-id'; after=@(New-MixedTab 'workspace-tab-initial' 'Shell' $true); mounted=@($initialOff,$new); first=@($initial,$newOff); second=@($initialOff,$new); failure='new-tab' }, + @{ name='missing-mounted-initial'; after=@($new); mounted=@($new); first=@($initial,$newOff); second=@($initialOff,$new); failure='mounted-pair' }, + @{ name='renamed-mounted-initial'; after=@($new); mounted=@((New-MixedTab 'workspace-tab-initial' 'Agent' $false),$new); first=@($initial,$newOff); second=@($initialOff,$new); failure='mounted-pair' }, + @{ name='initial-switch-did-not-select'; after=@($new); mounted=@($initialOff,$new); first=@($initialOff,$new); second=@($initialOff,$new); failure='initial-switch' }, + @{ name='new-switch-changed-id'; after=@($new); mounted=@($initialOff,$new); first=@($initial,$newOff); second=@($initialOff,(New-MixedTab 'workspace-tab-replaced' 'Shell' $true)); failure='new-switch' }, + @{ name='duplicate-mounted-selection'; after=@($new); mounted=@($initial,$new); first=@($initial,$newOff); second=@($initialOff,$new); failure='mounted-pair' } +)) { + Invoke-Mixed 1749 $test.name { + $d = Get-WorkspaceTabMountDecision @($initial) @($test.after) @($test.mounted) @($test.first) @($test.second) + Assert-Mixed (-not $d.ready -and $d.failure -ceq $test.failure) 'Permanent mounted-tab failure changed' + } +} +Invoke-Mixed 1757 'stable-selection-source' { + Assert-Mixed (Get-UiaSelectionEventDecision 1 'worktree-row-safe' '42,7,9' 'worktree-row-safe' '42,7,9').ready 'Stable event rejected' +} +foreach ($test in @( + @{name='zero';count=0;id='worktree-row-safe';runtime='42,7,9';failure='zero-events'}, + @{name='duplicate';count=2;id='worktree-row-safe';runtime='42,7,9';failure='duplicate-events'}, + @{name='wrong-element';count=1;id='worktree-row-foreign';runtime='42,7,9';failure='wrong-automation-id'}, + @{name='wrong-generation';count=1;id='worktree-row-safe';runtime='42,7,10';failure='wrong-runtime-id'} +)) { + Invoke-Mixed 1769 $test.name { + $d = Get-UiaSelectionEventDecision $test.count $test.id $test.runtime 'worktree-row-safe' '42,7,9' + Assert-Mixed (-not $d.ready -and $d.failure -ceq $test.failure) 'Selection refusal changed' + } +} +Invoke-Mixed 1777 'narrow-lane-and-real-pan' { + $g = Get-MultiProjectLaneGeometry @(220,34,1028,900) @(382,134,632,230) + Assert-Mixed (($g.band -join ',') -ceq '300,96,1822,250' -and ($g.open -join ',') -ceq '1690,106,1746,126') 'Compact Mac lane differs' + $null = Reject-Mixed { Get-MultiProjectClippedRectangle $g.open @(220,34,1028,900) } 'MULTIPROJECT_BOUNDS' + $g = Get-MultiProjectLaneGeometry @(220,34,1028,900) @(-390,134,-140,230) + $worktrees = @(($g.band[2]-72),106,($g.band[2]-18),126) + $clipped = Get-MultiProjectClippedRectangle $worktrees @(220,34,1028,900) + Assert-Mixed (($worktrees -join ',') -ceq '978,106,1032,126' -and ($clipped -join ',') -ceq '978,106,1028,126') 'Narrow Worktrees clipping hidden' +} +Invoke-Mixed 1789 'wide-lane-and-dpi' { + $g = Get-MultiProjectLaneGeometry @(220,34,1200,900) @(382,134,632,230) + $scaled = Get-MultiProjectLaneGeometry @(330,51,1542,1350) @(573,201,948,345) + Assert-Mixed (($g.band -join ',') -ceq '300,96,1822,250' -and ($scaled.band -join ',') -ceq '450,144,2733,375' -and + ($scaled.open -join ',') -ceq '2535,159,2619,189') 'Lane width or once-only scaling differs' + $negative = Get-MultiProjectLaneGeometry @(358,215,1402,942) @(19,315,268,411) @(1032,287,1318,307) + Assert-Mixed ($negative.scale -eq 1 -and ($negative.open -join ',') -ceq '1326,287,1382,307') 'Horizontal pan corrupts anchor' + for ($i=0;$i -lt 4;$i++) { Assert-Mixed ([Math]::Abs($negative.summary[$i]-@(1032,287,1318,307)[$i]) -le 1) 'Summary exceeds one-pixel budget' } +} +Invoke-Mixed 1804 'rounded-fitted-card' { + $g = Get-MultiProjectLaneGeometry @(228,85,1036,780) @(390,185,500,227) + Assert-Mixed ($g.scale -eq 0.44 -and $g.scaleSource -ceq 'card-width-rounded' -and + [Math]::Abs($g.normalizedHeight-95.45454545454545) -lt $g.heightTolerance) 'Fitted rounding budget lost' +} +Invoke-Mixed 1811 'obsolete-or-clipped-card-refused' { + foreach ($card in @(@(262,118,482,204),@(19,210,268,306),@(19,315,268,400),@(19,315,267,411))) { + $canvas = if ($card[0] -eq 262) { @(220,34,1200,900) } else { @(358,215,1402,942) } + $null = Reject-Mixed { Get-MultiProjectLaneGeometry $canvas $card } 'MULTIPROJECT_BOUNDS' + } +} +Invoke-Mixed 1818 'obsolete-lane-floor-refused' { + $g=Get-MultiProjectLaneGeometry @(220,34,1028,900) @(382,134,632,230) + Assert-Mixed (($g.band -join ',') -cne '244,72,1004,248' -and ($g.open -join ',') -cne '872,82,928,102') 'Old floor accepted' +} +$leftQuote=[char]0x201c;$rightQuote=[char]0x201d +$exactRename="Rename $leftQuote" + "Alpha renamed$rightQuote" +Invoke-Mixed 1824 'exact-unicode-rename-title' { + $title=Get-MultiProjectRenameTitle 'Alpha renamed' + Assert-Mixed ($title -ceq $exactRename -and (Test-MultiProjectRenameTitle $title $title)) 'Exact dynamic Rename title drifted' +} +foreach($title in @('Rename Loop','Rename "Alpha renamed"',"$exactRename extra","Rename $leftQuote$rightQuote",("Rename $leftQuote"+"Alpha`nrenamed$rightQuote"))) { + Invoke-Mixed 1837 $title { Assert-Mixed (-not (Test-MultiProjectRenameTitle $title $exactRename)) 'Malformed exact Rename title accepted' } +} +Invoke-Mixed 1843 'bounded-rename-fallback' { Assert-Mixed (Test-MultiProjectRenameTitle ("Rename $leftQuote"+"Owned loop$rightQuote")) 'Valid bounded fallback rejected' } +foreach($title in @('Rename Loop',"Rename $leftQuote$rightQuote",("Rename $leftQuote"+"nested $leftQuote"+"quote$rightQuote$rightQuote"),("Rename $leftQuote"+('x'*513)+$rightQuote))) { + Invoke-Mixed 1854 "malformed-length-$($title.Length)" { Assert-Mixed (-not (Test-MultiProjectRenameTitle $title)) 'Bounded Rename fallback widened' } +} +$sheet=[ordered]@{processId=4242;handle=[long]42;owner=[long]7;className='GraphCodeSheetHost';title=("Delete "+$leftQuote+'Alpha renamed'+$rightQuote+'?');contentClass='GraphCodeWindowsDialogContent';visible=$true;captionless=$true} +Invoke-Mixed 1870 'owned-captionless-delete-sheet' { + Assert-Mixed (Test-MultiProjectOwnedSheetIdentity $sheet 4242 ([IntPtr]7) $sheet.title 'GraphCodeWindowsDialogContent') 'Exact owned sheet rejected' +} +foreach($mutation in @('pid','owner','class','title','content','hidden','captioned','zero-handle')) { + Invoke-Mixed 1879 $mutation { + $candidate=[ordered]@{};foreach($key in $sheet.Keys){$candidate[$key]=$sheet[$key]} + switch($mutation){pid{$candidate.processId=7777} owner{$candidate.owner=[long]8} class{$candidate.className='#32770'} title{$candidate.title='Delete Loop'} + content{$candidate.contentClass='GraphCodeNativeForm'} hidden{$candidate.visible=$false} captioned{$candidate.captionless=$false} zero-handle{$candidate.handle=[long]0}} + Assert-Mixed (-not (Test-MultiProjectOwnedSheetIdentity $candidate 4242 ([IntPtr]7) $sheet.title 'GraphCodeWindowsDialogContent')) 'Invalid sheet identity accepted' + } +} +$banner=[ordered]@{processId=4242;automationId='sidebar-update-banner-83fd6a5f429785e1';name='Update available';controlType='ControlType.Button';enabled=$true;left=208.0;top=704.0;right=412.0;bottom=754.0} +Invoke-Mixed 1917 'owned-banner-after-Later' { Assert-Mixed (Test-UpdateBannerClickTarget $banner 4242 ([IntPtr]42) ([IntPtr]42) 4242 'Update offer deferred') 'Owned update banner rejected' } +foreach($mutation in @('process','window','focus','status')) { + Invoke-Mixed 1924 $mutation { + $candidate=[ordered]@{};foreach($key in $banner.Keys){$candidate[$key]=$banner[$key]} + $foreground=[IntPtr]42;$focused=4242;$status='Update offer deferred' + switch($mutation){process{$candidate.processId=7777} window{$foreground=[IntPtr]43} focus{$focused=7777} status{$status='Update available'}} + Assert-Mixed (-not (Test-UpdateBannerClickTarget $candidate 4242 ([IntPtr]42) $foreground $focused $status)) 'Foreign or stale banner accepted' + } +} +Invoke-Mixed 1951 'actual-caption-assignment' { + $assignments=@($gateAst.FindAll({param($n)$n -is [Management.Automation.Language.AssignmentStatementAst] -and $n.Left -is [Management.Automation.Language.VariableExpressionAst] -and $n.Left.VariablePath.UserPath -ceq 'laneOpenScreenX'},$true)) + $obsolete=@($gateAst.FindAll({param($n)$n -is [Management.Automation.Language.AssignmentStatementAst] -and $n.Left -is [Management.Automation.Language.VariableExpressionAst] -and $n.Left.VariablePath.UserPath -ceq 'laneWorktreesScreenX'},$true)) + Assert-Mixed ($assignments.Count -eq 1 -and $obsolete.Count -eq 0) 'Caption assignment missing/ambiguous or obsolete click retained' + $assignment=[scriptblock]::Create('param($laneSnapshot) '+$assignments[0].Extent.Text+';return $laneOpenScreenX') + foreach($x in @(614,866,1013)){Assert-Mixed ((& $assignment ([pscustomobject]@{OpenX=$x})) -eq $x) 'Actual caption assignment ignored exported snapshot'} +} + +# Remaining policy families use literal source-derived identities and geometry, +# never a local copy of the production predicates. +$workspaceOwner=@{path='A';node=$a;name='Alpha';title='Alpha loop'} +$rosterOwners=@($workspaceOwner,@{path='B';node=$b;name='Beta';title='Beta loop'}) +function New-MixedWorkspace { + [ordered]@{loopBars=@([ordered]@{automationId='workspace-loop-bar-1320898360543276851';name='Selected loop workspace';processId=4242;bounds=@(220,34,1200,80)}) + toolbars=@([ordered]@{automationId='workspace-toolbar-2086745613872223135';name='Alpha';processId=4242;bounds=@(8,1,280,33)});foreignWorkspaceFragmentCount=0} +} +function New-MixedRoster { + [ordered]@{ + projectRows=@([ordered]@{automationId='open-project-1737218920896369762';name='Alpha';processId=4242;bounds=@(12,100,232,126)}, + [ordered]@{automationId='open-project-1737217821384741551';name='Beta';processId=4242;bounds=@(12,148,232,174)}) + cards=@([ordered]@{automationId='canvas-card-1331915895917204893';name='Alpha, 1 loop';processId=4242;bounds=@(318,106,1804,126)}, + [ordered]@{automationId='canvas-card-1929002346198335884';name='Alpha loop';processId=4242;bounds=@(382,134,632,230)}, + [ordered]@{automationId='canvas-card-1331912597382320260';name='Beta, 1 loop';processId=4242;bounds=@(318,284,1804,304)}, + [ordered]@{automationId='canvas-card-1616358459734844849';name='Beta loop';processId=4242;bounds=@(382,312,632,408)}) + foreignCardFragmentCount=0;foreignProjectFragmentCount=0;canvasBounds=@(220,34,1200,900) + } +} +Invoke-Mixed 3392 'owner-surface-hash' { + $ids=@((Get-MultiProjectAutomationId loop $alpha $a),(Get-MultiProjectAutomationId project-card $alpha $a), + (Get-MultiProjectAutomationId overview-card $alpha $a),(Get-MultiProjectAutomationId overview-card $beta $b)) + Assert-Mixed (@($ids|Sort-Object -Unique).Count -eq 4 -and (Get-MultiProjectAutomationId project-card 'graphcode://stub/project' $a) -ceq 'canvas-card-1510499067760483540') 'Owner/surface hashes or FNV golden differ' +} +$retainedAlpha='D:\a\_temp\gu-239af6fb60ce40b5b2846baf38b9a9d7\mp\Alpha' +$retainedBeta='D:\a\_temp\gu-239af6fb60ce40b5b2846baf38b9a9d7\mp\Beta' +$retainedOwners=@(@{path=$retainedAlpha;name='Alpha';node=$a;title='Alpha loop'},@{path=$retainedBeta;name='Beta';node=$b;title='Beta loop'}) +Invoke-Mixed 3423 'retained-identities-current-compact-geometry' { + $p=[ordered]@{projectRows=@( + [ordered]@{automationId='open-project-1729193928419568695';name='Alpha';processId=3140;bounds=@(20,222,240,248)}, + [ordered]@{automationId='open-project-2225278778924952707';name='Beta';processId=3140;bounds=@(20,308,240,334)}) + cards=@([ordered]@{automationId=Get-MultiProjectAutomationId overview-project $retainedAlpha;name='Alpha, 1 loop';processId=3140;bounds=@(246,133,1754,153)}, + [ordered]@{automationId='canvas-card-1662987038540838399';name='Alpha loop';processId=3140;bounds=@(270,169,490,255)}, + [ordered]@{automationId=Get-MultiProjectAutomationId overview-project $retainedBeta;name='Beta, 1 loop';processId=3140;bounds=@(246,329,1754,349)}, + [ordered]@{automationId='canvas-card-1884655564485847249';name='Beta loop';processId=3140;bounds=@(270,365,490,451)}) + foreignCardFragmentCount=0;foreignProjectFragmentCount=0;canvasBounds=@(228,85,1036,780)} + Assert-Mixed (-not (Test-MultiProjectObservedRoster overview $p $retainedOwners $null 3140)) 'Historical dimensions accepted' + $compact=Copy-MultiProjectValue $p + $compact.cards[0].bounds=@(326,157,1812,177);$compact.cards[1].bounds=@(390,185,640,281);$compact.cards[2].bounds=@(326,335,1812,355);$compact.cards[3].bounds=@(390,363,640,459) + Assert-Mixed (Test-MultiProjectObservedRoster overview $compact $retainedOwners $null 3140) 'Current compact roster rejected' + $p.cards[0].bounds=@(37,308,1226,324);$p.cards[1].bounds=@(88,330,288,406);$p.cards[2].bounds=@(37,450,1226,466);$p.cards[3].bounds=@(88,472,288,548) + Assert-Mixed (Test-MultiProjectObservedRoster overview $p $retainedOwners $null 3140) '80-percent rounded roster rejected' +} +Invoke-Mixed 3443 'caption-and-notice-hash-goldens' { + foreach($g in @(@('overview-project','A','1331915895917204893'),@('overview-project','B','1331912597382320260'), + @('overview-worktree-notice','A','1294898434078301384'),@('overview-worktree-notice','B','1294901732613186017'), + @('overview-worktree-notice',$retainedAlpha,'1669006899659070917'),@('overview-worktree-notice',$retainedBeta,'1518601899186967385'))) { + Assert-Mixed ((Get-MultiProjectAutomationId $g[0] $g[1]) -ceq ('canvas-card-'+$g[2])) 'Caption/notice golden differs' + } +} +Invoke-Mixed 3458 'workspace-prefix-hash-goldens' { + foreach($g in @(@('workspace-loop-bar',$a,'1320898360543276851'),@('workspace-loop-bar',$b,'2044737300313126929'), + @('workspace-toolbar','A','2086745613872223135'),@('workspace-toolbar','B','2086746713383851346'))) { + Assert-Mixed ((Get-MultiProjectAutomationId $g[0] $g[1]) -ceq ($g[0]+'-'+$g[2])) 'Workspace prefix/hash golden differs' + } +} +Invoke-Mixed 3469 'unknown-kind-refused' {$null=Reject-Mixed {Get-MultiProjectAutomationId unsupported-marker A $a} 'MULTIPROJECT_KIND'} +Invoke-Mixed 3482 'owned-workspace-markers' {Assert-Mixed (Test-MultiProjectObservedSurface workspace $true (New-MixedWorkspace) $workspaceOwner 4242) 'Exact workspace rejected'} +Invoke-Mixed 3487 'restart-is-not-owner-marker' { + $metadata=Get-MultiProjectFragmentMetadata (New-MixedMetadata 4242 'workspace-restart-session-2140902926206787169') 4242 + Assert-Mixed ($metadata.observationReady -and -not (Test-MultiProjectObservedSurface workspace $true ([ordered]@{loopBars=@();toolbars=@();foreignWorkspaceFragmentCount=0}) $workspaceOwner 4242)) 'Restart chrome replaced owner markers' +} +foreach($mutation in @('project-only','missing-loop','missing-toolbar','wrong-prefix','wrong-node','wrong-project','wrong-pid','pid-type','loop-name','toolbar-name','loop-duplicate','toolbar-duplicate','bounds-empty','bounds-type','bounds-zero','selection-unmatched','foreign-marker-count')) { + Invoke-Mixed 3497 $mutation { + $p=New-MixedWorkspace;$selected=$true + switch($mutation){ + project-only{$p.loopBars=@();$p.toolbars=@()} missing-loop{$p.loopBars=@()} missing-toolbar{$p.toolbars=@()} + wrong-prefix{$p.loopBars[0].automationId='canvas-card-1320898360543276851'} wrong-node{$p.loopBars[0].automationId='workspace-loop-bar-2044737300313126929'} + wrong-project{$p.toolbars[0].automationId='workspace-toolbar-2086746713383851346'} wrong-pid{$p.toolbars[0].processId=7777} pid-type{$p.loopBars[0].processId='4242'} + loop-name{$p.loopBars[0].name='Alpha loop'} toolbar-name{$p.toolbars[0].name='Alpha loop'} loop-duplicate{$p.loopBars=@($p.loopBars[0],$p.loopBars[0])} + toolbar-duplicate{$p.toolbars=@($p.toolbars[0],$p.toolbars[0])} bounds-empty{$p.loopBars[0].bounds=@()} bounds-type{$p.toolbars[0].bounds[0]='8'} + bounds-zero{$p.loopBars[0].bounds=@(220,34,220,80)} selection-unmatched{$selected=$false} foreign-marker-count{$p.foreignWorkspaceFragmentCount=1}} + Assert-Mixed (-not (Test-MultiProjectObservedSurface workspace $selected $p $workspaceOwner 4242)) 'Wrong workspace marker projection accepted' + } +} +Invoke-Mixed 3524 'project-refuses-workspace-chrome' {Assert-Mixed (-not (Test-MultiProjectObservedSurface project $true (New-MixedWorkspace) $workspaceOwner 4242)) 'Project accepted workspace chrome'} +foreach($test in @( + @{line=3530;surface='project';owner=0;project=$true;loop=$true;expected=$true}, + @{line=3535;surface='project';owner=1;project=$false;loop=$false;expected=$true}, + @{line=3540;surface='workspace';owner=0;project=$false;loop=$true;expected=$true}, + @{line=3545;surface='overview';owner=0;project=$false;loop=$true;expected=$true}, + @{line=3550;surface='quick_chats';owner=0;project=$false;loop=$true;expected=$true}, + @{line=3555;surface='workspace';owner=0;project=$true;loop=$true;expected=$false}, + @{line=3560;surface='project';owner=0;project=$false;loop=$true;expected=$false}, + @{line=3565;surface='overview';owner=0;project=$true;loop=$true;expected=$false}, + @{line=3570;surface='quick_chats';owner=0;project=$true;loop=$true;expected=$false}, + @{line=3575;surface='workspace';owner=0;project=$false;loop=$false;expected=$false} +)) { + Invoke-Mixed $test.line $test.surface {Assert-Mixed ((Test-MultiProjectOwnerSelection $test.surface $rosterOwners[$test.owner] $workspaceOwner $test.project $test.loop) -eq $test.expected) 'Owner/loop selection boundary changed'} +} +Invoke-Mixed 3596 'complete-two-owner-four-fragment-roster' {Assert-Mixed (Test-MultiProjectObservedRoster overview (New-MixedRoster) $rosterOwners $null 4242) 'Complete roster rejected'} +Invoke-Mixed 3601 'obsolete-node-dimensions-refused' { + $p=New-MixedRoster;$p.cards[1].bounds=@(262,118,482,204) + Assert-Mixed (-not (Test-MultiProjectObservedRoster overview $p $rosterOwners $null 4242)) 'Historical dimensions accepted' +} +Invoke-Mixed 3608 'obsolete-caption-positions-refused' { + $p=New-MixedRoster;$p.canvasBounds=@(220,34,1028,900) + foreach($positions in @(@(@(578,82,864,102),@(578,278,864,298)),@(@(624,82,910,102),@(624,278,910,298)))) { + $p.cards[0].bounds=$positions[0];$p.cards[2].bounds=$positions[1] + Assert-Mixed (-not (Test-MultiProjectObservedRoster overview $p $rosterOwners $null 4242)) 'Obsolete caption grid accepted' + } + $p.cards[0].bounds=@(318,106,1804,126);$p.cards[2].bounds=@(318,284,1804,304) + Assert-Mixed (Test-MultiProjectObservedRoster overview $p $rosterOwners $null 4242) 'Producer-derived captions rejected' +} + +# Metadata and cached-content fixtures are deliberate data transports. Their +# methods contain no ownership/geometry policy or native UI invocation. +$captureReader={param($element,[bool]$content)$element.Capture($content)} +$capturePrior=@{ownership='owned';finalPID=@{value=4242};automationID=@{value='canvas-card-1983941480823304696'}} +Invoke-Mixed 3625 'fresh-owned-content-does-not-hide-missing-peer' { + $mock=New-MixedCapture @((New-MixedCaptureState),(New-MixedCaptureState),(New-MixedCaptureState)) + $e=Get-MultiProjectElementEvidence $mock 4242 $capturePrior 'mixed/missing-peer' $captureReader + $p=New-MixedRoster;$p.cards=@($p.cards[0],$p.cards[1],$p.cards[2]) + Assert-Mixed ($e.processId -eq 4242 -and -not (Test-MultiProjectObservedRoster overview $p $rosterOwners $null 4242)) 'Fresh ownership hid missing peer' +} +Invoke-Mixed 3635 'selected-project-roster-and-physical-connector-snapshots' { + $p=New-MixedRoster;$p.cards=@([ordered]@{automationId='canvas-card-1983941480823304696';name='Alpha loop';processId=4242;bounds=@(248,118,468,204)}) + Assert-Mixed (Test-MultiProjectObservedRoster project $p $rosterOwners $workspaceOwner 4242) 'Project roster rejected' + Assert-Mixed (Test-MultiProjectObservedSurface project $true ([ordered]@{loopBars=@();toolbars=@();foreignWorkspaceFragmentCount=0}) $workspaceOwner 4242) 'Project-only marker shape rejected' + $expected=@([ordered]@{automationId='canvas-card-1983941480823304696';name='UIA loop A'},[ordered]@{automationId='canvas-card-1983940381311676485';name='UIA loop B'}) + $dynamic=@([ordered]@{automationId=$expected[0].automationId;name=$expected[0].name;processId=4242;bounds=@(496,236,936,408)}, + [ordered]@{automationId=$expected[1].automationId;name=$expected[1].name;processId=4242;bounds=@(976,236,1416,408)}) + $graph=@(440,68,2400,1300) + Assert-Mixed (Get-DynamicProjectCardSnapshotDecision project $dynamic $expected 4242 $graph).ready 'DPI192 project snapshot rejected' + $empty=Copy-MultiProjectValue $dynamic;$empty[1].bounds=@(0,0,0,0) + $d=Get-DynamicProjectCardSnapshotDecision project $empty $expected 4242 $graph + Assert-Mixed (-not $d.ready -and $d.failure -ceq 'bounds-empty' -and $d.cards[1].automationId -ceq $expected[1].automationId) 'Empty bounds did not fail exactly' + foreach($mutation in @('missing','foreign','duplicate','order','wrong-surface','outside-viewport','overlap')) { + $candidate=Copy-MultiProjectValue $dynamic;$surface='project' + switch($mutation){missing{$candidate=@($candidate[0])} foreign{$candidate[1].processId=7777} duplicate{$candidate[1].automationId=$candidate[0].automationId} + order{$candidate=@($candidate[1],$candidate[0])} wrong-surface{$surface='overview'} outside-viewport{$candidate[1].bounds=@(2600,236,3040,408)} overlap{$candidate[1].bounds=@(700,236,1140,408)}} + Assert-Mixed (-not (Get-DynamicProjectCardSnapshotDecision $surface $candidate $expected 4242 $graph).ready) "Dynamic snapshot accepted $mutation" + } + $connectorGraph=@(600,312,1414,858);$client=@(147,196,1427,967) + $fitted=@([ordered]@{automationId=$expected[0].automationId;name=$expected[0].name;processId=4242;bounds=@(600,552,790,630)}, + [ordered]@{automationId=$expected[1].automationId;name=$expected[1].name;processId=4242;bounds=@(908,552,1114,630)}) + $d=Get-ConnectorDragSnapshotDecision $fitted $expected 4242 $connectorGraph $client $false + Assert-Mixed ($d.ready -and ($d.sourcePoint -join ',') -ceq '790,591' -and ($d.targetPoint -join ',') -ceq '1011,591' -and + $d.targetCenterVisible -and $d.sourceCardId -ceq $expected[0].automationId -and $d.targetCardId -ceq $expected[1].automationId) 'Fitted physical connector plan differs' + $cutoff=Copy-MultiProjectValue $fitted;$cutoff[0].bounds=@(488,365,988,557);$cutoff[1].bounds=@(1264,365,1764,557) + $d=Get-ConnectorDragSnapshotDecision $cutoff $expected 4242 $connectorGraph $client $false + Assert-Mixed ($d.ready -and ($d.targetCenter -join ',') -ceq '1514,461' -and -not $d.targetCenterVisible -and + ($d.targetPoint -join ',') -ceq '1339,461') 'Actual Size cutoff did not use visible card intersection' + foreach($mutation in @('wrong-connector','wrong-target','wrong-process','logical-card-space','logical-client-space','preexisting-dialog')) { + $candidate=Copy-MultiProjectValue $fitted;$candidateClient=@($client);$dialog=$false + switch($mutation){wrong-connector{$candidate[0].automationId='canvas-card-wrong-source'} wrong-target{$candidate[1].name='Wrong target'} + wrong-process{$candidate[1].processId=7777} logical-card-space{$candidate[0].bounds=@(248,118,468,204);$candidate[1].bounds=@(488,118,708,204)} + logical-client-space{$candidateClient=@(0,0,640,410)} preexisting-dialog{$dialog=$true}} + Assert-Mixed (-not (Get-ConnectorDragSnapshotDecision $candidate $expected 4242 $connectorGraph $candidateClient $dialog).ready) "Connector accepted $mutation" + } +} +foreach($mutation in @('extra-card','extra-project','duplicate-card','duplicate-project','foreign-card-pid','foreign-project-pid','foreign-card-count','foreign-project-count')) { + Invoke-Mixed 3732 $mutation { + $p=New-MixedRoster + switch($mutation){ + extra-card{$p.cards += [ordered]@{automationId='canvas-card-1999999999999999999';name='Unexpected cached loop';processId=4242;bounds=@(508,118,728,204)}} + extra-project{$p.projectRows += [ordered]@{automationId='open-project-1999999999999999999';name='Unexpected project';processId=4242;bounds=@(12,196,232,222)}} + duplicate-card{$p.cards += $p.cards[0]} duplicate-project{$p.projectRows += $p.projectRows[0]} + foreign-card-pid{$p.cards[0].processId=7777} foreign-project-pid{$p.projectRows[0].processId=7777} + foreign-card-count{$p.foreignCardFragmentCount=1} foreign-project-count{$p.foreignProjectFragmentCount=1}} + Assert-Mixed (-not (Test-MultiProjectObservedRoster overview $p $rosterOwners $null 4242)) 'Extra/duplicate/foreign roster fragment hidden' + } +} +Invoke-Mixed 3755 'four-fragments-two-nodes-two-captions' { + $expected=Get-MultiProjectExpectedCanvasRoster overview $rosterOwners $null + Assert-Mixed ($expected.Count -eq 4 -and @($expected|Where-Object classification -CEQ node).Count -eq 2 -and + @($expected|Where-Object classification -CEQ project-caption).Count -eq 2 -and $expected[0].projectPath -ceq 'A' -and + $null -eq $expected[0].nodeID -and $expected[0].identityKind -ceq 'overview-project') 'Complete caption/node taxonomy collapsed' +} +Invoke-Mixed 3763 'caption-geometry-separate-from-node' { + $g=Get-MultiProjectCaptionGeometry @(228,85,1036,780) @(390,185,640,281) @(326,157,1812,177) + Assert-Mixed (($g.caption -join ',') -ceq '326,157,1812,177') 'Source project caption differs' +} +foreach($mutation in @('two-nodes-unknown','unknown-caption','duplicate-caption','wrong-caption-owner','wrong-caption-pid','wrong-caption-name','wrong-caption-bounds','caption-as-node-bounds','hidden-chrome')) { + Invoke-Mixed 3771 $mutation { + $p=New-MixedRoster + switch($mutation){ + two-nodes-unknown{$p.cards=@($p.cards[1],$p.cards[3],[ordered]@{automationId='canvas-card-1999999999999999999';name='Unknown loop';processId=4242;bounds=@(508,118,728,204)})} + unknown-caption{$p.cards[0].automationId='canvas-card-1999999999999999999'} duplicate-caption{$p.cards += $p.cards[0]} + wrong-caption-owner{$p.cards[0].automationId='canvas-card-1331912597382320260'} wrong-caption-pid{$p.cards[0].processId=7777} + wrong-caption-name{$p.cards[0].name='Last inspected'} wrong-caption-bounds{$p.cards[0].bounds=@(750,278,1036,298)} + caption-as-node-bounds{$p.cards[1].bounds=$p.cards[0].bounds} hidden-chrome{$p.cards=@($p.cards[1],$p.cards[3])}} + Assert-Mixed (-not (Test-MultiProjectObservedRoster overview $p $rosterOwners $null 4242)) 'Classified roster hid unknown/wrong-owner/caption fragments' + } +} +Invoke-Mixed 3792 'literal-lane-and-once-scaled-dpi' { + $g=Get-MultiProjectLaneGeometry @(220,34,1200,900) @(382,134,632,230) + $scaled=Get-MultiProjectLaneGeometry @(330,51,1800,1350) @(573,201,948,345) + Assert-Mixed (($g.band -join ',') -ceq '300,96,1822,250' -and ($g.open -join ',') -ceq '1690,106,1746,126' -and + ($scaled.open -join ',') -ceq '2535,159,2619,189') 'Literal lane bounds or DPI scaling differs' +} +$overviewIds=@('overview-card:fixture:a','overview-card:fixture:b') +$projectIds=@('project-card:fixture:a','project-card:fixture:b') +$laneNames=@('UIA loop A','UIA loop B') +function New-MixedLaneCards([string[]]$Ids,[double[]]$First,[double[]]$Second) { + @([pscustomobject]@{automationId=$Ids[0];name=$laneNames[0];bounds=$First},[pscustomobject]@{automationId=$Ids[1];name=$laneNames[1];bounds=$Second}) +} +Invoke-Mixed 3816 'DPI192-fitted-destination-normalization' { + $overview=Get-MultiProjectNormalizedLaneGeometry (New-MixedLaneCards $overviewIds @(100,100,290,178) @(408,100,614,178)) $overviewIds $laneNames @(100,0,1200,900) 250 96 + $project=Get-MultiProjectNormalizedLaneGeometry (New-MixedLaneCards $projectIds @(120,100,324,184) @(450,100,670,184)) $projectIds $laneNames @(120,0,1200,900) 250 96 + $comparison=Compare-MultiProjectNormalizedLaneGeometry $project $overview + Assert-Mixed (($overview.ObservedPixelWidths -join ',') -ceq '190,206' -and ($overview.ObservedPixelHeights -join ',') -ceq '78,78' -and + $overview.HorizontalClipping[0] -ceq 'left' -and $overview.HorizontalClipping[1] -ceq 'none' -and $comparison.equal) 'Fitted lane source geometry lost' +} +foreach($mutation in @('wrong-source-width','wrong-source-height','wrong-observed-width','wrong-observed-height','wrong-order','overlap','foreign','missing','outside-viewport','both-clipped','no-full-peer')) { + Invoke-Mixed 3846 $mutation { + $ids=@($overviewIds);$width=250;$height=96;$first=@(100,100,290,178);$second=@(350,100,540,178);$viewport=@(0,0,1200,900) + switch($mutation){ + wrong-source-width{$width=249} wrong-source-height{$height=95} wrong-observed-width{$second=@(350,100,520,178)} + wrong-observed-height{$second=@(350,100,540,160)} wrong-order{$ids=@($overviewIds[1],$overviewIds[0])} + overlap{$second=@(250,100,440,178)} foreign{$ids=@($overviewIds[0],'overview-card:foreign:b')} + outside-viewport{$first=@(-1,100,189,178)} + both-clipped{$first=@(0,100,190,178);$second=@(306,100,500,178);$viewport=@(0,0,500,900)} + no-full-peer{$first=@(0,100,206,178);$second=@(294,100,500,178);$viewport=@(0,0,500,900)}} + $cards=if($mutation -ceq 'missing'){@([pscustomobject]@{automationId=$overviewIds[0];name=$laneNames[0];bounds=$first})}else{New-MixedLaneCards $ids $first $second} + $null=Reject-Mixed {Get-MultiProjectNormalizedLaneGeometry $cards $overviewIds $laneNames $viewport $width $height} 'MULTIPROJECT_BOUNDS' + } +} +$expected=[ordered]@{ + 1724=1;1733=1;1749=6;1757=1;1769=4;1777=1;1789=1;1804=1;1811=1;1818=1;1824=1;1837=5;1843=1;1854=4;1870=1;1879=8;1917=1;1924=4;1951=1 + 3392=1;3423=1;3443=1;3458=1;3469=1;3482=1;3487=1;3497=17;3524=1;3530=1;3535=1;3540=1;3545=1;3550=1;3555=1;3560=1;3565=1;3570=1;3575=1 + 3596=1;3601=1;3608=1;3625=1;3635=1;3732=8;3755=1;3763=1;3771=9;3792=1;3816=1;3846=11 +} +Complete-Mixed $expected @('mixed-ui','mixed-ui-policy','geometry-selection-roster','source-correspondence') diff --git a/Tools/windows/ValidationRunner/Ui/NativeUiSuite.cs b/Tools/windows/ValidationRunner/Ui/NativeUiSuite.cs new file mode 100644 index 00000000..31cf22a7 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/NativeUiSuite.cs @@ -0,0 +1,195 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using GraphCode.Validation.Ui.Fixtures; + +namespace GraphCode.Validation.Ui; + +public sealed class NativeUiSuite : IValidationSuite +{ + public string Id => "ui.native"; + public IReadOnlyList UnitIds { get; } = new[] + { + "ui.native.lookup.named-direct-viewport", "ui.native.lookup.hidden-write-read", + "ui.native.lookup.missing-id", "ui.native.lookup.missing-write-read", + "ui.native.lookup.direct-precedence", "ui.native.lookup.wrong-root", + "ui.native.lookup.wrong-viewport", "ui.native.lookup.other-sibling", + "ui.native.lookup.no-deeper-fallback", "ui.native.lookup.direct-viewport-required", + "ui.native.lookup.native-form", "ui.native.lookup.workspace-manager", + "ui.native.lookup.unknown-content", "ui.native.lookup.null-parent", + "ui.native.hit.nested-edit", "ui.native.hit.content-background", + "ui.native.hit.wrong-root", "ui.native.hit.wrong-container", + "ui.native.hit.no-recursion", "ui.native.hit.direct-child", + "ui.host-info.current-session" + }; + + public Task> RunAsync(ValidationContext context, CancellationToken cancellationToken) + { + var results = new List(); + var roots = new List(); + Exception? primary = null; + try + { + foreach (string name in new[] { "GraphCodeSheetHost", "GraphCodeSheetViewport", + "GraphCodeWindowsDialogContent", "GraphCodeNativeForm", "GraphCodeWorkspaceManager", + "GraphCodeUnknownSheetContent" }) + PromptLookupFixture.Register(name); + + IntPtr NewRoot(string cls) + { + IntPtr window = PromptLookupFixture.Create(cls, IntPtr.Zero, 0); + roots.Add(window); + return window; + } + void Check(string id, Action assertion) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + assertion(); + results.Add(new UnitResult(id, UnitStatus.Passed, 1, + "Actual compiled helper and hidden owned Win32 fixture; ValidationRunner.Tests.ps1:7431-7624")); + } + catch (InvalidOperationException failure) + { + results.Add(new UnitResult(id, UnitStatus.Failed, 1, failure.ToString())); + } + } + void Lookup(string id, IntPtr parent, int controlId, IntPtr expected) => + Check(id, () => ActivationCases.Require(GraphCodeUiaGateState.ControlById(parent, controlId) == expected, + $"RED: prompt lookup {id} differs")); + IntPtr Create(string cls, IntPtr parent, int id = 0) => PromptLookupFixture.Create(cls, parent, id); + + IntPtr prompt = NewRoot("GraphCodeSheetHost"); + IntPtr viewport = Create("GraphCodeSheetViewport", prompt); + IntPtr content = Create("GraphCodeWindowsDialogContent", viewport); + IntPtr edit = Create("EDIT", content, 9904); + Lookup("ui.native.lookup.named-direct-viewport", prompt, 9904, edit); + Check("ui.native.lookup.hidden-write-read", () => ActivationCases.Require( + !PromptLookupFixture.IsWindowVisible(prompt) && + GraphCodeUiaGateState.SetEditTextById(prompt, 9904, "owned fixture") && + GraphCodeUiaGateState.EditTextById(prompt, 9904) == "owned fixture", + "RED: prompt lookup hidden nested write/read did not use the same control")); + Lookup("ui.native.lookup.missing-id", prompt, 9905, IntPtr.Zero); + Check("ui.native.lookup.missing-write-read", () => ActivationCases.Require( + !GraphCodeUiaGateState.SetEditTextById(prompt, 9905, "wrong") && + GraphCodeUiaGateState.EditTextById(prompt, 9905) is null, + "RED: prompt lookup missing id returned successful text")); + IntPtr direct = Create("EDIT", prompt, 9904); + Lookup("ui.native.lookup.direct-precedence", prompt, 9904, direct); + + IntPtr wrongRoot = NewRoot("STATIC"); + IntPtr wrongRootViewport = Create("GraphCodeSheetViewport", wrongRoot); + IntPtr wrongRootContent = Create("GraphCodeWindowsDialogContent", wrongRootViewport); + Create("EDIT", wrongRootContent, 9904); + Lookup("ui.native.lookup.wrong-root", wrongRoot, 9904, IntPtr.Zero); + IntPtr wrongContainerRoot = NewRoot("GraphCodeSheetHost"); + IntPtr wrongContainer = Create("STATIC", wrongContainerRoot); + IntPtr wrongContent = Create("GraphCodeWindowsDialogContent", wrongContainer); + Create("EDIT", wrongContent, 9904); + Lookup("ui.native.lookup.wrong-viewport", wrongContainerRoot, 9904, IntPtr.Zero); + IntPtr namedViewport = Create("GraphCodeSheetViewport", wrongContainerRoot); + IntPtr namedContent = Create("GraphCodeWindowsDialogContent", namedViewport); + Lookup("ui.native.lookup.other-sibling", wrongContainerRoot, 9904, IntPtr.Zero); + IntPtr deeper = Create("STATIC", namedContent); + Create("EDIT", deeper, 9904); + Lookup("ui.native.lookup.no-deeper-fallback", wrongContainerRoot, 9904, IntPtr.Zero); + IntPtr deepRoot = NewRoot("GraphCodeSheetHost"); + IntPtr wrapper = Create("STATIC", deepRoot); + IntPtr deepViewport = Create("GraphCodeSheetViewport", wrapper); + IntPtr deepContent = Create("GraphCodeWindowsDialogContent", deepViewport); + Create("EDIT", deepContent, 9904); + Lookup("ui.native.lookup.direct-viewport-required", deepRoot, 9904, IntPtr.Zero); + IntPtr nativeRoot = NewRoot("GraphCodeSheetHost"); + IntPtr nativeViewport = Create("GraphCodeSheetViewport", nativeRoot); + IntPtr nativeContent = Create("GraphCodeNativeForm", nativeViewport); + IntPtr nativeEdit = Create("EDIT", nativeContent, 9100); + Lookup("ui.native.lookup.native-form", nativeRoot, 9100, nativeEdit); + IntPtr workspaceRoot = NewRoot("GraphCodeSheetHost"); + IntPtr workspaceViewport = Create("GraphCodeSheetViewport", workspaceRoot); + IntPtr workspaceContent = Create("GraphCodeWorkspaceManager", workspaceViewport); + IntPtr workspaceList = Create("LISTBOX", workspaceContent, 9721); + Lookup("ui.native.lookup.workspace-manager", workspaceRoot, 9721, workspaceList); + IntPtr unknownRoot = NewRoot("GraphCodeSheetHost"); + IntPtr unknownViewport = Create("GraphCodeSheetViewport", unknownRoot); + IntPtr unknownContent = Create("GraphCodeUnknownSheetContent", unknownViewport); + Create("EDIT", unknownContent, 9100); + Lookup("ui.native.lookup.unknown-content", unknownRoot, 9100, IntPtr.Zero); + Lookup("ui.native.lookup.null-parent", IntPtr.Zero, 9904, IntPtr.Zero); + ActivationCases.Require(results.Count == 14, "RED: prompt lookup case count was not 14"); + + IntPtr hitRoot = NewRoot("GraphCodeSheetHost"); + IntPtr hitViewport = Create("GraphCodeSheetViewport", hitRoot); + PromptLookupFixture.Place(hitViewport, 0, 0, 400, 300); + IntPtr hitContent = Create("GraphCodeWindowsDialogContent", hitViewport); + PromptLookupFixture.Place(hitContent, 15, 20, 350, 250); + IntPtr hitEdit = Create("EDIT", hitContent, 9904); + PromptLookupFixture.Place(hitEdit, 20, 25, 100, 24); + int[] editBounds = GraphCodeUiaGateState.WindowBounds(hitEdit); + Check("ui.native.hit.nested-edit", () => ActivationCases.Require( + !PromptLookupFixture.IsWindowVisible(hitRoot) && + GraphCodeMultiProjectRenameNative.ChildAt(hitRoot, (editBounds[0] + editBounds[2]) / 2, + (editBounds[1] + editBounds[3]) / 2) == hitEdit, + "RED: prompt hit did not resolve nested edit in hidden named viewport")); + int[] contentBounds = GraphCodeUiaGateState.WindowBounds(hitContent); + Check("ui.native.hit.content-background", () => ActivationCases.Require( + GraphCodeMultiProjectRenameNative.ChildAt(hitRoot, contentBounds[2] - 10, contentBounds[3] - 10) == hitContent, + "RED: prompt viewport background became an edit hit")); + IntPtr plainRoot = NewRoot("STATIC"); + IntPtr plainViewport = Create("GraphCodeSheetViewport", plainRoot); + IntPtr plainContent = Create("GraphCodeWindowsDialogContent", plainViewport); + Create("EDIT", plainContent, 9904); + int[] plainBounds = GraphCodeUiaGateState.WindowBounds(plainContent); + Check("ui.native.hit.wrong-root", () => ActivationCases.Require( + GraphCodeMultiProjectRenameNative.ChildAt(plainRoot, plainBounds[0] + 50, plainBounds[1] + 50) == plainViewport, + "RED: prompt hit descended through wrong root class")); + IntPtr otherRoot = NewRoot("GraphCodeSheetHost"); + IntPtr otherContent = Create("BUTTON", otherRoot); + Create("EDIT", otherContent, 9904); + int[] otherBounds = GraphCodeUiaGateState.WindowBounds(otherContent); + Check("ui.native.hit.wrong-container", () => ActivationCases.Require( + GraphCodeMultiProjectRenameNative.ChildAt(otherRoot, otherBounds[0] + 50, otherBounds[1] + 50) == otherContent, + "RED: prompt hit descended through wrong container class")); + IntPtr deepHitRoot = NewRoot("GraphCodeSheetHost"); + IntPtr deepHitViewport = Create("GraphCodeSheetViewport", deepHitRoot); + PromptLookupFixture.Place(deepHitViewport, 0, 0, 400, 300); + IntPtr deepHitContent = Create("GraphCodeWindowsDialogContent", deepHitViewport); + IntPtr hitWrapper = Create("BUTTON", deepHitContent); + Create("EDIT", hitWrapper, 9904); + int[] deepBounds = GraphCodeUiaGateState.WindowBounds(hitWrapper); + Check("ui.native.hit.no-recursion", () => ActivationCases.Require( + GraphCodeMultiProjectRenameNative.ChildAt(deepHitRoot, deepBounds[0] + 50, deepBounds[1] + 50) == hitWrapper, + "RED: prompt hit recursively searched deeper descendants")); + IntPtr directRoot = NewRoot("GraphCodeSheetHost"); + IntPtr directEdit = Create("EDIT", directRoot, 9904); + int[] directBounds = GraphCodeUiaGateState.WindowBounds(directEdit); + Check("ui.native.hit.direct-child", () => ActivationCases.Require( + GraphCodeMultiProjectRenameNative.ChildAt(directRoot, directBounds[0] + 50, directBounds[1] + 50) == directEdit, + "RED: prompt hit changed existing direct-child behavior")); + ActivationCases.Require(results.Count == 20, "RED: prompt hit case count was not 6"); + Check("ui.host-info.current-session", () => ActivationCases.Require( + GraphCodeUiaHostInfo.CurrentSessionId() == checked((uint)Process.GetCurrentProcess().SessionId), + "RED: UIA host context did not resolve the current Windows session")); + } + catch (Exception failure) + { + primary = failure; + throw; + } + finally + { + var cleanupFailures = new List(); + foreach (IntPtr window in roots) + if (!PromptLookupFixture.DestroyWindow(window)) + cleanupFailures.Add(new InvalidOperationException($"Prompt lookup fixture cleanup failed for {window}")); + if (cleanupFailures.Count != 0) + { + if (primary is not null) cleanupFailures.Insert(0, primary); + throw new AggregateException("Native UI fixture cleanup failed", cleanupFailures); + } + } + return Task.FromResult>(results); + } +} diff --git a/Tools/windows/ValidationRunner/Ui/Production/GraphCodeMultiProjectRenameNative.cs b/Tools/windows/ValidationRunner/Ui/Production/GraphCodeMultiProjectRenameNative.cs new file mode 100644 index 00000000..f422763e --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/Production/GraphCodeMultiProjectRenameNative.cs @@ -0,0 +1,77 @@ +#nullable disable +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +public static class GraphCodeMultiProjectRenameNative { + [StructLayout(LayoutKind.Sequential)] public struct Point { public int X, Y; } + [StructLayout(LayoutKind.Sequential)] public struct Rect { public int Left, Top, Right, Bottom; } + [StructLayout(LayoutKind.Sequential)] private struct MonitorInfo { + public int Size; public Rect Monitor, Work; public uint Flags; + } + [DllImport("user32.dll")] public static extern IntPtr GetAncestor(IntPtr window, uint flags); + [DllImport("user32.dll")] public static extern IntPtr WindowFromPoint(Point point); + [DllImport("user32.dll")] private static extern IntPtr RealChildWindowFromPoint(IntPtr window, Point point); + [DllImport("user32.dll", CharSet=CharSet.Unicode)] + private static extern IntPtr FindWindowEx(IntPtr parent, IntPtr childAfter, string className, string title); + [DllImport("user32.dll", CharSet=CharSet.Unicode, EntryPoint="GetClassNameW")] + private static extern int GetClassName(IntPtr window, System.Text.StringBuilder name, int capacity); + [DllImport("user32.dll", SetLastError=true)] private static extern bool GetClientRect(IntPtr window, out Rect rect); + [DllImport("user32.dll", SetLastError=true)] private static extern bool ClientToScreen(IntPtr window, ref Point point); + [DllImport("user32.dll", SetLastError=true)] private static extern bool ScreenToClient(IntPtr window, ref Point point); + [DllImport("user32.dll")] private static extern IntPtr MonitorFromWindow(IntPtr window, uint flags); + [DllImport("user32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + private static extern bool GetMonitorInfo(IntPtr monitor, ref MonitorInfo info); + public static int[] ClientBounds(IntPtr window) { + Rect rect; + if (!GetClientRect(window, out rect)) throw new Win32Exception(Marshal.GetLastWin32Error(), "Rename GetClientRect"); + var first = new Point { X=rect.Left, Y=rect.Top }; + var last = new Point { X=rect.Right, Y=rect.Bottom }; + if (!ClientToScreen(window, ref first) || !ClientToScreen(window, ref last)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "Rename ClientToScreen"); + return new[] { first.X, first.Y, last.X, last.Y }; + } + public static int[] WorkBounds(IntPtr window) { + var info = new MonitorInfo { Size=Marshal.SizeOf(typeof(MonitorInfo)) }; + if (!GetMonitorInfo(MonitorFromWindow(window, 2), ref info)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "Rename GetMonitorInfo"); + return new[] { info.Work.Left, info.Work.Top, info.Work.Right, info.Work.Bottom }; + } + private static bool ContainsScreenPoint(IntPtr window, int x, int y) { + Rect rect; + if (!GetClientRect(window, out rect)) return false; + var first = new Point { X=rect.Left, Y=rect.Top }; + var last = new Point { X=rect.Right, Y=rect.Bottom }; + if (!ClientToScreen(window, ref first) || !ClientToScreen(window, ref last)) return false; + return x >= first.X && x < last.X && y >= first.Y && y < last.Y; + } + public static IntPtr ChildAt(IntPtr window, int x, int y) { + var point = new Point { X=x, Y=y }; + if (!ScreenToClient(window, ref point)) throw new Win32Exception(Marshal.GetLastWin32Error(), "Rename ScreenToClient"); + IntPtr direct = RealChildWindowFromPoint(window, point); + var rootClass = new System.Text.StringBuilder(128); + var childClass = new System.Text.StringBuilder(128); + GetClassName(window, rootClass, rootClass.Capacity); + if (direct == IntPtr.Zero && + String.Equals(rootClass.ToString(), "GraphCodeSheetHost", StringComparison.Ordinal)) { + IntPtr viewport = FindWindowEx(window, IntPtr.Zero, "GraphCodeSheetViewport", null); + if (ContainsScreenPoint(viewport, x, y)) direct = viewport; + } + GetClassName(direct, childClass, childClass.Capacity); + if (!String.Equals(rootClass.ToString(), "GraphCodeSheetHost", StringComparison.Ordinal) || + !String.Equals(childClass.ToString(), "GraphCodeSheetViewport", StringComparison.Ordinal)) + return direct; + IntPtr content = FindWindowEx(direct, IntPtr.Zero, "GraphCodeWindowsDialogContent", null); + if (content == IntPtr.Zero) return direct; + var viewportPoint = new Point { X=x, Y=y }; + if (!ScreenToClient(direct, ref viewportPoint)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "Rename viewport ScreenToClient"); + IntPtr viewportChild = RealChildWindowFromPoint(direct, viewportPoint); + if (viewportChild != content && + !(viewportChild == IntPtr.Zero && ContainsScreenPoint(content, x, y))) return direct; + var contentPoint = new Point { X=x, Y=y }; + if (!ScreenToClient(content, ref contentPoint)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "Rename content ScreenToClient"); + IntPtr nested = RealChildWindowFromPoint(content, contentPoint); + return nested == IntPtr.Zero ? content : nested; + } +} diff --git a/Tools/windows/ValidationRunner/Ui/Production/GraphCodeUiaAttribution.cs b/Tools/windows/ValidationRunner/Ui/Production/GraphCodeUiaAttribution.cs new file mode 100644 index 00000000..5511b13a --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/Production/GraphCodeUiaAttribution.cs @@ -0,0 +1,78 @@ +#nullable disable +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Text; +public static class GraphCodeUiaAttribution { + private delegate bool EnumWindowsProc(IntPtr window, IntPtr parameter); + [DllImport("user32.dll")] + private static extern bool EnumWindows(EnumWindowsProc callback, IntPtr parameter); + [DllImport("user32.dll")] + private static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetClassName(IntPtr window, StringBuilder className, int capacity); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetWindowText(IntPtr window, StringBuilder text, int count); + [DllImport("user32.dll")] + private static extern bool IsWindowVisible(IntPtr window); + [DllImport("user32.dll")] + private static extern bool IsWindowEnabled(IntPtr window); + [DllImport("user32.dll")] + private static extern bool IsHungAppWindow(IntPtr window); + [DllImport("user32.dll")] + private static extern IntPtr GetWindow(IntPtr window, uint command); + [StructLayout(LayoutKind.Sequential)] + public struct Rect { public int Left, Top, Right, Bottom; } + [DllImport("user32.dll")] + private static extern bool GetWindowRect(IntPtr window, out Rect rect); + [DllImport("user32.dll")] + public static extern bool PrintWindow(IntPtr window, IntPtr hdc, uint flags); + public sealed class WindowRecord { + public long Handle; public long Owner; public string ClassName; public string Title; + public bool Visible, Enabled, Hung; public int Left, Top, Right, Bottom; + } + private static string ClassOf(IntPtr window) { + var text = new StringBuilder(256); + GetClassName(window, text, text.Capacity); + return text.ToString(); + } + public static long ForegroundHandle() { return GetForegroundWindow().ToInt64(); } + public static uint ProcessOf(IntPtr window) { + if (window == IntPtr.Zero) return 0; + uint processId; + GetWindowThreadProcessId(window, out processId); + return processId; + } + public static string ClassName(IntPtr window) { + return window == IntPtr.Zero ? "" : ClassOf(window); + } + public static Rect Bounds(IntPtr window) { + Rect rect; + if (window == IntPtr.Zero || !GetWindowRect(window, out rect)) return new Rect(); + return rect; + } + // Titles are read only for windows owned by processId (the gate's own shell). + public static WindowRecord[] ProcessWindows(uint processId) { + var results = new List(); + EnumWindows(delegate(IntPtr window, IntPtr parameter) { + uint owner; + GetWindowThreadProcessId(window, out owner); + if (owner != processId) return true; + var title = new StringBuilder(512); + GetWindowText(window, title, title.Capacity); + Rect rect; + GetWindowRect(window, out rect); + results.Add(new WindowRecord { + Handle = window.ToInt64(), Owner = GetWindow(window, 4).ToInt64(), + ClassName = ClassOf(window), Title = title.ToString(), + Visible = IsWindowVisible(window), Enabled = IsWindowEnabled(window), + Hung = IsHungAppWindow(window), + Left = rect.Left, Top = rect.Top, Right = rect.Right, Bottom = rect.Bottom + }); + return true; + }, IntPtr.Zero); + return results.ToArray(); + } +} diff --git a/Tools/windows/ValidationRunner/Ui/Production/GraphCodeUiaGateState.cs b/Tools/windows/ValidationRunner/Ui/Production/GraphCodeUiaGateState.cs new file mode 100644 index 00000000..ddc3a0c9 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/Production/GraphCodeUiaGateState.cs @@ -0,0 +1,1119 @@ +#nullable disable +using GraphCode.Windows; +using static GraphCode.Windows.UiaInputSafety; +using System; +using System.Runtime.InteropServices; +using System.Text; +using System.Windows.Automation; +public static class GraphCodeUiaGateState { + private sealed class NativeInputOperations : IUiaInputOperations { + public bool IsRetainedProcessAlive(uint processId) { return GraphCodeUiaGateState.IsRetainedProcessAlive(processId); } + public uint WindowProcessId(IntPtr window) { return GraphCodeUiaGateState.WindowProcessId(window); } + public bool WindowIsVisible(IntPtr window) { return GraphCodeUiaGateState.WindowIsVisible(window); } + public bool WindowIsEnabled(IntPtr window) { return GraphCodeUiaGateState.WindowIsEnabled(window); } + public IntPtr GetForegroundWindow() { return GraphCodeUiaGateState.GetForegroundWindow(); } + public IntPtr FocusedControlInDialog(IntPtr window) { return GraphCodeUiaGateState.FocusedControlInDialog(window); } + public IntPtr GetAncestor(IntPtr window, uint flags) { return GraphCodeUiaGateState.GetAncestor(window, flags); } + public bool GetCursorPos(out ScreenPoint point) { return GraphCodeUiaGateState.GetCursorPos(out point); } + public bool SetCursorPos(int x, int y) { return GraphCodeUiaGateState.SetCursorPos(x, y); } + public IntPtr WindowFromPoint(ScreenPoint point) { return GraphCodeUiaGateState.WindowFromPoint(point); } + public bool IsWindowVisible(IntPtr window) { return GraphCodeUiaGateState.IsWindowVisible(window); } + public bool GetMenuItemRect(IntPtr owner, IntPtr menu, uint position, out RECT item) { return GraphCodeUiaGateState.GetMenuItemRect(owner, menu, position, out item); } + public IntPtr FindPopupForMenu(uint processId, IntPtr menu) { return GraphCodeUiaGateState.FindPopupForMenu(processId, menu); } + public bool ScreenToClient(IntPtr window, ref ScreenPoint point) { return GraphCodeUiaGateState.ScreenToClient(window, ref point); } + public IntPtr MouseLParam(int x, int y) { return GraphCodeUiaGateState.MouseLParam(x, y); } + public uint GetMenuState(IntPtr menu, uint position, uint flags) { return GraphCodeUiaGateState.GetMenuState(menu, position, flags); } + public void Sleep(uint milliseconds) { GraphCodeUiaGateState.Sleep(milliseconds); } + public bool ResolvesToControl(IntPtr target, IntPtr owner, ScreenPoint point) { return GraphCodeUiaGateState.ResolvesToControl(target, owner, point); } + public uint SendInput(uint count, Input[] inputs, int size) { return GraphCodeUiaGateState.SendInput(count, inputs, size); } + public uint SendKeyInputs(uint count, KeyInputRecord[] inputs, int size) { return GraphCodeUiaGateState.SendKeyInputs(count, inputs, size); } + public IntPtr ControlById(IntPtr owner, int id) { return GraphCodeUiaGateState.ControlById(owner, id); } + public bool FocusControl(IntPtr owner, IntPtr edit) { return GraphCodeUiaGateState.FocusControl(owner, edit); } + public IntPtr SendMessage(IntPtr edit, uint message, UIntPtr first, IntPtr last) { return GraphCodeUiaGateState.SendMessage(edit, message, first, last); } + public string EditBufferText(IntPtr edit) { return GraphCodeUiaGateState.EditBufferText(edit); } + public string EditTextById(IntPtr owner, int id) { return GraphCodeUiaGateState.EditTextById(owner, id); } + public IntPtr SendMessageString(IntPtr edit, uint message, UIntPtr first, string text) { return GraphCodeUiaGateState.SendMessageString(edit, message, first, text); } + public IntPtr PopupMenuHandle(IntPtr popup) { return GraphCodeUiaGateState.PopupMenuHandle(popup); } + public uint PopupMenuItemId(IntPtr menu, int position) { return GraphCodeUiaGateState.PopupMenuItemId(menu, position); } + public bool PostMessage(IntPtr window, uint message, UIntPtr first, IntPtr last) { return GraphCodeUiaGateState.PostMessage(window, message, first, last); } + } + private static readonly UiaInputSafety InputSafety = new UiaInputSafety(new NativeInputOperations()); + + public static volatile bool LiveObserved; + public static volatile bool NamePropertyObserved; + public static volatile bool FocusObserved; + public static int LiveEvents; + public static int NamePropertyEvents; + public static int SelectedEvents; + public static int AddedEvents; + public static int RemovedEvents; + public static int TogglePropertyEvents; + public static uint LastEditClearExpected { get => InputSafety.LastEditClearExpected; set => InputSafety.LastEditClearExpected = value; } + public static uint LastEditClearSent { get => InputSafety.LastEditClearSent; set => InputSafety.LastEditClearSent = value; } + public static uint LastEditTextExpected { get => InputSafety.LastEditTextExpected; set => InputSafety.LastEditTextExpected = value; } + public static uint LastEditTextSent { get => InputSafety.LastEditTextSent; set => InputSafety.LastEditTextSent = value; } + public static bool LastEditUsedMessageFallback { get => InputSafety.LastEditUsedMessageFallback; set => InputSafety.LastEditUsedMessageFallback = value; } + public static string LiveSourceAutomationId; + public static string LiveSourceName; + public static string LiveSourceRuntimeId; + public static string NamePropertySourceAutomationId; + public static string FocusSourceAutomationId; + public static string SelectionSourceAutomationId; + public static string SelectionSourceRuntimeId; + public static string TogglePropertySourceAutomationId; + public static readonly AutomationEventHandler LiveHandler = HandleLive; + public static readonly AutomationPropertyChangedEventHandler NamePropertyHandler = HandleNameProperty; + public static readonly AutomationFocusChangedEventHandler FocusHandler = HandleFocus; + public static readonly AutomationEventHandler SelectedHandler = HandleSelected; + public static readonly AutomationEventHandler AddedHandler = HandleAdded; + public static readonly AutomationEventHandler RemovedHandler = HandleRemoved; + public static readonly AutomationPropertyChangedEventHandler TogglePropertyHandler = HandleToggleProperty; + private static void HandleLive(object sender, AutomationEventArgs eventArgs) { + var element = sender as AutomationElement; + if (element == null) return; + LiveSourceAutomationId = element.Current.AutomationId; + LiveSourceName = element.Current.Name; + LiveSourceRuntimeId = String.Join(",", element.GetRuntimeId()); + LiveEvents++; + LiveObserved = true; + } + private static void HandleNameProperty(object sender, AutomationPropertyChangedEventArgs eventArgs) { + var element = sender as AutomationElement; + if (element != null) NamePropertySourceAutomationId = element.Current.AutomationId; + NamePropertyEvents++; + NamePropertyObserved = true; + } + private static void HandleFocus(object sender, AutomationFocusChangedEventArgs eventArgs) { + var element = sender as AutomationElement; + if (element != null) FocusSourceAutomationId = element.Current.AutomationId; + FocusObserved = true; + } + private static void HandleSelected(object sender, AutomationEventArgs eventArgs) { + var element = sender as AutomationElement; + if (element != null) { + SelectionSourceAutomationId = element.Current.AutomationId; + SelectionSourceRuntimeId = String.Join(",", element.GetRuntimeId()); + } + SelectedEvents++; + } + private static void HandleAdded(object sender, AutomationEventArgs eventArgs) { + var element = sender as AutomationElement; + if (element != null) { + SelectionSourceAutomationId = element.Current.AutomationId; + SelectionSourceRuntimeId = String.Join(",", element.GetRuntimeId()); + } + AddedEvents++; + } + private static void HandleRemoved(object sender, AutomationEventArgs eventArgs) { + var element = sender as AutomationElement; + if (element != null) { + SelectionSourceAutomationId = element.Current.AutomationId; + SelectionSourceRuntimeId = String.Join(",", element.GetRuntimeId()); + } + RemovedEvents++; + } + private static void HandleToggleProperty(object sender, AutomationPropertyChangedEventArgs eventArgs) { + var element = sender as AutomationElement; + if (element != null) TogglePropertySourceAutomationId = element.Current.AutomationId; + TogglePropertyEvents++; + } + [DllImport("user32.dll", SetLastError = true)] + private static extern bool PostMessage(IntPtr window, uint message, UIntPtr wParam, IntPtr lParam); + [DllImport("user32.dll")] + private static extern IntPtr SendMessage(IntPtr window, uint message, UIntPtr wParam, IntPtr lParam); + [DllImport("user32.dll", EntryPoint = "SendMessageTimeoutW", SetLastError = true)] + private static extern IntPtr SendMessageTimeout( + IntPtr window, uint message, UIntPtr wParam, IntPtr lParam, + uint flags, uint timeoutMilliseconds, out UIntPtr result + ); + [DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "SendMessageW")] + private static extern IntPtr SendMessageString(IntPtr window, uint message, UIntPtr wParam, string lParam); + [DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "SendMessageW")] + private static extern IntPtr SendMessageText( + IntPtr window, uint message, UIntPtr wParam, StringBuilder lParam + ); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern IntPtr FindWindowEx(IntPtr parent, IntPtr childAfter, string className, string windowName); + private delegate bool EnumWindowsProc(IntPtr window, IntPtr parameter); + [DllImport("user32.dll")] + private static extern bool EnumWindows(EnumWindowsProc callback, IntPtr parameter); + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetClassName(IntPtr window, StringBuilder className, int capacity); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern bool SetWindowText(IntPtr window, string text); + [DllImport("user32.dll")] + private static extern int GetDlgCtrlID(IntPtr window); + [DllImport("user32.dll")] + private static extern IntPtr GetDlgItem(IntPtr dialog, int controlId); + [DllImport("user32.dll")] + private static extern IntPtr SetFocus(IntPtr window); + [DllImport("user32.dll")] + private static extern IntPtr GetFocus(); + [DllImport("user32.dll", SetLastError = true)] + private static extern bool SetForegroundWindow(IntPtr window); + [DllImport("user32.dll")] + private static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll", SetLastError = true)] + private static extern bool BringWindowToTop(IntPtr window); + [DllImport("kernel32.dll")] + private static extern uint GetCurrentThreadId(); + [DllImport("kernel32.dll")] + private static extern void Sleep(uint milliseconds); + [DllImport("user32.dll", SetLastError = true)] + private static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool attach); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetWindowText(IntPtr window, StringBuilder text, int count); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetWindowTextLength(IntPtr window); + [DllImport("user32.dll")] + private static extern bool ShowWindow(IntPtr window, int command); + [DllImport("user32.dll")] + private static extern IntPtr SetActiveWindow(IntPtr window); + [DllImport("user32.dll")] + private static extern bool IsWindowVisible(IntPtr window); + [DllImport("user32.dll")] + private static extern bool IsWindowEnabled(IntPtr window); + [DllImport("user32.dll")] + private static extern int GetMenuItemCount(IntPtr menu); + [DllImport("user32.dll")] + private static extern uint GetMenuItemID(IntPtr menu, int position); + [DllImport("user32.dll")] + private static extern uint GetMenuState(IntPtr menu, uint item, uint flags); + [DllImport("user32.dll")] + private static extern bool GetMenuItemRect(IntPtr window, IntPtr menu, uint position, out RECT rect); + [DllImport("user32.dll", SetLastError = true)] + private static extern bool SetCursorPos(int x, int y); + [DllImport("user32.dll", SetLastError = true)] + private static extern bool GetCursorPos(out ScreenPoint point); + + + [DllImport("user32.dll", SetLastError = true)] + private static extern uint SendInput(uint count, Input[] inputs, int size); + [DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "GetMenuStringW")] + private static extern int GetMenuString(IntPtr menu, uint item, StringBuilder text, int max, uint flags); + // The File/Loop/Terminal/Workspace/View/Help bar is a real SetMenu menu bar, + // not a TrackPopupMenu popup, so (unlike the popup context menu below) its + // live enabled state is readable directly through the Win32 menu API against + // GetMenu(shell) -- no MN_GETHMENU / popup-window workaround required. + // MF_BYCOMMAND (0) searches the whole menu tree for the command identifier. + public static uint MenuCommandState(IntPtr menuBar, uint command) { + if (menuBar == IntPtr.Zero) return 0xFFFFFFFF; + return GetMenuState(menuBar, command, 0x0000); + } + public static IntPtr GetMenuBar(IntPtr window) { + return GetMenu(window); + } + public static IntPtr FindPopupMenuWindow(uint processId) { + IntPtr result = IntPtr.Zero; + EnumWindows(delegate(IntPtr window, IntPtr parameter) { + uint owner; + GetWindowThreadProcessId(window, out owner); + if (owner != processId) return true; + var actualClass = new StringBuilder(256); + GetClassName(window, actualClass, actualClass.Capacity); + if (!String.Equals(actualClass.ToString(), "#32768", StringComparison.Ordinal)) return true; + if (!IsWindowVisible(window)) return true; + result = window; + return false; + }, IntPtr.Zero); + return result; + } + public static IntPtr FindVisibleOwnedSheetByContentClass( + uint processId, IntPtr expectedOwner, string contentClass) { + IntPtr result = IntPtr.Zero; + EnumWindows(delegate(IntPtr window, IntPtr parameter) { + uint owner; + GetWindowThreadProcessId(window, out owner); + if (owner == processId && IsWindowVisible(window) && + GetWindow(window, 4) == expectedOwner && + String.Equals(WindowClass(window), "GraphCodeSheetHost", StringComparison.Ordinal) && + String.Equals(SheetContentClass(window), contentClass, StringComparison.Ordinal)) { + result = window; + return false; + } + return true; + }, IntPtr.Zero); + return result; + } + public static int CountVisibleOwnedSheetsByContentClass( + uint processId, IntPtr expectedOwner, string contentClass) { + int count = 0; + EnumWindows(delegate(IntPtr window, IntPtr parameter) { + uint owner; + GetWindowThreadProcessId(window, out owner); + if (owner == processId && IsWindowVisible(window) && + GetWindow(window, 4) == expectedOwner && + String.Equals(WindowClass(window), "GraphCodeSheetHost", StringComparison.Ordinal) && + String.Equals(SheetContentClass(window), contentClass, StringComparison.Ordinal)) { + count++; + } + return true; + }, IntPtr.Zero); + return count; + } + public static IntPtr FindVisibleOwnedSheet(uint processId, string title) { + IntPtr result = IntPtr.Zero; + EnumWindows(delegate(IntPtr window, IntPtr parameter) { + uint owner; + GetWindowThreadProcessId(window, out owner); + if (owner == processId && IsWindowVisible(window) && + String.Equals(WindowClass(window), "GraphCodeSheetHost", StringComparison.Ordinal) && + String.Equals(WindowTitle(window), title, StringComparison.Ordinal)) { + result = window; + return false; + } + return true; + }, IntPtr.Zero); + return result; + } + // MN_GETHMENU. The live UIA tree exposes a popup menu only as an empty Pane + // with no MenuItem children, so the menu itself is read through the Win32 + // menu API against the real HMENU the shell handed to TrackPopupMenu. + public static IntPtr PopupMenuHandle(IntPtr popup) { + if (popup == IntPtr.Zero) return IntPtr.Zero; + return SendMessage(popup, 0x01E1, UIntPtr.Zero, IntPtr.Zero); + } + public static IntPtr FindPopupForMenu(uint processId, IntPtr menu) { + IntPtr result = IntPtr.Zero; + EnumWindows(delegate(IntPtr window, IntPtr parameter) { + uint owner; + GetWindowThreadProcessId(window, out owner); + if (owner == processId && IsWindowVisible(window) && + String.Equals(ClassOf(window), "#32768", StringComparison.Ordinal) && + PopupMenuHandle(window) == menu) { + result = window; + return false; + } + return true; + }, IntPtr.Zero); + return result; + } + public static bool HoverPopupMenuItem(IntPtr owner, IntPtr menu, int position) { return InputSafety.HoverPopupMenuItem(owner, menu, position); } + // Keyboard reveal of a real submenu: hilite the parent item (MN_SELECTITEM) + // and post VK_RIGHT, the same navigation a keyboard user performs. Unlike a + // WM_COMMAND posted to the shell (which TrackPopupMenu/TPM_RETURNCMD menus + // never route), this opens the shell's own submenu popup. + public static bool RevealSubmenuByKeyboard(IntPtr popup, IntPtr menu, int position) { + if (popup == IntPtr.Zero || menu == IntPtr.Zero || !IsWindowVisible(popup)) return false; + SendMessage(popup, 0x01E5, (UIntPtr)position, IntPtr.Zero); + if ((GetMenuState(menu, (uint)position, 0x0400) & 0x0080) == 0) return false; + return PostMessage(popup, 0x0100, (UIntPtr)0x27, IntPtr.Zero) && + PostMessage(popup, 0x0101, (UIntPtr)0x27, IntPtr.Zero); + } + public static int PopupMenuItemCount(IntPtr menu) { + if (menu == IntPtr.Zero) return -1; + return GetMenuItemCount(menu); + } + public static uint PopupMenuItemId(IntPtr menu, int position) { + if (menu == IntPtr.Zero) return 0; + return GetMenuItemID(menu, position); + } + public static string PopupMenuItemText(IntPtr menu, int position) { + if (menu == IntPtr.Zero) return ""; + var text = new StringBuilder(512); + GetMenuString(menu, (uint)position, text, text.Capacity, 0x0400); + return text.ToString(); + } + public static uint PopupMenuItemState(IntPtr menu, int position) { + if (menu == IntPtr.Zero) return 0xFFFFFFFF; + return GetMenuState(menu, (uint)position, 0x0400); + } + // MainWindow.wm_uia_context_menu (WM_APP + 44). + public static bool PostContextMenu(IntPtr window, uint target) { + return PostMessage(window, 0x802C, (UIntPtr)target, IntPtr.Zero); + } + // MainWindow.wm_uia_present_form (WM_APP + 45). + public static bool PostPresentForm(IntPtr window, uint form) { + return PostMessage(window, 0x802D, (UIntPtr)form, IntPtr.Zero); + } + public static bool DismissPopupMenu(IntPtr popup, IntPtr owner) { + bool posted = popup != IntPtr.Zero && + PostMessage(popup, 0x0100, (UIntPtr)0x1B, IntPtr.Zero); + if (!posted && owner != IntPtr.Zero) { + SendMessage(owner, 0x001F, UIntPtr.Zero, IntPtr.Zero); + } + return posted; + } + public static void CancelPopupMenu(IntPtr owner) { + if (owner != IntPtr.Zero) PostMessage(owner, 0x001F, UIntPtr.Zero, IntPtr.Zero); + } + public static IntPtr FindChild(IntPtr parent, string className) { + return FindWindowEx(parent, IntPtr.Zero, className, null); + } + public static IntPtr FindTopLevel(string className, uint processId) { + IntPtr result = IntPtr.Zero; + EnumWindows(delegate(IntPtr window, IntPtr parameter) { + uint owner; + GetWindowThreadProcessId(window, out owner); + if (owner != processId) return true; + var actualClass = new StringBuilder(256); + GetClassName(window, actualClass, actualClass.Capacity); + if (!String.Equals(actualClass.ToString(), className, StringComparison.Ordinal)) return true; + result = window; + return false; + }, IntPtr.Zero); + return result; + } + public static string[] GetListItems(IntPtr list) { + if (list == IntPtr.Zero) return new string[0]; + int count = (int)SendMessage(list, 0x018B, UIntPtr.Zero, IntPtr.Zero); + var result = new string[count]; + for (int index = 0; index < count; index++) { + int length = (int)SendMessage(list, 0x018A, (UIntPtr)index, IntPtr.Zero); + var text = new StringBuilder(length + 1); + SendMessageText(list, 0x0189, (UIntPtr)index, text); + result[index] = text.ToString(); + } + return result; + } + public static bool PostFixtureMutation(IntPtr window, uint mutation) { + return PostMessage(window, 0x802A, (UIntPtr)mutation, IntPtr.Zero); + } + public static bool PostPaletteRefresh(IntPtr window) { + return PostMessage(window, 0x802B, UIntPtr.Zero, IntPtr.Zero); + } + public static bool PostTaggedExitCollision(IntPtr window) { + return PostMessage(window, 0x0111, new UIntPtr(0x8000000000001008UL), IntPtr.Zero); + } + public static bool PostKeyboard(IntPtr window, uint key) { + return PostMessage(window, 0x0100, (UIntPtr)key, IntPtr.Zero) && + PostMessage(window, 0x0101, (UIntPtr)key, IntPtr.Zero); + } + public static bool SendReturn(IntPtr window) { + if (window == IntPtr.Zero) return false; + SendMessage(window, 0x0100, (UIntPtr)0x0D, IntPtr.Zero); + return true; + } + public static bool SendCommand(IntPtr window, uint command) { + if (window == IntPtr.Zero) return false; + UIntPtr result; + return SendMessageTimeout( + window, 0x0111, (UIntPtr)command, IntPtr.Zero, + 0x0001 | 0x0002, 5000, out result + ) != IntPtr.Zero; + } + public static bool ClickButton(IntPtr button) { + if (button == IntPtr.Zero) return false; + SendMessage(button, 0x00F5, UIntPtr.Zero, IntPtr.Zero); + return true; + } + public static int GetCheckState(IntPtr window) { + if (window == IntPtr.Zero) return -1; + return (int)SendMessage(window, 0x00F0, UIntPtr.Zero, IntPtr.Zero); + } + public static bool FocusControl(IntPtr parent, IntPtr control) { + if (parent == IntPtr.Zero || control == IntPtr.Zero) return false; + uint expectedProcessId = WindowProcessId(parent); + RequireOwnedInputWindow(parent, expectedProcessId); + if (WindowProcessId(control) != expectedProcessId || GetAncestor(control, 2) != parent || + !ActivateWindow(parent)) return false; + uint ignoredProcessId; + uint parentThread = GetWindowThreadProcessId(parent, out ignoredProcessId); + uint currentThread = GetCurrentThreadId(); + bool attached = currentThread != parentThread && + AttachThreadInput(currentThread, parentThread, true); + try { + RequireOwnedForeground(parent, expectedProcessId); + SetFocus(control); + return IsForegroundWindow(parent) && FocusedControlInDialog(parent) == control; + } finally { + if (attached) AttachThreadInput(currentThread, parentThread, false); + } + } + [StructLayout(LayoutKind.Sequential)] + private struct GuiThreadInfo { + public int Size; + public uint Flags; + public IntPtr Active, Focus, Capture, MenuOwner, MoveSize, Caret; + public RECT CaretRectangle; + } + [DllImport("user32.dll", SetLastError = true)] + private static extern bool GetGUIThreadInfo(uint thread, ref GuiThreadInfo info); + public static IntPtr FocusedControlInDialog(IntPtr parent) { + uint processId; + uint thread = GetWindowThreadProcessId(parent, out processId); + var info = new GuiThreadInfo { Size = Marshal.SizeOf(typeof(GuiThreadInfo)) }; + if (thread == 0 || !GetGUIThreadInfo(thread, ref info)) + throw new InvalidOperationException("Owned focus could not be observed through GetGUIThreadInfo"); + return info.Focus; + } + private static readonly System.Collections.Generic.Dictionary + RetainedProcesses = new System.Collections.Generic.Dictionary(); + public static void RetainProcess(System.Diagnostics.Process process) { + if (process == null || process.Handle == IntPtr.Zero) + throw new InvalidOperationException("UIA process has no retained launch handle"); + RetainedProcesses[checked((uint)process.Id)] = process; + } + private static bool IsRetainedProcessAlive(uint processId) { + System.Diagnostics.Process process; + if (!RetainedProcesses.TryGetValue(processId, out process)) return false; + try { return process.Handle != IntPtr.Zero && !process.HasExited; } + catch (ObjectDisposedException) { return false; } + catch (InvalidOperationException) { return false; } + } + private static void RequireOwnedInputWindow(IntPtr owner, uint expectedProcessId) { InputSafety.RequireOwnedInputWindow(owner, expectedProcessId); } + private static void RequireOwnedForeground(IntPtr owner, uint expectedProcessId) { InputSafety.RequireOwnedForeground(owner, expectedProcessId); } + public static void RequireOwnedKeyboardInput(IntPtr owner, uint expectedProcessId, IntPtr expectedFocus) { InputSafety.RequireOwnedKeyboardInput(owner, expectedProcessId, expectedFocus); } + private static void RequireOwnedMouseInput( + IntPtr owner, uint expectedProcessId, ScreenPoint expected, IntPtr expectedControl + ) { InputSafety.RequireOwnedMouseInput(owner, expectedProcessId, expected, expectedControl); } + public static bool IsControlOwnedBy(IntPtr parent, IntPtr control, int controlId) { + return parent != IntPtr.Zero && control != IntPtr.Zero && + GetDlgCtrlID(control) == controlId && GetAncestor(control, 2) == parent; + } + public static int ControlIdOf(IntPtr control) { + return control == IntPtr.Zero ? 0 : GetDlgCtrlID(control); + } + public static bool HasVisibleBounds(IntPtr window) { + RECT rect; + return window != IntPtr.Zero && IsWindowVisible(window) && + GetWindowRect(window, out rect) && rect.Right > rect.Left && rect.Bottom > rect.Top; + } + public static string LastActivationDiagnostic = "not attempted"; + private sealed class WindowActivationOperations : GraphCode.Windows.IUiaWindowActivationOperations { + public uint WindowProcessId(IntPtr window) { + return GraphCodeUiaGateState.WindowProcessId(window); + } + public bool IsRetainedProcessAlive(uint processId) { + return GraphCodeUiaGateState.IsRetainedProcessAlive(processId); + } + public void ShowWindow(IntPtr window, int command) { + GraphCodeUiaGateState.ShowWindow(window, command); + } + public GraphCode.Windows.UiaActivationCallResult BringWindowToTop(IntPtr window) { + bool succeeded = GraphCodeUiaGateState.BringWindowToTop(window); + int error = Marshal.GetLastWin32Error(); + return new GraphCode.Windows.UiaActivationCallResult(succeeded, error); + } + public GraphCode.Windows.UiaActivationCallResult SetForegroundWindow(IntPtr window) { + bool succeeded = GraphCodeUiaGateState.SetForegroundWindow(window); + int error = Marshal.GetLastWin32Error(); + return new GraphCode.Windows.UiaActivationCallResult(succeeded, error); + } + public bool IsForegroundWindow(IntPtr window) { + return GraphCodeUiaGateState.IsForegroundWindow(window); + } + } + private static readonly GraphCode.Windows.IUiaWindowActivationOperations ActivationOperations = + new WindowActivationOperations(); + public static bool ActivateWindow(IntPtr window) { + return GraphCode.Windows.UiaWindowActivation.ActivateWindow( + ActivationOperations, window, out LastActivationDiagnostic); + } + public static bool IsForegroundWindow(IntPtr window) { + return window != IntPtr.Zero && GetForegroundWindow() == window; + } + public static IntPtr CurrentForegroundWindow() { + return GetForegroundWindow(); + } + public static uint WindowProcessId(IntPtr window) { + if (window == IntPtr.Zero) return 0; + uint processId; + GetWindowThreadProcessId(window, out processId); + return processId; + } + public static string WindowClass(IntPtr window) { + if (window == IntPtr.Zero) return ""; + var text = new StringBuilder(256); + GetClassName(window, text, text.Capacity); + return text.ToString(); + } + public static string WindowTitle(IntPtr window) { + if (window == IntPtr.Zero) return ""; + int length = GetWindowTextLength(window); + var text = new StringBuilder(length + 1); + GetWindowText(window, text, text.Capacity); + return text.ToString(); + } + // Diagnostic only (Tools/windows/uia-live-gate.ps1's Get-DirectChildren + // exhaustion path): enumerates every top-level window currently owned by + // processId with its class, visibility, and title, so a failure that + // reports MainWindowHandle == 0 can be told apart as window-destroyed + // (empty result), window-hidden (a result exists but is not visible), or + // handle-churn (a visible, healthy-looking window exists under a handle + // Process.MainWindowHandle no longer reports). Does not filter or assert + // anything - it is read verbatim into a log line. + public static string[] DescribeTopLevelWindows(uint processId) { + var results = new System.Collections.ArrayList(); + EnumWindows(delegate(IntPtr window, IntPtr parameter) { + uint owner; + GetWindowThreadProcessId(window, out owner); + if (owner != processId) return true; + var classText = new StringBuilder(256); + GetClassName(window, classText, classText.Capacity); + int titleLength = GetWindowTextLength(window); + var titleText = new StringBuilder(titleLength + 1); + GetWindowText(window, titleText, titleText.Capacity); + bool visible = IsWindowVisible(window); + results.Add(window.ToInt64() + ":" + classText.ToString() + ":" + + (visible ? "visible" : "hidden") + ":" + titleText.ToString()); + return true; + }, IntPtr.Zero); + return (string[])results.ToArray(typeof(string)); + } + public static bool PostMouseClick(IntPtr window) { + return PostMessage(window, 0x0201, UIntPtr.Zero, IntPtr.Zero); + } + // A modal disables its owner for as long as it is up. Win32 refuses a caption + // close against a WS_DISABLED window, so the owner must be observed enabled + // again before a close can be attributed to the shell rather than to a modal + // that has not finished tearing down. + public static bool WindowIsEnabled(IntPtr window) { + return IsWindowEnabled(window); + } + public static bool WindowIsVisible(IntPtr window) { + return window != IntPtr.Zero && IsWindowVisible(window); + } + public static IntPtr WindowOwner(IntPtr window) { + return window == IntPtr.Zero ? IntPtr.Zero : GetWindow(window, 4); + } + public static bool IsCaptionlessSheet(IntPtr window) { + if (window == IntPtr.Zero || + !String.Equals(WindowClass(window), "GraphCodeSheetHost", StringComparison.Ordinal)) + return false; + ulong style = unchecked((ulong)GetWindowLongPtr(window, -16).ToInt64()); + return (style & 0x80000000UL) != 0 && (style & 0x00C00000UL) == 0; + } + public static string SheetContentClass(IntPtr window) { + if (window == IntPtr.Zero || + !String.Equals(WindowClass(window), "GraphCodeSheetHost", StringComparison.Ordinal)) + return ""; + IntPtr viewport = FindWindowEx(window, IntPtr.Zero, "GraphCodeSheetViewport", null); + if (viewport == IntPtr.Zero) return ""; + string[] contentClasses = new[] { + "GraphCodeWindowsDialogContent", + "GraphCodeNativeForm", + "GraphCodeProductSettings", + "GraphCodeWorkspaceManager", + "GraphCodeRepositoryIngressDialog", + "GraphCodeRepositoryOperationDialog", + "GraphCodeCodespaceIngressDialog", + "GraphCodeUpdateOffer", + "GraphCodeUpdateInstall" + }; + foreach (string contentClass in contentClasses) + if (FindWindowEx(viewport, IntPtr.Zero, contentClass, null) != IntPtr.Zero) + return contentClass; + return ""; + } + public static IntPtr FindVisibleProcessWindow(uint processId, string title) { + IntPtr result = IntPtr.Zero; + EnumWindows(delegate(IntPtr window, IntPtr parameter) { + uint owner; + GetWindowThreadProcessId(window, out owner); + if (owner == processId && IsWindowVisible(window) && + String.Equals(WindowTitle(window), title, StringComparison.Ordinal)) { + result = window; + return false; + } + return true; + }, IntPtr.Zero); + return result; + } + // Real client-coordinate mouse messages posted directly to the target window, + // matching the same WM_MOUSEMOVE/WM_LBUTTONDOWN/WM_LBUTTONUP messages the OS + // delivers for genuine mouse input, without moving the shared desktop's real + // cursor (multiple fleet sessions share this desktop). PostMouseButtonAt sets + // the MK_LBUTTON flag in wParam, matching the wParam a real WM_LBUTTONDOWN/UP + // carries; ClickAt below (and its existing sidebar-update-banner caller) keep + // working unchanged since App.zig's click handling only decodes the lParam + // x/y, not the button-state bits in wParam. + private static IntPtr MouseLParam(int x, int y) { + return (IntPtr)(((y & 0xFFFF) << 16) | (x & 0xFFFF)); + } + public static bool PostMouseButtonAt(IntPtr window, uint message, int x, int y) { + return PostMessage(window, message, (UIntPtr)0x0001, MouseLParam(x, y)); + } + public static bool ClickAt(IntPtr window, int x, int y) { + return PostMouseButtonAt(window, 0x0201, x, y) && PostMouseButtonAt(window, 0x0202, x, y); + } + public static bool PostMouseMoveAt(IntPtr window, int clientX, int clientY) { + return PostMessage(window, 0x0200, UIntPtr.Zero, MouseLParam(clientX, clientY)); + } + public static bool PostMouseDragMoveAt(IntPtr window, int clientX, int clientY) { + return PostMessage(window, 0x0200, (UIntPtr)0x0001, MouseLParam(clientX, clientY)); + } + public static IntPtr SendMouseButtonAt(IntPtr window, uint message, int clientX, int clientY) { + return SendMessage(window, message, (UIntPtr)0x0001, MouseLParam(clientX, clientY)); + } + public static bool PostMouseClickAt(IntPtr window, int clientX, int clientY) { + return PostMouseButtonAt(window, 0x0201, clientX, clientY) && + PostMouseButtonAt(window, 0x0202, clientX, clientY); + } + public static bool PostRightClickAt(IntPtr window, int clientX, int clientY) { + IntPtr point = MouseLParam(clientX, clientY); + return PostMessage(window, 0x0204, (UIntPtr)0x0002, point) && + PostMessage(window, 0x0205, UIntPtr.Zero, point); + } + public static int[] ClickOwnedScreenRectangle(IntPtr owner, int left, int top, int right, int bottom, bool rightClick) { return InputSafety.ClickOwnedScreenRectangle(owner, left, top, right, bottom, rightClick); } + public static bool PostOwnedScreenPoint(IntPtr owner, int screenX, int screenY, bool rightClick) { + if (!WindowIsVisible(owner)) return false; + var point = new ScreenPoint { X = screenX, Y = screenY }; + IntPtr hit = WindowFromPoint(point); + if (hit == IntPtr.Zero || GetAncestor(hit, 2) != owner || !ScreenToClient(owner, ref point)) + return false; + if (rightClick) { + return PostMessage(owner, 0x0204, (UIntPtr)0x0002, MouseLParam(point.X, point.Y)) && + PostMessage(owner, 0x0205, UIntPtr.Zero, MouseLParam(point.X, point.Y)); + } + return PostMessage(owner, 0x0201, (UIntPtr)0x0001, MouseLParam(point.X, point.Y)) && + PostMessage(owner, 0x0202, UIntPtr.Zero, MouseLParam(point.X, point.Y)); + } + public static bool IsOwnedPopupInput( + IntPtr owner, IntPtr popup, uint expectedProcessId, uint popupProcessId, + uint foregroundProcessId, IntPtr foregroundRoot, uint pointProcessId, + IntPtr pointRoot, bool popupVisible + ) { return UiaInputSafety.IsOwnedPopupInput(owner, popup, expectedProcessId, popupProcessId, foregroundProcessId, foregroundRoot, pointProcessId, pointRoot, popupVisible); } + + public static PopupItemHit ClickPopupMenuItem(IntPtr popup, IntPtr owner, int position, int commandId, uint expectedProcessId) { return InputSafety.ClickPopupMenuItem(popup, owner, position, commandId, expectedProcessId); } + private static void RequireOwnedPopupForeground(IntPtr popup, IntPtr owner, uint expectedProcessId) { InputSafety.RequireOwnedPopupForeground(popup, owner, expectedProcessId); } + // Sidebar.updateBannerRect/updateBannerAt are pixel-only hit-test geometry with + // no UIA identity of their own, so a genuine click requires the real live client + // height rather than an assumed window size. + public static int ClientHeight(IntPtr window) { + RECT rect; + if (window == IntPtr.Zero || !GetClientRect(window, out rect)) return 0; + return rect.Bottom - rect.Top; + } + // Posts the WM_MOUSEWHEEL the OS delivers to the window under the cursor. Its + // lParam is in screen coordinates; delta is in wheel units (negative scrolls + // content up, so the sidebar advances). + [DllImport("user32.dll")] + private static extern bool ClientToScreen(IntPtr window, ref ScreenPoint point); + public static bool WheelAt(IntPtr window, int clientX, int clientY, int delta) { + var point = new ScreenPoint { X = clientX, Y = clientY }; + if (window == IntPtr.Zero || !ClientToScreen(window, ref point)) return false; + return PostMessage(window, 0x020A, new UIntPtr((uint)((delta & 0xFFFF) << 16)), MouseLParam(point.X, point.Y)); + } + + [DllImport("user32.dll")] + private static extern bool GetClientRect(IntPtr window, out RECT rect); + [DllImport("user32.dll")] + private static extern IntPtr GetMenu(IntPtr window); + [DllImport("user32.dll")] + private static extern IntPtr GetSubMenu(IntPtr menu, int position); + public static IntPtr NativeMenu(IntPtr window) { + return window == IntPtr.Zero ? IntPtr.Zero : GetMenu(window); + } + public static IntPtr NativeSubMenu(IntPtr menu, int position) { + return menu == IntPtr.Zero ? IntPtr.Zero : GetSubMenu(menu, position); + } + // App.zig only rebuilds recent_folders (and the rest of the native chrome) + // from the live GraphModel on WM_INITMENUPOPUP - the same message real + // Windows sends right before a menu bar popup is displayed. Reading the + // HMENU without first sending this leaves it holding whatever was current + // the last time a menu was actually opened (or, at startup, an empty + // "No recent folders" placeholder installed before the fixture loaded). + // Sending it here reproduces the exact real trigger, not a shortcut. + public static void RefreshNativeMenuFromLiveModel(IntPtr window, IntPtr menu) { + SendMessage(window, 0x0117, (UIntPtr)(ulong)menu.ToInt64(), IntPtr.Zero); + } + + [DllImport("user32.dll")] + private static extern bool ScreenToClient(IntPtr window, ref ScreenPoint point); + [DllImport("user32.dll")] + private static extern bool IsIconic(IntPtr window); + public static bool IsWindowMinimized(IntPtr window) { return IsIconic(window); } + public static bool ScreenToClientPoint(IntPtr window, int screenX, int screenY, out int clientX, out int clientY) { + var point = new ScreenPoint { X = screenX, Y = screenY }; + bool ok = ScreenToClient(window, ref point); + clientX = point.X; + clientY = point.Y; + return ok; + } + public static int[] ClientScreenBounds(IntPtr window) { + RECT rect; + if (window == IntPtr.Zero || !GetClientRect(window, out rect)) return null; + var topLeft = new ScreenPoint { X = rect.Left, Y = rect.Top }; + var bottomRight = new ScreenPoint { X = rect.Right, Y = rect.Bottom }; + if (!ClientToScreen(window, ref topLeft) || !ClientToScreen(window, ref bottomRight)) + return null; + return new[] { topLeft.X, topLeft.Y, bottomRight.X, bottomRight.Y }; + } + public static bool PostCommand(IntPtr window, uint command) { + return PostMessage(window, 0x0111, (UIntPtr)command, IntPtr.Zero); + } + public static bool PostClose(IntPtr window) { + return PostMessage(window, 0x0010, UIntPtr.Zero, IntPtr.Zero); + } + public static bool SetFirstEditText(IntPtr parent, string text) { + var edit = FindWindowEx(parent, IntPtr.Zero, "Edit", null); + if (edit == IntPtr.Zero || !SetWindowText(edit, text)) return false; + ulong command = ((ulong)0x0300 << 16) | (uint)GetDlgCtrlID(edit); + SendMessage(parent, 0x0111, (UIntPtr)command, edit); + return true; + } + public static bool SetEditTextById(IntPtr parent, int controlId, string text) { + var edit = ControlById(parent, controlId); + // Cross-process SetWindowText only updates the stored caption; WM_SETTEXT reaches the EDIT buffer. + if (edit == IntPtr.Zero || SendMessageString(edit, 0x000C, UIntPtr.Zero, text) == IntPtr.Zero) return false; + ulong command = ((ulong)0x0300 << 16) | (uint)controlId; + SendMessage(parent, 0x0111, (UIntPtr)command, edit); + return true; + } + public static string EditTextById(IntPtr parent, int controlId) { + var edit = ControlById(parent, controlId); + if (edit == IntPtr.Zero) return null; + return EditBufferText(edit); + } + private static string EditBufferText(IntPtr edit) { + // Cross-process GetWindowText returns the stored caption; WM_GETTEXT reads the EDIT buffer. + int length = (int)SendMessage(edit, 0x000E, UIntPtr.Zero, IntPtr.Zero); + var text = new StringBuilder(length + 1); + SendMessageText(edit, 0x000D, (UIntPtr)text.Capacity, text); + return text.ToString(); + } + public static string FirstEditText(IntPtr parent) { + var edit = ControlById(parent, 9100); + if (edit == IntPtr.Zero) edit = ControlById(parent, 9904); + if (edit == IntPtr.Zero) edit = FindWindowEx(parent, IntPtr.Zero, "Edit", null); + if (edit == IntPtr.Zero) return null; + return EditBufferText(edit); + } + // Node creation sheet helpers. Every click below is a real cursor move plus + // SendInput at the live window rectangle of the target control, and the + // window under that point is checked first so a covered or mis-scaled target + // fails instead of silently clicking something else. + [DllImport("user32.dll")] + private static extern bool GetWindowRect(IntPtr window, out RECT rect); + [DllImport("user32.dll")] + private static extern IntPtr WindowFromPoint(ScreenPoint point); + private delegate bool EnumChildProc(IntPtr window, IntPtr parameter); + [DllImport("user32.dll")] + private static extern bool EnumChildWindows(IntPtr parent, EnumChildProc callback, IntPtr parameter); + + // INPUT is a union sized by MOUSEINPUT; the trailing padding keeps this + // keyboard record the same size so SendInput accepts cbSize. + + [DllImport("user32.dll", SetLastError = true, EntryPoint = "SendInput")] + private static extern uint SendKeyInputs(uint count, KeyInputRecord[] inputs, int size); + [DllImport("user32.dll", SetLastError = true, EntryPoint = "SystemParametersInfoW")] + private static extern bool SystemParametersInfoRect(uint action, uint param, out RECT rect, uint winIni); + [DllImport("user32.dll")] + private static extern IntPtr GetAncestor(IntPtr window, uint flags); + [DllImport("user32.dll")] + 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")] + private static extern IntPtr GetWindowLongPtr(IntPtr window, int index); + public sealed class ControlClick { + public int ControlId, Left, Top, Right, Bottom, ScreenX, ScreenY; + public int CursorBeforeX, CursorBeforeY, CursorAtX, CursorAtY; + public bool HitTarget; + public int WindowAtPointId; + public string WindowAtPointClass, WindowAtPointRootClass, WindowAtPointText; + public int WindowAtPointProcessId; + public int RealChildId; + public string RealChildClass, RealChildText; + public bool SameTopLevel; + public bool VisibleEmpty; + public int WorkLeft, WorkTop, WorkRight, WorkBottom; + public bool OutsideWorkArea; + public int CenterX, CenterY; + public bool CenterHitTarget; + public string WindowAtCenterClass, WindowAtCenterRootClass; + public bool ScanUsed; + public int ScannedPoints, CoveredPoints; + public int CoveringId, CoveringLeft, CoveringTop, CoveringRight, CoveringBottom; + public string CoveringClass, CoveringText; + public string[] Samples; + public string[] CoveringWindows; + public int[][] UncoveredRectangles; + public int[] ChosenUncoveredRectangle; + public bool NarrowUncovered; + } + public static int[][] SubtractCoveredRectangles(int[] visible, int[][] covers) { + var remaining = new System.Collections.ArrayList(); + if (visible[2] > visible[0] && visible[3] > visible[1]) remaining.Add(visible); + foreach (int[] cover in covers) { + var next = new System.Collections.ArrayList(); + foreach (int[] piece in remaining) { + int left = Math.Max(piece[0], cover[0]), top = Math.Max(piece[1], cover[1]); + int right = Math.Min(piece[2], cover[2]), bottom = Math.Min(piece[3], cover[3]); + if (left >= right || top >= bottom) { next.Add(piece); continue; } + if (piece[0] < left) next.Add(new[] { piece[0], piece[1], left, piece[3] }); + if (right < piece[2]) next.Add(new[] { right, piece[1], piece[2], piece[3] }); + if (piece[1] < top) next.Add(new[] { left, piece[1], right, top }); + if (bottom < piece[3]) next.Add(new[] { left, bottom, right, piece[3] }); + } + remaining = next; + } + var result = new int[remaining.Count][]; + remaining.CopyTo(result); + Array.Sort(result, delegate(int[] a, int[] b) { + long areaA = (long)(a[2] - a[0]) * (a[3] - a[1]); + long areaB = (long)(b[2] - b[0]) * (b[3] - b[1]); + int byArea = areaB.CompareTo(areaA); + if (byArea != 0) return byArea; + int byTop = a[1].CompareTo(b[1]); + return byTop != 0 ? byTop : a[0].CompareTo(b[0]); + }); + return result; + } + private static IntPtr ResolveChild(IntPtr dialog, ScreenPoint point) { + var clientPoint = point; + if (!ScreenToClient(dialog, ref clientPoint)) return IntPtr.Zero; + return RealChildWindowFromPoint(dialog, clientPoint); + } + private static bool ResolvesToControl(IntPtr target, IntPtr dialog, ScreenPoint point) { + IntPtr parent = GetAncestor(target, 1); + IntPtr root = GetAncestor(target, 2); + uint processId = WindowProcessId(target); + RECT bounds; + if (target == IntPtr.Zero || parent == IntPtr.Zero || root == IntPtr.Zero || processId == 0 || + (dialog != parent && dialog != root) || GetAncestor(parent, 2) != root || + WindowProcessId(parent) != processId || WindowProcessId(root) != processId || + !IsRetainedProcessAlive(processId) || !GetWindowRect(target, out bounds) || + point.X < bounds.Left || point.X >= bounds.Right || point.Y < bounds.Top || point.Y >= bounds.Bottom) + return false; + IntPtr atPoint = WindowFromPoint(point); + if (atPoint == IntPtr.Zero || GetAncestor(atPoint, 2) != root || WindowProcessId(atPoint) != processId) return false; + dialog = parent; + var clientPoint = point; + if (!ScreenToClient(dialog, ref clientPoint) || GetAncestor(target, 1) != parent || + GetAncestor(target, 2) != root || WindowProcessId(target) != processId) return false; + return RealChildWindowFromPoint(dialog, clientPoint) == target; + } + private static IntPtr RequireControlClickRoot(IntPtr target, IntPtr parent) { + IntPtr root = GetAncestor(target, 2); + uint processId = WindowProcessId(target); + if (target == IntPtr.Zero || parent == IntPtr.Zero || root == IntPtr.Zero || + GetAncestor(target, 1) != parent || GetAncestor(parent, 2) != root || + processId == 0 || WindowProcessId(parent) != processId || WindowProcessId(root) != processId || + !IsRetainedProcessAlive(processId)) + throw new InvalidOperationException("Control click target lost its exact parent/root ownership; no input was injected"); + return root; + } + private static string ClassOf(IntPtr window) { + if (window == IntPtr.Zero) return ""; + var name = new StringBuilder(128); + GetClassName(window, name, name.Capacity); + return name.ToString(); + } + public static int[] WindowBounds(IntPtr window) { + RECT rect; + if (window == IntPtr.Zero || !GetWindowRect(window, out rect)) return null; + return new[] { rect.Left, rect.Top, rect.Right, rect.Bottom }; + } + public static IntPtr ControlById(IntPtr parent, int controlId) { + if (parent == IntPtr.Zero) return IntPtr.Zero; + IntPtr direct = GetDlgItem(parent, controlId); + if (direct != IntPtr.Zero) return direct; + if (!String.Equals(ClassOf(parent), "GraphCodeSheetHost", StringComparison.Ordinal)) + return IntPtr.Zero; + IntPtr viewport = FindWindowEx(parent, IntPtr.Zero, "GraphCodeSheetViewport", null); + if (viewport == IntPtr.Zero) return IntPtr.Zero; + string[] contentClasses = new[] { + "GraphCodeWindowsDialogContent", + "GraphCodeNativeForm", + "GraphCodeProductSettings", + "GraphCodeWorkspaceManager", + "GraphCodeRepositoryIngressDialog", + "GraphCodeRepositoryOperationDialog", + "GraphCodeCodespaceIngressDialog", + "GraphCodeUpdateOffer", + "GraphCodeUpdateInstall" + }; + foreach (string contentClass in contentClasses) { + IntPtr content = FindWindowEx(viewport, IntPtr.Zero, contentClass, null); + if (content == IntPtr.Zero) continue; + IntPtr nested = GetDlgItem(content, controlId); + if (nested != IntPtr.Zero) return nested; + } + return IntPtr.Zero; + } + public static ControlClick ClickScreenPoint(IntPtr target) { + RECT rect; + if (target == IntPtr.Zero || !GetWindowRect(target, out rect) || + rect.Right <= rect.Left || rect.Bottom <= rect.Top) return null; + RECT work; + if (!SystemParametersInfoRect(0x0030, 0, out work, 0)) + throw new InvalidOperationException(String.Format("SPI_GETWORKAREA failed: Win32Error={0}", Marshal.GetLastWin32Error())); + var rectCenter = new ScreenPoint { X = (rect.Left + rect.Right) / 2, Y = (rect.Top + rect.Bottom) / 2 }; + IntPtr atRectCenter = WindowFromPoint(rectCenter); + // A user clicks the part of a control they can see. When the control lies + // inside the work area this is exactly its centre; only when it crosses the + // work-area edge does the point move into the visible portion. A control with + // no visible portion is reported as VisibleEmpty and never clicked. + int visibleLeft = Math.Max(rect.Left, work.Left), visibleTop = Math.Max(rect.Top, work.Top); + int visibleRight = Math.Min(rect.Right, work.Right), visibleBottom = Math.Min(rect.Bottom, work.Bottom); + var center = new ScreenPoint { X = (visibleLeft + visibleRight) / 2, Y = (visibleTop + visibleBottom) / 2 }; + bool visibleEmpty = visibleRight <= visibleLeft || visibleBottom <= visibleTop; + IntPtr atPoint = visibleEmpty ? IntPtr.Zero : WindowFromPoint(center); + IntPtr rootAtPoint = atPoint == IntPtr.Zero ? IntPtr.Zero : GetAncestor(atPoint, 2); + uint ownerProcess = 0; + if (rootAtPoint != IntPtr.Zero) GetWindowThreadProcessId(rootAtPoint, out ownerProcess); + // Real mouse input skips HTTRANSPARENT children such as plain static text; + // cross-process WindowFromPoint does not. RealChildWindowFromPoint applies + // the same rule within the dialog, and the top-level check still rejects + // any other window (for example the taskbar) covering the point. + IntPtr dialog = GetAncestor(target, 1); + IntPtr realChild = IntPtr.Zero; + if (!visibleEmpty && dialog != IntPtr.Zero) { + var clientPoint = center; + if (ScreenToClient(dialog, ref clientPoint)) realChild = RealChildWindowFromPoint(dialog, clientPoint); + } + bool sameTopLevel = rootAtPoint != IntPtr.Zero && rootAtPoint == GetAncestor(target, 2); + var hit = new ControlClick { + ControlId = GetDlgCtrlID(target), Left = rect.Left, Top = rect.Top, Right = rect.Right, + Bottom = rect.Bottom, ScreenX = center.X, ScreenY = center.Y, + HitTarget = !visibleEmpty && sameTopLevel && realChild == target, + VisibleEmpty = visibleEmpty, + WindowAtPointId = atPoint == IntPtr.Zero ? 0 : GetDlgCtrlID(atPoint), + WindowAtPointClass = ClassOf(atPoint), + WindowAtPointText = atPoint == IntPtr.Zero ? "" : EditBufferText(atPoint), + WindowAtPointRootClass = ClassOf(rootAtPoint), + WindowAtPointProcessId = (int)ownerProcess, + RealChildId = realChild == IntPtr.Zero ? 0 : GetDlgCtrlID(realChild), + RealChildClass = ClassOf(realChild), + RealChildText = realChild == IntPtr.Zero ? "" : EditBufferText(realChild), + SameTopLevel = sameTopLevel, + WorkLeft = work.Left, WorkTop = work.Top, WorkRight = work.Right, WorkBottom = work.Bottom, + OutsideWorkArea = rect.Left < work.Left || rect.Top < work.Top || rect.Right > work.Right || rect.Bottom > work.Bottom, + CenterX = rectCenter.X, CenterY = rectCenter.Y, + CenterHitTarget = atRectCenter == target, + WindowAtCenterClass = ClassOf(atRectCenter), + WindowAtCenterRootClass = ClassOf(atRectCenter == IntPtr.Zero ? IntPtr.Zero : GetAncestor(atRectCenter, 2)) + }; + // Subtract visible siblings above the target in Z order. A narrow uncovered + // strip can fall between grid rows, so try the largest residual rectangle + // before scanning the fixed grid as a diagnostic fallback. + if (!hit.HitTarget && !visibleEmpty && dialog != IntPtr.Zero) { + IntPtr covering = realChild != IntPtr.Zero && realChild != target && realChild != dialog ? realChild : atPoint; + RECT coveringRect; + if (covering != IntPtr.Zero && GetWindowRect(covering, out coveringRect)) { + hit.CoveringLeft = coveringRect.Left; hit.CoveringTop = coveringRect.Top; + hit.CoveringRight = coveringRect.Right; hit.CoveringBottom = coveringRect.Bottom; + } + hit.CoveringId = covering == IntPtr.Zero ? 0 : GetDlgCtrlID(covering); + hit.CoveringClass = ClassOf(covering); + hit.CoveringText = covering == IntPtr.Zero ? "" : EditBufferText(covering); + var covers = new System.Collections.ArrayList(); + var coveringWindows = new System.Collections.ArrayList(); + for (IntPtr sibling = GetWindow(target, 3); sibling != IntPtr.Zero; sibling = GetWindow(sibling, 3)) { + RECT siblingRect; + if (!IsWindowVisible(sibling) || !GetWindowRect(sibling, out siblingRect)) continue; + int left = Math.Max(visibleLeft, siblingRect.Left), top = Math.Max(visibleTop, siblingRect.Top); + int right = Math.Min(visibleRight, siblingRect.Right), bottom = Math.Min(visibleBottom, siblingRect.Bottom); + if (left >= right || top >= bottom) continue; + var overlapCenter = new ScreenPoint { X = (left + right) / 2, Y = (top + bottom) / 2 }; + if (ResolveChild(dialog, overlapCenter) != sibling) continue; + covers.Add(new[] { siblingRect.Left, siblingRect.Top, siblingRect.Right, siblingRect.Bottom }); + coveringWindows.Add(String.Format("{0}|{1}|{2}|{3},{4},{5},{6}", GetDlgCtrlID(sibling), + ClassOf(sibling), EditBufferText(sibling), siblingRect.Left, siblingRect.Top, siblingRect.Right, siblingRect.Bottom)); + } + hit.CoveringWindows = (string[])coveringWindows.ToArray(typeof(string)); + hit.UncoveredRectangles = SubtractCoveredRectangles(new[] { visibleLeft, visibleTop, visibleRight, visibleBottom }, + (int[][])covers.ToArray(typeof(int[]))); + foreach (int[] piece in hit.UncoveredRectangles) { + var candidate = new ScreenPoint { X = (piece[0] + piece[2]) / 2, Y = (piece[1] + piece[3]) / 2 }; + if (!ResolvesToControl(target, dialog, candidate)) continue; + if (piece[2] - piece[0] < 3 || piece[3] - piece[1] < 3) { + hit.NarrowUncovered = true; + continue; + } + center = candidate; + hit.ScreenX = candidate.X; hit.ScreenY = candidate.Y; + hit.HitTarget = true; + hit.ScanUsed = true; + hit.ChosenUncoveredRectangle = piece; + break; + } + int width = visibleRight - visibleLeft, height = visibleBottom - visibleTop; + var samples = new string[15]; + for (int row = 1; row <= 3; row++) { + for (int column = 1; column <= 5; column++) { + var candidate = new ScreenPoint { X = visibleLeft + column * width / 6, Y = visibleTop + row * height / 4 }; + IntPtr resolved = ResolveChild(dialog, candidate); + bool uncovered = ResolvesToControl(target, dialog, candidate); + if (uncovered && hit.NarrowUncovered) uncovered = false; + samples[hit.ScannedPoints++] = String.Format("{0},{1}|{2}|{3}|{4}|{5}", candidate.X, candidate.Y, + resolved == IntPtr.Zero ? 0 : GetDlgCtrlID(resolved), ClassOf(resolved), uncovered ? "control" : "covered", + resolved == IntPtr.Zero ? "" : EditBufferText(resolved)); + if (!uncovered) { + hit.CoveredPoints++; + } else if (!hit.HitTarget) { + center = candidate; + hit.ScreenX = candidate.X; hit.ScreenY = candidate.Y; + hit.HitTarget = true; + hit.ScanUsed = true; + } + } + } + hit.Samples = samples; + } + if (!hit.HitTarget) return hit; + IntPtr inputRoot = RequireControlClickRoot(target, dialog); + uint expectedProcessId = WindowProcessId(inputRoot); + RequireOwnedForeground(inputRoot, expectedProcessId); + ScreenPoint before, at; + if (!GetCursorPos(out before)) + throw new InvalidOperationException(String.Format("GetCursorPos before control click failed: Win32Error={0}", Marshal.GetLastWin32Error())); + if (!SetCursorPos(center.X, center.Y)) + throw new InvalidOperationException(String.Format("SetCursorPos for control click failed: Win32Error={0}", Marshal.GetLastWin32Error())); + if (!GetCursorPos(out at)) + throw new InvalidOperationException(String.Format("GetCursorPos after control move failed: Win32Error={0}", Marshal.GetLastWin32Error())); + if (at.X != center.X || at.Y != center.Y) + throw new InvalidOperationException(String.Format("Control cursor landed at ({0},{1}), expected ({2},{3})", at.X, at.Y, center.X, center.Y)); + hit.CursorBeforeX = before.X; hit.CursorBeforeY = before.Y; + hit.CursorAtX = at.X; hit.CursorAtY = at.Y; + var inputs = new[] { + new Input { Type = 0, Mouse = new MouseInput { Flags = 0x0002 } }, + new Input { Type = 0, Mouse = new MouseInput { Flags = 0x0004 } } + }; + if (RequireControlClickRoot(target, dialog) != inputRoot) + throw new InvalidOperationException("Control click target root changed; no input was injected"); + RequireOwnedMouseInput(inputRoot, expectedProcessId, center, target); + uint sent = SendInput((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(Input))); + if (sent != inputs.Length) + throw new InvalidOperationException(String.Format("SendInput injected {0} of {1} control mouse events: Win32Error={2}", sent, inputs.Length, Marshal.GetLastWin32Error())); + return hit; + } + public static int SendKeyInput(IntPtr owner, uint expectedProcessId, IntPtr expectedFocus, ushort virtualKey, int count) { return InputSafety.SendKeyInput(owner, expectedProcessId, expectedFocus, virtualKey, count); } + public static int[] SendUnicodeInput(IntPtr owner, uint expectedProcessId, IntPtr expectedFocus, string text) { return InputSafety.SendUnicodeInput(owner, expectedProcessId, expectedFocus, text); } + public static bool TypeEditTextById(IntPtr parent, int controlId, string text) { return InputSafety.TypeEditTextById(parent, controlId, text); } + public static int[] VisibleChildIds(IntPtr parent, int first, int last) { + var buffer = new int[Math.Max(0, last - first + 1)]; + int found = 0; + for (int id = first; id <= last; id++) { + IntPtr child = ControlById(parent, id); + if (child != IntPtr.Zero && IsWindowVisible(child)) buffer[found++] = id; + } + var result = new int[found]; + Array.Copy(buffer, result, found); + return result; + } + public static string[] VisibleStaticTexts(IntPtr parent) { + var buffer = new string[256]; + int found = 0; + if (parent == IntPtr.Zero) return new string[0]; + EnumChildWindows(parent, delegate(IntPtr window, IntPtr parameter) { + var actualClass = new StringBuilder(64); + GetClassName(window, actualClass, actualClass.Capacity); + if (String.Equals(actualClass.ToString(), "Static", StringComparison.OrdinalIgnoreCase) && + IsWindowVisible(window) && found < buffer.Length) { + buffer[found++] = EditBufferText(window); + } + return true; + }, IntPtr.Zero); + var result = new string[found]; + Array.Copy(buffer, result, found); + return result; + } + public static string WindowTextOf(IntPtr window) { + return window == IntPtr.Zero ? null : EditBufferText(window); + } + public static string ComboSelection(IntPtr parent, int controlId) { + IntPtr combo = ControlById(parent, controlId); + if (combo == IntPtr.Zero) return null; + int index = (int)SendMessage(combo, 0x0147, UIntPtr.Zero, IntPtr.Zero); + if (index < 0) return index + "|"; + int length = (int)SendMessage(combo, 0x0149, (UIntPtr)index, IntPtr.Zero); + var text = new StringBuilder(Math.Max(length, 0) + 1); + SendMessageText(combo, 0x0148, (UIntPtr)index, text); + return index + "|" + text.ToString(); + } +} diff --git a/Tools/windows/ValidationRunner/Ui/Production/GraphCodeUiaHostInfo.cs b/Tools/windows/ValidationRunner/Ui/Production/GraphCodeUiaHostInfo.cs new file mode 100644 index 00000000..a3f0a6be --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/Production/GraphCodeUiaHostInfo.cs @@ -0,0 +1,46 @@ +#nullable disable +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +public static class GraphCodeUiaHostInfo { + private const int UOI_NAME = 2; + [DllImport("user32.dll")] + private static extern IntPtr GetProcessWindowStation(); + [DllImport("user32.dll")] + private static extern IntPtr GetThreadDesktop(uint threadId); + [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool GetUserObjectInformation( + IntPtr handle, int index, StringBuilder info, uint length, out uint needed + ); + [DllImport("kernel32.dll")] + private static extern uint GetCurrentThreadId(); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool ProcessIdToSessionId(uint processId, out uint sessionId); + [DllImport("user32.dll")] + private static extern uint GetGuiResources(IntPtr process, uint flags); + private static string ObjectName(IntPtr handle) { + if (handle == IntPtr.Zero) return null; + var name = new StringBuilder(256); + uint needed; + if (!GetUserObjectInformation(handle, UOI_NAME, name, (uint)(name.Capacity * 2), out needed)) + return null; + return name.ToString(); + } + public static string CurrentWindowStationName() { + return ObjectName(GetProcessWindowStation()); + } + public static string CurrentDesktopName() { + return ObjectName(GetThreadDesktop(GetCurrentThreadId())); + } + public static uint CurrentSessionId() { + uint sessionId; + if (!ProcessIdToSessionId((uint)Process.GetCurrentProcess().Id, out sessionId)) + throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error()); + return sessionId; + } + public static uint GuiResourceCount(int processId, uint flags) { + using (var process = Process.GetProcessById(processId)) + return GetGuiResources(process.Handle, flags); + } +} diff --git a/Tools/windows/ValidationRunner/Ui/Production/UiaInputSafety.cs b/Tools/windows/ValidationRunner/Ui/Production/UiaInputSafety.cs new file mode 100644 index 00000000..e4895b7e --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/Production/UiaInputSafety.cs @@ -0,0 +1,334 @@ +#nullable disable +using System; +using System.Runtime.InteropServices; +using static GraphCode.Windows.UiaInputSafety; + +namespace GraphCode.Windows { +public interface IUiaInputOperations { + bool IsRetainedProcessAlive(uint processId); + uint WindowProcessId(IntPtr window); + bool WindowIsVisible(IntPtr window); + bool WindowIsEnabled(IntPtr window); + IntPtr GetForegroundWindow(); + IntPtr FocusedControlInDialog(IntPtr window); + IntPtr GetAncestor(IntPtr window, uint flags); + bool GetCursorPos(out ScreenPoint point); + bool SetCursorPos(int x, int y); + IntPtr WindowFromPoint(ScreenPoint point); + bool IsWindowVisible(IntPtr window); + bool GetMenuItemRect(IntPtr owner, IntPtr menu, uint position, out RECT item); + IntPtr FindPopupForMenu(uint processId, IntPtr menu); + bool ScreenToClient(IntPtr window, ref ScreenPoint point); + IntPtr MouseLParam(int x, int y); + uint GetMenuState(IntPtr menu, uint position, uint flags); + void Sleep(uint milliseconds); + bool ResolvesToControl(IntPtr target, IntPtr owner, ScreenPoint point); + uint SendInput(uint count, Input[] inputs, int size); + uint SendKeyInputs(uint count, KeyInputRecord[] inputs, int size); + IntPtr ControlById(IntPtr owner, int id); + bool FocusControl(IntPtr owner, IntPtr edit); + IntPtr SendMessage(IntPtr edit, uint message, UIntPtr first, IntPtr last); + string EditBufferText(IntPtr edit); + string EditTextById(IntPtr owner, int id); + IntPtr SendMessageString(IntPtr edit, uint message, UIntPtr first, string text); + IntPtr PopupMenuHandle(IntPtr popup); + uint PopupMenuItemId(IntPtr menu, int position); + bool PostMessage(IntPtr window, uint message, UIntPtr first, IntPtr last); +} +public sealed class UiaInputSafety { + private readonly IUiaInputOperations operations; + public UiaInputSafety(IUiaInputOperations operations) { this.operations = operations ?? throw new ArgumentNullException(nameof(operations)); } +[StructLayout(LayoutKind.Sequential)] +public struct MouseInput { + public int X, Y; + public uint MouseData, Flags, Time; + public UIntPtr ExtraInfo; + } +[StructLayout(LayoutKind.Sequential)] +public struct Input { + public uint Type; + public MouseInput Mouse; + } +[StructLayout(LayoutKind.Sequential)] +public struct RECT { public int Left; public int Top; public int Right; public int Bottom; } +[StructLayout(LayoutKind.Sequential)] +public struct ScreenPoint { public int X; public int Y; } +[StructLayout(LayoutKind.Sequential)] +public struct KeybdInput { + public ushort VirtualKey, ScanCode; + public uint Flags, Time; + public UIntPtr ExtraInfo; + } +[StructLayout(LayoutKind.Sequential)] +public struct KeyInputRecord { + public uint Type; + public KeybdInput Key; + public uint Pad0, Pad1; + } +public sealed class PopupItemHit { + public int Position, ItemId, Left, Top, Right, Bottom; + public int ScreenX, ScreenY, ClientX, ClientY, CursorBeforeX, CursorBeforeY; + public int CursorAtX, CursorAtY; + public bool Hilite, UsedKeyboardFallback, UsedSelectItemFallback; + public bool HiliteAtEnterFallback, HiliteBeforeEnter; + } +public uint LastEditClearExpected; +public uint LastEditClearSent; +public uint LastEditTextExpected; +public uint LastEditTextSent; +public bool LastEditUsedMessageFallback; +public void RequireOwnedInputWindow(IntPtr owner, uint expectedProcessId) { + if (owner == IntPtr.Zero || expectedProcessId == 0 || !operations.IsRetainedProcessAlive(expectedProcessId) || + operations.WindowProcessId(owner) != expectedProcessId || !operations.WindowIsVisible(owner) || !operations.WindowIsEnabled(owner)) + throw new InvalidOperationException("Input target lost retained process/window ownership; no input was injected"); + } +public void RequireOwnedForeground(IntPtr owner, uint expectedProcessId) { + RequireOwnedInputWindow(owner, expectedProcessId); + IntPtr foreground = operations.GetForegroundWindow(); + if (foreground != owner || operations.WindowProcessId(foreground) != expectedProcessId) + throw new InvalidOperationException("Owned foreground changed; no input was injected"); + } +public void RequireOwnedKeyboardInput(IntPtr owner, uint expectedProcessId, IntPtr expectedFocus) { + RequireOwnedForeground(owner, expectedProcessId); + IntPtr focus = operations.FocusedControlInDialog(owner); + if (focus == IntPtr.Zero || operations.WindowProcessId(focus) != expectedProcessId || + operations.GetAncestor(focus, 2) != owner || (expectedFocus != IntPtr.Zero && focus != expectedFocus)) + throw new InvalidOperationException("Owned keyboard focus changed; no key input was injected"); + RequireOwnedForeground(owner, expectedProcessId); + } +public void RequireOwnedMouseInput( + IntPtr owner, uint expectedProcessId, ScreenPoint expected, IntPtr expectedControl + ) { + RequireOwnedForeground(owner, expectedProcessId); + ScreenPoint actual; + if (!operations.GetCursorPos(out actual) || actual.X != expected.X || actual.Y != expected.Y) + throw new InvalidOperationException("Owned mouse cursor changed; no mouse input was injected"); + IntPtr hit = operations.WindowFromPoint(actual); + if (hit == IntPtr.Zero || operations.GetAncestor(hit, 2) != owner || operations.WindowProcessId(hit) != expectedProcessId || + (expectedControl != IntPtr.Zero && !operations.ResolvesToControl(expectedControl, owner, actual))) + throw new InvalidOperationException("Owned mouse hit changed; no mouse input was injected"); + } +public int SendKeyInput(IntPtr owner, uint expectedProcessId, IntPtr expectedFocus, ushort virtualKey, int count) { + if (count <= 0) return 0; + var inputs = new KeyInputRecord[count * 2]; + for (int index = 0; index < count; index++) { + inputs[index * 2] = new KeyInputRecord { Type = 1, Key = new KeybdInput { VirtualKey = virtualKey } }; + inputs[index * 2 + 1] = new KeyInputRecord { Type = 1, Key = new KeybdInput { VirtualKey = virtualKey, Flags = 0x0002 } }; + } + RequireOwnedKeyboardInput(owner, expectedProcessId, expectedFocus); + uint sent = operations.SendKeyInputs((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(KeyInputRecord))); + if (sent != inputs.Length) + throw new InvalidOperationException(String.Format("SendInput injected {0} of {1} key events (cbSize={2}): Win32Error={3}", sent, inputs.Length, Marshal.SizeOf(typeof(KeyInputRecord)), Marshal.GetLastWin32Error())); + return count; + } +public int[] SendUnicodeInput(IntPtr owner, uint expectedProcessId, IntPtr expectedFocus, string text) { + var records = new KeyInputRecord[text.Length * 2]; + int offset = 0; + foreach (char character in text) { + records[offset++] = new KeyInputRecord { Type = 1, Key = new KeybdInput { ScanCode = character, Flags = 4 } }; + records[offset++] = new KeyInputRecord { Type = 1, Key = new KeybdInput { ScanCode = character, Flags = 6 } }; + } + if (records.Length == 0) return new int[] { 0, 0 }; + RequireOwnedKeyboardInput(owner, expectedProcessId, expectedFocus); + uint sent = operations.SendKeyInputs((uint)records.Length, records, Marshal.SizeOf(typeof(KeyInputRecord))); + if (sent != records.Length) + throw new InvalidOperationException(String.Format("Owned Unicode SendInput injected {0}/{1}", sent, records.Length)); + return new int[] { checked((int)sent), records.Length }; + } +public bool TypeEditTextById(IntPtr parent, int controlId, string text) { + LastEditClearExpected = LastEditClearSent = 0; + LastEditTextExpected = LastEditTextSent = 0; + LastEditUsedMessageFallback = false; + IntPtr edit = operations.ControlById(parent, controlId); + if (edit == IntPtr.Zero || !operations.FocusControl(parent, edit)) return false; + uint expectedProcessId = operations.WindowProcessId(parent); + operations.SendMessage(edit, 0x00B1, UIntPtr.Zero, new IntPtr(-1)); + var clear = new KeyInputRecord[2]; + clear[0] = new KeyInputRecord { Type = 1, Key = new KeybdInput { VirtualKey = 0x2E } }; + clear[1] = new KeyInputRecord { Type = 1, Key = new KeybdInput { VirtualKey = 0x2E, Flags = 2 } }; + LastEditClearExpected = (uint)clear.Length; + RequireOwnedKeyboardInput(parent, expectedProcessId, edit); + LastEditClearSent = operations.SendKeyInputs(LastEditClearExpected, clear, Marshal.SizeOf(typeof(KeyInputRecord))); + if (LastEditClearSent != LastEditClearExpected) return false; + bool cleared = String.IsNullOrEmpty(operations.EditBufferText(edit)); + int[] typed = SendUnicodeInput(parent, expectedProcessId, edit, text); + LastEditTextExpected = checked((uint)typed[1]); + LastEditTextSent = checked((uint)typed[0]); + bool matched = cleared && String.Equals(operations.EditTextById(parent, controlId), text, StringComparison.Ordinal); + if (!matched) { + operations.SendMessageString(edit, 0x000C, UIntPtr.Zero, text); + LastEditUsedMessageFallback = true; + matched = String.Equals(operations.EditTextById(parent, controlId), text, StringComparison.Ordinal); + } + return matched; + } +public int[] ClickOwnedScreenRectangle(IntPtr owner, int left, int top, int right, int bottom, bool rightClick) { + uint expectedProcessId = operations.WindowProcessId(owner); + RequireOwnedForeground(owner, expectedProcessId); + if (!operations.WindowIsVisible(owner) || right <= left || bottom <= top) + throw new InvalidOperationException("Owned rectangle is not visibly hittable"); + var point = new ScreenPoint { X = (left + right) / 2, Y = (top + bottom) / 2 }; + IntPtr hit = operations.WindowFromPoint(point); + if (hit == IntPtr.Zero || operations.GetAncestor(hit, 2) != owner) + throw new InvalidOperationException("Live rectangle is covered by another top-level window"); + if (!operations.SetCursorPos(point.X, point.Y)) + throw new InvalidOperationException("Unable to move cursor into owned live rectangle"); + ScreenPoint actual; + if (!operations.GetCursorPos(out actual) || actual.X != point.X || actual.Y != point.Y) + throw new InvalidOperationException("Owned live-rectangle cursor position differs"); + var inputs = new Input[] { + new Input { Type = 0, Mouse = new MouseInput { Flags = rightClick ? 0x0008u : 0x0002u } }, + new Input { Type = 0, Mouse = new MouseInput { Flags = rightClick ? 0x0010u : 0x0004u } } + }; + RequireOwnedMouseInput(owner, expectedProcessId, point, IntPtr.Zero); + uint sent = operations.SendInput((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(Input))); + if (sent != inputs.Length) + throw new InvalidOperationException(String.Format("Owned rectangle SendInput sent {0}/{1}", sent, inputs.Length)); + return new int[] { left, top, right, bottom, actual.X, actual.Y, (int)sent, inputs.Length }; + } +public void RequireOwnedPopupForeground(IntPtr popup, IntPtr owner, uint expectedProcessId) { + IntPtr foreground = operations.GetForegroundWindow(); + IntPtr foregroundRoot = operations.GetAncestor(foreground, 2); + if (expectedProcessId == 0 || !operations.IsRetainedProcessAlive(expectedProcessId) || operations.WindowProcessId(owner) != expectedProcessId || + operations.WindowProcessId(popup) != expectedProcessId || operations.WindowProcessId(foreground) != expectedProcessId || + (foregroundRoot != owner && foregroundRoot != popup) || !operations.IsWindowVisible(popup)) + throw new InvalidOperationException("Popup input ownership changed; no further input was injected"); + } +public bool HoverPopupMenuItem(IntPtr owner, IntPtr menu, int position) { + RECT item; + if (menu == IntPtr.Zero || !operations.GetMenuItemRect(owner, menu, (uint)position, out item)) return false; + int x = item.Right - Math.Min(12, (item.Right - item.Left) / 2); + int y = (item.Top + item.Bottom) / 2; + uint ownerProcess = operations.WindowProcessId(owner); + IntPtr popup = operations.FindPopupForMenu(ownerProcess, menu); + if (popup == IntPtr.Zero) return false; + RequireOwnedPopupForeground(popup, owner, ownerProcess); + IntPtr hit = operations.WindowFromPoint(new ScreenPoint { X = x, Y = y }); + if (operations.GetAncestor(hit, 2) != popup || operations.WindowProcessId(hit) != ownerProcess) + throw new InvalidOperationException("Submenu hover point is covered; no cursor movement was injected"); + if (!operations.SetCursorPos(x, y)) + throw new InvalidOperationException(String.Format("SetCursorPos for submenu failed: Win32Error={0}", Marshal.GetLastWin32Error())); + ScreenPoint at; + if (!operations.GetCursorPos(out at)) return false; + var client = new ScreenPoint { X = x, Y = y }; + if (!operations.ScreenToClient(popup, ref client)) return false; + RequireOwnedPopupForeground(popup, owner, ownerProcess); + operations.SendMessage(popup, 0x0200, UIntPtr.Zero, operations.MouseLParam(client.X, client.Y)); + bool hilite = false; + for (int attempt = 0; attempt < 50 && !hilite; attempt++) { + hilite = (operations.GetMenuState(menu, (uint)position, 0x0400) & 0x0080) != 0; + if (!hilite) operations.Sleep(10); + } + return at.X == x && at.Y == y && hilite; + } +public static bool IsOwnedPopupInput( + IntPtr owner, IntPtr popup, uint expectedProcessId, uint popupProcessId, + uint foregroundProcessId, IntPtr foregroundRoot, uint pointProcessId, + IntPtr pointRoot, bool popupVisible + ) { + return owner != IntPtr.Zero && popup != IntPtr.Zero && expectedProcessId != 0 && + popupProcessId == expectedProcessId && foregroundProcessId == expectedProcessId && + (foregroundRoot == owner || foregroundRoot == popup) && + pointProcessId == expectedProcessId && pointRoot == popup && popupVisible; + } +public PopupItemHit ClickPopupMenuItem(IntPtr popup, IntPtr owner, int position, int commandId, uint expectedProcessId) { + if (popup == IntPtr.Zero || position < 0) return null; + IntPtr menu = operations.PopupMenuHandle(popup); + RECT item; + if (menu == IntPtr.Zero || !operations.GetMenuItemRect(owner, menu, (uint)position, out item) || + item.Right <= item.Left || item.Bottom <= item.Top || + operations.PopupMenuItemId(menu, position) != (uint)commandId) return null; + int screenX = (item.Left + item.Right) / 2; + int screenY = (item.Top + item.Bottom) / 2; + if (screenX < item.Left || screenX >= item.Right || + screenY < item.Top || screenY >= item.Bottom) return null; + var point = new ScreenPoint { + X = screenX, + Y = screenY + }; + if (!operations.ScreenToClient(popup, ref point)) return null; + ScreenPoint before, at; + if (!operations.GetCursorPos(out before)) + throw new InvalidOperationException(String.Format("GetCursorPos before popup click failed: Win32Error={0}", Marshal.GetLastWin32Error())); + RequireOwnedPopupForeground(popup, owner, expectedProcessId); + if (!operations.SetCursorPos(screenX, screenY)) + throw new InvalidOperationException(String.Format("SetCursorPos for popup click failed: Win32Error={0}", Marshal.GetLastWin32Error())); + if (!operations.GetCursorPos(out at)) + throw new InvalidOperationException(String.Format("GetCursorPos after popup move failed: Win32Error={0}", Marshal.GetLastWin32Error())); + if (at.X != screenX || at.Y != screenY) + throw new InvalidOperationException(String.Format("Popup cursor landed at ({0},{1}), expected ({2},{3})", at.X, at.Y, screenX, screenY)); + operations.SendMessage(popup, 0x0200, UIntPtr.Zero, operations.MouseLParam(point.X, point.Y)); + bool hilite = false; + for (int attempt = 0; attempt < 50 && !hilite; attempt++) { + hilite = (operations.GetMenuState(menu, (uint)position, 0x0400) & 0x0080) != 0; + if (!hilite) operations.Sleep(10); + } + bool selectItemFallback = false; + if (!hilite) { + operations.SendMessage(popup, 0x01E5, (UIntPtr)position, IntPtr.Zero); + selectItemFallback = true; + for (int attempt = 0; attempt < 50 && !hilite; attempt++) { + hilite = (operations.GetMenuState(menu, (uint)position, 0x0400) & 0x0080) != 0; + if (!hilite) operations.Sleep(10); + } + } + if (!hilite) + throw new InvalidOperationException("Popup item did not acknowledge hover before click"); + var inputs = new[] { + new Input { Type = 0, Mouse = new MouseInput { Flags = 0x0002 } }, + new Input { Type = 0, Mouse = new MouseInput { Flags = 0x0004 } } + }; + ScreenPoint cursorBeforeInput; + if (!operations.GetCursorPos(out cursorBeforeInput) || cursorBeforeInput.X != screenX || cursorBeforeInput.Y != screenY) + throw new InvalidOperationException("Popup input cursor changed; no mouse input was injected"); + uint inputOwnerProcessId = operations.WindowProcessId(owner); + if (inputOwnerProcessId != expectedProcessId || !operations.IsRetainedProcessAlive(expectedProcessId)) + throw new InvalidOperationException("Popup input ownership changed; no mouse input was injected"); + IntPtr foregroundBeforeInput = operations.GetForegroundWindow(); + IntPtr inputPointWindow = operations.WindowFromPoint(cursorBeforeInput); + if (!IsOwnedPopupInput( + owner, popup, inputOwnerProcessId, operations.WindowProcessId(popup), + operations.WindowProcessId(foregroundBeforeInput), operations.GetAncestor(foregroundBeforeInput, 2), + operations.WindowProcessId(inputPointWindow), operations.GetAncestor(inputPointWindow, 2), operations.IsWindowVisible(popup))) + throw new InvalidOperationException("Popup input ownership changed; no mouse input was injected"); + uint sent = operations.SendInput((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(Input))); + if (sent != inputs.Length) + throw new InvalidOperationException(String.Format("SendInput injected {0} of {1} popup mouse events: Win32Error={2}", sent, inputs.Length, Marshal.GetLastWin32Error())); + for (int attempt = 0; attempt < 20 && operations.IsWindowVisible(popup); attempt++) operations.Sleep(10); + bool keyboardFallback = false; + bool hiliteAtEnterFallback = false; + bool hiliteBeforeEnter = false; + if (operations.IsWindowVisible(popup)) { + // Injected clicks can be dropped (or held) by the desktop's input stack, + // and the menu can lose its hover hilite meanwhile. Enter with no hilited + // item ends TrackPopupMenu with command 0: the popup closes exactly as if + // the item had been chosen, but no command reaches the shell. Re-assert + // the intended item and prove its hilite immediately before Enter. + hiliteAtEnterFallback = (operations.GetMenuState(menu, (uint)position, 0x0400) & 0x0080) != 0; + operations.SendMessage(popup, 0x01E5, (UIntPtr)position, IntPtr.Zero); + hiliteBeforeEnter = (operations.GetMenuState(menu, (uint)position, 0x0400) & 0x0080) != 0; + } + if (operations.IsWindowVisible(popup)) { + if (!hiliteBeforeEnter) + throw new InvalidOperationException(String.Format("Popup item {0} was not hilited before the Enter fallback (hilite at fallback={1})", commandId, hiliteAtEnterFallback)); + RequireOwnedPopupForeground(popup, owner, expectedProcessId); + keyboardFallback = + operations.PostMessage(popup, 0x0100, (UIntPtr)0x0D, IntPtr.Zero) && + operations.PostMessage(popup, 0x0101, (UIntPtr)0x0D, IntPtr.Zero); + if (!keyboardFallback) + throw new InvalidOperationException(String.Format("Popup Enter fallback was rejected: Win32Error={0}", Marshal.GetLastWin32Error())); + } + return new PopupItemHit { + Position = position, ItemId = commandId, Left = item.Left, Top = item.Top, + Right = item.Right, Bottom = item.Bottom, + ScreenX = screenX, ScreenY = screenY, ClientX = point.X, ClientY = point.Y, + CursorBeforeX = before.X, CursorBeforeY = before.Y, + CursorAtX = at.X, CursorAtY = at.Y, Hilite = hilite, + UsedKeyboardFallback = keyboardFallback, + UsedSelectItemFallback = selectItemFallback, + HiliteAtEnterFallback = hiliteAtEnterFallback, + HiliteBeforeEnter = hiliteBeforeEnter + }; + } +} +} diff --git a/Tools/windows/ValidationRunner/Ui/SuiteRegistration.cs b/Tools/windows/ValidationRunner/Ui/SuiteRegistration.cs new file mode 100644 index 00000000..30ad437a --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/SuiteRegistration.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace GraphCode.Validation.Ui; + +public static class SuiteRegistration +{ + public static IReadOnlyList CreateSuites() => new IValidationSuite[] + { + new ActivationSuite(), new ManagedUiSuite(), new NativeUiSuite(), new ControlClickTopologySuite() + }.Concat(UiAdapterCases.CreateSuites()).ToArray(); +} + +public sealed class ActivationSuite : IValidationSuite +{ + public string Id => "ui.activation"; + public IReadOnlyList UnitIds { get; } = new[] + { + "ui.activation.foreground-refused", + "ui.activation.foreground-acquired", + "ui.activation.ownership-refused", + "ui.activation.invalid-window-refused" + }; + + public Task> RunAsync(ValidationContext context, CancellationToken cancellationToken) + { + Action[] cases = { ActivationCases.ForegroundRefused, ActivationCases.ForegroundAcquired, + ActivationCases.OwnershipRefused, ActivationCases.InvalidWindowRefused }; + var results = new List(); + for (int i = 0; i < cases.Length; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + cases[i](); + results.Add(new UnitResult(UnitIds[i], UnitStatus.Passed, 1, + "Actual shared UiaWindowActivation algorithm; typed test-only operations; nativeCalls=0")); + } + catch (InvalidOperationException failure) + { + results.Add(new UnitResult(UnitIds[i], UnitStatus.Failed, 1, failure.ToString())); + } + } + return Task.FromResult>(results); + } +} diff --git a/Tools/windows/ValidationRunner/Ui/UiAdapterCases.cs b/Tools/windows/ValidationRunner/Ui/UiAdapterCases.cs new file mode 100644 index 00000000..cb706f1b --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/UiAdapterCases.cs @@ -0,0 +1,264 @@ +using System.Collections.Generic; +using GraphCode.Validation.Adapters; + +namespace GraphCode.Validation.Ui; + +internal static class UiAdapterCases +{ + internal static readonly string[] PowerShellIds = + { + "ui.mock.isolation.absent", + "ui.mock.isolation.empty", + "ui.mock.isolation.valued", + "ui.mock.isolation.failure-identity", + "ui.mock.isolation.ordinary-diagnostic", + "ui.recovery.visibility.initially-empty", + "ui.recovery.visibility.initially-offscreen", + "ui.recovery.visibility.already-visible", + "ui.recovery.visibility.mark-only-empty", + "ui.recovery.visibility.delayed-visible", + "ui.recovery.visibility.outside-before-fit", + "ui.recovery.visibility.wrong-source-owner", + "ui.recovery.visibility.wrong-fit-owner", + "ui.recovery.visibility.fit-offscreen", + "ui.recovery.visibility.fit-disabled", + "ui.recovery.visibility.remains-empty", + "ui.recovery.visibility.remains-offscreen", + "ui.recovery.visibility.wrong-id-after-fit", + "ui.recovery.visibility.wrong-owner-after-fit", + "ui.recovery.visibility.outside-after-fit", + "ui.recovery.visibility.missing-after-fit", + "ui.recovery.visibility.mutant-skip-fit", + "ui.recovery.visibility.mutant-unsafe-offscreen-bounds", + "ui.recovery.visibility.mutant-wrong-identity-accepted", + "ui.recovery.visibility.mutant-outside-bounds-accepted" + }; + internal static readonly string[] DeleteIds = + { + "ui.delete.visibility.actual", + "ui.delete.visibility.missing-fit", + "ui.delete.visibility.wrong-identity", + "ui.delete.visibility.missing-offscreen", + "ui.delete.visibility.ungated-bounds", + "ui.delete.visibility.escaped-bounds", + "ui.delete.visibility.stale-visibility", + "ui.delete.visibility.missing-containment", + "ui.delete.visibility.missing-require", + "ui.delete.select.rejection", + "ui.delete.select.success", + "ui.delete.select.unavailable", + "ui.delete.select.foreign-pid", + "ui.delete.select.identity-drift", + "ui.delete.select.zero-bounds", + "ui.delete.select.offscreen", + "ui.delete.select.disabled", + "ui.delete.select.selected", + "ui.delete.select.runtime-unavailable", + "ui.delete.select.parent-unavailable", + "ui.delete.select.telemetry-write-failure", + "ui.delete.select.receipt-exists", + "ui.delete.select.receipt-unconfigured", + "ui.delete.select.deadline", + "ui.delete.select.long-identity", + "ui.delete.select.long-runtime", + "ui.delete.select.invalid-boolean", + "ui.delete.select.nonfinite-bounds", + "ui.delete.select.exception-chain", + "ui.delete.select.foreign-parent", + "ui.delete.restore.source-order", + "ui.delete.restore.owned-navigation", + "ui.delete.restore.foreground-refused", + "ui.delete.restore.missing-route", + "ui.delete.restore.foreign-route", + "ui.delete.restore.offscreen-route", + "ui.delete.restore.missing-card", + "ui.delete.restore.wrong-card-id", + "ui.delete.restore.foreign-card", + "ui.delete.restore.wrong-fit-id", + "ui.delete.restore.foreign-fit", + "ui.delete.restore.retained-workspace", + "ui.delete.restore.wrong-final-id", + "ui.delete.restore.foreign-final-card", + "ui.delete.restore.offscreen-final-card", + "ui.delete.restore.foreign-final-graph", + "ui.delete.restore.lost-selection", + "ui.delete.cleanup.source-order", + "ui.delete.cleanup.success-clears", + "ui.delete.cleanup.failed-removal-retains" + }; + internal static readonly string[] RootSourceIds = + { + "ui.production.consumer-linkage", + "ui.root.evaluator.actual-predicate-false", + "ui.root.evaluator.depth-boundary", + "ui.root.evaluator.depth-overflow", + "ui.root.evaluator.malformed-tree", + "ui.root.evaluator.missing-file", + "ui.root.evaluator.unknown-operator", + "ui.root.evaluator.unknown-resource", + "ui.root.evaluator.wrong-production-text", + "ui.root.source.line-5342", + "ui.root.source.line-5350", + "ui.root.source.line-5369", + "ui.root.source.line-5528", + "ui.root.source.line-5545", + "ui.root.source.line-5572", + "ui.root.source.line-5584", + "ui.root.source.line-5973", + "ui.root.source.line-5979", + "ui.root.source.line-5987", + "ui.root.source.line-6062", + "ui.root.source.line-6097", + "ui.root.source.line-6100", + "ui.root.source.line-6165", + "ui.root.source.line-6171", + "ui.root.source.line-6199", + "ui.root.source.line-6210", + "ui.root.source.line-6215", + "ui.root.source.line-6221", + "ui.root.source.line-6226", + "ui.root.source.line-6231", + "ui.root.source.line-6246", + "ui.root.source.line-6250", + "ui.root.source.line-6257", + "ui.root.source.line-6278", + "ui.root.source.line-6285", + "ui.root.source.line-6339", + "ui.root.source.line-6345", + "ui.root.source.line-6355", + "ui.root.source.line-6417", + "ui.root.source.line-6426", + "ui.root.source.line-6430", + "ui.root.source.line-6436", + "ui.root.source.line-6440", + "ui.root.source.line-6446", + "ui.root.source.line-6454", + "ui.root.source.line-6462", + "ui.root.source.line-6472", + "ui.root.source.line-6514", + "ui.root.source.line-6520", + "ui.root.source.line-6526", + "ui.root.source.line-6542", + "ui.root.source.line-6551", + "ui.root.source.line-6677", + "ui.root.source.line-6680", + "ui.root.source.line-6773", + "ui.root.source.line-6788", + "ui.root.source.line-6801", + "ui.root.source.line-6909", + "ui.root.source.line-6974", + "ui.root.source.line-7042", + "ui.root.source.line-7348", + "ui.root.source.line-7351", + "ui.root.source.line-7355", + "ui.root.source.line-7365", + "ui.root.source.line-7381", + "ui.root.source.line-7387", + "ui.root.source.line-7396" + }; + internal static readonly string[] FunctionalIds = + { + "ui.connector.blank-title", + "ui.connector.different-target-title", + "ui.connector.duplicate-source", + "ui.connector.duplicate-target", + "ui.connector.missing-source", + "ui.connector.missing-target", + "ui.connector.owned-pair", + "ui.connector.same-endpoint", + "ui.promotion.disabled-action", + "ui.promotion.enabled-heading", + "ui.promotion.exact", + "ui.promotion.extra-item", + "ui.promotion.reordered", + "ui.promotion.separator-heading", + "ui.promotion.wrong-caption", + "ui.root.source.line-5366", + "ui.root.source.line-5380", + "ui.root.source.line-5392", + "ui.root.source.line-5396", + "ui.root.source.line-5399", + "ui.root.source.line-5407", + "ui.root.source.line-5410", + "ui.root.source.line-5432", + "ui.root.source.line-5449", + "ui.root.source.line-5457", + "ui.root.source.line-5462", + "ui.root.source.line-5470", + "ui.root.source.line-5477", + "ui.root.source.line-5485", + "ui.root.source.line-5553", + "ui.root.source.line-5561", + "ui.root.source.line-5569", + "ui.root.source.line-5580", + "ui.root.source.line-5632", + "ui.root.source.line-5659", + "ui.root.source.line-5683", + "ui.root.source.line-5696", + "ui.root.source.line-5713", + "ui.root.source.line-5724", + "ui.root.source.line-5739", + "ui.root.source.line-5752", + "ui.root.source.line-5761", + "ui.root.source.line-5773", + "ui.root.source.line-5783", + "ui.root.source.line-5792", + "ui.root.source.line-5807", + "ui.root.source.line-5812", + "ui.root.source.line-6853", + "ui.root.source.line-6864", + "ui.root.source.line-6866", + "ui.root.source.line-6870", + "ui.root.source.line-6877", + "ui.root.source.line-6892", + "ui.root.source.line-6905", + "ui.root.source.line-6921", + "ui.root.source.line-6925", + "ui.root.source.line-6939", + "ui.root.source.line-6958", + "ui.root.source.line-6967", + "ui.root.source.line-7136", + "ui.root.source.line-7235", + "ui.root.source.line-7326", + "ui.root.source.line-7337", + "ui.root.source.line-7344" + }; + internal static readonly string[] DerivedIds = + { + "ui.host-info.diagnostics", + "ui.root.source.line-5501", + "ui.root.source.line-5514", + "ui.root.source.line-5524", + "ui.root.source.line-5537", + "ui.root.source.line-5897", + "ui.root.source.line-5905", + "ui.root.source.line-5914", + "ui.root.source.line-5918", + "ui.root.source.line-6057", + "ui.root.source.line-6093", + "ui.root.source.line-6157", + "ui.root.source.line-6182", + "ui.root.source.line-6276", + "ui.root.source.line-6292", + "ui.root.source.line-6298", + "ui.root.source.line-6305", + "ui.root.source.line-6314", + "ui.root.source.line-6326", + "ui.root.source.line-6352", + "ui.root.source.line-6412", + "ui.root.source.line-6467", + "ui.root.source.line-6591", + "ui.root.source.line-6654", + "ui.root.source.line-6754", + "ui.root.source.line-6770", + "ui.root.source.line-7027" + }; + internal static IReadOnlyList CreateSuites() => new IValidationSuite[] + { + new StandaloneSuites.CaseScriptSuite("ui.powershell-contracts", @"Tools\windows\ValidationRunner\Ui\UiPowerShellContracts.ps1", "UI_CASE_RESULT=", PowerShellIds), + new StandaloneSuites.CaseScriptSuite("ui.delete-contracts", @"Tools\windows\ValidationRunner\Ui\UiDeleteContracts.ps1", "UI_CASE_RESULT=", DeleteIds), + new StandaloneSuites.CaseScriptSuite("ui.root-source-contracts", @"Tools\windows\ValidationRunner\Ui\UiRootSourceContracts.ps1", "UI_CASE_RESULT=", RootSourceIds), + new StandaloneSuites.CaseScriptSuite("ui.functional-contracts", @"Tools\windows\ValidationRunner\Ui\UiFunctionalContracts.ps1", "UI_CASE_RESULT=", FunctionalIds), + new StandaloneSuites.CaseScriptSuite("ui.derived-contracts", @"Tools\windows\ValidationRunner\Ui\UiDerivedContracts.ps1", "UI_CASE_RESULT=", DerivedIds) + }; +} diff --git a/Tools/windows/ValidationRunner/Ui/UiDeleteContracts.ps1 b/Tools/windows/ValidationRunner/Ui/UiDeleteContracts.ps1 new file mode 100644 index 00000000..63b12ebb --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/UiDeleteContracts.ps1 @@ -0,0 +1,488 @@ +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) +$ErrorActionPreference = 'Stop' +$null = [IO.Directory]::CreateDirectory($ArtifactRoot) +$source = [IO.File]::ReadAllText((Join-Path $RepositoryRoot 'Tools\windows\uia-live-gate.ps1')) +$errors = $null +$ast = [Management.Automation.Language.Parser]::ParseInput($source, [ref]$null, [ref]$errors) +if ($errors.Count) { throw 'Delete Select source does not parse' } +$results = [Collections.Generic.List[object]]::new() +function Assert-Delete([bool] $Condition, [string] $Message) { + if (-not $Condition) { throw [InvalidOperationException]::new("Delete Select contract: $Message") } +} +function Invoke-DeleteCase([string] $Id, [scriptblock] $Assertion) { + $failure = $null + try { & $Assertion } catch { $failure = $_ } + $results.Add([ordered]@{ + id = $Id; status = $(if ($failure) { 'Failed' } else { 'Passed' }); executedCount = 1 + evidence = $(if ($failure) { $failure.ToString() } else { 'Production PS AST/scope/ScriptProperty/ErrorRecord and receipt contract; nativeCalls=0' }) + }) +} +function Confirm-DeleteVisibility([string] $Text) { + $start = $Text.IndexOf('$deleteLoopTitle =', [StringComparison]::Ordinal) + $end = if ($start -ge 0) { + $Text.IndexOf('$deleteTarget = Find-FragmentByIdWithRetry $root $renameGraphIdentity $rawWalker', $start, [StringComparison]::Ordinal) + } else { -1 } + if ($start -lt 0 -or $end -le $start) { return $false } + $region = $Text.Substring($start, $end - $start) + $normalized = [regex]::Replace($region, '\s+', ' ') + $offset = 0 + foreach ($required in @( + '$fitDeleteCanvas = Find-FragmentByIdWithRetry $root "fit-canvas" $rawWalker', + '$fitDeleteCanvas.Current.ProcessId -eq $process.Id', + '$fitDeleteCanvas.Current.Name -ceq "Fit canvas"', + '$fitDeleteCanvas.GetCurrentPattern( [System.Windows.Automation.InvokePattern]::Pattern ).Invoke()', + '$root = Wait-ForRootReconnect $process @($rawWalker, $controlWalker)', + 'for ($deleteVisibilityAttempt = 0; $deleteVisibilityAttempt -lt 40; $deleteVisibilityAttempt++)', + '$deleteTargetVisible = $false', + '$deleteCandidate = Find-FragmentById $root $renameGraphIdentity $rawWalker', + '$deleteGraph = Find-FragmentById $root "graph" $rawWalker', + '$deleteCandidate.Current.ProcessId -eq $process.Id', + '$deleteCandidate.Current.AutomationId -ceq $renameGraphIdentity', + '$deleteGraph.Current.ProcessId -eq $process.Id', + '$deleteTargetOffscreen = $deleteCandidate.Current.IsOffscreen', + 'if (-not $deleteTargetOffscreen) {', + '$deleteTargetBounds = $deleteCandidate.Current.BoundingRectangle', + '$deleteGraphBounds = $deleteGraph.Current.BoundingRectangle', + '$deleteTargetVisible = -not $deleteTargetOffscreen -and', + '-not $deleteTargetBounds.IsEmpty -and', + '$deleteTargetBounds.Width -gt 0 -and $deleteTargetBounds.Height -gt 0 -and', + '-not $deleteGraphBounds.IsEmpty -and', + '$deleteTargetBounds.Left -ge $deleteGraphBounds.Left -and', + '$deleteTargetBounds.Top -ge $deleteGraphBounds.Top -and', + '$deleteTargetBounds.Right -le $deleteGraphBounds.Right -and', + '$deleteTargetBounds.Bottom -le $deleteGraphBounds.Bottom', + 'if ($deleteTargetVisible) { break }', + 'Start-Sleep -Milliseconds 100', + '$root = Wait-ForRootReconnect $process @($rawWalker, $controlWalker)', + 'Require ($deleteTargetVisible)', + 'UIA_DELETE_TARGET_VISIBLE=' + )) { + $next = $normalized.IndexOf($required, $offset, [StringComparison]::Ordinal) + if ($next -lt 0) { return $false } + $offset = $next + $required.Length + } + $parseErrors = $null + $regionAst = [Management.Automation.Language.Parser]::ParseInput($region, [ref]$null, [ref]$parseErrors) + if ($parseErrors.Count) { return $false } + $guards = @($regionAst.FindAll({ + param($node) + $node -is [Management.Automation.Language.IfStatementAst] -and $node.Clauses.Count -eq 1 -and + [regex]::Replace($node.Clauses[0].Item1.Extent.Text, '[\s()]', '') -ceq '-not$deleteTargetOffscreen' + }, $true)) + if ($guards.Count -ne 1) { return $false } + $guard = $guards[0].Clauses[0].Item2 + foreach ($assignment in @( + '$deleteTargetBounds = $deleteCandidate.Current.BoundingRectangle', + '$deleteGraphBounds = $deleteGraph.Current.BoundingRectangle' + )) { + $assignments = @($regionAst.FindAll({ + param($node) + $node -is [Management.Automation.Language.AssignmentStatementAst] -and $node.Extent.Text -ceq $assignment + }, $true)) + if ($assignments.Count -ne 1 -or $assignments[0].Extent.StartOffset -lt $guard.Extent.StartOffset -or + $assignments[0].Extent.EndOffset -gt $guard.Extent.EndOffset) { return $false } + } + return $true +} +$visibilityCases = @( + @{ name = 'actual'; text = $source; accepted = $true }, + @{ name = 'missing-fit'; text = $source.Replace('$fitDeleteCanvas.GetCurrentPattern(', '$unusedFit.GetCurrentPattern('); accepted = $false }, + @{ name = 'wrong-identity'; text = $source.Replace('$deleteCandidate = Find-FragmentById $root $renameGraphIdentity $rawWalker', '$deleteCandidate = Find-FragmentById $root "other-card" $rawWalker'); accepted = $false }, + @{ name = 'missing-offscreen'; text = $source.Replace('$deleteTargetVisible = -not $deleteTargetOffscreen -and', '$deleteTargetVisible = $true -and'); accepted = $false }, + @{ name = 'ungated-bounds'; text = $source.Replace('if (-not $deleteTargetOffscreen) {', 'if ($true) {'); accepted = $false }, + @{ name = 'escaped-bounds'; text = $source.Replace('if (-not $deleteTargetOffscreen) {', 'if (-not $deleteTargetOffscreen) { } ; if ($true) {'); accepted = $false }, + @{ name = 'stale-visibility'; text = [regex]::new('(\$deleteVisibilityAttempt\+\+\)\s*\{\s*)\$deleteTargetVisible = \$false').Replace($source, '$1', 1); accepted = $false }, + @{ name = 'missing-containment'; text = $source.Replace('$deleteTargetBounds.Right -le $deleteGraphBounds.Right -and', '$true -and'); accepted = $false }, + @{ name = 'missing-require'; text = $source.Replace('Require ($deleteTargetVisible)', '$null = ($deleteTargetVisible)'); accepted = $false } +) +foreach ($case in $visibilityCases) { + Invoke-DeleteCase "ui.delete.visibility.$($case.name)" { + Assert-Delete ((Confirm-DeleteVisibility $case.text) -eq $case.accepted) ` + "$($case.name) must prove the same owned card visible inside graph bounds before Select reacquisition" + } +} + +$definitions = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -cin @('Get-DeleteTargetDiagnosticValue', 'Get-DeleteTargetIdentityHash', 'Write-DeleteTargetSelectFailure') +}, $true)) +Assert-Delete ($definitions.Count -eq 3) 'Expected all three actual diagnostic helpers' +foreach ($definition in $definitions) { + Assert-Delete ($definition.Extent.Text -notmatch 'FromHandle|Add-Type|Start-Process|New-Item|FindAll|FindFirst|System\.Windows|GraphCodeUiaGateState') ` + 'Diagnostic helper acquired native or UIA capability' + . ([scriptblock]::Create($definition.Extent.Text)) +} +$selects = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.InvokeMemberExpressionAst] -and + $node.Extent.Text -ceq '$deleteTargetSelection.Select()' +}, $true)) +Assert-Delete ($selects.Count -eq 1) 'Expected one actual Select call' +$statement = $selects[0].Parent +while ($statement.Parent -isnot [Management.Automation.Language.StatementBlockAst] -and + $statement.Parent -isnot [Management.Automation.Language.NamedBlockAst]) { $statement = $statement.Parent } +if ($statement.Parent.Parent -is [Management.Automation.Language.TryStatementAst] -and $statement.Parent.Statements.Count -eq 1) { + $statement = $statement.Parent.Parent +} +$expectedWrapper = @' +try { + $deleteTargetSelection.Select() + } catch { + try { + try { + Write-DeleteTargetSelectFailure $deleteTarget $deleteTargetSelection $renameGraphIdentity ` + $process.Id $shellWindow $_ $DeleteSelectFailureReceipt + } catch { + Write-Host ("UIA_DELETE_TARGET_SELECT_TELEMETRY_FAILURE=hresult:{0}" -f $_.Exception.HResult) + } + } finally { + throw + } + } +'@ +Assert-Delete ($statement.Extent.Text.Replace("`r`n", "`n") -ceq $expectedWrapper.Replace("`r`n", "`n")) 'Unsafe production Select wrapper' +$invokeSelect = [scriptblock]::Create($statement.Extent.Text) +function Assert-MockBindings($Target, $Selection, [hashtable] $State) { + foreach ($value in @($Target, $Selection)) { + Assert-Delete ($value.GetType() -eq [Management.Automation.PSCustomObject] -and + $value.PSObject.Properties['Current'] -is [Management.Automation.PSScriptProperty] -and + $value.PSObject.Properties['State'] -is [Management.Automation.PSNoteProperty] -and + [object]::ReferenceEquals($value.PSObject.Properties['State'].Value, $State)) 'Unbound mock Current or State' + } + Assert-Delete ($Target.PSObject.Methods['GetRuntimeId'] -is [Management.Automation.PSScriptMethod] -and + $Selection.PSObject.Methods['Select'] -is [Management.Automation.PSScriptMethod]) 'Non-script mock method' +} +$selectCases = @('rejection', 'success', 'unavailable', 'foreign-pid', 'identity-drift', + 'zero-bounds', 'offscreen', 'disabled', 'selected', 'runtime-unavailable', 'parent-unavailable', + 'telemetry-write-failure', 'receipt-exists', 'receipt-unconfigured', 'deadline', 'long-identity', + 'long-runtime', 'invalid-boolean', 'nonfinite-bounds', 'exception-chain', 'foreign-parent') +foreach ($case in $selectCases) { + Invoke-DeleteCase "ui.delete.select.$case" { + $state = @{ case = $case; selects = 0; reads = 0; original = [InvalidOperationException]::new('PRIVATE_EXCEPTION_SENTINEL') } + if ($case -ceq 'exception-chain') { + for ($index = 0; $index -lt 6; $index++) { + $state.original = [InvalidOperationException]::new('PRIVATE_CHAIN_SENTINEL', $state.original) + } + } + $deleteTarget = [pscustomobject]@{ State = $state } + $deleteTarget | Add-Member ScriptProperty Current { + $this.State.reads++ + if ($this.State.case -ceq 'unavailable') { throw 'PRIVATE_PROPERTY_SENTINEL' } + [pscustomobject]@{ + AutomationId = $(if ($this.State.case -ceq 'identity-drift') { 'PRIVATE_OTHER_ID' } + elseif ($this.State.case -ceq 'long-identity') { 'x' * 4097 } else { 'PRIVATE_TARGET_ID' }) + ProcessId = $(if ($this.State.case -ceq 'foreign-pid') { 7777 } else { 4242 }) + BoundingRectangle = [pscustomobject]@{ + Left = $(if ($this.State.case -ceq 'nonfinite-bounds') { [double]::NaN } else { 10.0 }); Top = 20.0 + Width = $(if ($this.State.case -ceq 'zero-bounds') { 0.0 } else { 30.0 }) + Height = $(if ($this.State.case -ceq 'zero-bounds') { 0.0 } else { 40.0 }) + } + IsOffscreen = ($this.State.case -ceq 'offscreen') + IsEnabled = $(if ($this.State.case -ceq 'invalid-boolean') { 'PRIVATE_BOOLEAN' } else { $this.State.case -cne 'disabled' }) + Name = 'PRIVATE_NAME_SENTINEL'; Value = 'PRIVATE_VALUE_SENTINEL' + } + } + $deleteTarget | Add-Member ScriptMethod GetRuntimeId { + $this.State.reads++ + if ($this.State.case -cin @('unavailable', 'runtime-unavailable')) { throw 'PRIVATE_RUNTIME_SENTINEL' } + if ($this.State.case -ceq 'deadline') { [Threading.Thread]::Sleep(550) } + if ($this.State.case -ceq 'long-runtime') { return [int[]](1..65) } + return [int[]]@(42, 7, 9) + } + $deleteTargetSelection = [pscustomobject]@{ State = $state } + $deleteTargetSelection | Add-Member ScriptMethod Select { + $this.State.selects++ + if ($this.State.case -cne 'success') { throw $this.State.original } + } + $deleteTargetSelection | Add-Member ScriptProperty Current { + $this.State.reads++ + if ($this.State.case -ceq 'unavailable') { throw 'PRIVATE_PATTERN_SENTINEL' } + $container = [pscustomobject]@{ State = $this.State } + $container | Add-Member ScriptProperty Current { + $this.State.reads++ + if ($this.State.case -ceq 'parent-unavailable') { throw 'PRIVATE_PARENT_SENTINEL' } + [pscustomobject]@{ + ProcessId = $(if ($this.State.case -ceq 'foreign-parent') { 7777 } else { 4242 }) + ControlType = [pscustomobject]@{ Id = 50033 }; ClassName = 'PRIVATE_CLASS_SENTINEL'; Name = 'PRIVATE_PARENT_SENTINEL' + } + } + [pscustomobject]@{ IsSelected = ($this.State.case -ceq 'selected'); SelectionContainer = $container } + } + $process = [pscustomobject]@{ Id = 4242 } + $shellWindow = [IntPtr]42 + $renameGraphIdentity = 'PRIVATE_TARGET_ID' + $ownedDirectory = Join-Path $ArtifactRoot ("delete-select-" + [guid]::NewGuid().ToString('N')) + $null = [IO.Directory]::CreateDirectory($ownedDirectory) + try { + $directory = if ($case -ceq 'telemetry-write-failure') { Join-Path $ownedDirectory 'absent' } else { $ownedDirectory } + $DeleteSelectFailureReceipt = Join-Path $directory 'delete-target-select-failure.json' + if ($case -ceq 'receipt-exists') { [IO.File]::WriteAllText($DeleteSelectFailureReceipt, 'preserved') } + if ($case -ceq 'receipt-unconfigured') { $DeleteSelectFailureReceipt = '' } + Assert-MockBindings $deleteTarget $deleteTargetSelection $state + $original = $null + try { $deleteTargetSelection.Select() } catch { $original = $_ } + $state.selects = 0 + Assert-MockBindings $deleteTarget $deleteTargetSelection $state + $output = @(& { try { & $invokeSelect } catch { $_ } } 6>&1) + $failures = @($output | Where-Object { $_ -is [Management.Automation.ErrorRecord] }) + $logs = @($output | Where-Object { $_ -is [Management.Automation.InformationRecord] }) + Assert-Delete ($state.selects -eq 1) "$case changed Select count" + if ($case -ceq 'success') { + Assert-Delete ($output.Count -eq 0 -and $state.reads -eq 0) 'Success emitted telemetry or sampled properties' + } else { + Assert-Delete ($failures.Count -eq 1 -and $failures[0].Exception.GetType() -eq $original.Exception.GetType() -and + $failures[0].Exception.Message -ceq $original.Exception.Message -and + $failures[0].Exception.HResult -eq $original.Exception.HResult -and + $failures[0].FullyQualifiedErrorId -ceq $original.FullyQualifiedErrorId -and + [object]::ReferenceEquals($failures[0].Exception.GetBaseException(), $state.original.GetBaseException())) ` + "$case changed the original exception" + $receiptLogs = @($logs | Where-Object { ([string]$_.MessageData).StartsWith('UIA_DELETE_TARGET_SELECT_FAILURE=') }) + Assert-Delete ($receiptLogs.Count -eq 1) "$case missing failure-only receipt" + $json = ([string]$receiptLogs[0].MessageData).Substring('UIA_DELETE_TARGET_SELECT_FAILURE='.Length) + Assert-Delete (-not $json.Contains('PRIVATE_')) "$case leaked private values" + $receipt = $json | ConvertFrom-Json + Assert-Delete ($receipt.expectedProcessId -eq 4242 -and $receipt.expectedWindow -eq 42) "$case lost owned context" + Assert-Delete ($state.reads -gt 0 -and $state.reads -le 16) "$case exceeded bounded reads" + switch -CaseSensitive ($case) { + unavailable { Assert-Delete ($receipt.processId.state -ceq 'unavailable' -and $receipt.bounds.state -ceq 'unavailable' -and $receipt.selected.state -ceq 'unavailable') 'Unavailable became success' } + foreign-pid { Assert-Delete ($receipt.processId.value -eq 7777 -and -not $receipt.ownedProcess) 'Foreign PID lost' } + identity-drift { Assert-Delete (-not $receipt.identityMatches) 'Identity drift lost' } + runtime-unavailable { Assert-Delete ($receipt.runtimeIdHash.state -ceq 'unavailable') 'Runtime unavailability lost' } + parent-unavailable { Assert-Delete ($receipt.parent.processId.state -ceq 'unavailable') 'Parent unavailability lost' } + zero-bounds { Assert-Delete ($receipt.bounds.value.width -eq 0 -and $receipt.bounds.value.height -eq 0) 'Zero bounds lost' } + offscreen { Assert-Delete ($receipt.offscreen.value -eq $true) 'Offscreen lost' } + disabled { Assert-Delete ($receipt.enabled.value -eq $false) 'Disabled lost' } + selected { Assert-Delete ($receipt.selected.value -eq $true) 'Selected state lost' } + deadline { Assert-Delete ($receipt.runtimeIdHash.state -ceq 'unavailable' -and $receipt.bounds.reason -ceq 'diagnostic-budget' -and $receipt.readCount -eq 3) 'Deadline allowed more reads' } + long-identity { Assert-Delete ($receipt.automationIdHash.state -ceq 'unavailable' -and $receipt.readCount -eq 2) 'Long identity allowed dependent reads' } + long-runtime { Assert-Delete ($receipt.runtimeIdHash.state -ceq 'unavailable') 'Long runtime accepted' } + invalid-boolean { Assert-Delete ($receipt.enabled.state -ceq 'unavailable') 'Invalid boolean accepted' } + nonfinite-bounds { Assert-Delete ($receipt.bounds.state -ceq 'unavailable') 'Nonfinite bounds accepted' } + exception-chain { Assert-Delete ($receipt.exceptionChainTruncated -and $receipt.exceptionHResults.Count -eq 4) 'Exception chain not capped' } + foreign-parent { Assert-Delete ($receipt.parent.processId.value -eq 7777 -and $receipt.parent.controlTypeId.reason -ceq 'untrusted-identity' -and $receipt.readCount -eq 9) 'Foreign parent allowed dependent reads' } + } + Assert-Delete ($receipt.readCount -le 11 -and $receipt.readLimit -eq 11 -and $receipt.budgetMilliseconds -eq 500 -and + $json.Length -le 8192 -and $receipt.exceptionHResults.Count -ge 1 -and $receipt.exceptionHResults.Count -le 4) "$case lost explicit bounds" + if ($case -cin @('foreign-pid', 'identity-drift', 'unavailable')) { + Assert-Delete ($receipt.bounds.reason -ceq 'untrusted-identity' -and $receipt.readCount -le 2) "$case sampled dependent state" + } + foreach ($unavailable in @('retired', 'active', 'invokable', 'providerBranch')) { + Assert-Delete ($receipt.$unavailable.state -ceq 'unavailable') 'Private provider state inferred' + } + $path = Join-Path $directory 'delete-target-select-failure.json' + if ($case -cin @('telemetry-write-failure', 'receipt-exists', 'receipt-unconfigured')) { + Assert-Delete (@($logs | Where-Object { ([string]$_.MessageData).StartsWith('UIA_DELETE_TARGET_SELECT_TELEMETRY_FAILURE=') }).Count -eq 1) 'Receipt write failure hidden' + if ($case -ceq 'receipt-exists') { Assert-Delete ([IO.File]::ReadAllText($path) -ceq 'preserved') 'Existing receipt overwritten' } + } else { Assert-Delete ([IO.File]::ReadAllText($path) -ceq $json) 'Raw receipt differs' } + } + } finally { [IO.Directory]::Delete($ownedDirectory, $true) } + } +} +Assert-Delete ($visibilityCases.Count -eq 9 -and $selectCases.Count -eq 21 -and $results.Count -eq 30) 'Wrong positive case count' +Invoke-DeleteCase 'ui.delete.restore.source-order' { + $restoreDefinitions = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq 'Restore-DeleteLoopCanvas' + }, $true)) + Assert-Delete ($restoreDefinitions.Count -eq 1) 'Expected one actual Delete canvas restoration helper' + $selection = $source.IndexOf('$deleteTargetSelection.Select()', [StringComparison]::Ordinal) + $selected = $source.IndexOf('Require $deleteTargetSelection.Current.IsSelected', $selection, [StringComparison]::Ordinal) + $restore = $source.IndexOf('Restore-DeleteLoopCanvas $process $root $shellWindow', $selected, [StringComparison]::Ordinal) + $menu = $source.IndexOf('$deleteLoopPopup = Open-CanvasCardContextMenu', $selected, [StringComparison]::Ordinal) + Assert-Delete ($selection -ge 0 -and $selected -gt $selection -and $restore -gt $selected -and $menu -gt $restore) ` + 'Delete must restore the canvas after the unchanged Select assertion and before the owned pointer route' + $calls = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] -and $node.GetCommandName() -ceq 'Restore-DeleteLoopCanvas' + }, $true)) + Assert-Delete ($calls.Count -eq 1 -and $calls[0].Extent.Text -match '\$renameGraphIdentity \$rawWalker') ` + 'Delete restoration must bind the same renamed card identity and walker exactly once' +} +$restoreCases = @( + @{ name = 'owned-navigation'; failure = $null; order = 'foreground,wait-route,show,reconnect,lookup:canvas-card-owned,lookup:fit-canvas,fit,wait-card,selected,reconnect' }, + @{ name = 'foreground-refused'; failure = 'Delete Loop could not acquire its owned foreground before Show in Graph'; order = 'foreground' }, + @{ name = 'missing-route'; failure = 'controlled route publication missing'; order = 'foreground,wait-route' }, + @{ name = 'foreign-route'; failure = 'Delete Loop omitted its visible owned Show in Graph action'; order = 'foreground,wait-route' }, + @{ name = 'offscreen-route'; failure = 'Delete Loop omitted its visible owned Show in Graph action'; order = 'foreground,wait-route' }, + @{ name = 'missing-card'; failure = 'Delete Loop lost its same owned card after Show in Graph'; order = 'foreground,wait-route,show,reconnect,lookup:canvas-card-owned' }, + @{ name = 'wrong-card-id'; failure = 'Delete Loop lost its same owned card after Show in Graph'; order = 'foreground,wait-route,show,reconnect,lookup:canvas-card-owned' }, + @{ name = 'foreign-card'; failure = 'Delete Loop lost its same owned card after Show in Graph'; order = 'foreground,wait-route,show,reconnect,lookup:canvas-card-owned' }, + @{ name = 'wrong-fit-id'; failure = 'Delete Loop graph reentry omitted the exact owned Fit Canvas action'; order = 'foreground,wait-route,show,reconnect,lookup:canvas-card-owned,lookup:fit-canvas' }, + @{ name = 'foreign-fit'; failure = 'Delete Loop graph reentry omitted the exact owned Fit Canvas action'; order = 'foreground,wait-route,show,reconnect,lookup:canvas-card-owned,lookup:fit-canvas' }, + @{ name = 'retained-workspace'; failure = 'controlled restored card publication missing'; order = 'foreground,wait-route,show,reconnect,lookup:canvas-card-owned,lookup:fit-canvas,fit,wait-card' }, + @{ name = 'wrong-final-id'; failure = 'controlled restored card publication missing'; order = 'foreground,wait-route,show,reconnect,lookup:canvas-card-owned,lookup:fit-canvas,fit,wait-card' }, + @{ name = 'foreign-final-card'; failure = 'controlled restored card publication missing'; order = 'foreground,wait-route,show,reconnect,lookup:canvas-card-owned,lookup:fit-canvas,fit,wait-card' }, + @{ name = 'offscreen-final-card'; failure = 'controlled restored card publication missing'; order = 'foreground,wait-route,show,reconnect,lookup:canvas-card-owned,lookup:fit-canvas,fit,wait-card' }, + @{ name = 'foreign-final-graph'; failure = 'Delete Loop same card did not return to its visible canvas after Show in Graph and Fit Canvas'; order = 'foreground,wait-route,show,reconnect,lookup:canvas-card-owned,lookup:fit-canvas,fit,wait-card' }, + @{ name = 'lost-selection'; failure = 'Delete Loop Show in Graph changed the selected card identity'; order = 'foreground,wait-route,show,reconnect,lookup:canvas-card-owned,lookup:fit-canvas,fit,wait-card,selected' } +) +foreach ($test in $restoreCases) { + Invoke-DeleteCase "ui.delete.restore.$($test.name)" { + $definition = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq 'Restore-DeleteLoopCanvas' + }, $true)) + Assert-Delete ($definition.Count -eq 1) 'Expected one actual Delete canvas restoration helper' + & (Join-Path $RepositoryRoot 'Tools\windows\Import-CompiledValidationAssembly.ps1') ` + -AssemblyPath $AssemblyPath -RequiredType 'GraphCodeUiaGateState' | Out-Null + Assert-Delete ($definition[0].Extent.Text -notmatch '\[GraphCode[^\]]*\]::|SendInput|PostMouse|Start-Process|Add-Type') ` + 'Restoration fixture must exercise the production route without direct native effects' + . ([scriptblock]::Create($definition[0].Extent.Text)) + $state = @{ name = $test.name; order = [Collections.Generic.List[string]]::new(); waits = 0; reconnects = 0 } + $process = [pscustomobject]@{ Id = 4242 } + $initialRoot = [pscustomobject]@{ label = 'workspace-root' } + $restoredRoot = [pscustomobject]@{ label = 'restored-root' } + $finalRoot = [pscustomobject]@{ label = 'final-root' } + $ownedWalker = [pscustomobject]@{ fixture = 'owned-walker' } + function Require([bool] $condition, [string] $message) { if (-not $condition) { throw [InvalidOperationException]::new($message) } } + function New-RestoreElement([string] $Kind) { + $id = switch ($Kind) { route { 'workspace-show-graph-owned' } fit { 'fit-canvas' } graph { 'graph' } default { 'canvas-card-owned' } } + if (($Kind -ceq 'card' -and $state.name -ceq 'wrong-card-id') -or + ($Kind -ceq 'final-card' -and $state.name -ceq 'wrong-final-id')) { $id = 'canvas-card-foreign' } + if ($Kind -ceq 'fit' -and $state.name -ceq 'wrong-fit-id') { $id = 'fit-foreign' } + $owner = if (($Kind -ceq 'route' -and $state.name -ceq 'foreign-route') -or + ($Kind -ceq 'card' -and $state.name -ceq 'foreign-card') -or + ($Kind -ceq 'fit' -and $state.name -ceq 'foreign-fit') -or + ($Kind -ceq 'final-card' -and $state.name -ceq 'foreign-final-card') -or + ($Kind -ceq 'graph' -and $state.name -ceq 'foreign-final-graph')) { 7777 } else { 4242 } + $label = switch ($Kind) { route { 'Show in Graph' } fit { 'Fit canvas' } default { 'fixture' } } + $element = [pscustomobject]@{ + Kind = $Kind; State = $state + Current = [pscustomobject]@{ + AutomationId = $id; Name = $label; ProcessId = $owner + IsOffscreen = ($Kind -ceq 'route' -and $state.name -ceq 'offscreen-route') -or + ($Kind -ceq 'final-card' -and $state.name -ceq 'offscreen-final-card') + } + } + $element | Add-Member ScriptMethod GetCurrentPattern { + param($pattern) + if ($this.Kind -cin @('route','fit')) { + if ($pattern -ne [System.Windows.Automation.InvokePattern]::Pattern) { throw 'Unexpected route pattern' } + $invoke = [pscustomobject]@{ Kind = $this.Kind; State = $this.State } + $invoke | Add-Member ScriptMethod Invoke { + $this.State.order.Add($(if ($this.Kind -ceq 'route') { 'show' } else { 'fit' })) + } + return $invoke + } + if ($pattern -ne [System.Windows.Automation.SelectionItemPattern]::Pattern -or $this.Kind -cne 'final-card') { + throw 'Unexpected selection pattern' + } + $this.State.order.Add('selected') + return [pscustomobject]@{ Current = [pscustomobject]@{ IsSelected = $this.State.name -cne 'lost-selection' } } + } + return $element + } + function Ensure-ShellForeground($Owner, $Label) { + Assert-Delete ($Owner -eq [IntPtr]42 -and $Label -ceq 'Delete Loop Show in Graph') 'Wrong foreground authority' + $state.order.Add('foreground') + return $state.name -cne 'foreground-refused' + } + function Wait-ForRootReconnect($Process, $Walkers) { + Assert-Delete ($Process.Id -eq 4242 -and $Walkers.Count -eq 1 -and [object]::ReferenceEquals($Walkers[0],$ownedWalker)) 'Wrong reconnect authority' + $state.order.Add('reconnect'); $state.reconnects++ + if ($state.reconnects -eq 1) { return $restoredRoot } + return $finalRoot + } + function Find-FragmentByIdWithRetry($Root, [string] $Id, $Walker) { + Assert-Delete ([object]::ReferenceEquals($Root,$restoredRoot) -and [object]::ReferenceEquals($Walker,$ownedWalker)) 'Lookup used a stale workspace root' + $state.order.Add("lookup:$Id") + if ($Id -ceq 'canvas-card-owned') { + if ($state.name -ceq 'missing-card') { return $null } + return New-RestoreElement card + } + Assert-Delete ($Id -ceq 'fit-canvas') 'Lookup changed the selected card identity' + return New-RestoreElement fit + } + function Wait-ForGraphChildren($Root, $Walker, [scriptblock] $Filter, [scriptblock] $Ready) { + Assert-Delete ([object]::ReferenceEquals($Walker,$ownedWalker)) 'Graph wait changed walker' + $state.waits++ + if ($state.waits -eq 1) { + Assert-Delete ([object]::ReferenceEquals($Root,$initialRoot)) 'Route wait did not use selected workspace root' + $state.order.Add('wait-route') + $children = if ($state.name -ceq 'missing-route') { @() } else { @(New-RestoreElement route) } + $items = @($children | Where-Object $Filter) + if (-not (& $Ready $items)) { throw [InvalidOperationException]::new('controlled route publication missing') } + } else { + Assert-Delete ($state.waits -eq 2 -and [object]::ReferenceEquals($Root,$restoredRoot)) 'Card wait did not use reconnected graph root' + $state.order.Add('wait-card') + $children = @(New-RestoreElement final-card) + if ($state.name -ceq 'retained-workspace') { $children += New-RestoreElement route } + $items = @($children | Where-Object $Filter) + if (-not (& $Ready $items)) { throw [InvalidOperationException]::new('controlled restored card publication missing') } + } + return [pscustomobject]@{ Graph = (New-RestoreElement graph); Items = $items; Children = $children } + } + $caught = $null + $actualRoot = $null + try { $actualRoot = Restore-DeleteLoopCanvas $process $initialRoot ([IntPtr]42) 'canvas-card-owned' $ownedWalker } + catch { $caught = $_ } + Assert-Delete (($state.order -join ',') -ceq $test.order) "Restoration observation/action order differs: $($state.order -join ',')" + if ($null -eq $test.failure) { + Assert-Delete ($null -eq $caught -and [object]::ReferenceEquals($actualRoot,$finalRoot)) 'Restoration did not return the final freshly reconnected root' + } else { + Assert-Delete ($caught -and $caught.Exception.Message -ceq $test.failure -and $null -eq $actualRoot) ` + "Restoration refusal changed: $($caught.Exception.Message)" + } + } +} +$stressRemoval = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] -and $node.GetCommandName() -ceq 'Remove-Job' -and + @($node.CommandElements | Where-Object { $_.Extent.Text -ceq '$stressJob' }).Count -eq 1 +}, $true) | Sort-Object { $_.Extent.StartOffset }) +function Get-StressReleaseStatements { + Assert-Delete ($stressRemoval.Count -eq 2) 'Expected primary and final owned stress-job removal' + $remove = $stressRemoval[0].Parent + while ($remove.Parent -isnot [Management.Automation.Language.StatementBlockAst]) { $remove = $remove.Parent } + $siblings = @($remove.Parent.Statements) + $position = [Array]::IndexOf($siblings, $remove) + Assert-Delete ($position -ge 0 -and $position + 1 -lt $siblings.Count) 'Primary stress removal has no following ownership update' + $clear = $siblings[$position + 1] + Assert-Delete ($clear -is [Management.Automation.Language.AssignmentStatementAst] -and + $clear.Left.Extent.Text -ceq '$stressJob' -and $clear.Right.Extent.Text -ceq '$null') ` + 'Successful stress removal must immediately clear its retained job reference' + return @($remove, $clear) +} +Invoke-DeleteCase 'ui.delete.cleanup.source-order' { + $statements = @(Get-StressReleaseStatements) + $guard = $stressRemoval[1].Parent + while ($null -ne $guard -and $guard -isnot [Management.Automation.Language.IfStatementAst]) { $guard = $guard.Parent } + Assert-Delete ($stressRemoval[1].Extent.Text -match '-ErrorAction Stop' -and + $null -ne $guard -and $guard.Clauses.Count -eq 1 -and + $guard.Clauses[0].Item1.Extent.Text -ceq '$stressJob') 'Final cleanup must retain its live-reference guard and terminating removal' + Assert-Delete ($statements.Count -eq 2) 'Stress release source statement count changed' +} +foreach ($removalFails in @($false, $true)) { + $id = if ($removalFails) { 'ui.delete.cleanup.failed-removal-retains' } else { 'ui.delete.cleanup.success-clears' } + Invoke-DeleteCase $id { + $statements = @(Get-StressReleaseStatements) + $state = @{ calls = 0; fail = $removalFails; original = [IO.IOException]::new('controlled stress removal failure') } + $ownedJob = [pscustomobject]@{ fixture = 'owned-stress-job' } + $stressJob = $ownedJob + function Remove-Job { + param($Job, [switch] $Force) + Assert-Delete ([object]::ReferenceEquals($Job, $ownedJob) -and $Force) 'Removal did not target the retained owned job' + $state.calls++ + if ($state.fail) { throw $state.original } + } + $caught = $null + try { . ([scriptblock]::Create(($statements | ForEach-Object { $_.Extent.Text }) -join "`n")) } catch { $caught = $_ } + Assert-Delete ($state.calls -eq 1) 'Stress removal replayed or was skipped' + if ($removalFails) { + Assert-Delete ($caught -and [object]::ReferenceEquals($caught.Exception, $state.original) -and + [object]::ReferenceEquals($stressJob, $ownedJob)) 'Failed stress removal cleared authority or replaced its original failure' + } else { + Assert-Delete ($null -eq $caught -and $null -eq $stressJob) 'Successful stress removal left a stale reference' + } + } +} +Assert-Delete ($restoreCases.Count -eq 16 -and $results.Count -eq 50) ` + "Delete restoration and cleanup require twenty additional cases without replacing the original thirty: scenarios=$($restoreCases.Count) results=$($results.Count)" +[IO.File]::WriteAllText((Join-Path $ArtifactRoot 'ui-delete-results.json'), ($results | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false)) +foreach ($result in $results) { Write-Output ('UI_CASE_RESULT=' + ($result | ConvertTo-Json -Compress -Depth 8)) } +if (@($results | Where-Object status -cne 'Passed').Count -ne 0) { exit 1 } diff --git a/Tools/windows/ValidationRunner/Ui/UiDerivedContracts.ps1 b/Tools/windows/ValidationRunner/Ui/UiDerivedContracts.ps1 new file mode 100644 index 00000000..4305c677 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/UiDerivedContracts.ps1 @@ -0,0 +1,320 @@ +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) +$ErrorActionPreference = 'Stop' +$bindings = & (Join-Path $RepositoryRoot 'Tools\windows\Import-CompiledValidationAssembly.ps1') ` + -AssemblyPath $AssemblyPath -RequiredType 'GraphCodeUiaGateState' +$source = [IO.File]::ReadAllText((Join-Path $RepositoryRoot 'Tools\windows\uia-live-gate.ps1')) +$nativeSource = [IO.File]::ReadAllText((Join-Path $RepositoryRoot 'Tools\windows\ValidationRunner\Ui\Production\GraphCodeUiaGateState.cs')) +$activationSource = [IO.File]::ReadAllText((Join-Path $RepositoryRoot 'Tools\windows\UiaWindowActivation.cs')) +$errors = $null +$ast = [Management.Automation.Language.Parser]::ParseInput($source, [ref]$null, [ref]$errors) +if ($errors.Count) { throw 'Actual UIA gate does not parse' } +$results = [Collections.Generic.List[object]]::new() +function Assert-Derived([bool] $Condition, [string] $Message) { + if (-not $Condition) { throw [InvalidOperationException]::new($Message) } +} +function Invoke-Derived([string] $Id, [scriptblock] $Assertion) { + $failure = $null + try { & $Assertion } catch { $failure = $_ } + $results.Add([ordered]@{ + id = $Id; status = $(if ($failure) { 'Failed' } else { 'Passed' }); executedCount = 1 + evidence = $(if ($failure) { $failure.ToString() } else { 'Exact current production PS/source/compiled binding derived contract' }) + }) +} +function Read-Function([string] $Name) { + $definitions = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq $Name + }, $true)) + Assert-Derived ($definitions.Count -eq 1) "Expected one actual production helper: $Name" + return $definitions[0].Extent.Text +} +function Get-Region([string] $Text, [string] $Start, [string] $End) { + $begin = $Text.IndexOf($Start, [StringComparison]::Ordinal) + $finish = if ($begin -ge 0) { $Text.IndexOf($End, $begin, [StringComparison]::Ordinal) } else { -1 } + Assert-Derived ($begin -ge 0 -and $finish -gt $begin) "Production region boundary missing: $Start" + return $Text.Substring($begin, $finish - $begin) +} +function Confirm-Order([string] $Text, [string[]] $Needles) { + $offset = 0 + foreach ($needle in $Needles) { + $next = $Text.IndexOf($needle, $offset, [StringComparison]::Ordinal) + if ($next -lt 0) { return $false } + $offset = $next + $needle.Length + } + return $true +} +function Assert-ContainsAll([string] $Text, [string[]] $Needles) { + Assert-Derived ($Needles.Count -gt 0) 'Source requirement list is empty' + foreach ($needle in $Needles) { Assert-Derived ($Text.Contains($needle)) "Production contract missing literal: $needle" } +} +$caption = $null +Invoke-Derived 'ui.root.source.line-5501' { + $script:caption = Get-Region $source '$laneProjectId = Get-MultiProjectAutomationId "overview-project" $fixtureProjectPath' ` + '$surfaceActionPatterns["quick-chats-destination"].Invoke()' +} +Invoke-Derived 'ui.root.source.line-5514' { + Assert-Derived ($null -ne $caption) 'Caption region unavailable' + Assert-ContainsAll $caption @('$captions = @($children | Where-Object', '$_.Current.AutomationId -ceq $laneProjectId', + 'lane caption snapshot must expose exactly one project caption', 'lane caption snapshot must not expose an uninspected worktree chip', + 'CaptionBounds = $caption', 'OpenX = [int][Math]::Round(($caption.Left + $caption.Right) / 2)', 'UIA_LANE_NO_WORKTREE_CHIP_EVIDENCE=') +} +Invoke-Derived 'ui.root.source.line-5524' { + Assert-Derived ($null -ne $caption) 'Caption region unavailable' + foreach ($obsolete in @('Worktrees not inspected', '$laneOpenNoticeOffset', '$laneWorktreesNoticeOffset', + '$laneActionsRightNoticeOffset', 'overview lane Worktrees click')) { + Assert-Derived (-not $caption.Contains($obsolete)) "Obsolete caption contract retained: $obsolete" + } +} +Invoke-Derived 'ui.root.source.line-5537' { + $diagnostic = $source.LastIndexOf('Get-UiaPrelaunchDiagnostics', [StringComparison]::Ordinal) + $launch = $source.IndexOf('$process = Start-UiaOwnedProcess $Shell', [StringComparison]::Ordinal) + Assert-Derived ($diagnostic -ge 0 -and $launch -ge 0 -and $diagnostic -lt $launch) 'Prelaunch diagnostics occur after shell launch' + foreach ($pattern in @('GetCurrentWindowStationName|WindowStation', 'GetCurrentDesktopName|DesktopName', 'desktopHeap', 'sessionId')) { + Assert-Derived ($source -match $pattern) 'Prelaunch diagnostics omit host evidence' + } +} +Invoke-Derived 'ui.root.source.line-5897' { + Assert-Derived ($null -ne $bindings.GetType('GraphCodeUiaHostInfo', $false)) 'Production host-info type missing' +} +Invoke-Derived 'ui.root.source.line-5905' { + $hostInfo = [IO.File]::ReadAllText((Join-Path $RepositoryRoot 'Tools\windows\ValidationRunner\Ui\Production\GraphCodeUiaHostInfo.cs')) + Assert-Derived ($hostInfo.Contains('public static class GraphCodeUiaHostInfo') -and + $source.Contains('"GraphCodeUiaHostInfo"') -and $source.Contains('Import-CompiledValidationAssembly.ps1')) 'Ordinary host-info source is not required by production consumer' +} +Invoke-Derived 'ui.root.source.line-5914' { + Assert-Derived ([GraphCodeUiaHostInfo]::CurrentSessionId() -is [uint32]) 'Current Windows session type is not UInt32' +} +foreach ($name in @('Protect-UiaStartupDiagnosticText', 'Get-UiaStartupDiagnosticValue', 'Get-UiaPrelaunchDiagnostics')) { + . ([scriptblock]::Create((Read-Function $name))) +} +$hostContext = Get-UiaPrelaunchDiagnostics +foreach ($id in @('ui.root.source.line-5918', 'ui.host-info.diagnostics')) { + Invoke-Derived $id { + Assert-Derived ($hostContext.sessionId.state -eq 'available' -and $hostContext.currentProcessId -eq $PID -and + $hostContext.desktopHeap.state -eq 'usage_unavailable') 'Host diagnostics lost explicit session or heap state' + } +} +function Confirm-Ingress([string] $Gate, [string] $Native) { + $method = [regex]::Match($Native, '(?s)public static bool SendCommand\(IntPtr window, uint command\)\s*\{(?.*?)\n\s*\}') + $body = [regex]::Replace($method.Groups['body'].Value, '\s+', ' ').Trim() + $expected = 'if (window == IntPtr.Zero) return false; UIntPtr result; return SendMessageTimeout( window, 0x0111, (UIntPtr)command, IntPtr.Zero, 0x0001 | 0x0002, 5000, out result ) != IntPtr.Zero;' + if (-not $method.Success -or $body -cne $expected) { return $false } + try { $region = Get-Region $Gate 'foreach ($ingress in @(' 'foreach ($nativeForm in @(' } catch { return $false } + return (Confirm-Order $region @('action=submit-invalid phase=before', '[GraphCodeUiaGateState]::SendCommand(', 'action=submit-invalid phase=after')) -and + (Confirm-Order $region @('action=validation-enumeration phase=before', '$validationContent = @(', '.FindAll(', 'action=validation-enumeration phase=after')) -and + (Confirm-Order $region @('[GraphCodeUiaGateState]::PostClose(', 'action=close-wait phase=before', 'Wait-UiaOwnedSheetGoneByContentClass', 'action=close-wait phase=after')) +} +Invoke-Derived 'ui.root.source.line-6057' { + Assert-Derived (Confirm-Ingress $source $nativeSource) 'Actual ingress source lost bounded attribution' + Assert-Derived (-not (Confirm-Ingress $source ($nativeSource.Replace('return SendMessageTimeout(', 'if (false) return SendMessageTimeout(')))) 'Dead bounded call mutant accepted' + Assert-Derived (-not (Confirm-Ingress ($source.Replace('action=submit-invalid phase=after', 'action=submit-invalid phase=before')) $nativeSource)) 'Reordered ingress marker mutant accepted' +} +function Confirm-SheetRetry([string] $Gate) { + try { $body = Get-Region $Gate 'function Wait-UiaOwnedSheetByContentClass(' 'function Wait-UiaOwnedSheetGoneByContentClass(' } catch { return $false } + return [regex]::Matches($body, '(?s)if \(Test-MultiProjectOwnedSheetIdentity \$identity \$processId \$owner\s*`\s*[\r\n]+\s*\$expectedHostTitle \$contentClass\) \{\s*return \$element\s*\}').Count -eq 1 -and + [regex]::Matches($body, 'return \$element').Count -eq 1 -and $body -match 'Get-UiaOwnedSheetIdentity \$element' -and + $body -match 'UIA_OWNED_SHEET_RETRY' -and $body -match 'Start-Sleep -Milliseconds 50' -and + $body -match '\$watch\.ElapsedMilliseconds -lt \$timeoutMilliseconds' -and $body -notmatch 'Assert-UiaOwnedSheet \$element' +} +Invoke-Derived 'ui.root.source.line-6093' { + Assert-Derived (Confirm-SheetRetry $source) 'Actual owned-sheet retry proof failed' + $mutant = [regex]::Replace($source, + '(?s)if \(Test-MultiProjectOwnedSheetIdentity \$identity \$processId \$owner\s*`\s*[\r\n]+\s*\$expectedHostTitle \$contentClass\) \{\s*return \$element\s*\}', + 'if ($true) { $null = $element }; return $element', 1) + Assert-Derived (-not (Confirm-SheetRetry $mutant)) 'Unconditional owned-sheet return mutant accepted' +} +function Confirm-DeleteContext([string] $Gate) { + try { $region = Get-Region $Gate '$deleteLoopTitle =' 'Require ([GraphCodeUiaGateState]::PostFixtureMutation($shellWindow, 21))' } catch { return $false } + $region = [regex]::Replace($region, '\s+', ' ').Trim() + if ($region -match 'SendCommand\(\$shellWindow,\s*5103\)') { return $false } + return Confirm-Order $region @( + '$deleteTarget = Find-FragmentByIdWithRetry $root $renameGraphIdentity $rawWalker', '$deleteTargetSelection.Select()', + 'Require $deleteTargetSelection.Current.IsSelected', 'UIA_DELETE_LOOP_TARGET', + '$deleteLoopPopup = Open-CanvasCardContextMenu $process $root $shellWindow ` $renameGraphIdentity $rawWalker "Delete Loop"', + '$deleteLoopItems = @(Get-PopupMenuItems $deleteLoopPopup)', + '$_.Id -eq 5103 -and $_.Enabled', 'Require ($null -ne $deleteLoopItem)', + '$deleteLoopPopup, $shellWindow, [int]$deleteLoopItem.Position, [int]$deleteLoopItem.Id', 'Require ($null -ne $deleteLoopClick)', + 'UIA_DELETE_LOOP_MENU', '"GraphCodeWindowsDialogContent" $deleteLoopTitle "Delete Loop custom sheet"', + '[IntPtr]$deleteLoopDialog.Current.NativeWindowHandle, 9808', + 'Wait-UiaOwnedSheetGoneByContentClass $process.Id $shellWindow ` "GraphCodeWindowsDialogContent") "Delete Loop custom sheet remained open after Cancel"') +} +Invoke-Derived 'ui.root.source.line-6157' { + $helper = [regex]::Replace((Read-Function 'Open-CanvasCardContextMenu'), '\s+', ' ') + Assert-Derived (Confirm-Order $helper @( + 'Require (Ensure-ShellForeground $ownerWindow', '$card = Find-FragmentByIdWithRetry $root $automationId $walker', + '$card.Current.AutomationId -ceq $automationId', '$card.Current.ProcessId -eq $process.Id -and -not $card.Current.IsOffscreen', + '$canvas = Find-FragmentByIdWithRetry $root "graph" $walker', '$bounds = $card.Current.BoundingRectangle', + '$canvasBounds = $canvas.Current.BoundingRectangle', '[double]::IsNaN($_) -or [double]::IsInfinity($_)', + '$visible = Get-MultiProjectClippedRectangle $cardRect $canvasRect', + '[GraphCodeUiaGateState]::PostOwnedScreenPoint($ownerWindow, $x, $y, $true)', + '$popup = Wait-ForPopupMenu $process $ownerWindow $label', 'Require ($popup -ne [IntPtr]::Zero)', 'return $popup')) ` + 'Actual owned stable-card context helper lost foreground/identity/geometry/popup proof' + Assert-Derived (Confirm-DeleteContext $source) 'Actual Delete context proof failed' + $mutants = @( + @('Open-CanvasCardContextMenu $process $root $shellWindow', '[GraphCodeUiaGateState]::SendCommand($shellWindow, 5103)'), + @('Require ($null -ne $deleteLoopItem)', '$null = ($null -ne $deleteLoopItem)'), + @('$deleteLoopPopup, $shellWindow, [int]$deleteLoopItem.Position, [int]$deleteLoopItem.Id', '$deleteLoopPopup, $shellWindow, 0, 5103'), + @('[IntPtr]$deleteLoopDialog.Current.NativeWindowHandle, 9808', '[IntPtr]$deleteLoopDialog.Current.NativeWindowHandle, 7'), + @('Wait-UiaOwnedSheetGoneByContentClass $process.Id $shellWindow', 'Wait-UiaOwnedSheetGoneDisabled $process.Id $shellWindow') + ) + $checked = 0 + foreach ($mutant in $mutants) { + Assert-Derived ($source.Contains($mutant[0])) 'Delete context mutation anchor missing' + Assert-Derived (-not (Confirm-DeleteContext ($source.Replace($mutant[0], $mutant[1])))) 'Delete context mutant accepted' + $checked++ + } + Assert-Derived ($checked -eq 5) 'Delete context mutation count changed' +} +Invoke-Derived 'ui.root.source.line-6182' { + foreach ($pattern in @('UIA_RENAME_DISPATCH', 'UIA_RENAME_INPUT', 'UIA_RENAME_OUTCOME', 'SetEditTextById', + 'Native Title edit control \(id 9904\)', 'Goal summary edit control \(id 9100\)', 'Update node dialog did not open', + 'Update node cancellation left the dialog open', 'UIA_UPDATE_NODE_SUBMIT_STATE', 'UIA_UPDATE_NODE_DISPATCH', + 'UIA_DELETE_LOOP_SHEET_EVIDENCE=', 'cancelId = 9808', 'deleteId = 9800')) { + Assert-Derived ($source -match $pattern) 'Rename/Delete/Edit Details source proof missing' + } + Assert-Derived ($source.Contains("Delete $([char]0x201c)UIA loop A$([char]0x201d)?") -and + $source.Contains("Its terminal session is ended and every edge touching it is removed. This can't be undone.")) 'Exact Delete copy missing' +} +foreach ($group in @( + @{ line = 6276; required = @('Test-MultiProjectRenameReceipt', 'Get-MultiProjectClippedRectangle', 'ClickOwnedScreenRectangle', + 'PostOwnedScreenPoint', '[int] $maximumAttempts = 100', 'postedFallback = $postedFallback', 'rightClickFallback = $rightClickFallback', + 'SetTextMessage', '$api.PSObject.Methods["SetText"]', 'messageFallbacks', 'LastFocusPostedFallback', 'LastFocusControlFallback', + 'renameButtonFallback', 'Wait-MultiProjectPeerSettled', 'Sketch-Field 9904 "rename stable immediately before submit"', + 'Invoke-MultiProjectTypeText 9904 $typedTitle $inputEvidence', 'source = "live-uia"', 'beforeSelection', 'afterSelection', + 'Windows unchanged-title rename was incorrectly treated as a no-op', 'observedOwnerCount', 'observedCardCount', + 'projectBounds', 'sidebarBounds', 'source-derived, not a UIA caption') }, + @{ line = 6292; required = @('Test-MultiProjectObservedRoster $surface $fragmentProjection $owners $selectedOwner $multiProcess.Id', + 'totalObservedCardCount', 'totalObservedProjectRowCount', 'expectedMatchedOwners', 'unexpectedCardIds', 'unexpectedProjectIds', + 'foreignProjectFragmentCount', 'static Graph destination is not an ordinary project row') }, + @{ line = 6298; required = @('Get-MultiProjectExpectedCanvasRoster', 'Get-MultiProjectCaptionGeometry', 'expectedNodeCardCount', + 'expectedProjectCaptionCount', 'projectCaptionCount', 'canvasFragmentClasses', 'overview-project', 'project-caption', + 'workspace navigation was not requested', + 'Get-MultiProjectCaptionGeometry $overview.canvasBounds $row.card.bounds (@($overview.canvasFragmentClasses | Where-Object { $_.classification -ceq "project-caption" -and $_.projectPath -ceq $owner.path })[0].bounds)') }, + @{ line = 6305; required = @('$pixelWidth = $card[2] - $card[0]', '$pixelHeight = $card[3] - $card[1]', + '$scaleSource = if ([Math]::Abs($pixelHeight - 96) -le 1)', '$pixelWidth / 250', '$normalizedHeight = $pixelHeight / $scale', + '$heightTolerance = if ($scaleSource -ceq "card-width-rounded" -and $scale -lt 1)', '[Math]::Max(1, $pixelWidth - 1) + 0.01', + 'scaleSource = $scaleSource', 'normalizedHeight = $normalizedHeight', 'heightTolerance = $heightTolerance') } +)) { + Invoke-Derived "ui.root.source.line-$($group.line)" { + foreach ($required in $group.required) { + if ($required -ceq 'SetTextMessage') { + Assert-Derived ($bindings.GetType('GraphCodeMultiProjectEditNative', $false) -ne $null -and + $source.Contains('"GraphCodeMultiProjectEditNative"')) 'Production consumer does not require the compiled edit component' + Assert-ContainsAll ([IO.File]::ReadAllText((Join-Path $RepositoryRoot 'Tools\windows\ValidationRunner\Host\Production\GraphCodeMultiProjectEditNative.cs'))) @($required) + } else { Assert-ContainsAll $source @($required) } + } + } +} +Invoke-Derived 'ui.root.source.line-6314' { + $body = Read-Function 'Get-MultiProjectObservation' + Assert-Derived (-not $body.Contains('.StartsWith(') -and -not $body.Contains('$projectSelected -eq $selected')) 'Observer accepts prefixes or obsolete selection' + Assert-ContainsAll $body @('Test-MultiProjectMetadataBatch', 'unresolvedMetadataCount', + 'Test-MultiProjectOwnerSelection $surface $owner $selectedOwner $projectSelected $loopSelected') + Assert-ContainsAll $source @('UIA_MULTIPROJECT_METADATA_REACQUIRE=') +} +Invoke-Derived 'ui.root.source.line-6326' { + Assert-ContainsAll $source @('function Complete-MultiProjectCapture', 'function Wait-MultiProjectCapture', + 'function Initialize-MultiProjectProcessJob', 'StandaloneProcessJob', 'rootStartUtcTicks', 'targetStartUtcTicks', + 'if (-not $capture.rootProcess.WaitForExit($remaining))', 'MULTIPROJECT_CAPTURE_DRAIN', 'MULTIPROJECT_CAPTURE_TEARDOWN=', + 'MultiProjectTeardown:', 'WaitAll', 'ownedJobEmpty', '$null = Complete-MultiProjectCapture $child $logDirectory 5000 $primaryError') + $streamAssembly = & (Join-Path $RepositoryRoot 'Tools\windows\Import-CompiledValidationAssembly.ps1') ` + -AssemblyPath $AssemblyPath -RequiredType 'GraphCode.Validation.Ownership.UiaOwnedStreamCapture' + Assert-Derived ([object]::ReferenceEquals($bindings, $streamAssembly)) 'Capture uses a foreign stream component' + Assert-ContainsAll ([IO.File]::ReadAllText((Join-Path $RepositoryRoot 'Tools\windows\ValidationRunner\Ownership\UiaOwnedStreamCapture.cs'))) @('while ((count = await reader.ReadAsync', 'return text.ToString();') +} +Invoke-Derived 'ui.root.source.line-6352' { + Assert-ContainsAll $activationSource @('public interface IUiaWindowActivationOperations', + 'operations.ShowWindow(window, 9);', 'operations.IsForegroundWindow(window)', + 'operations.WindowProcessId(window) == expectedProcessId', 'operations.IsRetainedProcessAlive(expectedProcessId)', + 'broughtToTop.Succeeded ? 0 : broughtToTop.Error', 'foregroundSet.Succeeded ? 0 : foregroundSet.Error', + 'no synthetic keys or input-queue attachment') + Assert-Derived ($activationSource -notmatch '\b(DllImport|LibraryImport|SendInput|keybd_event|AttachThreadInput|Marshal)\b' -and + [regex]::Matches($nativeSource, 'int error = Marshal.GetLastWin32Error\(\);').Count -eq 2) 'Activation native errors or synthetic-input boundary changed' +} +function Confirm-Settings([string] $Gate) { + try { + $body = Get-Region $Gate '$backendFocus = Retain-FocusWithRetry' 'Require ([GraphCodeUiaGateState]::PostCommand($settingsShellWindow, 0x5002))' + } catch { return $false } + return Confirm-Order ([regex]::Replace($body, '\s+', ' ').Trim()) @( + '$backendFocus = Retain-FocusWithRetry', 'Require ($null -ne $backendFocus.Focused)', + 'Require ([GraphCodeUiaGateState]::IsForegroundWindow($settingsWindow))', + 'Product Settings window lost foreground ownership before accepting backend focus', + 'PostKeyboard( [IntPtr]$backendButton.Current.NativeWindowHandle, 0x0D', 'Require ($remainingSettings -eq $settingsHandle)', + '$savedSettings.defaultBackend -eq "copilotCLI"', '$savedSettings.gateSentinel -eq "preserve"', + '$savedSettingsBytes = [IO.File]::ReadAllBytes($settingsPath)', 'PostKeyboard( [IntPtr]$backendButton.Current.NativeWindowHandle, 0x1B', + 'Wait-UiaOwnedSheetGoneByContentClass $settingsProcess.Id $settingsShellWindow', '"GraphCodeProductSettings"', + '[Convert]::ToBase64String($savedSettingsBytes)', 'Escape changed already-persisted Product Settings') +} +Invoke-Derived 'ui.root.source.line-6412' { + Assert-Derived (Confirm-Settings $source) 'Actual immediate settings contract failed' + Assert-Derived (-not (Confirm-Settings ($source.Replace('Require ([GraphCodeUiaGateState]::IsForegroundWindow($settingsWindow))', + '$null = [GraphCodeUiaGateState]::IsForegroundWindow($settingsWindow)')))) 'Missing foreground mutant accepted' + Assert-Derived (-not (Confirm-Settings ($source.Replace('Require ($remainingSettings -eq $settingsHandle)', '$null = ($remainingSettings -eq $settingsHandle)')))) 'Closing Return mutant accepted' +} +Invoke-Derived 'ui.root.source.line-6467' { + $app = [IO.File]::ReadAllText((Join-Path $RepositoryRoot 'graphcode-windows\src\App.zig')) + foreach ($pattern in @('fn showUiaContextMenu', 'MainWindow\.wm_uia_context_menu => \{', '(?s)wparam == MainWindow\.menu_watchdog_timer_id.*?c\.EndMenu\(\)')) { + Assert-Derived ($app -match $pattern) 'Actual App context menu/watchdog contract missing' + } +} +Invoke-Derived 'ui.root.source.line-6591' { + $history = @( + [pscustomobject]@{ requestID = 'create-request'; command = '{"projectPath":"graphcode://stub/project","command":{"createNode":{"_0":{"id":"11111111-1111-4111-8111-111111111111","title":"UIA created timed loop","loopType":"timeBased","triggerPrompt":"Summarize new commits","heartbeatIntervalSeconds":3600,"backend":"copilotCLI","modelTier":"capable"}}}}' }, + [pscustomobject]@{ requestID = 'outcome-request'; command = '{"projectPath":"graphcode://stub/project","command":{"openNode":{"_0":"11111111-1111-4111-8111-111111111111"}}}' } + ) + $retained = @($history | Where-Object requestID -CEQ 'create-request') + $command = $retained[0].command | ConvertFrom-Json + Assert-Derived ($retained.Count -eq 1 -and [string]$command.projectPath -ceq 'graphcode://stub/project' -and + [string]$command.command.createNode._0.triggerPrompt -ceq 'Summarize new commits' -and + [double]$command.command.createNode._0.heartbeatIntervalSeconds -eq 3600) 'Retained request history selected wrong command' +} +Invoke-Derived 'ui.root.source.line-6654' { + $needles = @( + '$custodyMenuClick = Sketch-ClickMenu $custodyParent 5119', '$custodyCancel = Sketch-Cancel "New Child"', + '$custodyParent = Open-SketchNodeMenu $renameFinalTitle', '$custodyTurnTile = Assert-SketchControl 9600', + 'Sketch-Type 9104 $custodyFirstInstruction', '$null = Sketch-Combo 9112 1 "Claude Code"', + '$custodyInstructionAfterBackend = Sketch-Field 9104', + '(Edge-GraphCount $custodyAfter) -eq (Edge-GraphCount $custodyBefore) + 2', + '$custodyNewCommands = @($custodyAfter.receivedGraphCommands |', '$custodyExpectedOutcome = [ordered]@{', + '$custodyOutcomes = @($custodyNewCommands | Where-Object {', '[string]$custodyOutcomes[0].requestID -cne $custodyRequest -and', + '@($custodyAfter.unansweredRequests) -notcontains [string]$custodyOutcomes[0].requestID -and', + '$custodyRenderWait = Wait-SketchGraphCard', '$custodyWorkspaceId = Get-MultiProjectAutomationId "workspace-loop-bar" $custodyId', + '$custodyWorkspace = Wait-ForGraphChildren $renameRoot $rawWalker', + '$custodyShowGraph.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke()', + '$custodyGraphReturn = Wait-ForGraphChildren $renameRoot $rawWalker', '$custodyRendered = Open-SketchNodeMenu "UIA custody child"') + Assert-Derived (Confirm-Order $source $needles) 'Custody reopen/type/backend/unique request/render order changed' + Assert-Derived ([regex]::Matches($source, [regex]::Escape('$custodyTurnTile = Assert-SketchControl 9600')).Count -eq 1) 'Custody Turn tile selected more than once' +} +Invoke-Derived 'ui.root.source.line-6754' { + Assert-Derived ($nativeSource.Contains('public static class GraphCodeUiaGateState') -and + $bindings.GetType('GraphCodeUiaGateState', $false)) 'Complete ordinary production gate helper missing' +} +Invoke-Derived 'ui.root.source.line-6770' { + $type = $bindings.GetType('GraphCodeUiaGateState', $true) + $calls = @([regex]::Matches($source, '\[GraphCodeUiaGateState\]::(?[\w]+)') | + ForEach-Object { $_.Groups['member'].Value } | Sort-Object -Unique) + Assert-Derived ($calls.Count -gt 0) 'No actual production gate member calls found' + foreach ($name in $calls) { + Assert-Derived ($type.GetMember($name, [Reflection.BindingFlags]'Public,Static').Count -gt 0) "Actual production gate calls undefined compiled member: $name" + } +} +Invoke-Derived 'ui.root.source.line-7027' { + Assert-Derived (Confirm-Order $source @('UIA_COMPOSITE_NAVIGATION_EVIDENCE=', + '$connectorActual = Find-FragmentByIdWithRetry $root "actual-size" $rawWalker', + '$connectorActual.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke()', + '$root = Wait-ForRootReconnect $process @($rawWalker, $controlWalker)', 'Get-DynamicProjectCardCapture $_', + '$connectorDecision = Get-ConnectorDragSnapshotDecision')) 'Connector evidence does not freeze after normalization/reconnect' + foreach ($pattern in @('\[GraphCodeUiaGateState\]::ClientScreenBounds\(\$shellWindow\)', 'FindVisibleOwnedSheetByContentClass\(', + 'UIA_CONNECTOR_DRAG_EVIDENCE=', 'coordinateSpace = "physical-screen"', 'targetCenterVisible = \$connectorDecision\.targetCenterVisible', + '\[GraphCodeUiaGateState\]::PostMouseDragMoveAt\(', '\$visibleTargetBounds = @\(')) { + Assert-Derived ($source -match $pattern) 'Connector physical-screen/clipped target contract missing' + } +} +$null = [IO.Directory]::CreateDirectory($ArtifactRoot) +[IO.File]::WriteAllText((Join-Path $ArtifactRoot 'ui-derived-results.json'), ($results | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false)) +foreach ($result in $results) { Write-Output ('UI_CASE_RESULT=' + ($result | ConvertTo-Json -Compress -Depth 8)) } +if (@($results | Where-Object status -cne 'Passed').Count -ne 0) { exit 1 } diff --git a/Tools/windows/ValidationRunner/Ui/UiFunctionalContracts.ps1 b/Tools/windows/ValidationRunner/Ui/UiFunctionalContracts.ps1 new file mode 100644 index 00000000..e2bde819 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/UiFunctionalContracts.ps1 @@ -0,0 +1,394 @@ +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) +$ErrorActionPreference = 'Stop' +& (Join-Path $RepositoryRoot 'Tools\windows\Import-CompiledValidationAssembly.ps1') ` + -AssemblyPath $AssemblyPath -RequiredType 'GraphCodeUiaGateState' | Out-Null +$source = [IO.File]::ReadAllText((Join-Path $RepositoryRoot 'Tools\windows\uia-live-gate.ps1')) +$errors = $null +$ast = [Management.Automation.Language.Parser]::ParseInput($source, [ref]$null, [ref]$errors) +$results = [Collections.Generic.List[object]]::new() +function Assert-Functional([bool] $Condition, [string] $Message) { + if (-not $Condition) { throw [InvalidOperationException]::new($Message) } +} +function Invoke-Functional([string] $Id, [scriptblock] $Assertion, [int] $Count = 1) { + $failure = $null + try { & $Assertion } catch { $failure = $_ } + $results.Add([ordered]@{ + id = $Id; status = $(if ($failure) { 'Failed' } else { 'Passed' }); executedCount = $Count + evidence = $(if ($failure) { $failure.ToString() } else { 'Current production PS function/AST with deliberate managed fixtures' }) + }) +} +function Read-Function([string] $Name) { + $definitions = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq $Name + }, $true)) + Assert-Functional ($definitions.Count -eq 1) "Expected one actual production helper: $Name" + return $definitions[0].Extent.Text +} +Invoke-Functional 'ui.root.source.line-5366' { Assert-Functional ($errors.Count -eq 0) 'UIA production source does not parse' } +Invoke-Functional 'ui.root.source.line-5553' { Assert-Functional ($errors.Count -eq 0) 'UIA startup source does not parse' } +Assert-Functional ($errors.Count -eq 0) 'Cannot import malformed production source' +foreach ($binding in @( + @{ line = 5380; name = 'Test-MultiProjectSourceSupportedChromeId' }, + @{ line = 5407; name = 'Wait-MultiProjectWorkspaceMarkerPublication' }, + @{ line = 5457; name = 'Get-MultiProjectCardActivationRectangle' }, + @{ line = 5561; name = 'Get-SidebarNavigationSnapshot' }, + @{ line = 5569; name = 'Get-SidebarNavigationBoundsProof' }, + @{ line = 6853; name = 'Get-EdgeTextAttemptDecision' }, + @{ line = 6921; name = 'Wait-SketchGraphCard' }, + @{ line = 6939; name = 'Get-SketchCardAutomationId' }, + @{ line = 7136; name = 'Select-EdgeConnectorCardPair' } +)) { + Invoke-Functional "ui.root.source.line-$($binding.line)" { $null = Read-Function $binding.name } + . ([scriptblock]::Create((Read-Function $binding.name))) +} +. ([scriptblock]::Create((Read-Function 'New-SidebarNavigationCapture'))) +. ([scriptblock]::Create((Read-Function 'Test-SketchPromotionSubmenuItems'))) +function Test-MultiProjectAutomationPrefix([string] $value, [string] $prefix) { + return $value.StartsWith($prefix, [StringComparison]::Ordinal) +} +Invoke-Functional 'ui.root.source.line-5392' { + $checked = 0 + foreach ($id in @('wire-node-1000000000000000001', 'mark-entry-1000000000000000002', + 'overview-wire-node-1000000000000000003', 'overview-mark-entry-1000000000000000004')) { + Assert-Functional (Test-MultiProjectSourceSupportedChromeId $id 'owned') "Stable recovery control rejected: $id" + $checked++ + } + Assert-Functional ($checked -eq 4) 'Stable recovery census did not execute four cases' +} 4 +Invoke-Functional 'ui.root.source.line-5396' { + Assert-Functional (-not (Test-MultiProjectSourceSupportedChromeId 'future-unknown-control-1000000000000000005' 'owned')) 'Unknown chrome accepted' +} +Invoke-Functional 'ui.root.source.line-5399' { + Assert-Functional (-not (Test-MultiProjectSourceSupportedChromeId 'overview-wire-node-1000000000000000003' 'foreign')) 'Foreign chrome accepted' +} +Invoke-Functional 'ui.root.source.line-5410' { + Assert-Functional ((Read-Function 'Wait-MultiProjectWorkspaceMarkerPublication') -notmatch 'Invoke-MultiProjectControl|PostOwned|ClickOwned|PostMouse|InvokePattern') ` + 'Workspace-marker retry replays navigation or input' +} +Invoke-Functional 'ui.root.source.line-5432' { + $clock = @{ Now = [DateTimeOffset]::Parse('2026-10-27T00:00:00Z') } + $reads = @{ Count = 0 } + $loopBarId = 'workspace-loop-bar-1000000000000000001' + $toolbarId = 'workspace-toolbar-1000000000000000002' + $observed = Wait-MultiProjectWorkspaceMarkerPublication { + $reads.Count++ + if ($reads.Count -lt 3) { return @() } + @([pscustomobject]@{ automationID = [pscustomobject]@{ value = $loopBarId }; ownership = 'owned'; observationReady = $true }, + [pscustomobject]@{ automationID = [pscustomobject]@{ value = $toolbarId }; ownership = 'owned'; observationReady = $true }) + } $loopBarId $toolbarId $clock.Now.AddSeconds(1) { $clock.Now } { + param([int] $Milliseconds) $clock.Now = $clock.Now.AddMilliseconds($Milliseconds) + } + Assert-Functional ($observed.attempts -eq 3 -and $observed.loopBar -ceq $loopBarId -and $observed.toolbar -ceq $toolbarId) ` + 'Delayed exact markers did not satisfy bounded reacquisition' +} +Invoke-Functional 'ui.root.source.line-5449' { + $clock = @{ Now = [DateTimeOffset]::Parse('2026-10-27T00:00:00Z') } + $failure = $null + try { + $null = Wait-MultiProjectWorkspaceMarkerPublication { + @([pscustomobject]@{ automationID = [pscustomobject]@{ value = 'workspace-loop-bar-1999999999999999999' }; ownership = 'owned'; observationReady = $true }) + } 'workspace-loop-bar-1000000000000000001' 'workspace-toolbar-1000000000000000002' $clock.Now.AddMilliseconds(250) { $clock.Now } { + param([int] $Milliseconds) $clock.Now = $clock.Now.AddMilliseconds($Milliseconds) + } + } catch { $failure = $_ } + Assert-Functional ($failure -and $failure.Exception.Message -ceq 'MULTIPROJECT_WORKSPACE_MARKERS: exact owned loop bar and toolbar did not publish before the deadline') ` + 'Unknown marker absence no longer fails closed' +} +Invoke-Functional 'ui.root.source.line-5462' { + $rectangle = Get-MultiProjectCardActivationRectangle @(0.0, 0.0, 250.0, 96.0) + Assert-Functional (($rectangle -join ',') -ceq '0,0,250,24') 'Card activation lost source-sized title strip' +} +Invoke-Functional 'ui.root.source.line-5470' { + $rectangle = Get-MultiProjectCardActivationRectangle @(0.0, 0.0, 250.0, 96.0) + $checked = 0 + foreach ($recovery in @(@(11.0, 31.0, 84.0, 51.0), @(90.0, 31.0, 176.0, 51.0))) { + Assert-Functional (-not ($rectangle[0] -lt $recovery[2] -and $rectangle[2] -gt $recovery[0] -and + $rectangle[1] -lt $recovery[3] -and $rectangle[3] -gt $recovery[1])) 'Activation overlaps a recovery control' + $checked++ + } + Assert-Functional ($checked -eq 2) 'Recovery intersection count changed' +} 2 +Invoke-Functional 'ui.root.source.line-5477' { + $failure = $null + try { $null = Get-MultiProjectCardActivationRectangle @(0.0, 0.0, 0.0, 96.0) } catch { $failure = $_ } + Assert-Functional ($failure -and $failure.Exception.Message -ceq 'MULTIPROJECT_BOUNDS: positive card rectangle required for activation') 'Invalid activation bounds accepted' +} +Invoke-Functional 'ui.root.source.line-5485' { + Assert-Functional ((Read-Function 'Open-MultiProjectLoop').Contains('Get-MultiProjectCardActivationRectangle $row.card.bounds')) 'Live loop navigation clicks whole card' +} +Invoke-Functional 'ui.root.source.line-5580' { + $null = Read-Function 'New-SidebarNavigationCapture' + Assert-Functional ($source -match '\$sidebarCaptures\.Add\(\(New-SidebarNavigationCapture \$sidebarNavigationChildren\)\)') 'Sidebar retains mutable captures' +} +function New-SidebarElement([string] $Id, [string] $Name, [double] $Left, [double] $Top, + [double] $Right, [double] $Bottom, [int] $Owner = 4242) { + [pscustomobject]@{ Current = [pscustomobject]@{ + AutomationId = $Id; Name = $Name; ProcessId = $Owner + BoundingRectangle = [pscustomobject]@{ Left = $Left; Top = $Top; Right = $Right; Bottom = $Bottom; Width = $Right - $Left; Height = $Bottom - $Top } + } } +} +$sidebar = @( + (New-SidebarElement 'overview-destination:graph' 'Graph' 12 20 220 52), + (New-SidebarElement 'quick-chats-destination:quick-chats' 'Quick Chats' 12 56 220 88), + (New-SidebarElement 'sidebar-section-1' 'Local Projects' 12 100 220 121), + (New-SidebarElement 'open-project-1' 'UIA project' 12 121 220 153), + (New-SidebarElement 'sidebar-section-2' 'Remote Repositories' 12 168 220 189), + (New-SidebarElement 'open-project-2' 'Temporary remote' 12 192 220 224), + (New-SidebarElement 'overview-destination' 'Graph' 12 17 220 49), + (New-SidebarElement 'quick-chats-destination' 'Quick Chats' 12 53 220 85), + (New-SidebarElement 'project-new-loop-1' 'New Loop' 174 17 198 49), + (New-SidebarElement 'project-disclosure-2' 'Collapse project' 198 17 220 49), + (New-SidebarElement 'quick-chat-new-3' 'New Chat' 174 53 198 85), + (New-SidebarElement 'quick-chats-disclosure-4' 'Collapse Quick Chats' 198 53 220 85), + (New-SidebarElement 'project-new-loop-5' 'New Loop' 174 121 198 153), + (New-SidebarElement 'project-disclosure-6' 'Collapse project' 198 121 220 153) +) +$initial = @($sidebar | ForEach-Object { + if ($_.Current.Name -in @('Remote Repositories', 'Temporary remote')) { New-SidebarElement $_.Current.AutomationId $_.Current.Name 0 0 0 0 } else { $_ } +}) +$remote = @($sidebar | ForEach-Object { + if ($_.Current.Name -in @('Remote Repositories', 'Temporary remote')) { $_ } else { New-SidebarElement $_.Current.AutomationId $_.Current.Name 0 0 0 0 } +}) +Invoke-Functional 'ui.root.source.line-5632' { + $snapshot = Get-SidebarNavigationSnapshot $sidebar + Assert-Functional (($snapshot.destinationIds -join '|') -ceq 'overview-destination|quick-chats-destination' -and + ($snapshot.destinationNames -join '|') -ceq 'Graph|Quick Chats' -and $snapshot.ownerNames.Count -eq 2 -and + ($snapshot.ownerNames -join '|') -ceq 'UIA project|Temporary remote' -and + ($snapshot.sectionNames -join '|') -ceq 'Local Projects|Remote Repositories' -and $snapshot.recentRowIds.Count -eq 0 -and + $snapshot.rowBoundsValid -and $snapshot.controlsValid -and $snapshot.controlsChecked -eq 6) 'Sidebar baseline oracle differs' +} +Invoke-Functional 'ui.root.source.line-5659' { + $mutable = @($sidebar | ForEach-Object { + $b = $_.Current.BoundingRectangle + New-SidebarElement $_.Current.AutomationId $_.Current.Name $b.Left $b.Top $b.Right $b.Bottom + }) + $frozen = New-SidebarNavigationCapture $mutable + foreach ($item in $mutable) { + foreach ($name in @('Left', 'Top', 'Right', 'Bottom', 'Width', 'Height')) { $item.Current.BoundingRectangle.$name = 0 } + } + $proof = Get-SidebarNavigationBoundsProof @($frozen) 4242 + Assert-Functional ($proof.structureValid -and $proof.rowBoundsValid -and $proof.controlsValid) 'Sidebar capture retained mutable handles' +} +Invoke-Functional 'ui.root.source.line-5683' { + $proof = Get-SidebarNavigationBoundsProof @([pscustomobject]@{ Children = $initial }, [pscustomobject]@{ Children = $remote }) 4242 + Assert-Functional ($proof.structureValid -and $proof.rowBoundsValid -and $proof.controlsValid -and + $proof.rowsChecked -eq 6 -and $proof.controlsChecked -eq 6) 'DPI192 bounded captures rejected' +} +foreach ($test in @( + @{ line = 5696; mode = 'missing' }, @{ line = 5713; mode = 'foreign' }, @{ line = 5724; mode = 'duplicate' }, @{ line = 5739; mode = 'wrong-order' } +)) { + Invoke-Functional "ui.root.source.line-$($test.line)" { + $first = $initial + $second = $remote + switch ($test.mode) { + missing { $second = @($remote | Where-Object { $_.Current.AutomationId -ne 'open-project-2' }) } + foreign { $second = @($remote | ForEach-Object { + if ($_.Current.AutomationId -eq 'open-project-2') { New-SidebarElement $_.Current.AutomationId $_.Current.Name 12 192 220 224 9999 } else { $_ } + }) } + duplicate { $second = @($remote + (New-SidebarElement 'open-project-2' 'Temporary remote' 12 192 220 224)) } + wrong-order { + $first = @($initial | Where-Object { $_.Current.AutomationId -notin @('open-project-1', 'open-project-2') }) + + @($initial | Where-Object { $_.Current.AutomationId -eq 'open-project-2' }) + + @($initial | Where-Object { $_.Current.AutomationId -eq 'open-project-1' }) + } + } + $proof = Get-SidebarNavigationBoundsProof @([pscustomobject]@{ Children = $first }, [pscustomobject]@{ Children = $second }) 4242 + Assert-Functional (-not $proof.structureValid) "Sidebar accepted $($test.mode) owned row" + } +} +Invoke-Functional 'ui.root.source.line-5752' { + $snapshot = Get-SidebarNavigationSnapshot @($sidebar | Where-Object { $_.Current.AutomationId -notin @('open-project-1', 'project-new-loop-5', 'project-disclosure-6') }) + Assert-Functional (($snapshot.ownerNames -join '|') -ceq 'Temporary remote') 'Local owner collapse not isolated' +} +Invoke-Functional 'ui.root.source.line-5761' { + $snapshot = Get-SidebarNavigationSnapshot @($sidebar | Where-Object { $_.Current.AutomationId -ne 'open-project-2' }) + Assert-Functional (($snapshot.ownerNames -join '|') -ceq 'UIA project') 'Remote owner collapse not isolated' +} +Invoke-Functional 'ui.root.source.line-5773' { + $snapshot = Get-SidebarNavigationSnapshot @($sidebar | Where-Object { $_.Current.AutomationId -notin @('sidebar-section-2', 'open-project-2') }) + Assert-Functional (($snapshot.ownerNames -join '|') -ceq 'UIA project' -and + ($snapshot.sectionNames -join '|') -ceq 'Local Projects' -and $snapshot.recentRowIds.Count -eq 0) 'Temporary owner survived restoration' +} +Invoke-Functional 'ui.root.source.line-5783' { + $snapshot = Get-SidebarNavigationSnapshot @($sidebar + (New-SidebarElement 'project-row-recent' 'Recent folder' 12 228 220 260)) + Assert-Functional ($snapshot.recentRowIds.Count -eq 1) 'Unopened recent folder not detected' +} +Invoke-Functional 'ui.root.source.line-5792' { + $clipped = @($sidebar | Where-Object { $_.Current.AutomationId -ne 'project-new-loop-5' }) + + (New-SidebarElement 'project-new-loop-5' 'New Loop' 174 121 228 153) + Assert-Functional (-not (Get-SidebarNavigationSnapshot $clipped).controlsValid) 'Control outside owner accepted' +} +Invoke-Functional 'ui.root.source.line-5807' { + $reordered = @($sidebar | Where-Object { $_.Current.AutomationId -notin @('open-project-1', 'open-project-2') }) + + @((New-SidebarElement 'open-project-2' 'Temporary remote' 12 124 220 156), (New-SidebarElement 'open-project-1' 'UIA project' 12 192 220 224)) + Assert-Functional (((Get-SidebarNavigationSnapshot $reordered).ownerNames -join '|') -ceq 'Temporary remote|UIA project') 'Owner order evidence lost' +} +Invoke-Functional 'ui.root.source.line-5812' { + $sidebarIds = @(5632,5659,5683,5696,5713,5724,5739,5752,5761,5773,5783,5792,5807) + $executed = @($results | Where-Object { $_.id -in @($sidebarIds | ForEach-Object { "ui.root.source.line-$_" }) }) + Assert-Functional ($executed.Count -eq 13) 'Sidebar fixture execution count changed' +} 13 + +$retrySource = Read-Function 'Get-EdgeTextAttemptDecision' +$retryDecisions = [Collections.Generic.List[string]]::new() +Invoke-Functional 'ui.root.source.line-6866' { + $attempts = 0 + for ($attempt = 1; $attempt -le 5; $attempt++) { + $attempts++ + $value = if ($attempts -eq 1) { '' } else { 'expected text' } + $decision = Get-EdgeTextAttemptDecision $true $value 'expected text' $attempt 5 + $retryDecisions.Add($decision) + if ($decision -eq 'complete') { break } + Assert-Functional ($decision -eq 'retry') 'Unexpected text retry decision' + } + Assert-Functional ($attempts -eq 2 -and $value -ceq 'expected text') 'Stable empty text did not retry exactly once' +} 2 +Invoke-Functional 'ui.root.source.line-6864' { + Assert-Functional ($retryDecisions.Count -eq 2 -and ($retryDecisions -join ',') -ceq 'retry,complete') 'Unexpected text retry decision sequence' +} +Invoke-Functional 'ui.root.source.line-6870' { + Assert-Functional ($retrySource -match '\$actual -ceq \$expected') 'Exact expected-text verification missing' +} +$mutatedRetry = $retrySource.Replace('if ($actual -ceq $expected) { return "complete" }', 'if ($stable) { return "complete" }') +Invoke-Functional 'ui.root.source.line-6877' { + Assert-Functional ($mutatedRetry -cne $retrySource) 'Retry mutation anchor missing' +} +Invoke-Functional 'ui.root.source.line-6892' { + $attempts = 0 + for ($attempt = 1; $attempt -le 5; $attempt++) { + $attempts++ + $value = if ($attempts -eq 1) { '' } else { 'expected text' } + $decision = & { + . ([scriptblock]::Create($mutatedRetry)) + Get-EdgeTextAttemptDecision $true $value 'expected text' $attempt 5 + } + if ($decision -eq 'complete') { break } + } + Assert-Functional (-not ($attempts -eq 2 -and $value -ceq 'expected text')) 'Exact-match mutant escaped regression' +} +Invoke-Functional 'ui.root.source.line-6905' { + $attempts = 0 + $failure = $null + try { + for ($attempt = 1; $attempt -le 5; $attempt++) { $attempts++; $null = Get-EdgeTextAttemptDecision $true '' 'expected text' $attempt 5 } + } catch { $failure = $_ } + Assert-Functional ($attempts -eq 5 -and $failure -and $failure.Exception.Message -match 'attempts=5/5') 'Stable-wrong text did not exhaust exactly five attempts' +} 5 +Invoke-Functional 'ui.root.source.line-6925' { + $body = Read-Function 'Wait-SketchGraphCard' + foreach ($pattern in @('\$baselineCount -gt 0', '\$lastTitleCount -eq 1 -and \$lastIdentityCount -eq 1', + '\$expectedNodeId', '\$expectedAutomationId', '\$_.Current\.AutomationId -ceq \$expectedAutomationId', '\$graphSequence')) { + Assert-Functional ($body -match $pattern) 'Custody card wait lost bounded unique identity proof' + } + Assert-Functional ($body -notmatch 'Invoke\(\)|PostRightClick|Actual Size') 'Custody wait performs an action' +} +Invoke-Functional 'ui.root.source.line-6958' { + $checked = 0 + foreach ($expected in @( + @('66666666-6666-4666-8666-000000000001', 'canvas-card-1213325934034940293'), + @('66666666-6666-4666-8666-000000000002', 'canvas-card-1213322635500055660'), + @('66666666-6666-4666-8666-000000000003', 'canvas-card-1213323735011683871'))) { + Assert-Functional ((Get-SketchCardAutomationId $expected[0]) -ceq $expected[1]) 'Source-derived card identity differs' + $checked++ + } + Assert-Functional ($checked -eq 3) 'Sketch identity case count changed' +} 3 +Invoke-Functional 'ui.root.source.line-6967' { + $body = Read-Function 'Open-SketchNodeMenu' + foreach ($pattern in @('\$cards\.Count -eq 1', '\$ExpectedCardId', + '\$cards\[0\]\.Current\.AutomationId -ceq \$ExpectedCardId', 'Get-DirectChildren \$graph \$rawWalker')) { + Assert-Functional ($body -match $pattern) 'Custody right-click bypasses fresh unique identity proof' + } +} + +function New-ConnectorCard([string] $Id, [string] $Name) { + [pscustomobject]@{ Current = [pscustomobject]@{ AutomationId = $Id; Name = $Name } } +} +$connectorCases = @( + @{ name = 'owned-pair'; source = 'source'; target = 'target'; expected = $true; cards = @( + (New-ConnectorCard 'canvas-card-source' 'source'), (New-ConnectorCard 'canvas-card-target' 'target')) }, + @{ name = 'duplicate-source'; source = 'source'; target = 'target'; expected = $false; cards = @( + (New-ConnectorCard 'canvas-card-source-a' 'source'), (New-ConnectorCard 'canvas-card-source-b' 'source'), (New-ConnectorCard 'canvas-card-target' 'target')) }, + @{ name = 'missing-source'; source = 'source'; target = 'target'; expected = $false; cards = @((New-ConnectorCard 'canvas-card-target' 'target')) }, + @{ name = 'missing-target'; source = 'source'; target = 'target'; expected = $false; cards = @( + (New-ConnectorCard 'canvas-card-source' 'source'), (New-ConnectorCard 'button-target' 'target')) }, + @{ name = 'different-target-title'; source = 'source'; target = 'target'; expected = $false; cards = @( + (New-ConnectorCard 'canvas-card-source' 'source'), (New-ConnectorCard 'canvas-card-target' 'other target')) }, + @{ name = 'duplicate-target'; source = 'source'; target = 'target'; expected = $false; cards = @( + (New-ConnectorCard 'canvas-card-source' 'source'), (New-ConnectorCard 'canvas-card-target-a' 'target'), (New-ConnectorCard 'canvas-card-target-b' 'target')) }, + @{ name = 'same-endpoint'; source = 'same'; target = 'same'; expected = $false; cards = @((New-ConnectorCard 'canvas-card-same' 'same')) }, + @{ name = 'blank-title'; source = ' '; target = 'target'; expected = $false; cards = @( + (New-ConnectorCard 'canvas-card-source' 'source'), (New-ConnectorCard 'canvas-card-target' 'target')) } +) +foreach ($test in $connectorCases) { + Invoke-Functional "ui.connector.$($test.name)" { + $failure = $null + $pair = $null + try { $pair = Select-EdgeConnectorCardPair $test.cards $test.source $test.target } catch { $failure = $_ } + if ($test.expected) { + Assert-Functional (-not $failure -and $pair.Source.Current.AutomationId -ceq 'canvas-card-source' -and + $pair.Target.Current.AutomationId -ceq 'canvas-card-target') 'Named source/target pair differs' + } else { Assert-Functional ($null -ne $failure) "Ambiguous connector pair accepted: $($test.name)" } + } +} +Invoke-Functional 'ui.root.source.line-7235' { + Assert-Functional (@($results | Where-Object { $_.id.StartsWith('ui.connector.', [StringComparison]::Ordinal) }).Count -eq 8) 'Connector fixture case count changed' +} 8 +function New-PromotionItems { + # Native menu captions deliberately use the production em dash. + @( + [pscustomobject]@{ Id = 0; Text = 'Keep the session, add a shape'; Enabled = $false; Separator = $false }, + [pscustomobject]@{ Id = 5116; Text = "Goal $([char]0x2014) asks for a done check"; Enabled = $true; Separator = $false }, + [pscustomobject]@{ Id = 5117; Text = "Turn $([char]0x2014) asks where to pause"; Enabled = $true; Separator = $false }, + [pscustomobject]@{ Id = 5118; Text = "Timed $([char]0x2014) asks for a cadence"; Enabled = $true; Separator = $false } + ) +} +foreach ($name in @('exact', 'extra-item', 'reordered', 'enabled-heading', 'disabled-action', 'wrong-caption', 'separator-heading')) { + Invoke-Functional "ui.promotion.$name" { + $items = @(New-PromotionItems) + switch ($name) { + extra-item { $items += [pscustomobject]@{ Id = 5120; Text = 'Unexpected'; Enabled = $true; Separator = $false } } + reordered { $items = @($items[0], $items[2], $items[1], $items[3]) } + enabled-heading { $items[0].Enabled = $true } + disabled-action { $items[1].Enabled = $false } + wrong-caption { $items[2].Text = "Turn $([char]0x2014) unexpected caption" } + separator-heading { $items[0].Separator = $true } + } + Assert-Functional ([bool](Test-SketchPromotionSubmenuItems $items) -eq ($name -ceq 'exact')) "Promotion submenu oracle differs: $name" + } +} +$nativeForms = [IO.File]::ReadAllText((Join-Path $RepositoryRoot 'graphcode-windows\src\NativeForms.zig')) +$nativeLabels = [regex]::Match($nativeForms, '(?s)const node_labels = \[_\]\[\]const u8\{(.*?)\};') +Invoke-Functional 'ui.root.source.line-7326' { Assert-Functional $nativeLabels.Success 'NativeForms node_labels table missing' } +$labelAssignments = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.AssignmentStatementAst] -and $node.Left.Extent.Text -ceq '$nodeSheetFieldLabels' +}, $true)) +Invoke-Functional 'ui.root.source.line-7337' { + Assert-Functional ($labelAssignments.Count -eq 1 -and $labelAssignments[0].Right.Find({ + param($node) $node -is [Management.Automation.Language.HashtableAst] + }, $true)) 'Production node creation label map missing' +} +Invoke-Functional 'ui.root.source.line-7344' { + Assert-Functional ($nativeLabels.Success -and $labelAssignments.Count -eq 1) 'Label sources unavailable' + $labels = @([regex]::Matches($nativeLabels.Groups[1].Value, '"((?:[^"\\]|\\.)*)"') | ForEach-Object { $_.Groups[1].Value }) + $hashAst = $labelAssignments[0].Right.Find({ param($node) $node -is [Management.Automation.Language.HashtableAst] }, $true) + $map = $hashAst.SafeGetValue() + $checked = 0 + foreach ($id in @(9100,9102,9103,9104,9106,9107,9108,9109,9110,9111,9112,9113,9114)) { + $expected = $labels[$id - 9100] + Assert-Functional (-not [string]::IsNullOrWhiteSpace($expected) -and [string]$map[$id] -eq $expected) "Node creation label differs: $id" + $checked++ + } + Assert-Functional ($checked -eq 13) 'Node label count changed' +} 13 + +$null = [IO.Directory]::CreateDirectory($ArtifactRoot) +[IO.File]::WriteAllText((Join-Path $ArtifactRoot 'ui-functional-results.json'), ($results | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false)) +foreach ($result in $results) { Write-Output ('UI_CASE_RESULT=' + ($result | ConvertTo-Json -Compress -Depth 8)) } +if (@($results | Where-Object status -cne 'Passed').Count -ne 0) { exit 1 } diff --git a/Tools/windows/ValidationRunner/Ui/UiPowerShellContracts.ps1 b/Tools/windows/ValidationRunner/Ui/UiPowerShellContracts.ps1 new file mode 100644 index 00000000..d98e1d90 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/UiPowerShellContracts.ps1 @@ -0,0 +1,261 @@ +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) +$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) +$OutputEncoding = [Text.UTF8Encoding]::new($false) +$null = [IO.Directory]::CreateDirectory($ArtifactRoot) +& (Join-Path $RepositoryRoot 'Tools\windows\Import-CompiledValidationAssembly.ps1') ` + -AssemblyPath $AssemblyPath -RequiredType 'GraphCodeUiaGateState' | Out-Null +$source = [IO.File]::ReadAllText((Join-Path $RepositoryRoot 'Tools\windows\uia-live-gate.ps1')) +$errors = $null +$ast = [Management.Automation.Language.Parser]::ParseInput($source, [ref]$null, [ref]$errors) +if ($errors.Count -ne 0) { throw 'Actual production UIA gate does not parse' } +$results = [Collections.Generic.List[object]]::new() + +function Assert-UiContract([bool] $Condition, [string] $Message) { + if (-not $Condition) { throw [InvalidOperationException]::new($Message) } +} +function Invoke-UiCase([string] $Id, [scriptblock] $Assertion) { + $failure = $null + try { & $Assertion } catch { $failure = $_ } + $results.Add([ordered]@{ + id = $Id; status = $(if ($failure) { 'Failed' } else { 'Passed' }); executedCount = 1 + evidence = $(if ($failure) { $failure.ToString() } else { 'Real production PowerShell functions with deliberate PSCustomObject fixture; nativeCalls=0' }) + }) +} +function Get-ProductionDefinition([string] $Name) { + $definitions = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq $Name + }, $true)) + Assert-UiContract ($definitions.Count -eq 1) "Expected one production function: $Name" + return $definitions[0].Extent.Text +} +function Invoke-IsolatedUiMock([scriptblock] $Action) { + $name = 'GRAPHCODE_VALIDATION_ROOT' + $before = [Environment]::GetEnvironmentVariables('Process') + try { + if ($before.Contains($name)) { Remove-Item -LiteralPath "Env:\$name" -ErrorAction Stop } + & $Action 6>&1 | ForEach-Object { + if ([string]$_ -ceq 'GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven') { + Write-Host 'UIA_MOCK_RETENTION_SIGNAL=observed' + } elseif ($_ -is [Management.Automation.InformationRecord]) { + Write-Host $_.MessageData + } else { $_ } + } + } finally { + if ($before.Contains($name)) { [Environment]::SetEnvironmentVariable($name, $before[$name], 'Process') } + elseif (Test-Path -LiteralPath "Env:\$name") { Remove-Item -LiteralPath "Env:\$name" -ErrorAction Stop } + } +} +$originalEnvironment = [Environment]::GetEnvironmentVariables('Process') +try { + foreach ($membership in @('absent', 'empty', 'valued')) { + Invoke-UiCase "ui.mock.isolation.$membership" { + if ($membership -ceq 'absent') { + if (Test-Path Env:\GRAPHCODE_VALIDATION_ROOT) { Remove-Item Env:\GRAPHCODE_VALIDATION_ROOT } + } else { + [Environment]::SetEnvironmentVariable('GRAPHCODE_VALIDATION_ROOT', + $(if ($membership -ceq 'empty') { '' } else { $ArtifactRoot }), 'Process') + } + $before = [Environment]::GetEnvironmentVariables('Process') + $messages = @(Invoke-IsolatedUiMock { + Assert-UiContract (-not [Environment]::GetEnvironmentVariables('Process').Contains('GRAPHCODE_VALIDATION_ROOT')) ` + 'Mock retained enclosing validation-root authority' + Write-Host 'GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven' + Write-Output 'mock-case-passed' + } 6>&1 | ForEach-Object { [string]$_ }) + $after = [Environment]::GetEnvironmentVariables('Process') + Assert-UiContract ($messages -notcontains 'GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven' -and + $messages -contains 'UIA_MOCK_RETENTION_SIGNAL=observed' -and $messages -contains 'mock-case-passed' -and + $before.Contains('GRAPHCODE_VALIDATION_ROOT') -eq $after.Contains('GRAPHCODE_VALIDATION_ROOT') -and + (-not $before.Contains('GRAPHCODE_VALIDATION_ROOT') -or + [string]$before['GRAPHCODE_VALIDATION_ROOT'] -ceq [string]$after['GRAPHCODE_VALIDATION_ROOT']) -and + -not (Test-Path -LiteralPath (Join-Path $ArtifactRoot 'ownership-unproven.json'))) ` + "UIA mock fixture leaked retention intent or changed environment membership: $membership" + } + } + Invoke-UiCase 'ui.mock.isolation.failure-identity' { + $expected = [IO.IOException]::new('real mock assertion failure') + $failure = $null + try { Invoke-IsolatedUiMock { throw $expected } } catch { $failure = $_ } + Assert-UiContract ($failure -and [object]::ReferenceEquals($failure.Exception, $expected)) ` + 'UIA mock fixture swallowed or replaced a genuine assertion failure' + } + Invoke-UiCase 'ui.mock.isolation.ordinary-diagnostic' { + $text = 'prefix GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven' + $output = @(Invoke-IsolatedUiMock { Write-Output $text }) + Assert-UiContract ($output.Count -eq 1 -and $output[0] -ceq $text) 'UIA fixture filtered ordinary diagnostic output' + } +} finally { + if ($originalEnvironment.Contains('GRAPHCODE_VALIDATION_ROOT')) { + [Environment]::SetEnvironmentVariable('GRAPHCODE_VALIDATION_ROOT', $originalEnvironment['GRAPHCODE_VALIDATION_ROOT'], 'Process') + } elseif (Test-Path Env:\GRAPHCODE_VALIDATION_ROOT) { Remove-Item Env:\GRAPHCODE_VALIDATION_ROOT } +} + +$recoveryDefinition = Get-ProductionDefinition 'Wait-UiaUnwiredRecoveryVisibility' +$marker = $source.IndexOf(' $unwiredRecoveryChildren = @(Get-DirectChildren $graph $rawWalker)', [StringComparison]::Ordinal) +Assert-UiContract ($marker -ge 0) 'Recovery visibility region missing' +$regionStart = $source.LastIndexOf(' $graph = Find-FragmentByIdWithRetry $root "graph" $rawWalker', $marker, [StringComparison]::Ordinal) +$regionEnd = $source.IndexOf(' $wireAutomationId =', $marker, [StringComparison]::Ordinal) +Assert-UiContract ($regionStart -ge 0 -and $regionEnd -gt $regionStart) 'Recovery visibility region boundary missing' +$visibilityScene = [scriptblock]::Create($source.Substring($regionStart, $regionEnd - $regionStart)) + +function Require([bool] $condition, [string] $message) { Assert-UiContract $condition $message } +function Start-Sleep { param([int] $Milliseconds) $state.sleeps++ } +function New-RecoveryElement([string] $Kind, [bool] $Initial) { + $identity = switch ($Kind) { wire { 'wire-node-owned' } mark { 'mark-entry-owned' } graph { 'graph' } fit { 'fit-canvas' } } + $label = switch ($Kind) { wire { 'Wire it up' } mark { 'Mark as entry' } graph { 'Graph' } fit { 'Fit canvas' } } + $owner = 4242 + if (($Initial -and $Kind -ceq 'wire' -and $state.name -ceq 'wrong-source-owner') -or + ($Kind -ceq 'fit' -and $state.name -ceq 'wrong-fit-owner') -or + (-not $Initial -and $Kind -ceq 'wire' -and $state.name -ceq 'wrong-owner-after-fit')) { $owner = 7777 } + if (-not $Initial -and $Kind -ceq 'wire' -and $state.name -ceq 'wrong-id-after-fit') { $identity = 'wire-node-foreign' } + $offscreen = ($Kind -ceq 'fit' -and $state.name -ceq 'fit-offscreen') -or + ($Kind -cin @('wire', 'mark') -and + (($Initial -and $state.name -ceq 'initially-offscreen') -or + (-not $Initial -and $state.name -ceq 'remains-offscreen') -or + (-not $Initial -and $state.name -ceq 'delayed-visible' -and $state.reconnects -lt 3))) + $left = if ($Kind -ceq 'mark') { 200 } else { 100 } + $box = [pscustomobject]@{ Left = $left; Top = 100; Right = $left + 80; Bottom = 130; Width = 80; Height = 30; IsEmpty = $false } + if ($Kind -ceq 'graph') { + $box = [pscustomobject]@{ Left = 0; Top = 0; Right = 600; Bottom = 400; Width = 600; Height = 400; IsEmpty = $false } + } elseif ($Kind -cin @('wire', 'mark')) { + $empty = ($Initial -and $state.name -cne 'already-visible' -and $state.name -cne 'outside-before-fit' -and + ($state.name -cne 'mark-only-empty' -or $Kind -ceq 'mark')) -or + (-not $Initial -and $state.name -ceq 'remains-empty') + if ($empty) { $box = [pscustomobject]@{ Left = 0; Top = 0; Right = 0; Bottom = 0; Width = 0; Height = 0; IsEmpty = $true } } + if ($Kind -ceq 'wire' -and (($Initial -and $state.name -ceq 'outside-before-fit') -or + (-not $Initial -and $state.name -ceq 'outside-after-fit'))) { + $box = [pscustomobject]@{ Left = -10; Top = 100; Right = 70; Bottom = 130; Width = 80; Height = 30; IsEmpty = $false } + } + } + $current = [pscustomobject]@{ + AutomationId = $identity; Name = $label; ProcessId = $owner; IsOffscreen = $offscreen + IsEnabled = -not ($Kind -ceq 'fit' -and $state.name -ceq 'fit-disabled') + ControlType = [System.Windows.Automation.ControlType]::Button; Box = $box; State = $state + } + $current | Add-Member ScriptProperty BoundingRectangle { + if ($this.IsOffscreen) { $this.State.offscreenBoundsReads++; throw 'Offscreen recovery bounds were read' } + $this.State.boundsReads++ + return $this.Box + } + $element = [pscustomobject]@{ Current = $current; State = $state; Kind = $Kind } + $element | Add-Member ScriptMethod GetCurrentPattern { + param($pattern) + if ($pattern -ne [System.Windows.Automation.InvokePattern]::Pattern) { throw 'Unexpected recovery pattern' } + if ($this.Kind -ceq 'fit') { return $this.State.fitPattern } + $this.State.patternReads++ + return $this.State.actionPattern + } + return $element +} +function Get-DirectChildren($graph, $walker) { @(New-RecoveryElement wire $true; New-RecoveryElement mark $true) } +function Find-FragmentByIdWithRetry($root, [string] $id, $walker) { + switch -CaseSensitive ($id) { graph { New-RecoveryElement graph $true } fit-canvas { New-RecoveryElement fit $true } + default { throw "Unexpected initial recovery lookup: $id" } } +} +function Wait-ForRootReconnect($process, $walkers) { + Assert-UiContract ($process.Id -eq 4242) 'Foreign recovery root reconnect' + $state.reconnects++ + return $root +} +function Find-FragmentById($root, [string] $id, $walker) { + if ($id -ceq 'graph') { return New-RecoveryElement graph $false } + Assert-UiContract ($id -cin @('wire-node-owned', 'mark-entry-owned')) "Recovery reacquisition requested a different ID: $id" + $state.targetLookups++ + if ($state.name -ceq 'missing-after-fit' -and $id -ceq 'wire-node-owned') { return $null } + return New-RecoveryElement $(if ($id -ceq 'wire-node-owned') { 'wire' } else { 'mark' }) $false +} +$recoveryCases = @( + @{ name = 'initially-empty'; expected = $true; fits = 1; reconnects = 1 }, + @{ name = 'initially-offscreen'; expected = $true; fits = 1; reconnects = 1 }, + @{ name = 'already-visible'; expected = $true; fits = 0; reconnects = 0 }, + @{ name = 'mark-only-empty'; expected = $true; fits = 1; reconnects = 1 }, + @{ name = 'delayed-visible'; expected = $true; fits = 1; reconnects = 3 }, + @{ name = 'outside-before-fit'; expected = $true; fits = 1; reconnects = 1 }, + @{ name = 'wrong-source-owner'; expected = $false; fits = 0; reconnects = 0 }, + @{ name = 'wrong-fit-owner'; expected = $false; fits = 0; reconnects = 0 }, + @{ name = 'fit-offscreen'; expected = $false; fits = 0; reconnects = 0 }, + @{ name = 'fit-disabled'; expected = $false; fits = 0; reconnects = 0 }, + @{ name = 'remains-empty'; expected = $false; fits = 1; reconnects = 40 }, + @{ name = 'remains-offscreen'; expected = $false; fits = 1; reconnects = 40 }, + @{ name = 'wrong-id-after-fit'; expected = $false; fits = 1; reconnects = 40 }, + @{ name = 'wrong-owner-after-fit'; expected = $false; fits = 1; reconnects = 40 }, + @{ name = 'outside-after-fit'; expected = $false; fits = 1; reconnects = 40 }, + @{ name = 'missing-after-fit'; expected = $false; fits = 1; reconnects = 40 } +) +function Confirm-RecoveryCase($Case, [string] $Definition) { + . ([scriptblock]::Create($Definition)) + $state = @{ name = $Case.name; fits = 0; reconnects = 0; targetLookups = 0; patternReads = 0 + boundsReads = 0; offscreenBoundsReads = 0; recoveryInvokes = 0; sleeps = 0 } + $state.fitPattern = [pscustomobject]@{ State = $state } + $state.fitPattern | Add-Member ScriptMethod Invoke { $this.State.fits++ } + $state.actionPattern = [pscustomobject]@{ State = $state } + $state.actionPattern | Add-Member ScriptMethod Invoke { $this.State.recoveryInvokes++; throw 'Visibility contract must not invoke a recovery action' } + $process = [pscustomobject]@{ Id = 4242 } + $root = [pscustomobject]@{ Owner = 4242 } + $rawWalker = [pscustomobject]@{ Mocked = $true } + $failure = $null + try { $null = & $visibilityScene } catch { $failure = $_ } + $passed = ($null -eq $failure) -eq $Case.expected -and $state.fits -eq $Case.fits -and + $state.reconnects -eq $Case.reconnects -and $state.offscreenBoundsReads -eq 0 -and $state.recoveryInvokes -eq 0 -and + $state.patternReads -eq $(if ($Case.expected) { 2 } else { 0 }) -and $state.targetLookups -eq (2 * $Case.reconnects) + if (-not $Case.expected) { + $expectedDiagnostic = if ($Case.name -ceq 'wrong-source-owner') { + 'Unwired recovery source controls did not belong to the owned graph' + } elseif ($Case.fits -eq 0) { 'Unwired recovery could not acquire the owned visible Fit canvas action' } + else { 'Unwired recovery actions remained unavailable after Fit canvas' } + $passed = $passed -and $failure -and $failure.Exception.Message -ceq $expectedDiagnostic + } + return [pscustomobject]@{ + passed = [bool]$passed + name = $Case.name + expectedAccepted = $Case.expected + actualAccepted = ($null -eq $failure) + expectedFits = $Case.fits + expectedReconnects = $Case.reconnects + actual = [ordered]@{ + fits = $state.fits; reconnects = $state.reconnects; targetLookups = $state.targetLookups + patternReads = $state.patternReads; boundsReads = $state.boundsReads + offscreenBoundsReads = $state.offscreenBoundsReads; recoveryInvokes = $state.recoveryInvokes; sleeps = $state.sleeps + } + failure = $(if ($failure) { $failure.Exception.ToString() } else { $null }) + } +} +foreach ($case in $recoveryCases) { + Invoke-UiCase "ui.recovery.visibility.$($case.name)" { + $observation = Confirm-RecoveryCase $case $recoveryDefinition + Assert-UiContract $observation.passed ("Recovery visibility behavior differs: " + + ($observation | ConvertTo-Json -Compress -Depth 6 -EscapeHandling EscapeNonAscii)) + } +} +foreach ($mutant in @( + @{ name = 'skip-fit'; case = 'initially-empty'; old = ').Invoke()'; replacement = ') | Out-Null' }, + @{ name = 'unsafe-offscreen-bounds'; case = 'initially-offscreen'; old = 'if ($control.Current.IsOffscreen) { return $false }'; replacement = 'if ($false) { return $false }' }, + @{ name = 'wrong-identity-accepted'; case = 'wrong-id-after-fit'; old = '$wire.Current.AutomationId -cne $wireId -or'; replacement = '$false -or' }, + @{ name = 'outside-bounds-accepted'; case = 'outside-after-fit'; old = '$bounds.Left -lt $graphBounds.Left -or'; replacement = '$false -or' } +)) { + Invoke-UiCase "ui.recovery.visibility.mutant-$($mutant.name)" { + $case = $recoveryCases | Where-Object name -CEQ $mutant.case + $baseline = Confirm-RecoveryCase $case $recoveryDefinition + Assert-UiContract $baseline.passed ("Recovery mutant requires a valid unmodified baseline: " + + ($baseline | ConvertTo-Json -Compress -Depth 6 -EscapeHandling EscapeNonAscii)) + Assert-UiContract ([regex]::Matches($recoveryDefinition, [regex]::Escape($mutant.old)).Count -eq 1) 'Recovery mutation anchor is not unique' + $definition = $recoveryDefinition.Replace($mutant.old, $mutant.replacement) + $parseErrors = $null + $null = [Management.Automation.Language.Parser]::ParseInput($definition, [ref]$null, [ref]$parseErrors) + Assert-UiContract ($parseErrors.Count -eq 0) 'Recovery mutant parser failure' + $observation = Confirm-RecoveryCase $case $definition + Assert-UiContract (-not $observation.passed) ("Recovery mutant unexpectedly preserved visibility contract: " + + ($observation | ConvertTo-Json -Compress -Depth 6 -EscapeHandling EscapeNonAscii)) + } +} +Assert-UiContract ($results.Count -eq 25) 'PowerShell UI fixture requires five isolation and twenty recovery cases' +[IO.File]::WriteAllText((Join-Path $ArtifactRoot 'ui-powershell-results.json'), + ($results | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false)) +foreach ($result in $results) { Write-Output ('UI_CASE_RESULT=' + ($result | ConvertTo-Json -Compress -Depth 8 -EscapeHandling EscapeNonAscii)) } +if (@($results | Where-Object status -cne 'Passed').Count -ne 0) { exit 1 } diff --git a/Tools/windows/ValidationRunner/Ui/UiRootSourceContracts.ps1 b/Tools/windows/ValidationRunner/Ui/UiRootSourceContracts.ps1 new file mode 100644 index 00000000..8bf8e983 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/UiRootSourceContracts.ps1 @@ -0,0 +1,181 @@ +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) +$ErrorActionPreference = 'Stop' +$uiRoot = Join-Path $RepositoryRoot 'Tools\windows\ValidationRunner\Ui' +$catalogText = [IO.File]::ReadAllText((Join-Path $uiRoot 'source-assertions.json')) +if ($catalogText.Length -gt 1048576) { throw 'UI source assertion data exceeded its size limit' } +$catalog = @($catalogText | ConvertFrom-Json -Depth 100) +if ($catalog.Count -ne 58) { throw 'Root UI declarative source assertion count must remain 58' } +$paths = @{ + gate = 'Tools\windows\uia-live-gate.ps1' + stub = 'Tools\windows\Stub-Daemon.ps1' + validation = 'Tools\windows\validate.ps1' + shellTests = 'Tools\windows\Tests\WindowsShell.Tests.ps1' + hardening = 'Tools\windows\Tests\Hardening.Tests.ps1' + workflow = '.github\workflows\windows-hardening.yml' + designTokens = 'graphcode-windows\src\DesignTokens.zig' + laneLayout = 'graphcode-windows\src\LaneLayout.zig' + graphCanvas = 'graphcode-windows\src\GraphCanvas.zig' + 'gate-state' = 'Tools\windows\ValidationRunner\Ui\Production\GraphCodeUiaGateState.cs' + 'input-safety' = 'Tools\windows\ValidationRunner\Ui\Production\UiaInputSafety.cs' + 'host-info' = 'Tools\windows\ValidationRunner\Ui\Production\GraphCodeUiaHostInfo.cs' +} +$resources = @{} +$linkage = @{ verified = $false } +function Assert-ProductionSourceLinkage { + if ($linkage.verified) { return } + $gateText = [IO.File]::ReadAllText((Join-Path $RepositoryRoot $paths.gate)) + if ($gateText -match '\bclass\s+(GraphCodeUiaGateState|GraphCodeUiaHostInfo|GraphCodeUiaAttribution|GraphCodeMultiProjectRenameNative)\b' -or + -not $gateText.Contains('Import-CompiledValidationAssembly.ps1') -or + -not $gateText.Contains('-AssemblyPath $validationAssemblyPath -RequiredType $requiredType')) { + throw 'UIA production consumer does not load the single compiled production binding' + } + $bindings = $null + foreach ($name in @('GraphCodeUiaGateState', 'GraphCodeUiaHostInfo', 'GraphCodeUiaAttribution', + 'GraphCodeMultiProjectRenameNative', 'GraphCode.Windows.UiaWindowActivation')) { + if (-not $gateText.Contains('"' + $name + '"')) { throw "Production gate does not require the shared type: $name" } + $current = & (Join-Path $RepositoryRoot 'Tools\windows\Import-CompiledValidationAssembly.ps1') ` + -AssemblyPath $AssemblyPath -RequiredType $name + if ($bindings -and -not [object]::ReferenceEquals($current, $bindings)) { throw 'UIA production components do not share one binding assembly' } + $bindings = $current + } + $type = $bindings.GetType('GraphCodeUiaGateState', $true) + $input = $type.GetField('InputSafety', [Reflection.BindingFlags]'NonPublic,Static') + if (-not $input -or $input.FieldType -ne $bindings.GetType('GraphCode.Windows.UiaInputSafety', $true)) { + throw 'Production gate does not use the exact tested shared input component' + } + $project = [IO.File]::ReadAllText((Join-Path $RepositoryRoot 'Tools\windows\ValidationRunner\Bindings\Windows\WindowsValidationBindings.csproj')) + if (-not $project.Contains('Compile Include="..\..\Ui\Production\*.cs"') -or + -not $project.Contains('Compile Include="..\..\..\UiaWindowActivation.cs"')) { + throw 'Production binding does not compile the actual current shared sources' + } + $linkage.verified = $true +} +function Read-SourceResource([string] $Name) { + if (-not $paths.ContainsKey($Name)) { throw "Undeclared UI source resource: $Name" } + if (-not $resources.ContainsKey($Name)) { + if ($Name -cin @('gate-state', 'input-safety', 'host-info')) { Assert-ProductionSourceLinkage } + $text = [IO.File]::ReadAllText((Join-Path $RepositoryRoot $paths[$Name])) + $resources[$Name] = $text + } + return $resources[$Name] +} +function Get-AssertionValue($Node, [int] $Depth = 0) { + if ($Depth -gt 96 -or $null -eq $Node) { throw 'UI source assertion tree is missing or exceeds its depth limit' } + switch -CaseSensitive ($Node.kind) { + literal { return $Node.value } + resource { return Read-SourceResource $Node.name } + not { return -not (Get-AssertionValue $Node.operand ($Depth + 1)) } + contains { + return ([string](Get-AssertionValue $Node.source ($Depth + 1))).Contains([string](Get-AssertionValue $Node.needle ($Depth + 1))) + } + binary { + if ($Node.operator -ceq 'And') { return (Get-AssertionValue $Node.left ($Depth + 1)) -and (Get-AssertionValue $Node.right ($Depth + 1)) } + if ($Node.operator -ceq 'Or') { return (Get-AssertionValue $Node.left ($Depth + 1)) -or (Get-AssertionValue $Node.right ($Depth + 1)) } + $left = Get-AssertionValue $Node.left ($Depth + 1) + $right = Get-AssertionValue $Node.right ($Depth + 1) + switch -CaseSensitive ($Node.operator) { + Imatch { return $left -match $right } + Inotmatch { return $left -notmatch $right } + Cmatch { return $left -cmatch $right } + Cnotmatch { return $left -cnotmatch $right } + Ieq { return $left -eq $right } + Ine { return $left -ne $right } + Ceq { return $left -ceq $right } + Cne { return $left -cne $right } + Igt { return $left -gt $right } + Ige { return $left -ge $right } + Ilt { return $left -lt $right } + Ile { return $left -le $right } + default { throw "Unknown declarative source assertion operator: $($Node.operator)" } + } + } + default { throw "Unknown declarative source assertion kind: $($Node.kind)" } + } +} +$seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) +$results = [Collections.Generic.List[object]]::new() +$linkageFailure = $null +try { Assert-ProductionSourceLinkage } catch { $linkageFailure = $_ } +$results.Add([ordered]@{ + id = 'ui.production.consumer-linkage'; status = $(if ($linkageFailure) { 'Failed' } else { 'Passed' }); executedCount = 1 + evidence = $(if ($linkageFailure) { $linkageFailure.ToString() } else { 'Actual gate imports exact file-backed shared assembly and current ordinary production sources' }) +}) +function Invoke-EvaluatorControl([string] $Name, [scriptblock] $Assertion) { + $failure = $null + try { & $Assertion } catch { $failure = $_ } + $results.Add([ordered]@{ + id = "ui.root.evaluator.$Name"; status = $(if ($failure) { 'Failed' } else { 'Passed' }); executedCount = 1 + evidence = $(if ($failure) { $failure.ToString() } else { 'Bounded source evaluator negative control; nativeCalls=0' }) + }) +} +function Assert-EvaluatorRefusal([scriptblock] $Action) { + $refused = $false + try { $null = & $Action } catch { $refused = $true } + if (-not $refused) { throw 'Malformed source assertion did not fail closed' } +} +Invoke-EvaluatorControl 'unknown-operator' { + Assert-EvaluatorRefusal { Get-AssertionValue @{ kind = 'binary'; operator = 'execute'; left = @{ kind = 'literal'; value = 1 }; right = @{ kind = 'literal'; value = 1 } } } +} +Invoke-EvaluatorControl 'unknown-resource' { + Assert-EvaluatorRefusal { Get-AssertionValue @{ kind = 'resource'; name = 'not-a-production-resource' } } +} +Invoke-EvaluatorControl 'missing-file' { + $paths['missing-control'] = 'Tools\windows\ValidationRunner\.local\absent-source-oracle-control' + try { + Assert-EvaluatorRefusal { Get-AssertionValue @{ kind = 'resource'; name = 'missing-control' } } + } finally { $paths.Remove('missing-control') } +} +Invoke-EvaluatorControl 'malformed-tree' { + Assert-EvaluatorRefusal { Get-AssertionValue @{ kind = 'binary'; operator = 'Imatch'; left = $null; right = @{ kind = 'literal'; value = 'anything' } } } +} +Invoke-EvaluatorControl 'depth-boundary' { + $tree = @{ kind = 'literal'; value = $false } + for ($level = 0; $level -lt 96; $level++) { $tree = @{ kind = 'not'; operand = $tree } } + if (Get-AssertionValue $tree) { throw 'Allowed-depth assertion changed its boolean result' } +} +Invoke-EvaluatorControl 'depth-overflow' { + $tree = @{ kind = 'literal'; value = $false } + for ($level = 0; $level -lt 97; $level++) { $tree = @{ kind = 'not'; operand = $tree } } + Assert-EvaluatorRefusal { Get-AssertionValue $tree } +} +function Test-OnlyGateResource($Node) { + switch -CaseSensitive ($Node.kind) { + resource { return $Node.name -ceq 'gate' } + literal { return $true } + binary { return (Test-OnlyGateResource $Node.left) -and (Test-OnlyGateResource $Node.right) } + not { return Test-OnlyGateResource $Node.operand } + contains { return (Test-OnlyGateResource $Node.source) -and (Test-OnlyGateResource $Node.needle) } + default { throw 'Unexpected node in evaluator control' } + } +} +$control = @($catalog | Where-Object { Test-OnlyGateResource $_.condition })[0] +Invoke-EvaluatorControl 'actual-predicate-false' { + if ($null -eq $control -or (Get-AssertionValue $control.condition)) { throw 'Actual original production predicate did not evaluate false' } +} +Invoke-EvaluatorControl 'wrong-production-text' { + $actual = Read-SourceResource 'gate' + try { + $resources['gate'] = 'deliberately wrong production source' + if (-not (Get-AssertionValue $control.condition)) { throw 'Source assertion accepted deliberately wrong production text' } + } finally { $resources['gate'] = $actual } +} +foreach ($test in $catalog) { + if (-not $seen.Add($test.id)) { throw "Duplicate source assertion ID: $($test.id)" } + $failure = $null + try { + if (Get-AssertionValue $test.condition) { throw [InvalidOperationException]::new($test.diagnostic) } + } catch { $failure = $_ } + $results.Add([ordered]@{ + id = $test.id; status = $(if ($failure) { 'Failed' } else { 'Passed' }); executedCount = 1 + evidence = $(if ($failure) { $failure.ToString() } else { "Actual production source; original PS operators; source lines $($test.sourceLines); nativeCalls=0" }) + }) +} +$null = [IO.Directory]::CreateDirectory($ArtifactRoot) +[IO.File]::WriteAllText((Join-Path $ArtifactRoot 'ui-root-source-results.json'), + ($results | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false)) +foreach ($result in $results) { Write-Output ('UI_CASE_RESULT=' + ($result | ConvertTo-Json -Compress -Depth 8)) } +if (@($results | Where-Object status -cne 'Passed').Count -ne 0) { exit 1 } diff --git a/Tools/windows/ValidationRunner/Ui/UiSourceContracts.ps1 b/Tools/windows/ValidationRunner/Ui/UiSourceContracts.ps1 new file mode 100644 index 00000000..41a907b3 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/UiSourceContracts.ps1 @@ -0,0 +1,137 @@ +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) +$ErrorActionPreference = 'Stop' +$uiRoot = Join-Path $RepositoryRoot 'Tools\windows\ValidationRunner\Ui' +$activation = [IO.File]::ReadAllText((Join-Path $RepositoryRoot 'Tools\windows\UiaWindowActivation.cs')) +$gate = [IO.File]::ReadAllText((Join-Path $uiRoot 'Production\GraphCodeUiaGateState.cs')) +$inputSource = [IO.File]::ReadAllText((Join-Path $uiRoot 'Production\UiaInputSafety.cs')) +$results = [Collections.Generic.List[object]]::new() + +function Assert-UiContract([bool] $Condition, [string] $Message) { + if (-not $Condition) { throw [InvalidOperationException]::new($Message) } +} +function Invoke-SourceCase([string] $Id, [scriptblock] $Assertion) { + $failure = $null + try { & $Assertion } catch { $failure = $_ } + $results.Add([ordered]@{ + id = $Id; status = $(if ($failure) { 'Failed' } else { 'Passed' }); executedCount = 1 + evidence = $(if ($failure) { $failure.ToString() } else { 'Actual ordinary build-time production source; nativeCalls=0' }) + }) +} +function Read-CSharpMethod([string] $Source, [string] $Signature) { + $start = $Source.IndexOf($Signature, [StringComparison]::Ordinal) + if ($start -lt 0) { throw "Production method missing: $Signature" } + $brace = $Source.IndexOf('{', $start) + $depth = 1 + $end = $brace + 1 + while ($end -lt $Source.Length -and $depth -gt 0) { + if ($Source[$end] -eq '{') { $depth++ } + if ($Source[$end] -eq '}') { $depth-- } + $end++ + } + if ($depth -ne 0) { throw "Production method has no closing brace: $Signature" } + return $Source.Substring($start, $end - $start) +} +function Confirm-ActivationSource([string] $Gate, [string] $Component) { + $gateText = [regex]::Replace($Gate, '\s+', ' ') + $componentText = [regex]::Replace($Component, '\s+', ' ') + $requiredComponent = @( + 'public interface IUiaWindowActivationOperations', + 'uint expectedProcessId = operations.WindowProcessId(window);', + 'if (window == IntPtr.Zero || !operations.IsRetainedProcessAlive(expectedProcessId))', + 'operations.ShowWindow(window, 9);', + 'operations.IsForegroundWindow(window) && operations.WindowProcessId(window) == expectedProcessId && operations.IsRetainedProcessAlive(expectedProcessId)', + 'broughtToTop.Succeeded, broughtToTop.Succeeded ? 0 : broughtToTop.Error', + 'foregroundSet.Succeeded, foregroundSet.Succeeded ? 0 : foregroundSet.Error, observed', + 'observedOwnedForeground={4}; no synthetic keys or input-queue attachment' + ) + foreach ($needle in $requiredComponent) { if (-not $componentText.Contains($needle)) { return $false } } + if ($componentText -match '\b(DllImport|LibraryImport|SendInput|keybd_event|AttachThreadInput|Marshal)\b') { return $false } + foreach ($needle in @( + 'private sealed class WindowActivationOperations : GraphCode.Windows.IUiaWindowActivationOperations', + 'return GraphCodeUiaGateState.WindowProcessId(window);', + 'return GraphCodeUiaGateState.IsRetainedProcessAlive(processId);', + 'GraphCodeUiaGateState.ShowWindow(window, command);', + 'return GraphCodeUiaGateState.IsForegroundWindow(window);', + 'bool succeeded = GraphCodeUiaGateState.BringWindowToTop(window); int error = Marshal.GetLastWin32Error(); return new GraphCode.Windows.UiaActivationCallResult(succeeded, error);', + 'bool succeeded = GraphCodeUiaGateState.SetForegroundWindow(window); int error = Marshal.GetLastWin32Error(); return new GraphCode.Windows.UiaActivationCallResult(succeeded, error);', + 'return GraphCode.Windows.UiaWindowActivation.ActivateWindow( ActivationOperations, window, out LastActivationDiagnostic);' + )) { if (-not $gateText.Contains($needle)) { return $false } } + return $true +} +$activationCases = @( + @{ name = 'actual'; gate = $gate; component = $activation; expected = $true }, + @{ name = 'unshared'; gate = $gate.Replace('GraphCode.Windows.UiaWindowActivation.ActivateWindow(', 'UnsharedActivation('); component = $activation; expected = $false }, + @{ name = 'missing-initial-owner'; gate = $gate; component = $activation.Replace('!operations.IsRetainedProcessAlive(expectedProcessId)', 'false'); expected = $false }, + @{ name = 'missing-fresh-owner'; gate = $gate; component = $activation.Replace('operations.IsRetainedProcessAlive(expectedProcessId);', 'true;'); expected = $false }, + @{ name = 'missing-restore'; gate = $gate; component = $activation.Replace('operations.ShowWindow(window, 9);', ''); expected = $false }, + @{ name = 'missing-native-error'; gate = $gate.Replace('int error = Marshal.GetLastWin32Error();', 'int error = 0;'); component = $activation; expected = $false } +) +foreach ($case in $activationCases) { + Invoke-SourceCase "ui.activation.source.$($case.name)" { + Assert-UiContract ((Confirm-ActivationSource $case.gate $case.component) -eq $case.expected) ` + 'UIA activation source contract accepted an unshared, unowned or undiagnosed activation mutant' + } +} + +function Confirm-PopupSource([string] $Source) { + try { $click = Read-CSharpMethod $Source 'public PopupItemHit ClickPopupMenuItem(' } catch { return $false } + if ([regex]::Matches($click, '\boperations\.SendInput\(').Count -ne 1 -or + [regex]::Matches($click, 'RequireOwnedPopupForeground\(popup, owner, expectedProcessId\);').Count -ne 2 -or + -not $click.Contains('int commandId, uint expectedProcessId)') -or + $click -match '\b(SetForegroundWindow|SetFocus|BringWindowToTop)\(') { return $false } + $offset = 0 + foreach ($needle in @( + 'if (!operations.GetCursorPos(out before))', + 'RequireOwnedPopupForeground(popup, owner, expectedProcessId);', + 'if (!operations.SetCursorPos(screenX, screenY))', + 'var inputs = new[]', + 'ScreenPoint cursorBeforeInput;', + 'if (!operations.GetCursorPos(out cursorBeforeInput) || cursorBeforeInput.X != screenX || cursorBeforeInput.Y != screenY)', + 'throw new InvalidOperationException("Popup input cursor changed; no mouse input was injected");', + 'uint inputOwnerProcessId = operations.WindowProcessId(owner);', + 'if (inputOwnerProcessId != expectedProcessId || !operations.IsRetainedProcessAlive(expectedProcessId))', + 'IntPtr foregroundBeforeInput = operations.GetForegroundWindow();', + 'IntPtr inputPointWindow = operations.WindowFromPoint(cursorBeforeInput);', + 'if (!IsOwnedPopupInput(', + 'operations.WindowProcessId(foregroundBeforeInput), operations.GetAncestor(foregroundBeforeInput, 2),', + 'operations.WindowProcessId(inputPointWindow), operations.GetAncestor(inputPointWindow, 2), operations.IsWindowVisible(popup)))', + 'throw new InvalidOperationException("Popup input ownership changed; no mouse input was injected");', + 'uint sent = operations.SendInput((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(Input)));', + 'RequireOwnedPopupForeground(popup, owner, expectedProcessId);', + 'keyboardFallback =', + 'operations.PostMessage(popup, 0x0100, (UIntPtr)0x0D, IntPtr.Zero)' + )) { + $next = $click.IndexOf($needle, $offset, [StringComparison]::Ordinal) + if ($next -lt 0) { return $false } + $offset = $next + $needle.Length + } + return $true +} +$popupCases = @( + @{ name = 'actual'; source = $inputSource; expected = $true }, + @{ name = 'bypass-guard'; source = $inputSource.Replace('if (!IsOwnedPopupInput(', 'if (false && !IsOwnedPopupInput('); expected = $false }, + @{ name = 'stale-foreground'; source = $inputSource.Replace('IntPtr foregroundBeforeInput = operations.GetForegroundWindow();', 'IntPtr foregroundBeforeInput = owner;'); expected = $false }, + @{ name = 'stale-cursor'; source = $inputSource.Replace('operations.WindowFromPoint(cursorBeforeInput)', 'operations.WindowFromPoint(new ScreenPoint { X = screenX, Y = screenY })'); expected = $false }, + @{ name = 'missing-held-owner-check'; source = $inputSource.Replace('if (inputOwnerProcessId != expectedProcessId || !operations.IsRetainedProcessAlive(expectedProcessId))', 'if (false)'); expected = $false }, + @{ name = 'missing-pre-cursor-guard'; source = [regex]::Replace($inputSource, '(?m)^ RequireOwnedPopupForeground\(popup, owner, expectedProcessId\);', ' ;'); expected = $false }, + @{ name = 'missing-pre-enter-guard'; source = $inputSource.Replace(' RequireOwnedPopupForeground(popup, owner, expectedProcessId);', ' ;'); expected = $false }, + @{ name = 'wrong-hit-root'; source = $inputSource.Replace('operations.GetAncestor(inputPointWindow, 2)', 'operations.GetAncestor(owner, 2)'); expected = $false }, + @{ name = 'extra-input'; source = $inputSource.Replace('uint sent = operations.SendInput((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(Input)));', 'operations.SendInput((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(Input))); uint sent = operations.SendInput((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(Input)));'); expected = $false } +) +foreach ($case in $popupCases) { + Invoke-SourceCase "ui.popup.source.$($case.name)" { + Assert-UiContract ((Confirm-PopupSource $case.source) -eq $case.expected) ` + "Popup input safety contract: $($case.name) must fail closed before real input on foreign foreground or hit point" + } +} +Assert-UiContract ($activationCases.Count -eq 6 -and $popupCases.Count -eq 9 -and $results.Count -eq 15) ` + 'Source guard case count changed' +$null = [IO.Directory]::CreateDirectory($ArtifactRoot) +$report = Join-Path $ArtifactRoot 'ui-source-results.json' +[IO.File]::WriteAllText($report, ($results | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false)) +foreach ($result in $results) { Write-Output ('UI_CASE_RESULT=' + ($result | ConvertTo-Json -Compress -Depth 8)) } +if (@($results | Where-Object status -cne 'Passed').Count -ne 0) { exit 1 } diff --git a/Tools/windows/ValidationRunner/Ui/derived-source-correspondence.json b/Tools/windows/ValidationRunner/Ui/derived-source-correspondence.json new file mode 100644 index 00000000..876cbf8b --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/derived-source-correspondence.json @@ -0,0 +1,749 @@ +[ + { + "id": "ui.root.source.line-5346", + "sourceLines": "5346-5348", + "diagnostic": "RED: shell validation does not exercise delayed correlated responses", + "owner": "Host", + "state": "Incomplete", + "adapterRationale": "Not a declared source resource: source" + }, + { + "id": "ui.root.source.line-5366", + "sourceLines": "5366-5368", + "diagnostic": "RED: UIA live gate source does not parse", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-5380", + "sourceLines": "5380-5382", + "diagnostic": "RED: multi-project observer lacks a focused source-supported chrome classifier", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: null", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-5392", + "sourceLines": "5392-5394", + "diagnostic": "RED: multi-project observer rejects stable recovery control $identity", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: CommandAst", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-5396", + "sourceLines": "5396-5398", + "diagnostic": "RED: multi-project observer weakens its census by accepting an unknown element", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: CommandAst", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-5399", + "sourceLines": "5399-5401", + "diagnostic": "RED: multi-project observer accepts a foreign recovery control", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: CommandAst", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-5407", + "sourceLines": "5407-5409", + "diagnostic": "RED: multi-project observer lacks a bounded workspace-marker reacquire", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: null", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-5410", + "sourceLines": "5410-5413", + "diagnostic": "RED: workspace-marker reacquire replays navigation or input", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-5432", + "sourceLines": "5432-5435", + "diagnostic": "RED: delayed exact workspace markers do not satisfy the bounded reacquire", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-5449", + "sourceLines": "5449-5451", + "diagnostic": "RED: permanent or unknown workspace-marker absence no longer fails closed", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: permanentFailure", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-5457", + "sourceLines": "5457-5459", + "diagnostic": "RED: multi-project loop activation lacks a recovery-control-safe card target", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: null", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-5462", + "sourceLines": "5462-5464", + "diagnostic": "RED: card activation target does not retain the source-sized title strip", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Unsupported assertion operator: Join", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-5470", + "sourceLines": "5470-5472", + "diagnostic": "RED: multi-project card activation still lands on an unwired recovery control", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: overlaps", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-5477", + "sourceLines": "5477-5479", + "diagnostic": "RED: invalid multi-project card activation bounds no longer fail closed", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: invalidActivation", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-5485", + "sourceLines": "5485-5488", + "diagnostic": "RED: live multi-project loop navigation still clicks the whole card rectangle", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: null", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-5501", + "sourceLines": "5501-5503", + "diagnostic": "RED: lane caption gate does not bind the source-derived overview-project identity", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: laneCaptionStart", + "implementation": "UiDerivedContracts.ps1" + }, + { + "id": "ui.root.source.line-5514", + "sourceLines": "5514-5516", + "diagnostic": "RED: lane caption gate lacks the no-summary caption/open-project contract: $required", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: laneCaptionSource", + "implementation": "UiDerivedContracts.ps1" + }, + { + "id": "ui.root.source.line-5524", + "sourceLines": "5524-5526", + "diagnostic": "RED: lane caption gate retains obsolete summary/button contract: $obsolete", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: laneCaptionSource", + "implementation": "UiDerivedContracts.ps1" + }, + { + "id": "ui.root.source.line-5537", + "sourceLines": "5537-5544", + "diagnostic": "RED: UIA prelaunch diagnostics omit process, desktop heap, window station, or session evidence", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: prelaunchDiagnostic", + "implementation": "UiDerivedContracts.ps1" + }, + { + "id": "ui.root.source.line-5553", + "sourceLines": "5553-5555", + "diagnostic": "RED: UIA live gate no longer parses after startup diagnostic changes", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-5561", + "sourceLines": "5561-5563", + "diagnostic": "RED: UIA live gate is missing its functional sidebar navigation snapshot oracle", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: null", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-5569", + "sourceLines": "5569-5571", + "diagnostic": "RED: UIA live gate is missing its bounded sidebar scroll/reacquire proof", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: null", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-5580", + "sourceLines": "5580-5583", + "diagnostic": "RED: UIA live gate retains mutable sidebar handles instead of freezing each reacquired capture", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: null", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-5632", + "sourceLines": "5632-5641", + "diagnostic": "RED: Sidebar navigation oracle missed destination order, open owners, section names, or contained controls", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Unsupported assertion operator: Join", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-5659", + "sourceLines": "5659-5662", + "diagnostic": "RED: Sidebar capture retained mutable UIA handles instead of point-in-time evidence", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-5683", + "sourceLines": "5683-5687", + "diagnostic": "RED: Sidebar bounds proof rejected exact DPI192 identities observed across bounded viewport captures", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-5696", + "sourceLines": "5696-5698", + "diagnostic": "RED: Sidebar bounds proof accepted a missing owned row", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-5713", + "sourceLines": "5713-5715", + "diagnostic": "RED: Sidebar bounds proof accepted a foreign-process row", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-5724", + "sourceLines": "5724-5726", + "diagnostic": "RED: Sidebar bounds proof accepted a duplicate owned row", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-5739", + "sourceLines": "5739-5741", + "diagnostic": "RED: Sidebar bounds proof accepted wrong open-owner order", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-5752", + "sourceLines": "5752-5754", + "diagnostic": "RED: Sidebar navigation oracle did not isolate local owner collapse", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Unsupported assertion operator: Join", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-5761", + "sourceLines": "5761-5763", + "diagnostic": "RED: Sidebar navigation oracle did not isolate remote owner collapse", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Unsupported assertion operator: Join", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-5773", + "sourceLines": "5773-5777", + "diagnostic": "RED: Sidebar navigation oracle accepted a temporary remote owner after fixture restore", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Unsupported assertion operator: Join", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-5783", + "sourceLines": "5783-5785", + "diagnostic": "RED: Sidebar navigation oracle failed to detect a projected unopened recent folder", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-5792", + "sourceLines": "5792-5794", + "diagnostic": "RED: Sidebar navigation oracle accepted a row control extending beyond its owner", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-5807", + "sourceLines": "5807-5809", + "diagnostic": "RED: Sidebar navigation oracle failed to preserve the owner order evidence", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Unsupported assertion operator: Join", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-5812", + "sourceLines": "5812-5814", + "diagnostic": "RED: Sidebar navigation fixture execution count changed unexpectedly", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: sidebarFixtureCount", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-5838", + "sourceLines": "5838-5838", + "diagnostic": "RED: UIA startup capture helper is missing: $helperName", + "owner": "Ownership", + "state": "Incomplete", + "adapterRationale": "Not a declared source resource: null" + }, + { + "id": "ui.root.source.line-5845", + "sourceLines": "5845-5850", + "diagnostic": "RED: UIA startup capture does not retain bounded, redacted child output", + "owner": "Ownership", + "state": "Incomplete", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst" + }, + { + "id": "ui.root.source.line-5853", + "sourceLines": "5853-5856", + "diagnostic": "RED: UIA startup capture does not bound oversized diagnostics", + "owner": "Ownership", + "state": "Incomplete", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst" + }, + { + "id": "ui.root.source.line-5859", + "sourceLines": "5859-5861", + "diagnostic": "RED: UIA startup capture does not distinguish a missing child log", + "owner": "Ownership", + "state": "Incomplete", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst" + }, + { + "id": "ui.root.source.line-5882", + "sourceLines": "5882-5888", + "diagnostic": "RED: retained UIA startup report omits or exposes captured child and app output", + "owner": "Ownership", + "state": "Incomplete", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst" + }, + { + "id": "ui.root.source.line-5897", + "sourceLines": "5897-5899", + "diagnostic": "RED: UIA host context native diagnostics type is missing", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: hostInfoClassAt", + "implementation": "UiDerivedContracts.ps1" + }, + { + "id": "ui.root.source.line-5905", + "sourceLines": "5905-5908", + "diagnostic": "RED: UIA host context native diagnostics type is missing", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: hostInfoAddTypeAt", + "implementation": "UiDerivedContracts.ps1" + }, + { + "id": "ui.root.source.line-5914", + "sourceLines": "5914-5916", + "diagnostic": "RED: UIA host context did not resolve the current Windows session", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Unsupported assertion operator: IsNot", + "implementation": "UiDerivedContracts.ps1" + }, + { + "id": "ui.root.source.line-5918", + "sourceLines": "5918-5922", + "diagnostic": "RED: UIA host context diagnostics omitted explicit session or desktop-heap status", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst", + "implementation": "UiDerivedContracts.ps1" + }, + { + "id": "ui.root.source.line-5945", + "sourceLines": "5945-5945", + "diagnostic": "RED: UIA owned-process traversal missed a controlled child", + "owner": "Ownership", + "state": "Incomplete", + "adapterRationale": "Not a declared source resource: treeObserved" + }, + { + "id": "ui.root.source.line-5947", + "sourceLines": "5947-5949", + "diagnostic": "RED: UIA owned-process teardown returned before the controlled root exited", + "owner": "Ownership", + "state": "Incomplete", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst" + }, + { + "id": "ui.root.source.line-5970", + "sourceLines": "5970-5972", + "diagnostic": "RED: UIA owned-process traversal adopts processes older than a reused parent PID: $($pidReuseAdopted -join ',')", + "owner": "Ownership", + "state": "Incomplete", + "adapterRationale": "Unsupported assertion operator: Join" + }, + { + "id": "ui.root.source.line-6057", + "sourceLines": "6057-6061", + "diagnostic": "RED: UIA ingress commands and scene transitions are not bounded and attributable", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: CommandAst", + "implementation": "UiDerivedContracts.ps1" + }, + { + "id": "ui.root.source.line-6093", + "sourceLines": "6093-6096", + "diagnostic": "RED: UIA owned-sheet acquisition throws on stale handles instead of retrying exact identity", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: CommandAst", + "implementation": "UiDerivedContracts.ps1" + }, + { + "id": "ui.root.source.line-6157", + "sourceLines": "6157-6164", + "diagnostic": "RED: UIA Delete Loop invocation does not re-establish the stable fixture selection after Rename", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: CommandAst", + "implementation": "UiDerivedContracts.ps1" + }, + { + "id": "ui.root.source.line-6182", + "sourceLines": "6182-6198", + "diagnostic": "RED: UIA gate does not verify dynamic Rename, custom Delete, or Edit Details sheet behavior", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Unsupported source observation", + "implementation": "UiDerivedContracts.ps1" + }, + { + "id": "ui.root.source.line-6276", + "sourceLines": "6276-6276", + "diagnostic": "RED: multi-project live evidence lacks $required", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: required", + "implementation": "UiDerivedContracts.ps1" + }, + { + "id": "ui.root.source.line-6292", + "sourceLines": "6292-6292", + "diagnostic": "RED: unfiltered multi-project observer roster lacks $required", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: required", + "implementation": "UiDerivedContracts.ps1" + }, + { + "id": "ui.root.source.line-6298", + "sourceLines": "6298-6298", + "diagnostic": "RED: explicit complete overview taxonomy lacks $required", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: required", + "implementation": "UiDerivedContracts.ps1" + }, + { + "id": "ui.root.source.line-6305", + "sourceLines": "6305-6307", + "diagnostic": "RED: multi-project fitted card geometry lacks denominator-derived rounding budget: $required", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: required", + "implementation": "UiDerivedContracts.ps1" + }, + { + "id": "ui.root.source.line-6314", + "sourceLines": "6314-6321", + "diagnostic": "RED: new observer prefix/typed metadata/active-surface selection boundary remains unguarded or silently omitted", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: metadataObserver", + "implementation": "UiDerivedContracts.ps1" + }, + { + "id": "ui.root.source.line-6326", + "sourceLines": "6326-6326", + "diagnostic": "RED: bounded multi-project capture lacks $required", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: required", + "implementation": "UiDerivedContracts.ps1" + }, + { + "id": "ui.root.source.line-6352", + "sourceLines": "6352-6354", + "diagnostic": "RED: UIA foreground failure hides native return values and last-error diagnostics", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: CommandAst", + "implementation": "UiDerivedContracts.ps1" + }, + { + "id": "ui.root.source.line-6412", + "sourceLines": "6412-6416", + "diagnostic": "RED: UIA live gate does not prove immediate Product Settings persistence, foreground focus, Return retention, and Escape close", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: CommandAst", + "implementation": "UiDerivedContracts.ps1" + }, + { + "id": "ui.root.source.line-6467", + "sourceLines": "6467-6471", + "diagnostic": "RED: the shell cannot open a gate-requested context menu, or an abandoned popup can block its message loop forever", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: appSource", + "implementation": "UiDerivedContracts.ps1" + }, + { + "id": "ui.root.source.line-6591", + "sourceLines": "6591-6596", + "diagnostic": "RED: retained New Loop request history cannot select the create after a later outcome command", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst", + "implementation": "UiDerivedContracts.ps1" + }, + { + "id": "ui.root.source.line-6654", + "sourceLines": "6654-6676", + "diagnostic": "RED: custody reopened path must select Turn once, type instruction, change backend and prove unchanged instruction", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: firstCustodyOpen", + "implementation": "UiDerivedContracts.ps1" + }, + { + "id": "ui.root.source.line-6754", + "sourceLines": "6754-6756", + "diagnostic": "RED: UIA gate embedded C# type source is missing", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst", + "implementation": "UiDerivedContracts.ps1" + }, + { + "id": "ui.root.source.line-6770", + "sourceLines": "6770-6772", + "diagnostic": "RED: UIA gate calls undefined embedded C# member(s): $($uiaGateMissingMembers -join ', ')", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst", + "implementation": "UiDerivedContracts.ps1" + }, + { + "id": "ui.root.source.line-6853", + "sourceLines": "6853-6855", + "diagnostic": "RED: stable-but-wrong native text has no extracted retry-decision helper", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: edgeTextHelperAst", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-6864", + "sourceLines": "6864-6864", + "diagnostic": "Unexpected edge text retry decision '$decision", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: decision", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-6866", + "sourceLines": "6866-6868", + "diagnostic": "RED: stable empty text did not retry exactly once before the expected second attempt", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: edgeTextAttempts", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-6870", + "sourceLines": "6870-6872", + "diagnostic": "RED: edge retry-decision helper lost exact expected-text verification", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: edgeTextHelperSource", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-6877", + "sourceLines": "6877-6879", + "diagnostic": "RED: exact-match mutation negative did not find the expected helper branch", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: mutatedEdgeTextHelperSource", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-6892", + "sourceLines": "6892-6894", + "diagnostic": "RED: removing exact expected-text comparison evaded the stable-wrong retry regression", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: mutationAttempts", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-6905", + "sourceLines": "6905-6908", + "diagnostic": "RED: stable-wrong edge text did not exhaust and report exactly five attempts; calls=$edgeTextAttempts error=$($edgeTextExhaustion.Exception.Message)", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: edgeTextAttempts", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-6921", + "sourceLines": "6921-6923", + "diagnostic": "RED: custody graph-card wait helper is not extractable", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: custodyCardWaitAst", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-6925", + "sourceLines": "6925-6933", + "diagnostic": "RED: custody card wait lacks a positive baseline, unique UUID-bound observation, or action-free polling", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: custodyCardWaitSource", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-6939", + "sourceLines": "6939-6941", + "diagnostic": "RED: source-derived sketch-card AutomationId helper is missing", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: sketchCardIdAst", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-6958", + "sourceLines": "6958-6960", + "diagnostic": "RED: source-derived UIA card identity differs for $($expectedCard.NodeId)", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: CommandAst", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-6967", + "sourceLines": "6967-6973", + "diagnostic": "RED: custody right-click can bypass fresh-fragment, unique-card, or hashed child-identity checks", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: openSketchMenuAst", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-7027", + "sourceLines": "7027-7041", + "diagnostic": "RED: connector evidence does not freeze exact physical-screen endpoints and clamp the target to its visible card intersection", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: compositeEvidenceIndex", + "implementation": "UiDerivedContracts.ps1" + }, + { + "id": "ui.root.source.line-7136", + "sourceLines": "7136-7138", + "diagnostic": "RED: connector-card selector is not extractable for named functional cases", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: connectorCardSelectorAst", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-7235", + "sourceLines": "7235-7239", + "diagnostic": "RED: edge connector functional case '$($case.Name)' failed: $($selectionError.Exception.Message)", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: null", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-7326", + "sourceLines": "7326-7328", + "diagnostic": "RED: NativeForms.zig node_labels table not found for node creation sheet label contract", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: nativeFormsSource", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-7337", + "sourceLines": "7337-7339", + "diagnostic": "RED: UIA live gate node creation sheet label map is missing", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: labelHashAst", + "implementation": "UiFunctionalContracts.ps1" + }, + { + "id": "ui.root.source.line-7344", + "sourceLines": "7344-7346", + "diagnostic": "RED: UIA live gate node creation sheet label lookup for $labelId returned '$actualLabel', expected '$expectedLabel' from NativeForms.zig", + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Unsupported source observation", + "implementation": "UiFunctionalContracts.ps1" + } +] diff --git a/Tools/windows/ValidationRunner/Ui/source-assertions.json b/Tools/windows/ValidationRunner/Ui/source-assertions.json new file mode 100644 index 00000000..6fe6d17e --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/source-assertions.json @@ -0,0 +1,9176 @@ +[ + { + "id": "ui.root.source.line-5342", + "sourceLines": "5342-5344", + "diagnostic": "RED: terminal validation does not run provider pin no-divergence contracts", + "condition": { + "right": { + "kind": "literal", + "value": "(?s)\"terminal-gate\" \\{\\s*& .*?ProviderPins\\.Tests\\.ps1.*?TerminalGate\\.Tests\\.ps1" + }, + "left": { + "kind": "resource", + "name": "validation" + }, + "kind": "binary", + "operator": "Inotmatch" + } + }, + { + "id": "ui.root.source.line-5350", + "sourceLines": "5350-5352", + "diagnostic": "RED: Windows shell validation does not execute the UI Automation live gate", + "condition": { + "right": { + "kind": "literal", + "value": "(?s)Pinned GraphCode Windows shell build and smoke.*?Native UI Automation live gate.*?uia-live-gate\\.ps1" + }, + "left": { + "kind": "resource", + "name": "validation" + }, + "kind": "binary", + "operator": "Inotmatch" + } + }, + { + "id": "ui.root.source.line-5369", + "sourceLines": "5369-5374", + "diagnostic": "RED: UIA live gate does not exercise and hash both exact cycle-card hierarchy scenes", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_CYCLE_HIERARCHY_SHA256=" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_CYCLE_HIERARCHY_EVIDENCE=" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "PostFixtureMutation\\(\\$shellWindow, 23\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "\\[switch\\]\\$CycleHierarchyOnly" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-5528", + "sourceLines": "5528-5534", + "diagnostic": "RED: UIA startup failure does not capture both child streams, app log, and prelaunch host diagnostics", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "startup-failure\\.json" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "prelaunch-diagnostics\\.json" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Get-UiaPrelaunchDiagnostics" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "RedirectStandardOutput" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "function Get-UiaStartupFileDiagnostic" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-5545", + "sourceLines": "5545-5548", + "diagnostic": "RED: UIA teardown does not enumerate, reap, and verify all owned descendants", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_PROCESS_TREE_CLEANUP=verified" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "function Get-UiaOwnedProcessDescendants" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-5572", + "sourceLines": "5572-5574", + "diagnostic": "RED: UIA live gate does not scroll, reacquire, and prove each exact sidebar row in a bounded sequence", + "condition": { + "right": { + "kind": "literal", + "value": "(?s)\\$sidebarScrollTargets = @\\(.*?ScrollIntoView\\(\\).*?Wait-ForRootReconnect.*?Get-SidebarNavigationBoundsProof.*?UIA_SIDEBAR_BOUNDS_EVIDENCE=" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + } + }, + { + "id": "ui.root.source.line-5584", + "sourceLines": "5584-5586", + "diagnostic": "RED: UIA live gate does not bound the later Needs-you row proof and restore the sidebar viewport", + "condition": { + "right": { + "kind": "literal", + "value": "(?s)\\$needsYouIds = .*?ScrollIntoView\\(\\).*?Needs-you row remained offscreen after bounded scroll/reacquire.*?UIA_SIDEBAR_SCROLL_RESTORED=overview-destination" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + } + }, + { + "id": "ui.root.source.line-5973", + "sourceLines": "5973-5978", + "diagnostic": "RED: UIA gate does not identify visible shell HWND, background root access, missing modal and short TEMP remedy", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "Require \\(\\[GraphCodeUiaGateState\\]::WindowIsVisible\\(\\$shellWindow\\)\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "maxSandboxRootUtf16" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_UPDATE_DIALOG_DIAGNOSTICS" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "UIA_ROOT_ACCESS" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-5979", + "sourceLines": "5979-5986", + "diagnostic": "RED: UIA gate does not use and verify the shared owned update sheet when desktop-tree lookup omits it", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "\\[System\\.Windows\\.Automation\\.AutomationElement\\]::NameProperty, \"GraphCode Update Available\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\[System\\.Windows\\.Automation\\.AutomationElement\\]::NameProperty, \"An update is available\\.\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Get-UiaOwnedSheetIdentity \\$updateDialog" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Wait-UiaOwnedSheetByContentClass \\$process\\.Id \\$shellWindow" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "FromHandle\\(\\$nativeUpdateWindow\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "UIA_UPDATE_DIALOG_DIRECT" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-5987", + "sourceLines": "5987-6000", + "diagnostic": "RED: UIA update-banner click does not use the owned published bounds and source-defined Later contract", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "\\$updateBannerViewportBottom|\\$updateBannerTop|\\$updateBannerBottom" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$graph = Find-FragmentById \\$root \"graph\" \\$rawWalker" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$loops = Find-FragmentById \\$root \"loops\" \\$rawWalker" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$projects = Find-FragmentById \\$root \"projects\" \\$rawWalker" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "RawView after update banner root" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$status\\.Current\\.Name -ceq \"Update offer deferred\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Wait-UiaOwnedSheetGoneByContentClass \\$process\\.Id \\$shellWindow \"GraphCodeUpdateOffer\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_UPDATE_BANNER_CLICK_EVIDENCE=" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "ScreenToClientPoint\\(\\s*\\$shellWindow, \\$updateBannerScreenX, \\$updateBannerScreenY" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Test-UpdateBannerClickTarget \\$updateBannerTarget \\$process\\.Id \\$shellWindow" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$updateBanner\\.Current\\.BoundingRectangle" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "function Test-UpdateBannerClickTarget" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6062", + "sourceLines": "6062-6065", + "diagnostic": "RED: UIA native-form checks still depend on desktop Window control discovery instead of the owned captionless sheet", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)Wait-UiaOwnedSheetGoneByContentClass \\$process\\.Id \\$shellWindow\\s*`\\s*[\\r\\n]+\\s*\"GraphCodeNativeForm\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "(?s)\\$nativeFormWindow = Wait-UiaOwnedSheetByContentClass \\$process\\.Id \\$shellWindow\\s*`\\s*[\\r\\n]+\\s*\"GraphCodeNativeForm\" \\$nativeForm\\.Title" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6097", + "sourceLines": "6097-6099", + "diagnostic": "RED: UIA sheet-close waits return before modal teardown re-enables the owner", + "condition": { + "right": { + "kind": "literal", + "value": "(?s)function Wait-UiaOwnedSheetGoneByContentClass\\(.*?\\$sheetGone = \\[GraphCodeUiaGateState\\]::FindVisibleOwnedSheetByContentClass\\(.*?if \\(\\$sheetGone -and \\[GraphCodeUiaGateState\\]::WindowIsEnabled\\(\\$owner\\)\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + } + }, + { + "id": "ui.root.source.line-6100", + "sourceLines": "6100-6103", + "diagnostic": "RED: UIA Rename checks still depend on desktop Window control discovery instead of the owned captionless prompt sheet", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)Wait-UiaOwnedSheetGoneByContentClass \\$process\\.Id \\$shellWindow\\s*`\\s*[\\r\\n]+\\s*\"GraphCodeWindowsDialogContent\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "(?s)\\$renameDialog = Wait-UiaOwnedSheetByContentClass \\$process\\.Id \\$shellWindow\\s*`\\s*[\\r\\n]+\\s*\"GraphCodeWindowsDialogContent\" \\$fixtureRenameTitle" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6165", + "sourceLines": "6165-6170", + "diagnostic": "RED: UIA Rename and Update evidence reads the daemon log before the asynchronous command is recorded", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)\\$renameLiveCommand = ConvertFrom-Json -InputObject \\(Wait-DaemonCommandLog.*?\"connected-daemon Rename submission\"\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)\\$updateCommandJson = Wait-DaemonCommandLog \\$daemonCommandLogPath.*?\"Edit Details submission\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)\\$renameCommandJson = Wait-DaemonCommandLog \\$daemonCommandLogPath.*?\"Rename submission\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "(?s)function Wait-DaemonCommandLog\\(.*?\\[Diagnostics\\.Stopwatch\\]::StartNew\\(\\).*?\\$watch\\.ElapsedMilliseconds -lt \\$timeoutMilliseconds.*?throw \"\\$label did not reach the daemon command log within" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6171", + "sourceLines": "6171-6181", + "diagnostic": "RED: migrated captionless sheets still use desktop Window discovery instead of owned content-class lookup", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)\"GraphCodeWindowsDialogContent\"\\s+\\$renameLiveTitle" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)\"GraphCodeNativeForm\"\\s+\"Edit edge\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)\"GraphCodeWindowsDialogContent\"\\s+\"Delete Edge\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)\"GraphCodeWindowsDialogContent\"\\s+\"Delete All Loops\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)\"GraphCodeNativeForm\"\\s+\"Choose a saved template\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)\"GraphCodeNativeForm\"\\s+\"Create or edit node\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)\"GraphCodeNativeForm\"\\s+\"Update node\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)\"GraphCodeWindowsDialogContent\"\\s+\\$deleteLoopTitle" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "\\$(deleteLoopCondition|updateWindowCondition|nodeFormCondition|sidebarNodeFormCondition|templatePickerCondition|deleteLoopsCondition|deleteEdgeCondition|edgeDialogCondition|renameLiveCondition)\\b" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6199", + "sourceLines": "6199-6209", + "diagnostic": "RED: shared sheet title/hierarchy/identity contracts are missing or retain the obsolete Rename Loop title", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "SheetContentClass" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "WindowOwner" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "IsCaptionlessSheet" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Test-MultiProjectOwnedSheetIdentity" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "GraphCodeSheetViewport" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "GraphCodeSheetHost" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Get-MultiProjectRenameTitle" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Test-MultiProjectRenameTitle" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "\"Rename Loop\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6210", + "sourceLines": "6210-6214", + "diagnostic": "RED: update, Product Settings, or native form discovery still assumes a migrated content class is top-level", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "Assert-UiaOwnedSheet \\$nativeFormWindow \\$process\\.Id \\$shellWindow \\$nativeForm\\.Title" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "FindVisibleOwnedSheetByContentClass\\(\\s*\\[uint32\\]\\$settingsProcess\\.Id, \\$settingsShellWindow, \"GraphCodeProductSettings\"\\s*\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "FindTopLevel\\(\"GraphCode(UpdateOffer|ProductSettings)\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6215", + "sourceLines": "6215-6220", + "diagnostic": "RED: migrated sheet identity checks do not retain exact surface-specific host titles", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "\\$settingsShellWindow \"GraphCode Settings\" \"GraphCodeProductSettings\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\"GraphCodeRepositoryIngressDialog\" \\$ingress\\.Title" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\"GraphCodeUpdateOffer\" \"GraphCode Update Available\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "\"GraphCode sheet\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6221", + "sourceLines": "6221-6225", + "diagnostic": "RED: UIA gate writes or reads cross-process edit text through the window caption instead of WM_SETTEXT/WM_GETTEXT", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)SetEditTextById\\(IntPtr parent, int controlId, string text\\) \\{[^}]*SetWindowText\\(" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "SendMessageText\\(edit, 0x000D" + }, + "left": { + "kind": "resource", + "name": "gate-state" + }, + "kind": "binary", + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "SendMessageText\\(edit, 0x000D", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } + }, + "left": { + "right": { + "kind": "literal", + "value": "SendMessageString\\(edit, 0x000C" + }, + "left": { + "kind": "resource", + "name": "gate-state" + }, + "kind": "binary", + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "SendMessageString\\(edit, 0x000C", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6226", + "sourceLines": "6226-6230", + "diagnostic": "RED: UIA gate reads the daemon command log without tolerating the recorder's open write handle", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "ReadAllText\\(\\$daemonCommandLogPath\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\[IO\\.FileShare\\]::ReadWrite -bor \\[IO\\.FileShare\\]::Delete" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "function Read-DaemonCommandLog" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6231", + "sourceLines": "6231-6239", + "diagnostic": "RED: UIA gate never observes a rename result returned by a connected daemon", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "rename stub daemon never applied the dispatched rename" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_CONNECTED_RENAME_PROPAGATION_UNCONFIRMED" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_CONNECTED_RENAME_PROPAGATION_CONFIRMED" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Remove-Item Env:GRAPHCODE_UIA_CONNECTION_FAILURE" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "-ApplyGraphCommands" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_CONNECTED_DAEMON_MODEL" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "UIA_CONNECTED_RENAME_PROPAGATION" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6246", + "sourceLines": "6246-6249", + "diagnostic": "RED: UIA gate throws on the known-intermittent graph card/sidebar propagation outcome instead of logging it", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)Require \\(\\$renameLiveSidebarTitle -eq \\$renameFinalTitle\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "(?s)Require \\(\\$renameLiveGraphTitle -eq \\$renameFinalTitle\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6250", + "sourceLines": "6250-6255", + "diagnostic": "RED: UIA gate no longer exercises the forced disconnected connection-failure path or reports the banner it observed", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "connectionFailureBanner = \\$connectionFailureBannerEvidence" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Write-Host \\(\"UIA_CONNECTION_FAILURE_BANNER_EVIDENCE=\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)\\$connectionFailureBannerEvidence = \\[ordered\\]@\\{\\s*name = \\[string\\]\\$connectionAlert\\.Current\\.Name" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "\\$env:GRAPHCODE_UIA_CONNECTION_FAILURE = \"1\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6257", + "sourceLines": "6257-6265", + "diagnostic": "RED: N3e lacks opt-in independent owner/receipt/publication state and the simultaneous live multi-project rename evidence", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)multiProjectRename = \\$multiProjectRenameEvidence.*?\\}\\s*\\|\\s*ConvertTo-Json -Depth 8 -Compress" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Get-MultiProjectAutomationId" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_MULTIPROJECT_RENAME_EVIDENCE=" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Invoke-MultiProjectPublicationControl" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Invoke-MultiProjectRequest" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function New-MultiProjectPeer" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "\\[switch\\] \\$SeedMultiProjects" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6278", + "sourceLines": "6278-6284", + "diagnostic": "RED: dynamic project cards are not frozen after bounded Fit Canvas normalization and reacquisition", + "condition": { + "right": { + "kind": "not", + "operand": { + "kind": "contains", + "needle": { + "kind": "literal", + "value": "\"fit-canvas\"" + }, + "source": { + "kind": "resource", + "name": "gate" + } + } + }, + "left": { + "right": { + "kind": "not", + "operand": { + "kind": "contains", + "needle": { + "kind": "literal", + "value": "UIA_DYNAMIC_PROJECT_CARD_EVIDENCE=" + }, + "source": { + "kind": "resource", + "name": "gate" + } + } + }, + "left": { + "right": { + "kind": "not", + "operand": { + "kind": "contains", + "needle": { + "kind": "literal", + "value": "function Get-DynamicProjectCardCapture" + }, + "source": { + "kind": "resource", + "name": "gate" + } + } + }, + "left": { + "right": { + "kind": "not", + "operand": { + "kind": "contains", + "needle": { + "kind": "literal", + "value": "Get-DynamicProjectCardSnapshotDecision \"project\"" + }, + "source": { + "kind": "resource", + "name": "gate" + } + } + }, + "left": { + "kind": "not", + "operand": { + "kind": "contains", + "needle": { + "kind": "literal", + "value": "function Get-DynamicProjectCardSnapshotDecision" + }, + "source": { + "kind": "resource", + "name": "gate" + } + } + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6285", + "sourceLines": "6285-6288", + "diagnostic": "RED: workspace observation still accepts a project-only projection without mandatory owned loop-bar and toolbar proof", + "condition": { + "right": { + "kind": "contains", + "needle": { + "kind": "literal", + "value": "if ($null -ne $loopBar) { $matches = $matches -and" + }, + "source": { + "kind": "resource", + "name": "gate" + } + }, + "left": { + "kind": "not", + "operand": { + "kind": "contains", + "needle": { + "kind": "literal", + "value": "$matches = Test-MultiProjectObservedSurface $surface $matches $workspaceProjection $selectedOwner $multiProcess.Id" + }, + "source": { + "kind": "resource", + "name": "gate" + } + } + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6339", + "sourceLines": "6339-6344", + "diagnostic": "RED: stub daemon cannot apply a renameNode command and republish its graph", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "function New-StubGraphEvent" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "appliedRenames" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$frame\\.command\\.graphCommand\\.command\\.renameNode" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "\\$ApplyGraphCommands" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6345", + "sourceLines": "6345-6350", + "diagnostic": "RED: migrated standalone dialogs are not discovered by exact owner/PID/direct content class", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)\\$ingressDialog = Wait-ForDesktopElement.*?\\$ingress\\.Title" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "GraphCodeRepositoryIngressDialog" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Wait-UiaOwnedSheetGoneByContentClass" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "function Wait-UiaOwnedSheetByContentClass" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6355", + "sourceLines": "6355-6372", + "diagnostic": "RED: UIA live gate does not prove foreground ownership before accepting row focus", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)Require \\(\\[GraphCodeUiaGateState\\]::IsForegroundWindow\\(\\$settingsWindow\\)\\).*?Product Settings window lost foreground ownership before accepting backend focus" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Product Settings backend control could not retain foreground focus" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$backendFocus = Retain-FocusWithRetry" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Retain-FocusWithRetry \\$shellWindow \\$safeFocusRow \\$safeRowId \"before-retention\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$element\\.SetFocus\\(\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\[GraphCodeUiaGateState\\]::ActivateWindow\\(\\$window\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Test-FocusedElementIdentity \\$candidate \\$element \\$expectedAutomationId" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Retain-FocusWithRetry" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Hide-TestProviderZmxWindows|HideProcessWindows|HideWindow\\(\\$foreground\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_FOCUS_DIAGNOSTICS" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "IsForegroundWindow\\(\\$window\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\[DllImport\\(\"kernel32\\.dll\"\\)\\]\\s*private static extern uint GetCurrentThreadId" + }, + "left": { + "kind": "resource", + "name": "gate-state" + }, + "kind": "binary", + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "\\[DllImport\\(\"kernel32\\.dll\"\\)\\]\\s*private static extern uint GetCurrentThreadId", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "PostMessage\\(window, 0x0101, \\(UIntPtr\\)key, IntPtr\\.Zero\\)" + }, + "left": { + "kind": "resource", + "name": "gate-state" + }, + "kind": "binary", + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "PostMessage\\(window, 0x0101, \\(UIntPtr\\)key, IntPtr\\.Zero\\)", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "IsRetainedProcessAlive\\(expectedProcessId\\)" + }, + "left": { + "kind": "resource", + "name": "input-safety" + }, + "kind": "binary", + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "IsRetainedProcessAlive\\(expectedProcessId\\)", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "AttachThreadInput\\(currentThread, foregroundThread" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "keybd_event" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6417", + "sourceLines": "6417-6425", + "diagnostic": "RED: UIA live gate does not wait deterministically for asynchronous modal windows", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "Open Folder picker close" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "project-row New Loop node form" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "empty project New Loop node form" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "empty global New Loop node form" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_WAIT_DIAGNOSTICS" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Wait-ForDesktopElementGone" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "function Wait-ForDesktopElement" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6426", + "sourceLines": "6426-6429", + "diagnostic": "RED: UIA live gate reintroduced short fixed polling around New Loop modal commands", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)Open Folder command was rejected.*?Start-Sleep -Milliseconds 200\\s*[\\r\\n]+\\s*Require \\(\\[GraphCodeUiaGateState\\]::PostCommand\\(\\$shellWindow, 4602\\)\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "(?s)New Loop command was rejected.*?for \\(\\$index = 0; \\$index -lt 40 -and \\$null -eq \\$.*NodeForm" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6430", + "sourceLines": "6430-6435", + "diagnostic": "RED: UIA live gate does not reacquire GraphCode shell foreground within a bounded deadline before command paths, or performs the invasive Alt-tap activation even when already foreground", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)function Ensure-ShellForeground\\(.*?if \\(\\[GraphCodeUiaGateState\\]::IsForegroundWindow\\(\\$window\\)\\) \\{\\s*[\\r\\n]+\\s*return \\$true\\s*[\\r\\n]+\\s*\\}" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_FOREGROUND_DIAGNOSTICS phase=\\$label \\$\\(Get-FocusDiagnostics \\$window\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\[GraphCodeUiaGateState\\]::ActivateWindow\\(\\$window\\)\\s*[\\r\\n]+\\s*\\$acquired = \\$activated -and \\[GraphCodeUiaGateState\\]::IsForegroundWindow\\(\\$window\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "function Ensure-ShellForeground" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6436", + "sourceLines": "6436-6439", + "diagnostic": "RED: UIA modal waits do not boundedly reacquire foreground after a post-command foreground loss", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_WAIT_DIAGNOSTICS label=\\$label foregroundRecoveries=\\$foregroundRecoveries" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "(?s)function Wait-ForDesktopElement\\(.*?\\[switch\\] \\$RecoverForeground.*?\\$remainingMilliseconds = \\[Math\\]::Max\\(.*?\\$recoveryTimeout = \\[Math\\]::Min\\(1000, \\$remainingMilliseconds\\).*?UIA_WAIT_FOREGROUND_RECOVERY label=\\$label.*?Ensure-ShellForeground.*?-TimeoutMilliseconds \\$recoveryTimeout" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6440", + "sourceLines": "6440-6444", + "diagnostic": "RED: UIA live gate New Loop invocation is not preceded by verified foreground recovery at every site", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)Require \\(Ensure-ShellForeground \\$shellWindow \"empty project New Loop\"\\)\\s*`\\s*[\\r\\n]+\\s*\"GraphCode shell did not reacquire foreground before empty project New Loop command\"\\s*[\\r\\n]+\\s*Require \\(\\[GraphCodeUiaGateState\\]::PostCommand\\(\\$shellWindow, 4602\\)\\)\\s*`\\s*[\\r\\n]+\\s*\"empty project New Loop command was rejected\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)Require \\(Ensure-ShellForeground \\$shellWindow \"empty global New Loop\"\\)\\s*`\\s*[\\r\\n]+\\s*\"GraphCode shell did not reacquire foreground before empty global New Loop command\"\\s*[\\r\\n]+\\s*Require \\(\\[GraphCodeUiaGateState\\]::PostCommand\\(\\$shellWindow, 4602\\)\\)\\s*`\\s*[\\r\\n]+\\s*\"empty global New Loop command was rejected\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "(?s)Require \\(\\$null -ne \\$projectNewLoop\\) \"project row omitted New Loop\"\\s*[\\r\\n]+\\s*Require \\(Ensure-ShellForeground \\$shellWindow \"project-row New Loop\"\\)\\s*`\\s*[\\r\\n]+\\s*\"GraphCode shell did not reacquire foreground before invoking project-row New Loop\"\\s*[\\r\\n]+\\s*\\$projectNewLoop\\.GetCurrentPattern" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6446", + "sourceLines": "6446-6453", + "diagnostic": "RED: UIA live gate cannot open, read, or dismiss a native TrackPopupMenu popup", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "PostMessage\\(window, 0x802C, \\(UIntPtr\\)target, IntPtr\\.Zero\\)" + }, + "left": { + "kind": "resource", + "name": "gate-state" + }, + "kind": "binary", + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "PostMessage\\(window, 0x802C, \\(UIntPtr\\)target, IntPtr\\.Zero\\)", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "SendMessage\\(popup, 0x01E1, UIntPtr\\.Zero, IntPtr\\.Zero\\)" + }, + "left": { + "kind": "resource", + "name": "gate-state" + }, + "kind": "binary", + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "SendMessage\\(popup, 0x01E1, UIntPtr\\.Zero, IntPtr\\.Zero\\)", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "FindPopupMenuWindow" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Close-PopupMenu" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Get-PopupMenuItems" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "function Wait-ForPopupMenu" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6454", + "sourceLines": "6454-6461", + "diagnostic": "RED: UIA live gate does not assert the live project context menu's disabled Move item and deterministic dismissal", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "project context menu did not dismiss, leaving the shell blocked in its modal loop" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)PostContextMenu\\(\\$shellWindow, 2\\).*?\\$_\\.Id -in @\\(5149, 5151, 5144\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$moveProjectItem\\.Text -eq \\$moveProjectMenuText" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Require \\(-not \\$moveProjectItem\\.Enabled\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)PostContextMenu\\(\\$shellWindow, 1\\).*?Wait-ForPopupMenu \\$process \\$shellWindow \"project\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "\\$moveProjectMenuText = \"Move Project\\.\\.\\. \\(unavailable: daemon support required\\)\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6462", + "sourceLines": "6462-6465", + "diagnostic": "RED: Windows shell validation does not run the context menu and gate fixture message tests", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)Context menu and gate fixture message executable tests.*?zig test src\\\\MainWindow\\.zig" + }, + "left": { + "kind": "resource", + "name": "shellTests" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "(?s)Context menu and gate fixture message executable tests.*?zig test src\\\\GraphContextMenu\\.zig" + }, + "left": { + "kind": "resource", + "name": "shellTests" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6472", + "sourceLines": "6472-6513", + "diagnostic": "RED: UIA live gate does not measure blank-canvas, node-card, and edge context menus from live geometry", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "UIA loop A|UIA loop B" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "daemonCommandUnchanged = \\$canvasEdgeDaemonCommandUnchanged" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "editActionDialogOpened = \\$canvasEdgeEditDialogOpened" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Create or edit edge" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)Wait-UiaOwnedSheetByContentClass \\$process\\.Id \\$shellWindow\\s*`\\s*[\\r\\n]+\\s*\"GraphCodeNativeForm\" \"Edit edge\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "actionClick = \\$editEdgeClickEvidence" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_CANVAS_EDGE_ACTION_CLICK_EVIDENCE" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "actionPopupClosed = \\$edgePopupClosed" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "directCommandFallback = \\$editEdgeDirectCommandFallback" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "keyboardFallback = \\$editEdgeClick\\.UsedKeyboardFallback" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "hilite = \\$editEdgeClick\\.Hilite" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "cursorBefore = @\\(\\$editEdgeClick\\.CursorBeforeX" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "SendInput" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)SendInput\\(\\(uint\\)inputs\\.Length.*?for \\(int attempt = 0; attempt < 20 && operations\\.IsWindowVisible\\(popup\\); attempt\\+\\+\\).*?PostMessage\\(popup, 0x0100, \\(UIntPtr\\)0x0D, IntPtr\\.Zero\\).*?PostMessage\\(popup, 0x0101, \\(UIntPtr\\)0x0D, IntPtr\\.Zero\\)" + }, + "left": { + "kind": "resource", + "name": "input-safety" + }, + "kind": "binary", + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "(?s)SendInput\\(\\(uint\\)inputs\\.Length.*?for \\(int attempt = 0; attempt < 20 && IsWindowVisible\\(popup\\); attempt\\+\\+\\).*?PostMessage\\(popup, 0x0100, \\(UIntPtr\\)0x0D, IntPtr\\.Zero\\).*?PostMessage\\(popup, 0x0101, \\(UIntPtr\\)0x0D, IntPtr\\.Zero\\)", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "SendMessage\\(popup, 0x01E5, \\(UIntPtr\\)position, IntPtr\\.Zero\\)" + }, + "left": { + "kind": "resource", + "name": "input-safety" + }, + "kind": "binary", + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "SendMessage\\(popup, 0x01E5, \\(UIntPtr\\)position, IntPtr\\.Zero\\)", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UsedSelectItemFallback" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "public bool Hilite, UsedKeyboardFallback" + }, + "left": { + "kind": "resource", + "name": "input-safety" + }, + "kind": "binary", + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "public bool Hilite, UsedKeyboardFallback", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } + }, + "left": { + "right": { + "kind": "binary", + "operator": "Or", + "left": { + "kind": "binary", + "operator": "Inotmatch", + "left": { + "kind": "resource", + "name": "gate-state" + }, + "right": { + "kind": "literal", + "value": "private static extern void Sleep\\(uint milliseconds\\)" + } + }, + "right": { + "kind": "binary", + "operator": "Inotmatch", + "left": { + "kind": "resource", + "name": "input-safety" + }, + "right": { + "kind": "literal", + "value": "(?s)for \\(int attempt = 0; attempt < 50 && !hilite; attempt\\+\\+\\).*?GetMenuState\\(menu, \\(uint\\)position, 0x0400\\).*?operations\\.Sleep\\(10\\).*?if \\(!hilite\\).*?throw new InvalidOperationException" + } + }, + "migration": { + "legacyPattern": "(?s)private static extern void Sleep\\(uint milliseconds\\).*?for \\(int attempt = 0; attempt < 50 && !hilite; attempt\\+\\+\\).*?GetMenuState\\(menu, \\(uint\\)position, 0x0400\\).*?Sleep\\(10\\).*?if \\(!hilite\\).*?throw new InvalidOperationException", + "reason": "Sleep ABI declaration and policy body now live in two explicitly linked production sources" + } + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "SendMessage\\(popup, 0x0200, UIntPtr\\.Zero, operations\\.MouseLParam\\(point\\.X, point\\.Y\\)\\)" + }, + "left": { + "kind": "resource", + "name": "input-safety" + }, + "kind": "binary", + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "SendMessage\\(popup, 0x0200, UIntPtr\\.Zero, MouseLParam\\(point\\.X, point\\.Y\\)\\)", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "GetCursorPos" + }, + "left": { + "kind": "resource", + "name": "input-safety" + }, + "kind": "binary", + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "GetCursorPos", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "SetCursorPos" + }, + "left": { + "kind": "resource", + "name": "input-safety" + }, + "kind": "binary", + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "SetCursorPos", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "ClickPopupMenuItem" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "GetMenuItemRect" + }, + "left": { + "kind": "resource", + "name": "input-safety" + }, + "kind": "binary", + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "GetMenuItemRect", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Close-PopupMenu" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "state = \\$_.State" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "checked = \\$_.Checked" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function ConvertTo-PopupMenuEvidence" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Checked\\s+=\\s+\\(\\(\\$state -band 0x8\\) -ne 0\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "PopupMenuItemState" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "PostRightClickAt\\(\\$shellWindow" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "PostRightClickAt\\(\\$ownerWindow" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "BoundingRectangle" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "canvas \\$\\(\\$probe\\.Label\\) context menu point .*? is outside live graph bounds" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)Find-FragmentByIdWithRetry \\$root \"actual-size\" \\$rawWalker.*?\\.Invoke\\(\\).*?\\$graph = Find-FragmentByIdWithRetry \\$root \"graph\" \\$rawWalker.*?\\$canvasContextCards" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\"canvas edge\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\"canvas node\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\"canvas background\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "canvasContextMenu = \\$canvasContextMenuEvidence" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$canvasContextMenuEvidence" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "UIA_CANVAS_CONTEXT_MENU_EVIDENCE" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6514", + "sourceLines": "6514-6516", + "diagnostic": "RED: UIA final summary loses nested canvas menu items and edge action measurements", + "condition": { + "right": { + "kind": "literal", + "value": "(?s)canvasContextMenu = \\$canvasContextMenuEvidence.*?\\}\\s*\\|\\s*ConvertTo-Json -Depth 8 -Compress" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + } + }, + { + "id": "ui.root.source.line-6520", + "sourceLines": "6520-6525", + "diagnostic": "RED: UIA popup Enter fallback can close the menu without choosing the intended item", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_SKETCH_MENU_CLICK command=\\$id .*?hiliteBeforeEnter=\\$\\(\\$click\\.HiliteBeforeEnter\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_EDGE_MENU_CLICK command=\\$command .*?hiliteBeforeEnter=\\$\\(\\$click\\.HiliteBeforeEnter\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "public bool HiliteAtEnterFallback, HiliteBeforeEnter;" + }, + "left": { + "kind": "resource", + "name": "input-safety" + }, + "kind": "binary", + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "public bool HiliteAtEnterFallback, HiliteBeforeEnter;", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } + }, + "left": { + "right": { + "kind": "literal", + "value": "(?s)for \\(int attempt = 0; attempt < 20 && operations\\.IsWindowVisible\\(popup\\); attempt\\+\\+\\) operations\\.Sleep\\(10\\);.*?hiliteAtEnterFallback = \\(operations\\.GetMenuState\\(menu, \\(uint\\)position, 0x0400\\) & 0x0080\\) != 0;\\s*operations\\.SendMessage\\(popup, 0x01E5, \\(UIntPtr\\)position, IntPtr\\.Zero\\);\\s*hiliteBeforeEnter = \\(operations\\.GetMenuState\\(menu, \\(uint\\)position, 0x0400\\) & 0x0080\\) != 0;.*?if \\(!hiliteBeforeEnter\\)\\s*throw new InvalidOperationException.*?PostMessage\\(popup, 0x0100, \\(UIntPtr\\)0x0D, IntPtr\\.Zero\\)" + }, + "left": { + "kind": "resource", + "name": "input-safety" + }, + "kind": "binary", + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "(?s)for \\(int attempt = 0; attempt < 20 && IsWindowVisible\\(popup\\); attempt\\+\\+\\) Sleep\\(10\\);.*?hiliteAtEnterFallback = \\(GetMenuState\\(menu, \\(uint\\)position, 0x0400\\) & 0x0080\\) != 0;\\s*SendMessage\\(popup, 0x01E5, \\(UIntPtr\\)position, IntPtr\\.Zero\\);\\s*hiliteBeforeEnter = \\(GetMenuState\\(menu, \\(uint\\)position, 0x0400\\) & 0x0080\\) != 0;.*?if \\(!hiliteBeforeEnter\\)\\s*throw new InvalidOperationException.*?PostMessage\\(popup, 0x0100, \\(UIntPtr\\)0x0D, IntPtr\\.Zero\\)", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6526", + "sourceLines": "6526-6541", + "diagnostic": "RED: UIA wait timeouts are not attributed to step, deadline, foreground owner, shell windows, and a shell-only capture", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)RemoveFromSelection\\(\\).*?GetSelection\\(\\)\\.Count -eq 0.*?RawView Worktrees event subscription.*?SelectionSourceRuntimeId = \\$null.*?\\$selectionEventRow = \\$safeRow.*?AddAutomationEventHandler\\(.*?\\$selectionEventDeadlineUtc.*?Get-UiaSelectionEventDecision \\$selectedOnTime.*?\\$safeRowId \\$safeRowRuntimeId.*?Require \\$selectedDecision\\.ready.*?\\$selectedAfterStableDrain.*?RemoveAutomationEventHandler\\(\\$selectedEvent, \\$selectionEventRow" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Write-UiaLateEventAttribution \"ElementSelected after Select\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Write-UiaWaitAttribution \"gate failure: \\$failureStep\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Write-UiaWaitAttribution \"multi-project observation: \\$surface\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Write-UiaWaitAttribution \"sketch/custody modal: \\$title\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Write-UiaWaitAttribution \"edge modal: \\$title\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Write-UiaWaitAttribution \"foreground: \\$label\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Write-UiaWaitAttribution \"desktop element gone: \\$label\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Write-UiaWaitAttribution \"desktop element: \\$label\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Write-UiaWaitAttribution \"popup menu: \\$label\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)function Get-FocusDiagnostics.*?\\$foregroundTitle = if \\(\\$expectedProcessId -ne 0 -and \\$foregroundProcessId -eq \\$expectedProcessId\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "PrintWindow\\(\\$window, \\$hdc, 2\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_WAIT_TIMEOUT_ATTRIBUTION=" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "function Write-UiaWaitAttribution" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6542", + "sourceLines": "6542-6550", + "diagnostic": "RED: stub daemon cannot apply exactly the createNode it received and republish the created loop type", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "if \\(\\$renameApplied -or \\$createApplied -or \\$edgeApplied(?: -or \\$promotionApplied)?\\)" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$null = Write-StubResultFile \\$ResultPath \\$json" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)function Write-StubResultFile.*?for \\(\\$attempt = 0; \\$attempt -lt 40; \\$attempt\\+\\+\\).*?Set-Content -LiteralPath \\$path -Value \\$json -NoNewline -ErrorAction Stop.*?catch \\[IO\\.IOException\\].*?\\$nativeCode = Get-StubResultWriteWin32Error \\$exception.*?if \\(\\$nativeCode -notin @\\(32, 33\\)\\).*?STUB_RESULT_WRITE_FAILURE.*?throw.*?STUB_RESULT_WRITE_EXHAUSTED.*?STUB_RESULT_WRITE_RETRY.*?Start-Sleep -Milliseconds 25" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)function Get-StubResultWriteWin32Error.*?if \\(\\$current -is \\[IO\\.IOException\\]\\).*?\\$current\\.HResult -band 0xFFFF.*?if \\(\\$nativeCode -in @\\(32, 33\\)\\).*?return 0" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$nodeLoopTypes\\[\\$id\\]" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "appliedCreates" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "\\$frame\\.command\\.graphCommand\\.command\\.createNode\\._0" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6551", + "sourceLines": "6551-6577", + "diagnostic": "RED: UIA live gate does not drive the node creation sheet through conditional fields, rejected input, and a daemon-rendered create", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "renderedCardCount -gt 0" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "renderedSidebarCount -gt 0" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "afterEdit = " + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "afterTileClick = " + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)UIA_NODE_CREATION_SHEET_EVIDENCE=.*?\\$postCreateWorkspaceId = Get-MultiProjectAutomationId \"workspace-loop-bar\" \\$createdNodeId.*?\\$postCreateWorkspace = Wait-ForGraphChildren \\$renameRoot \\$rawWalker.*?\\^workspace-\\(loop-bar\\|show-graph\\)-.*?AutomationId -ceq \\$postCreateWorkspaceId.*?Selected loop workspace.*?\\^workspace-show-graph-.*?Show in Graph.*?\\$postCreateShowGraph = @\\(.*?workspace-show-graph-.*?Show in Graph.*?GetCurrentPattern\\(\\[System\\.Windows\\.Automation\\.InvokePattern\\]::Pattern\\)\\.Invoke\\(\\).*?\\$postCreateOverview = Wait-ForGraphChildren \\$renameRoot \\$rawWalker.*?\\^workspace-\\(loop-bar\\|show-graph\\)-.*?Count -eq 0.*?\\^canvas-card-.*?Count -gt 0.*?New Loop exact-ID workspace did not settle on an active graph before sketch/custody.*?# --- Connected sketch promotion and custody child" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\[double\\]\\$wireNode\\.heartbeatIntervalSeconds -eq \\$expectedHeartbeatIntervalSeconds" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$nodeSheetReceivedCommands\\[0\\]\\.command\\s*\\|\\s*ConvertFrom-Json" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$nodeSheetReceivedCommands\\.Count -eq 1" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$nodeSheetReceivedCommands\\s*=\\s*@\\(\\$nodeSheetStubAfter\\.receivedGraphCommands\\s*\\|\\s*Where-Object" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)for \\(\\$index = 0; \\$index -lt 100 -and \\$null -eq \\$nodeSheetDispatchedCommand; \\$index\\+\\+\\).*?Start-Sleep -Milliseconds 50" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Test-NodeCreationLogHasCreate" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "appliedCreates" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "node creation dispatched modelTier" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$wireNode\\s*=\\s*\\$nodeSheetDispatchedCommand\\.command\\.createNode\\._0" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "ComboSelection\\(" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "SendKeyInput\\(" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "reasonClearedOnTypeChange = " + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "commandLogUnchanged = " + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Say what to do each time to continue\\." + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Say what done looks like and use positive timing values\\." + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "9600 \\+ \\$tileIndex" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "VisibleChildIds\\(" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "ClickScreenPoint" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$nodeCreationSheetEvidence = \\[ordered\\]@\\{" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "UIA_NODE_CREATION_SHEET_EVIDENCE" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6677", + "sourceLines": "6677-6679", + "diagnostic": "RED: UIA final summary omits the parsable node creation sheet evidence", + "condition": { + "right": { + "kind": "literal", + "value": "(?s)nodeCreationSheet = \\$nodeCreationSheetEvidence.*?\\}\\s*\\|\\s*ConvertTo-Json -Depth 8 -Compress" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + } + }, + { + "id": "ui.root.source.line-6680", + "sourceLines": "6680-6750", + "diagnostic": "RED: UIA gate does not prove all sketch promotions and custody child through native interaction, correlated wire, no mutation and rendered hit tests", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "createdBy" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "promoteNode" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "appliedPromotionRequests" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "appliedPromotions" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "rejected/cancelled no-dispatch evidence combines unchanged UIA-recorder bytes with unchanged stub received/applied/request/response/graph counts" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "editing is not validated and that result does not establish a dropped key" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "instructionEditing = \"validated by native Sketch-Type exact readback before and after backend change\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "requestCount = \\[int\\]\\$before\\.requestCount" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "appliedPromotionRequests = @\\(\\$before\\.appliedPromotionRequests\\)\\.Count" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "daemonCountsBefore = \\$beforeCounts; daemonCountsAfter = \\$afterCounts" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "firstInstruction -ceq \\$custodyFirstInstruction" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "instructionUnchanged = \\(\\$custodySubmitFields\\.firstInstruction -ceq \\$custodyFirstInstruction\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$custodyRendered = Open-SketchNodeMenu \"UIA custody child\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$custodyGraphReturn = Wait-ForGraphChildren \\$renameRoot \\$rawWalker" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$custodyShowGraph\\.GetCurrentPattern\\(\\[System\\.Windows\\.Automation\\.InvokePattern\\]::Pattern\\)\\.Invoke\\(\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$custodyWorkspace = Wait-ForGraphChildren \\$renameRoot \\$rawWalker" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$custodyWorkspaceId = Get-MultiProjectAutomationId \"workspace-loop-bar\" \\$custodyId" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\(\\$custodyLogged \\| ConvertTo-Json" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$custodyLogged = \\(Read-DaemonCommandLog" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$custodyCommand\\.projectPath -ceq \"graphcode://stub/project\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "exactIdOutcome = @\\{" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "-not \\[string\\]::IsNullOrWhiteSpace\\(\\[string\\]\\$custodyOutcomes\\[0\\]\\.requestID\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\(ConvertTo-SketchCanonicalJson \\$custodyOutcomeCommand\\) -ceq \\$custodyExpectedOutcomeJson" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$custodyOutcomes\\.Count -eq 1" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$custodyExpectedOutcomeJson = ConvertTo-SketchCanonicalJson \\$custodyExpectedOutcome" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Select-Object -Skip @\\(\\$custodyBefore\\.receivedGraphCommands\\)\\.Count" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$custodyNewCommands\\.Count -eq 2" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\[int\\]\\$custodyAfter\\.responseCount -eq \\[int\\]\\$custodyBefore\\.responseCount \\+ 2" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\[int\\]\\$custodyAfter\\.requestCount -eq \\[int\\]\\$custodyBefore\\.requestCount \\+ 2" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$custodyWire = \\$custodyCommand\\.command\\.createNode\\._0" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$custodyCommand = \\[string\\]\\$custodyReceived\\[0\\]\\.command \\| ConvertFrom-Json" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$custodyReceived\\.Count -eq 1" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$custodyReceived = @\\(\\$custodyAfter\\.receivedGraphCommands \\| Where-Object \\{ \\$_.requestID -ceq \\$custodyRequest \\}\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$custodyInstructionAfterBackend = Sketch-Field 9104" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Sketch-Type 9104 \\$custodyFirstInstruction" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$custodyFirstInstruction = \"Work on the requested Windows shell task\\.\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "receivedWireRaw = \\$receivedWireRaw" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Test-SketchPromotionReceipt \\$after \\$request \\$expectedWire" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Test-SketchPromotionReceipt" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function ConvertTo-SketchCanonicalJson" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "renderedHitTests" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Read-EdgeStableText" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "TypeEditTextById" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "IsControlOwnedBy" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)public bool HoverPopupMenuItem.*?FindPopupForMenu\\(ownerProcess, menu\\).*?SendMessage\\(popup, 0x0200, UIntPtr\\.Zero, operations\\.MouseLParam\\(client\\.X, client\\.Y\\)\\).*?GetMenuState\\(menu, \\(uint\\)position, 0x0400\\)" + }, + "left": { + "kind": "resource", + "name": "input-safety" + }, + "kind": "binary", + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "(?s)public static bool HoverPopupMenuItem.*?FindPopupForMenu\\(ownerProcess, menu\\).*?SendMessage\\(popup, 0x0200, UIntPtr\\.Zero, MouseLParam\\(client\\.X, client\\.Y\\)\\).*?GetMenuState\\(menu, \\(uint\\)position, 0x0400\\)", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "ClickPopupMenuItem" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)\\$renderedPromotedState = \\$sameCardAutomationId -and\\s*\\$promotionChoices\\.Count -eq 0 -and \\$newChildChoices\\.Count -eq 1" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$sameCardAutomationId = \\$renderMenu\\.cardId -ceq \\$menu\\.cardId" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)function Sketch-Cancel.*?Start-Sleep -Milliseconds 200.*?\\$keyboardFallback = \\[GraphCodeUiaGateState\\]::PostKeyboard\\(\\$script:edgeWorkflowWindow, 0x1B\\).*?keyboardFallback = \\$keyboardFallback" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)function Sketch-Submit.*?Start-Sleep -Milliseconds 200.*?\\$buttonFallback = \\[GraphCodeUiaGateState\\]::ClickButton\\(\\$button\\).*?buttonFallback = \\$buttonFallback" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$providerZmxBaseline" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_PROVIDER_PROCESS_CLEANUP=" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Stop-UiaOwnedProviderProcesses" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "modalCommandFallback" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "SendCommand\\(\\s*\\$renameShellWindow, \\[uint32\\]\\$case\\.Command\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)\\$reopen = Open-SketchNodeMenu \\$title.*?\\$reopenKeyboardReveal = \\[GraphCodeUiaGateState\\]::RevealSubmenuByKeyboard\\(.*?Turn promotion submenu did not reopen by hover or keyboard" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "real Promote to submenu popup did not open by hover or keyboard" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$submenuKeyboardReveal = \\[GraphCodeUiaGateState\\]::RevealSubmenuByKeyboard\\(" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "public static bool RevealSubmenuByKeyboard\\(IntPtr popup, IntPtr menu, int position\\)" + }, + "left": { + "kind": "resource", + "name": "gate-state" + }, + "kind": "binary", + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "public static bool RevealSubmenuByKeyboard\\(IntPtr popup, IntPtr menu, int position\\)", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_SKETCH_PROMOTION_RENDER_ATTEMPT=" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "appliedPromotionRequests" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "appliedPromotions" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "commandLogBytesUnchanged" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "createdBy" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "promotion\\.timed" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "promotion\\.turn" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "promotion\\.goal" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)sketchCustody = \\$sketchCustodyEvidence.*?\\}\\s*\\|\\s*ConvertTo-Json -Depth 8 -Compress" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "UIA_SKETCH_CUSTODY_EVIDENCE=" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6773", + "sourceLines": "6773-6787", + "diagnostic": "RED: UIA live gate does not prove the native edge create/edit round trip and no-mutation paths", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "graphCommandsAfter" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "graphCommandsBefore" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "renderedEdgeId" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "commandLogBytesUnchanged" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "appliedEdgeUpdates" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "appliedEdgeCreates" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "ClickPopupMenuItem" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "WindowIsVisible\\(\\$edgeWorkflowWindow\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Enter the template or script that should carry context\\." + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "updateEdge" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "createEdge" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)edgeWorkflow = \\$edgeWorkflowEvidence.*?\\}\\s*\\|\\s*ConvertTo-Json -Depth 8 -Compress" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "UIA_EDGE_WORKFLOW_EVIDENCE=" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6788", + "sourceLines": "6788-6800", + "diagnostic": "RED: UIA edge retry/modal/identity proof regressed", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "\\$Matches\\[1\\] -in @\\(" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Require \\(\\$after -eq \"\\$index\\|\\$expected\"\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "postedFallbacks=\\$postedFallbacks" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)\\$postedFallbacks = 0.*?\\$fallbackDelta = \\$index - \\[int\\]\\(\\$after\\.Split\\(\"\\|\"\\)\\[0\\]\\).*?\\[GraphCodeUiaGateState\\]::PostKeyboard\\(\\$control, \\$fallbackKey\\).*?\\$postedFallbacks\\+\\+" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_EDGE_COMBO id=\\$id attempt=\\$attempt" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$delta = \\$index - \\[int\\]\\(\\$after\\.Split\\(\"\\|\"\\)\\[0\\]\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "waitCommandFallback" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "commandFallback = \\$commandFallback" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_EDGE_MODAL_CENSUS" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "WindowIsVisible\\(\\$edgeWorkflowWindow\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "FindVisibleProcessWindow\\(\\[uint32\\]\\$renameProcess\\.Id, \\$title\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6801", + "sourceLines": "6801-6844", + "diagnostic": "RED: edge native text entry lacks native ownership, live layout, focus, clear/retype, or exact verification", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "for \\(\\$layoutRetry = 0; \\$layoutRetry -lt 20" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "attempts=\\$attemptsExecuted/5" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Get-EdgeTextAttemptDecision \\$stable \\$after \\$text \\$attempt 5" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "for \\(\\$attempt = 1; \\$attempt -le 5; \\$attempt\\+\\+\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "clearSent=\\$clearSent/\\$clearExpected textSent=\\$textSent/\\$textExpected" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Require \\(\\$inputCountsFull\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "LastEditTextSent = checked\\(\\(uint\\)typed\\[0\\]\\)" + }, + "left": { + "kind": "resource", + "name": "input-safety" + }, + "kind": "binary", + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "LastEditTextSent = checked\\(\\(uint\\)typed\\[0\\]\\)", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "SendUnicodeInput\\(parent, expectedProcessId, edit, text\\)" + }, + "left": { + "kind": "resource", + "name": "input-safety" + }, + "kind": "binary", + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "SendUnicodeInput\\(parent, expectedProcessId, edit, text\\)", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "LastEditClearSent = operations\\.SendKeyInputs\\(LastEditClearExpected" + }, + "left": { + "kind": "resource", + "name": "input-safety" + }, + "kind": "binary", + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "LastEditClearSent = SendKeyInputs\\(LastEditClearExpected", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "LastEditTextSent" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "LastEditTextExpected" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "LastEditClearSent" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "LastEditClearExpected" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Read-EdgeStableText 9107 \"cycle guard max\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Read-EdgeStableText 9106 \"cycle guard until\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Read-EdgeStableText 9105 \"payload\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_EDGE_SUBMIT_FIELD name=\\$label id=\\$id" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Read-EdgeStableText\\(" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$renameProcess\\.WaitForInputIdle\\(1000\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_EDGE_TEXT_STABLE id=\\$id attempt=\\$attempt" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "for \\(\\$stableRetry = 0; \\$stableRetry -lt 10" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "binary", + "operator": "Or", + "left": { + "kind": "binary", + "operator": "Inotmatch", + "left": { + "kind": "resource", + "name": "input-safety" + }, + "right": { + "kind": "literal", + "value": "(?s)var clear = new KeyInputRecord\\[2\\].*?EditBufferText\\(edit\\).*?SendUnicodeInput\\(parent, expectedProcessId, edit, text\\)" + } + }, + "right": { + "kind": "binary", + "operator": "Inotmatch", + "left": { + "kind": "resource", + "name": "input-safety" + }, + "right": { + "kind": "literal", + "value": "(?s)public int\\[\\] SendUnicodeInput\\(.*?var records = new KeyInputRecord\\[text\\.Length \\* 2\\]" + } + }, + "migration": { + "legacyPattern": "(?s)var clear = new KeyInputRecord\\[2\\].*?EditBufferText\\(edit\\).*?var records = new KeyInputRecord\\[text\\.Length \\* 2\\]", + "reason": "Preserve clear/read/Unicode call and Unicode record creation without depending on method declaration order" + } + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "SendMessage\\(edit, 0x00B1, UIntPtr\\.Zero, new IntPtr\\(-1\\)\\)" + }, + "left": { + "kind": "resource", + "name": "input-safety" + }, + "kind": "binary", + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "SendMessage\\(edit, 0x00B1, UIntPtr\\.Zero, new IntPtr\\(-1\\)\\)", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "SendMessageText\\(edit, 0x000D" + }, + "left": { + "kind": "resource", + "name": "gate-state" + }, + "kind": "binary", + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "SendMessageText\\(edit, 0x000D", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "VirtualKey = 0x2E" + }, + "left": { + "kind": "resource", + "name": "input-safety" + }, + "kind": "binary", + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "VirtualKey = 0x2E", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "WindowTextOf\\(\\$edgeWorkflowWindow\\) -eq \\$script:edgeWorkflowTitle" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "WindowProcessId\\(\\$edgeWorkflowWindow\\) -eq \\$renameProcess\\.Id" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "FocusedControlInDialog\\(\\$edgeWorkflowWindow\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "HasVisibleBounds\\(\\$control\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "IsControlOwnedBy\\(\\$edgeWorkflowWindow, \\$control, \\$id\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Require \\$completed" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "messageFallback=\\$messageFallback" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)if \\(\\$messageFallback\\).*?\\$renameProcess\\.WaitForInputIdle\\(1000\\).*?SetEditTextById\\(\\$edgeWorkflowWindow, \\$id, \\$text\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "SendMessageString\\(edit, 0x000C, UIntPtr\\.Zero, text\\)" + }, + "left": { + "kind": "resource", + "name": "gate-state" + }, + "kind": "binary", + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "SendMessageString\\(edit, 0x000C, UIntPtr\\.Zero, text\\)", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "LastEditUsedMessageFallback" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\[GraphCodeUiaGateState\\]::TypeEditTextById\\(\\$edgeWorkflowWindow, \\$id, \\$text\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_EDGE_TEXT_STABLE id=\\$id attempt=\\$attempt" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Edge-TypeText \\$field\\.Id \\$field\\.Text" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)function Edge-Click.*?Start-Sleep -Milliseconds 200.*?\\$buttonFallback = \\[GraphCodeUiaGateState\\]::ClickButton\\(\\$control\\).*?buttonFallback = \\$buttonFallback" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "public static bool ClickButton\\(IntPtr button\\)" + }, + "left": { + "kind": "resource", + "name": "gate-state" + }, + "kind": "binary", + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "public static bool ClickButton\\(IntPtr button\\)", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Get-EdgeTextAttemptDecision\\(" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "function Edge-TypeText\\(" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6909", + "sourceLines": "6909-6915", + "diagnostic": "RED: custody child render check has no bounded observation-only card reacquisition", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "Open-SketchNodeMenu \"UIA custody child\" -SkipActualSize -ExpectedCardId" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Wait-SketchGraphCard \"UIA custody child\" \\$custodyId" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_SKETCH_CUSTODY_CARD_WAIT=" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Get-SketchCardAutomationId\\(" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "function Wait-SketchGraphCard\\(" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6974", + "sourceLines": "6974-7009", + "diagnostic": "RED: edge workflow does not use unique connector dragging and locked endpoints while preserving edit/CAS and no-mutation proofs", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "if \\(\\$renameApplied -or \\$createApplied -or \\$edgeApplied(?: -or \\$promotionApplied)?\\)" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "appliedEdgeUpdateRequests" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "appliedEdgeCreateRequests" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "appliedEdgeUpdateRequests" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "edgeCancelBytesUnchanged" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "edgeChange\\.spec\\.condition -eq \"onSuccess\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "edgeChange\\.expectedSpec\\.condition -eq \"onFailure\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "edgeCreateWire\\.command\\.createEdge\\.from" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Get-DirectChildren \\$graph \\$rawWalker" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Assert-EdgePrefill \"1\\|Only after success\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Assert-EdgePrefill \"2\\|Only after failure\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Edge-Click 2 \"cancel changed edge\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Edge-Click 1 \"update edge OK\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Edge-Click 1 \"valid edge OK\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Edge-Click 1 \"invalid edge OK\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Edge-TypeText \\$field\\.Id \\$field\\.Text" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Edge-Combo 9104 1 \"Apply a text template\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Edge-Combo 9103 1 \"Only after success\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Edge-Combo 9103 2 \"Only after failure\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Edge-Combo 9102 0" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Open-EdgeMenu \\$true 5110" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Read-EdgeStableText 9101 \"locked target identity\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Read-EdgeStableText 9100 \"locked source identity\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\[GraphCodeUiaGateState\\]::PostMouseButtonAt\\(\\s*\\$renameShellWindow,\\s*0x0202" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\[GraphCodeUiaGateState\\]::PostMouseButtonAt\\(\\s*\\$renameShellWindow,\\s*0x0201" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Select-EdgeConnectorCardPair \\$cards \\$sourceTitle \"Stub node B\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Open-EdgeCreateDialogByConnectorDrag\\b" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Select-EdgeConnectorCardPair\\(" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "if \\(\\$title -eq \"Create or edit edge\"\\)\\s*\\{\\s*5120" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$edgeSourceChoice\\b|\\$edgeTargetChoice\\b" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$edgeCreateMenu\\b" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Edge-Combo 9101\\b" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Edge-Combo 9100\\b" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "Open-EdgeMenu \\$false\\s+5120" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-7042", + "sourceLines": "7042-7130", + "diagnostic": "RED: overview/project navigation evidence depends on absolute fitted card coordinates instead of normalized shared geometry", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "\\.Equals\\(\\$laneSnapshot\\.CardBounds\\[" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$cardCaptures \\| ForEach-Object \\{ \\$_.bounds \\}" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$sameNormalizedLaneGeometry\\s*=\\s*\\{" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$readNormalizedLaneGeometry\\s*=\\s*\\{" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Require \\$restoredLaneComparison\\.Equal" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "normalizedExpected = \\$laneSnapshotGeometry" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "normalizedActual = \\$restoredLaneGeometry" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_RESTORED_LANE_NORMALIZED_EVIDENCE=" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Compare-MultiProjectNormalizedLaneGeometry `\\s*\\$restoredLaneGeometry \\$laneSnapshotGeometry" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "restored lane sheet pan up was rejected" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "restored lane sheet pan move was rejected" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "restored lane sheet pan down was rejected" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "before-restored-lane-sheet-pan" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "nonOverlap = \\$laneNonOverlap" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "normalizedDeltaYError = \\$laneNormalizedComparison\\.DeltaYError" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "normalizedDeltaXError = \\$laneNormalizedComparison\\.DeltaXError" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "ExpectedDeltaYBudget = \\$expectedDeltaYBudget" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "ActualDeltaYBudget = \\$actualDeltaYBudget" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "ExpectedDeltaXBudget = \\$expectedDeltaXBudget" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "ActualDeltaXBudget = \\$actualDeltaXBudget" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "normalizedExpectedDeltaYBudget = \\$laneNormalizedComparison\\.ExpectedDeltaYBudget" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "normalizedActualDeltaYBudget = \\$laneNormalizedComparison\\.ActualDeltaYBudget" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "normalizedExpectedDeltaXBudget = \\$laneNormalizedComparison\\.ExpectedDeltaXBudget" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "normalizedActualDeltaXBudget = \\$laneNormalizedComparison\\.ActualDeltaXBudget" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "normalizedDeltaYTolerance = \\$laneNormalizedComparison\\.DeltaYTolerance" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "normalizedDeltaXTolerance = \\$laneNormalizedComparison\\.DeltaXTolerance" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "normalizedEqual = \\$laneNormalizedEqual" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "normalizedExpected = \\$laneSnapshotGeometry" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "normalizedActual = \\$laneOpenedGeometry" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_LANE_OPEN_NORMALIZED_EVIDENCE=" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Compare-MultiProjectNormalizedLaneGeometry `\\s*\\$laneOpenedGeometry \\$laneSnapshotGeometry" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Get-MultiProjectNormalizedLaneGeometry `\\s*\\$laneOpenedCaptures \\$laneProjectIds \\$laneNames" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Get-MultiProjectNormalizedLaneGeometry `\\s*\\$laneSnapshot\\.Cards \\$laneOverviewIds \\$laneNames" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$_.Current\\.AutomationId -cin \\$laneOverviewIds" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$restoredLaneProbe = Wait-ForGraphChildren \\$root \\$rawWalker" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "opened project requires one exact card for \\$id" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$laneOpenedCards = @\\(\\$laneProjectIds \\| ForEach-Object" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$_.Current\\.AutomationId -cin \\$laneProjectIds" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$laneOpenedProbe = Wait-ForGraphChildren \\$root \\$rawWalker" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "restored overview lane geometry omitted the exact owned Fit Canvas action" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "opened project lane geometry omitted the exact owned Fit Canvas action" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "lane overview geometry omitted the exact owned Fit Canvas action" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$firstBounds = @\\(\\$cardCaptures\\[0\\]\\.bounds\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "bounds = @\\(\\$rect.Left, \\$rect.Top, \\$rect.Right, \\$rect.Bottom\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "automationId = \\[string\\]\\$_.Current.AutomationId" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$cards = @\\(\\$laneOverviewIds \\| ForEach-Object" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$deltaYTolerance = \\$actualDeltaYBudget \\+ \\$expectedDeltaYBudget \\+ 0\\.01" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$deltaXTolerance = \\$actualDeltaXBudget \\+ \\$expectedDeltaXBudget \\+ 0\\.01" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\[Math\\]::Max\\(1, \\$expected\\.PixelHeight - 1\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\[Math\\]::Max\\(1, \\$actual\\.PixelHeight - 1\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\[Math\\]::Max\\(1, \\$expected\\.PixelWidth - 1\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\[Math\\]::Max\\(1, \\$actual\\.PixelWidth - 1\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$expectedDeltaYBudget = 96 \\*" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$actualDeltaYBudget = 96 \\*" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$expectedDeltaXBudget = 250 \\*" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$actualDeltaXBudget = 250 \\*" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "VerticalClipping = \\$verticalClipping" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "HorizontalClipping = \\$horizontalClipping" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "ObservedPixelHeights = \\$visibleHeights" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "ObservedPixelWidths = \\$visibleWidths" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$heightTolerance = 1 / \\$scaleY \\+ 0\\.01" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$widthTolerance = 1 / \\$scaleX \\+ 0\\.01" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "PixelHeight = \\[double\\]\\$pixelHeight" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "PixelWidth = \\[double\\]\\$pixelWidth" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "ScaleY = \\$scaleY" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "ScaleX = \\$scaleX" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$rect\\[3\\] - \\$pixelHeight" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$rect\\[2\\] - \\$pixelWidth" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "normalized lane requires an unclipped full-size peer on each axis" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$verticalFullPeer = \\$false" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$horizontalFullPeer = \\$false" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "lane card height differs without an exact viewport clipping edge" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "lane card width differs without an exact viewport clipping edge" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$pixelHeight = \\[double\\]\\(\\(\\$visibleHeights \\| Measure-Object -Maximum\\)\\.Maximum\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$pixelWidth = \\[double\\]\\(\\(\\$visibleWidths \\| Measure-Object -Maximum\\)\\.Maximum\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "normalized lane cards overlap" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "normalized lane card is outside the graph viewport" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "normalized lane card identity/order differs from the exact fixture" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "normalized lane source geometry must remain exactly 250x96" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Compare-MultiProjectNormalizedLaneGeometry" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Get-MultiProjectNormalizedLaneGeometry" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "y \\+ scaled\\(Tokens\\.loop_card_height, state\\)" + }, + "left": { + "kind": "resource", + "name": "graphCanvas" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "x \\+ scaled\\(Tokens\\.loop_card_width, state\\)" + }, + "left": { + "kind": "resource", + "name": "graphCanvas" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "pub const card_height: f32 = 96;" + }, + "left": { + "kind": "resource", + "name": "laneLayout" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "pub const card_width: f32 = 250;" + }, + "left": { + "kind": "resource", + "name": "laneLayout" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "pub const loop_card_height: i32 = 96;" + }, + "left": { + "kind": "resource", + "name": "designTokens" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "pub const loop_card_width: i32 = 250;" + }, + "left": { + "kind": "resource", + "name": "designTokens" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-7348", + "sourceLines": "7348-7350", + "diagnostic": "RED: UIA live gate node creation sheet label comparison can pass vacuously on an empty expected list", + "condition": { + "right": { + "kind": "literal", + "value": "expected label list is empty or blank" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + } + }, + { + "id": "ui.root.source.line-7351", + "sourceLines": "7351-7354", + "diagnostic": "RED: UIA focus retention waits for any FocusChanged event instead of the focused row identity", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "Require \\(\\[GraphCodeUiaGateState\\]::FocusSourceAutomationId -eq \\$safeRowId\\) \"FocusChanged source identity changed\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "(?s)for \\(\\$index = 0; \\$index -lt 20 -and\\s*\\[GraphCodeUiaGateState\\]::FocusSourceAutomationId -ne \\$safeRowId; \\$index\\+\\+\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-7355", + "sourceLines": "7355-7364", + "diagnostic": "RED: UIA live gate node creation sheet cannot measure and record controls occluded outside the monitor work area", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_NODE_CREATION_OCCLUSION" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "overlapPixels = " + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "footerOccludedByTaskbar = " + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "has no visible portion inside" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Require \\(-not \\$hit\\.VisibleEmpty\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "RealChildWindowFromPoint\\(dialog, clientPoint\\)" + }, + "left": { + "kind": "resource", + "name": "gate-state" + }, + "kind": "binary", + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "RealChildWindowFromPoint\\(dialog, clientPoint\\)", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "HitTarget = !visibleEmpty && sameTopLevel && realChild == target" + }, + "left": { + "kind": "resource", + "name": "gate-state" + }, + "kind": "binary", + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "HitTarget = !visibleEmpty && sameTopLevel && realChild == target", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } + }, + "left": { + "right": { + "kind": "literal", + "value": "SystemParametersInfoRect\\(0x0030" + }, + "left": { + "kind": "resource", + "name": "gate-state" + }, + "kind": "binary", + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "SystemParametersInfoRect\\(0x0030", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-7365", + "sourceLines": "7365-7380", + "diagnostic": "RED: UIA live gate node creation sheet cannot click and record a footer control partly covered by scrolled content", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "uncoveredRectangles = " + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "ChosenUncoveredRectangle = piece" + }, + "left": { + "kind": "resource", + "name": "gate-state" + }, + "kind": "binary", + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "ChosenUncoveredRectangle = piece", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "GetWindow\\(target, 3\\)" + }, + "left": { + "kind": "resource", + "name": "gate-state" + }, + "kind": "binary", + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "GetWindow\\(target, 3\\)", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "SubtractCoveredRectangles" + }, + "left": { + "kind": "resource", + "name": "gate-state" + }, + "kind": "binary", + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "SubtractCoveredRectangles", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "has no verified uncovered point in" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_NODE_CREATION_CONTENT_OCCLUSION" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "createCentreClicks = " + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "coveredFraction = " + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "footerOccludedByContent = " + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\} else if \\(!hit\\.HitTarget\\) \\{" + }, + "left": { + "kind": "resource", + "name": "gate-state" + }, + "kind": "binary", + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "\\} else if \\(!hit\\.HitTarget\\) \\{", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "return RealChildWindowFromPoint\\(dialog, clientPoint\\) == target;" + }, + "left": { + "kind": "resource", + "name": "gate-state" + }, + "kind": "binary", + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "return RealChildWindowFromPoint\\(dialog, clientPoint\\) == target;", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "for \\(int column = 1; column <= 5; column\\+\\+\\)" + }, + "left": { + "kind": "resource", + "name": "gate-state" + }, + "kind": "binary", + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "for \\(int column = 1; column <= 5; column\\+\\+\\)", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "for \\(int row = 1; row <= 3; row\\+\\+\\)" + }, + "left": { + "kind": "resource", + "name": "gate-state" + }, + "kind": "binary", + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "for \\(int row = 1; row <= 3; row\\+\\+\\)", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } + }, + "left": { + "right": { + "kind": "literal", + "value": "if \\(!hit\\.HitTarget && !visibleEmpty && dialog != IntPtr\\.Zero\\)" + }, + "left": { + "kind": "resource", + "name": "gate-state" + }, + "kind": "binary", + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "if \\(!hit\\.HitTarget && !visibleEmpty && dialog != IntPtr\\.Zero\\)", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-7381", + "sourceLines": "7381-7386", + "diagnostic": "RED: UIA live gate does not check the native modal remains visible after rejected Create", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "Require \\$stillOpen" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$stillOpen = \\$nativeVisible -and \\$nativeTitle -eq \\$nodeSheetTitle" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$nativeVisible = \\[GraphCodeUiaGateState\\]::WindowIsVisible\\(\\$nodeSheetWindow\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "UIA_NODE_CREATION_INVALID_WINDOW" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-7387", + "sourceLines": "7387-7395", + "diagnostic": "RED: UIA live gate omits modal liveness retry, native closure, or footer-click geometry", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "sub-3px verified uncovered strip" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)function Invoke-NodeSheetClick.*?Start-Sleep -Milliseconds 200.*?\\$buttonFallback = \\[GraphCodeUiaGateState\\]::ClickButton\\(\\$control\\).*?buttonFallback = \\$buttonFallback" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_NODE_CREATION_FOOTER_CLICK" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "if \\(-not \\[GraphCodeUiaGateState\\]::WindowIsVisible\\(\\$nodeSheetWindow\\) -or" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Require \\$nodeSheetClosed" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_NODE_CREATION_INVALID_WINDOW" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "(?s)for \\(\\$attempt = 1; \\$attempt -le 10; \\$attempt\\+\\+\\).*?uiaRecoveredAtAttempt = \\$attempt" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-7396", + "sourceLines": "7396-7403", + "diagnostic": "RED: occluded Goal Create cannot submit with a measured, focused native Enter fallback", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "Invoke-NodeSheetClick 1 \"Create \\(\\$loopType, invalid\\)\" -allowOccludedEnter:\\(\\$loopType -eq \"goalBased\"\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "SendKeyInput\\(\\$nodeSheetWindow, \\[uint32\\]\\$renameProcess.Id, \\$goalEdit, 0x0D, 1\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "FocusControl\\(\\$nodeSheetWindow, \\$goalEdit\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "IsForegroundWindow\\(\\$nodeSheetWindow\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$allowOccludedEnter -and -not \\$hit\\.HitTarget -and \\$hit\\.ScannedPoints -gt 0" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "mouseSubmitUnavailable = " + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + } +] diff --git a/Tools/windows/ValidationRunner/Ui/source-case-map.json b/Tools/windows/ValidationRunner/Ui/source-case-map.json new file mode 100644 index 00000000..a0a3ef3e --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/source-case-map.json @@ -0,0 +1,2187 @@ +{ + "snapshotManifestSha256": "CC8ED5141AE38CB425F4E41AB7F2E4EF1F07F5AE4CAD7FA969A68645B07A0A2F", + "units": [ + { + "id": "ui.activation.foreground-refused", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "195-290", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.activation.foreground-acquired", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "195-290", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.activation.ownership-refused", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "195-290", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.activation.invalid-window-refused", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "195-290", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.input.owned-key", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.input.foreign-foreground", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.input.foreign-focus", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.input.sibling-focus", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.input.missing-focus", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.input.dead-retained-process", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.input.hidden-window", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.input.disabled-window", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.input.owned-unicode", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.input.unicode-foreign-focus", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.input.owned-mouse", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.input.mouse-hit-loss", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.input.mouse-cursor-loss", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.input.mouse-foreign-foreground", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.input.owned-edit", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.input.edit-focus-loss-after-select", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.input.edit-focus-loss-after-read", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.input.owned-submenu-hover", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.input.submenu-foreign-foreground", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.input.submenu-unretained-process", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.input.submenu-covered-point", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.popup.predicate.owned-shell", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1221-1255", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.popup.predicate.owned-popup", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1221-1255", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.popup.predicate.foreign-foreground", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1221-1255", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.popup.predicate.foreign-foreground-root", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1221-1255", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.popup.predicate.foreign-hit", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1221-1255", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.popup.predicate.underlying-shell-hit", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1221-1255", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.popup.predicate.hidden-popup", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1221-1255", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.popup.predicate.unknown-owner", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1221-1255", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.popup.predicate.foreign-popup", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1221-1255", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.popup.predicate.absent-popup", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1221-1255", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.popup.effect.owned", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1256-1328", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.popup.effect.owned-enter", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1256-1328", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.popup.effect.foreign-foreground", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1256-1328", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.popup.effect.foreign-after-hover", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1256-1328", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.popup.effect.foreign-before-enter", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1256-1328", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.popup.effect.foreign-owner-all", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1256-1328", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.popup.effect.foreign-hit", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1256-1328", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.popup.effect.underlying-shell-hit", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1256-1328", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.popup.effect.hidden-popup", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1256-1328", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.popup.effect.cursor-changed", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1256-1328", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.popup.effect.cursor-unreadable", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1256-1328", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.geometry.seven-pixel-band", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7626-7643", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.geometry.multiple-covers", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7626-7643", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.gate-state.activation-reference", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7408-7430", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.gate-state.input-reference", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7408-7430", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.native.lookup.named-direct-viewport", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7431-7624", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.native.lookup.hidden-write-read", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7431-7624", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.native.lookup.missing-id", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7431-7624", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.native.lookup.missing-write-read", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7431-7624", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.native.lookup.direct-precedence", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7431-7624", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.native.lookup.wrong-root", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7431-7624", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.native.lookup.wrong-viewport", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7431-7624", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.native.lookup.other-sibling", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7431-7624", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.native.lookup.no-deeper-fallback", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7431-7624", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.native.lookup.direct-viewport-required", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7431-7624", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.native.lookup.native-form", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7431-7624", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.native.lookup.workspace-manager", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7431-7624", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.native.lookup.unknown-content", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7431-7624", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.native.lookup.null-parent", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7431-7624", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.native.hit.nested-edit", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7431-7624", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.native.hit.content-background", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7431-7624", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.native.hit.wrong-root", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7431-7624", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.native.hit.wrong-container", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7431-7624", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.native.hit.no-recursion", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7431-7624", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.native.hit.direct-child", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7431-7624", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.host-info.current-session", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5896-5924", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.activation.source.actual", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "169-223", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.activation.source.unshared", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "169-223", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.activation.source.missing-initial-owner", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "169-223", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.activation.source.missing-fresh-owner", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "169-223", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.activation.source.missing-restore", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "169-223", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.activation.source.missing-native-error", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "169-223", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.popup.source.actual", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1164-1220", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.popup.source.bypass-guard", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1164-1220", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.popup.source.stale-foreground", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1164-1220", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.popup.source.stale-cursor", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1164-1220", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.popup.source.missing-held-owner-check", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1164-1220", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.popup.source.missing-pre-cursor-guard", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1164-1220", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.popup.source.missing-pre-enter-guard", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1164-1220", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.popup.source.wrong-hit-root", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1164-1220", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.popup.source.extra-input", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1164-1220", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.mock.isolation.absent", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "19-88", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.mock.isolation.empty", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "19-88", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.mock.isolation.valued", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "19-88", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.mock.isolation.failure-identity", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "19-88", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.mock.isolation.ordinary-diagnostic", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "19-88", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.recovery.visibility.initially-empty", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "989-1162", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.recovery.visibility.initially-offscreen", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "989-1162", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.recovery.visibility.already-visible", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "989-1162", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.recovery.visibility.mark-only-empty", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "989-1162", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.recovery.visibility.delayed-visible", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "989-1162", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.recovery.visibility.outside-before-fit", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "989-1162", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.recovery.visibility.wrong-source-owner", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "989-1162", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.recovery.visibility.wrong-fit-owner", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "989-1162", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.recovery.visibility.fit-offscreen", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "989-1162", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.recovery.visibility.fit-disabled", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "989-1162", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.recovery.visibility.remains-empty", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "989-1162", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.recovery.visibility.remains-offscreen", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "989-1162", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.recovery.visibility.wrong-id-after-fit", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "989-1162", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.recovery.visibility.wrong-owner-after-fit", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "989-1162", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.recovery.visibility.outside-after-fit", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "989-1162", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.recovery.visibility.missing-after-fit", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "989-1162", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.recovery.visibility.mutant-skip-fit", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "989-1162", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.recovery.visibility.mutant-unsafe-offscreen-bounds", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "989-1162", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.recovery.visibility.mutant-wrong-identity-accepted", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "989-1162", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.recovery.visibility.mutant-outside-bounds-accepted", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "989-1162", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.delete.visibility.actual", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1330-1424", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.delete.visibility.missing-fit", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1330-1424", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.delete.visibility.wrong-identity", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1330-1424", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.delete.visibility.missing-offscreen", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1330-1424", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.delete.visibility.ungated-bounds", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1330-1424", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.delete.visibility.escaped-bounds", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1330-1424", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.delete.visibility.stale-visibility", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1330-1424", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.delete.visibility.missing-containment", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1330-1424", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.delete.visibility.missing-require", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1330-1424", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.delete.select.rejection", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.delete.select.success", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.delete.select.unavailable", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.delete.select.foreign-pid", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.delete.select.identity-drift", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.delete.select.zero-bounds", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.delete.select.offscreen", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.delete.select.disabled", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.delete.select.selected", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.delete.select.runtime-unavailable", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.delete.select.parent-unavailable", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.delete.select.telemetry-write-failure", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.delete.select.receipt-exists", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.delete.select.receipt-unconfigured", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.delete.select.deadline", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.delete.select.long-identity", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.delete.select.long-runtime", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.delete.select.invalid-boolean", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.delete.select.nonfinite-bounds", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.delete.select.exception-chain", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.delete.select.foreign-parent", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.host-info.diagnostics", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5896-5924", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.production.consumer-linkage", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "169-194;6751-6778;7408-7430", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5342", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5342-5344", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5350", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5350-5352", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5369", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5369-5374", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5528", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5528-5534", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5545", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5545-5548", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5572", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5572-5574", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5584", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5584-5586", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5973", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5973-5978", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5979", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5979-5986", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5987", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5987-6000", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6062", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6062-6065", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6097", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6097-6099", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6100", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6100-6103", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6165", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6165-6170", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6171", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6171-6181", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6199", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6199-6209", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6210", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6210-6214", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6215", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6215-6220", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6221", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6221-6225", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6226", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6226-6230", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6231", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6231-6239", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6246", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6246-6249", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6250", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6250-6255", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6257", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6257-6265", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6278", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6278-6284", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6285", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6285-6288", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6339", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6339-6344", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6345", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6345-6350", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6355", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6355-6372", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6417", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6417-6425", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6426", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6426-6429", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6430", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6430-6435", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6436", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6436-6439", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6440", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6440-6444", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6446", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6446-6453", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6454", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6454-6461", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6462", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6462-6465", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6472", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6472-6513", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6514", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6514-6516", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6520", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6520-6525", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6526", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6526-6541", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6542", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6542-6550", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6551", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6551-6577", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6677", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6677-6679", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6680", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6680-6750", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6773", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6773-6787", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6788", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6788-6800", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6801", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6801-6844", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6909", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6909-6915", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6974", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6974-7009", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-7042", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7042-7130", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-7348", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7348-7350", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-7351", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7351-7354", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-7355", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7355-7364", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-7365", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7365-7380", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-7381", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7381-7386", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-7387", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7387-7395", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-7396", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7396-7403", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.evaluator.unknown-operator", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "Compiled source evaluator negative controls", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.evaluator.unknown-resource", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "Compiled source evaluator negative controls", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.evaluator.missing-file", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "Compiled source evaluator negative controls", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.evaluator.malformed-tree", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "Compiled source evaluator negative controls", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.evaluator.actual-predicate-false", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "Compiled source evaluator negative controls", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.evaluator.wrong-production-text", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "Compiled source evaluator negative controls", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.evaluator.depth-boundary", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "Compiled source evaluator negative controls", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.evaluator.depth-overflow", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "Compiled source evaluator negative controls", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5346", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5346-5348", + "state": "Incomplete", + "owner": "Host" + }, + { + "id": "ui.root.source.line-5366", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5366-5368", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5380", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5380-5382", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5392", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5392-5394", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5396", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5396-5398", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5399", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5399-5401", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5407", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5407-5409", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5410", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5410-5413", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5432", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5432-5435", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5449", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5449-5451", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5457", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5457-5459", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5462", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5462-5464", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5470", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5470-5472", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5477", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5477-5479", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5485", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5485-5488", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5501", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5501-5503", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5514", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5514-5516", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5524", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5524-5526", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5537", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5537-5544", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5553", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5553-5555", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5561", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5561-5563", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5569", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5569-5571", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5580", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5580-5583", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5632", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5632-5641", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5659", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5659-5662", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5683", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5683-5687", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5696", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5696-5698", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5713", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5713-5715", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5724", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5724-5726", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5739", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5739-5741", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5752", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5752-5754", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5761", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5761-5763", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5773", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5773-5777", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5783", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5783-5785", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5792", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5792-5794", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5807", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5807-5809", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5812", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5812-5814", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5838", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5838-5838", + "state": "Incomplete", + "owner": "Ownership" + }, + { + "id": "ui.root.source.line-5845", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5845-5850", + "state": "Incomplete", + "owner": "Ownership" + }, + { + "id": "ui.root.source.line-5853", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5853-5856", + "state": "Incomplete", + "owner": "Ownership" + }, + { + "id": "ui.root.source.line-5859", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5859-5861", + "state": "Incomplete", + "owner": "Ownership" + }, + { + "id": "ui.root.source.line-5882", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5882-5888", + "state": "Incomplete", + "owner": "Ownership" + }, + { + "id": "ui.root.source.line-5897", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5897-5899", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5905", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5905-5908", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5914", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5914-5916", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5918", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5918-5922", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5945", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5945-5945", + "state": "Incomplete", + "owner": "Ownership" + }, + { + "id": "ui.root.source.line-5947", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5947-5949", + "state": "Incomplete", + "owner": "Ownership" + }, + { + "id": "ui.root.source.line-5970", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5970-5972", + "state": "Incomplete", + "owner": "Ownership" + }, + { + "id": "ui.root.source.line-6057", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6057-6061", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6093", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6093-6096", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6157", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6157-6164", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6182", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6182-6198", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6276", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6276-6276", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6292", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6292-6292", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6298", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6298-6298", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6305", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6305-6307", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6314", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6314-6321", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6326", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6326-6326", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6352", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6352-6354", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6412", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6412-6416", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6467", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6467-6471", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6591", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6591-6596", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6654", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6654-6676", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6754", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6754-6756", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6770", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6770-6772", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6853", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6853-6855", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6864", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6864-6864", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6866", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6866-6868", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6870", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6870-6872", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6877", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6877-6879", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6892", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6892-6894", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6905", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6905-6908", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6921", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6921-6923", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6925", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6925-6933", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6939", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6939-6941", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6958", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6958-6960", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6967", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6967-6973", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-7027", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7027-7041", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-7136", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7136-7138", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-7235", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7235-7239", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-7326", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7326-7328", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-7337", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7337-7339", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-7344", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7344-7346", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.promotion.enabled-heading", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7130-7323", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.connector.owned-pair", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7130-7323", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.connector.missing-source", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7130-7323", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.promotion.reordered", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7130-7323", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.promotion.disabled-action", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7130-7323", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.connector.missing-target", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7130-7323", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.connector.different-target-title", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7130-7323", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.promotion.extra-item", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7130-7323", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.connector.duplicate-source", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7130-7323", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.connector.same-endpoint", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7130-7323", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.promotion.separator-heading", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7130-7323", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.connector.blank-title", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7130-7323", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.promotion.wrong-caption", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7130-7323", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.promotion.exact", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7130-7323", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.connector.duplicate-target", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7130-7323", + "state": "Implemented", + "owner": "Ui" + } + ], + "sourceAdaptations": [ + { + "subject": "Activation", + "detail": "One actual Tools/windows/UiaWindowActivation.cs is linked by the shared production library. Test-only operations implement that same interface in a separate namespace." + }, + { + "subject": "Input and popup", + "detail": "Complete actual gate helper delegates to one UiaInputSafety instance. Only static-to-instance native receiver calls and shared interop types moved; policy bodies, diagnostics and observation order remain shared with typed operation-only fixtures." + }, + { + "subject": "Source guards", + "detail": "39 original literal observations target explicit current consumed production files. Legacy patterns and receiver-only changes are retained beside each assertion. Sleep ABI/policy and Unicode clear/read/call/record creation checks are split across explicit sources without relying on declaration order." + }, + { + "subject": "Delete context source correspondence", + "detail": "The frozen legacy source predicate expected direct PostContextMenu and Wait-ForPopupMenu calls. The actual frozen Cards gate already used Open-CanvasCardContextMenu. The migrated guard verifies its actual stable-identity callsite plus foreground, owner, visibility, finite clipped geometry, owned right-click, nonzero popup, enabled command ID, Cancel and teardown. All five original mutant obligations are retained against real current anchors." + }, + { + "subject": "No legacy execution", + "detail": "The exported blocked runner is a read-only case oracle, never an executed, extracted or embedded payload. Focused PS adapters are independently authored; only named actual production gate functions are invoked where PS scope, AST, ScriptProperty or ErrorRecord semantics are under test." + }, + { + "subject": "Evidence boundaries", + "detail": "Four activation,21 input,10 popup predicate,9 source,11 effect,14 hidden native lookup and6 hidden native hit cases remain explicit. No claim is made for native02, unrelated GUI walkthroughs, macOS parity, or a full host green while other mandatory families remain incomplete." + } + ] +} diff --git a/Tools/windows/ValidationRunner/ValidationRunner.csproj b/Tools/windows/ValidationRunner/ValidationRunner.csproj new file mode 100644 index 00000000..5a65c0f1 --- /dev/null +++ b/Tools/windows/ValidationRunner/ValidationRunner.csproj @@ -0,0 +1,22 @@ + + + Exe + net10.0-windows + true + true + enable + enable + true + false + + + + + + + + + + + + diff --git a/Tools/windows/ValidationRunner/global.json b/Tools/windows/ValidationRunner/global.json new file mode 100644 index 00000000..794b4150 --- /dev/null +++ b/Tools/windows/ValidationRunner/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.403", + "rollForward": "disable", + "allowPrerelease": false + } +} diff --git a/Tools/windows/bootstrap.ps1 b/Tools/windows/bootstrap.ps1 index ac3075ff..cfcd8d92 100644 --- a/Tools/windows/bootstrap.ps1 +++ b/Tools/windows/bootstrap.ps1 @@ -17,6 +17,7 @@ $ErrorActionPreference = "Stop" # lines instead of PowerShell's high-overhead per-record rendering. $ProgressPreference = "SilentlyContinue" $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..") +. (Join-Path $PSScriptRoot "DotNetSdk.ps1") if (-not $ToolRoot) { $ToolRoot = Join-Path $repoRoot ".graphcode-tools" } @@ -68,6 +69,7 @@ function Test-BootstrapPathBudget( Test-BootstrapPathBudget $repoRoot $ProviderRoot New-Item -ItemType Directory -Force $ToolRoot, $ProviderRoot | Out-Null +$dotnet = Resolve-GraphCodeDotNetSdk -RepositoryRoot $repoRoot -ToolRoot $ToolRoot -InstallIfMissing function Format-ZigDownloadSize([long] $Bytes) { if ($Bytes -lt 1KB) { @@ -506,6 +508,8 @@ if (-not $swift -and -not $SkipSwift) { } $values = [ordered]@{ + GRAPHCODE_DOTNET10403 = $dotnet + DOTNET_ROOT = [IO.Path]::GetDirectoryName($dotnet) GRAPHCODE_ZIG0152 = $zig0152 GRAPHCODE_ZIG0160 = $zig0160 GRAPHCODE_WINGHOSTTY_ROOT = $winghosttyRoot diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 92e7e804..80a7fef0 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -5,1388 +5,24 @@ param( [string[]] $ArgumentList = @(), [switch]$SidebarParityOnly, [switch]$UnwiredRecoveryOnly, - [switch]$CycleHierarchyOnly + [switch]$CycleHierarchyOnly, + [string] $DeleteSelectFailureReceipt = $env:GRAPHCODE_UIA_DELETE_SELECT_FAILURE_RECEIPT ) $ErrorActionPreference = "Stop" -Add-Type -AssemblyName UIAutomationClient -Add-Type -AssemblyName UIAutomationTypes -Add-Type -AssemblyName System.Drawing -Add-Type -TypeDefinition @" -using System; -using System.Runtime.InteropServices; -using System.Text; -using System.Windows.Automation; -public static class GraphCodeUiaGateState { - public static volatile bool LiveObserved; - public static volatile bool NamePropertyObserved; - public static volatile bool FocusObserved; - public static int LiveEvents; - public static int NamePropertyEvents; - public static int SelectedEvents; - public static int AddedEvents; - public static int RemovedEvents; - public static int TogglePropertyEvents; - public static uint LastEditClearExpected; - public static uint LastEditClearSent; - public static uint LastEditTextExpected; - public static uint LastEditTextSent; - public static bool LastEditUsedMessageFallback; - public static string LiveSourceAutomationId; - public static string LiveSourceName; - public static string LiveSourceRuntimeId; - public static string NamePropertySourceAutomationId; - public static string FocusSourceAutomationId; - public static string SelectionSourceAutomationId; - public static string SelectionSourceRuntimeId; - public static string TogglePropertySourceAutomationId; - public static readonly AutomationEventHandler LiveHandler = HandleLive; - public static readonly AutomationPropertyChangedEventHandler NamePropertyHandler = HandleNameProperty; - public static readonly AutomationFocusChangedEventHandler FocusHandler = HandleFocus; - public static readonly AutomationEventHandler SelectedHandler = HandleSelected; - public static readonly AutomationEventHandler AddedHandler = HandleAdded; - public static readonly AutomationEventHandler RemovedHandler = HandleRemoved; - public static readonly AutomationPropertyChangedEventHandler TogglePropertyHandler = HandleToggleProperty; - private static void HandleLive(object sender, AutomationEventArgs eventArgs) { - var element = sender as AutomationElement; - if (element == null) return; - LiveSourceAutomationId = element.Current.AutomationId; - LiveSourceName = element.Current.Name; - LiveSourceRuntimeId = String.Join(",", element.GetRuntimeId()); - LiveEvents++; - LiveObserved = true; - } - private static void HandleNameProperty(object sender, AutomationPropertyChangedEventArgs eventArgs) { - var element = sender as AutomationElement; - if (element != null) NamePropertySourceAutomationId = element.Current.AutomationId; - NamePropertyEvents++; - NamePropertyObserved = true; - } - private static void HandleFocus(object sender, AutomationFocusChangedEventArgs eventArgs) { - var element = sender as AutomationElement; - if (element != null) FocusSourceAutomationId = element.Current.AutomationId; - FocusObserved = true; - } - private static void HandleSelected(object sender, AutomationEventArgs eventArgs) { - var element = sender as AutomationElement; - if (element != null) { - SelectionSourceAutomationId = element.Current.AutomationId; - SelectionSourceRuntimeId = String.Join(",", element.GetRuntimeId()); - } - SelectedEvents++; - } - private static void HandleAdded(object sender, AutomationEventArgs eventArgs) { - var element = sender as AutomationElement; - if (element != null) { - SelectionSourceAutomationId = element.Current.AutomationId; - SelectionSourceRuntimeId = String.Join(",", element.GetRuntimeId()); - } - AddedEvents++; - } - private static void HandleRemoved(object sender, AutomationEventArgs eventArgs) { - var element = sender as AutomationElement; - if (element != null) { - SelectionSourceAutomationId = element.Current.AutomationId; - SelectionSourceRuntimeId = String.Join(",", element.GetRuntimeId()); - } - RemovedEvents++; - } - private static void HandleToggleProperty(object sender, AutomationPropertyChangedEventArgs eventArgs) { - var element = sender as AutomationElement; - if (element != null) TogglePropertySourceAutomationId = element.Current.AutomationId; - TogglePropertyEvents++; - } - [DllImport("user32.dll", SetLastError = true)] - private static extern bool PostMessage(IntPtr window, uint message, UIntPtr wParam, IntPtr lParam); - [DllImport("user32.dll")] - private static extern IntPtr SendMessage(IntPtr window, uint message, UIntPtr wParam, IntPtr lParam); - [DllImport("user32.dll", EntryPoint = "SendMessageTimeoutW", SetLastError = true)] - private static extern IntPtr SendMessageTimeout( - IntPtr window, uint message, UIntPtr wParam, IntPtr lParam, - uint flags, uint timeoutMilliseconds, out UIntPtr result - ); - [DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "SendMessageW")] - private static extern IntPtr SendMessageString(IntPtr window, uint message, UIntPtr wParam, string lParam); - [DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "SendMessageW")] - private static extern IntPtr SendMessageText( - IntPtr window, uint message, UIntPtr wParam, StringBuilder lParam - ); - [DllImport("user32.dll", CharSet = CharSet.Unicode)] - private static extern IntPtr FindWindowEx(IntPtr parent, IntPtr childAfter, string className, string windowName); - private delegate bool EnumWindowsProc(IntPtr window, IntPtr parameter); - [DllImport("user32.dll")] - private static extern bool EnumWindows(EnumWindowsProc callback, IntPtr parameter); - [DllImport("user32.dll")] - private static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId); - [DllImport("user32.dll", CharSet = CharSet.Unicode)] - private static extern int GetClassName(IntPtr window, StringBuilder className, int capacity); - [DllImport("user32.dll", CharSet = CharSet.Unicode)] - private static extern bool SetWindowText(IntPtr window, string text); - [DllImport("user32.dll")] - private static extern int GetDlgCtrlID(IntPtr window); - [DllImport("user32.dll")] - private static extern IntPtr GetDlgItem(IntPtr dialog, int controlId); - [DllImport("user32.dll")] - private static extern IntPtr SetFocus(IntPtr window); - [DllImport("user32.dll")] - private static extern IntPtr GetFocus(); - [DllImport("user32.dll", SetLastError = true)] - private static extern bool SetForegroundWindow(IntPtr window); - [DllImport("user32.dll")] - private static extern IntPtr GetForegroundWindow(); - [DllImport("user32.dll", SetLastError = true)] - private static extern bool BringWindowToTop(IntPtr window); - [DllImport("kernel32.dll")] - private static extern uint GetCurrentThreadId(); - [DllImport("kernel32.dll")] - private static extern void Sleep(uint milliseconds); - [DllImport("user32.dll", SetLastError = true)] - private static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool attach); - [DllImport("user32.dll", CharSet = CharSet.Unicode)] - private static extern int GetWindowText(IntPtr window, StringBuilder text, int count); - [DllImport("user32.dll", CharSet = CharSet.Unicode)] - private static extern int GetWindowTextLength(IntPtr window); - [DllImport("user32.dll")] - private static extern bool ShowWindow(IntPtr window, int command); - [DllImport("user32.dll")] - private static extern IntPtr SetActiveWindow(IntPtr window); - [DllImport("user32.dll")] - private static extern void keybd_event(byte virtualKey, byte scanCode, uint flags, UIntPtr extraInfo); - [DllImport("user32.dll")] - private static extern bool IsWindowVisible(IntPtr window); - [DllImport("user32.dll")] - private static extern bool IsWindowEnabled(IntPtr window); - [DllImport("user32.dll")] - private static extern int GetMenuItemCount(IntPtr menu); - [DllImport("user32.dll")] - private static extern uint GetMenuItemID(IntPtr menu, int position); - [DllImport("user32.dll")] - private static extern uint GetMenuState(IntPtr menu, uint item, uint flags); - [DllImport("user32.dll")] - private static extern bool GetMenuItemRect(IntPtr window, IntPtr menu, uint position, out RECT rect); - [DllImport("user32.dll", SetLastError = true)] - private static extern bool SetCursorPos(int x, int y); - [DllImport("user32.dll", SetLastError = true)] - private static extern bool GetCursorPos(out ScreenPoint point); - [StructLayout(LayoutKind.Sequential)] - private struct MouseInput { - public int X, Y; - public uint MouseData, Flags, Time; - public UIntPtr ExtraInfo; - } - [StructLayout(LayoutKind.Sequential)] - private struct Input { - public uint Type; - public MouseInput Mouse; - } - [DllImport("user32.dll", SetLastError = true)] - private static extern uint SendInput(uint count, Input[] inputs, int size); - [DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "GetMenuStringW")] - private static extern int GetMenuString(IntPtr menu, uint item, StringBuilder text, int max, uint flags); - // The File/Loop/Terminal/Workspace/View/Help bar is a real SetMenu menu bar, - // not a TrackPopupMenu popup, so (unlike the popup context menu below) its - // live enabled state is readable directly through the Win32 menu API against - // GetMenu(shell) -- no MN_GETHMENU / popup-window workaround required. - // MF_BYCOMMAND (0) searches the whole menu tree for the command identifier. - public static uint MenuCommandState(IntPtr menuBar, uint command) { - if (menuBar == IntPtr.Zero) return 0xFFFFFFFF; - return GetMenuState(menuBar, command, 0x0000); - } - public static IntPtr GetMenuBar(IntPtr window) { - return GetMenu(window); - } - public static IntPtr FindPopupMenuWindow(uint processId) { - IntPtr result = IntPtr.Zero; - EnumWindows(delegate(IntPtr window, IntPtr parameter) { - uint owner; - GetWindowThreadProcessId(window, out owner); - if (owner != processId) return true; - var actualClass = new StringBuilder(256); - GetClassName(window, actualClass, actualClass.Capacity); - if (!String.Equals(actualClass.ToString(), "#32768", StringComparison.Ordinal)) return true; - if (!IsWindowVisible(window)) return true; - result = window; - return false; - }, IntPtr.Zero); - return result; - } - public static IntPtr FindVisibleOwnedSheetByContentClass( - uint processId, IntPtr expectedOwner, string contentClass) { - IntPtr result = IntPtr.Zero; - EnumWindows(delegate(IntPtr window, IntPtr parameter) { - uint owner; - GetWindowThreadProcessId(window, out owner); - if (owner == processId && IsWindowVisible(window) && - GetWindow(window, 4) == expectedOwner && - String.Equals(WindowClass(window), "GraphCodeSheetHost", StringComparison.Ordinal) && - String.Equals(SheetContentClass(window), contentClass, StringComparison.Ordinal)) { - result = window; - return false; - } - return true; - }, IntPtr.Zero); - return result; - } - public static int CountVisibleOwnedSheetsByContentClass( - uint processId, IntPtr expectedOwner, string contentClass) { - int count = 0; - EnumWindows(delegate(IntPtr window, IntPtr parameter) { - uint owner; - GetWindowThreadProcessId(window, out owner); - if (owner == processId && IsWindowVisible(window) && - GetWindow(window, 4) == expectedOwner && - String.Equals(WindowClass(window), "GraphCodeSheetHost", StringComparison.Ordinal) && - String.Equals(SheetContentClass(window), contentClass, StringComparison.Ordinal)) { - count++; - } - return true; - }, IntPtr.Zero); - return count; - } - public static IntPtr FindVisibleOwnedSheet(uint processId, string title) { - IntPtr result = IntPtr.Zero; - EnumWindows(delegate(IntPtr window, IntPtr parameter) { - uint owner; - GetWindowThreadProcessId(window, out owner); - if (owner == processId && IsWindowVisible(window) && - String.Equals(WindowClass(window), "GraphCodeSheetHost", StringComparison.Ordinal) && - String.Equals(WindowTitle(window), title, StringComparison.Ordinal)) { - result = window; - return false; - } - return true; - }, IntPtr.Zero); - return result; - } - // MN_GETHMENU. The live UIA tree exposes a popup menu only as an empty Pane - // with no MenuItem children, so the menu itself is read through the Win32 - // menu API against the real HMENU the shell handed to TrackPopupMenu. - public static IntPtr PopupMenuHandle(IntPtr popup) { - if (popup == IntPtr.Zero) return IntPtr.Zero; - return SendMessage(popup, 0x01E1, UIntPtr.Zero, IntPtr.Zero); - } - public static IntPtr FindPopupForMenu(uint processId, IntPtr menu) { - IntPtr result = IntPtr.Zero; - EnumWindows(delegate(IntPtr window, IntPtr parameter) { - uint owner; - GetWindowThreadProcessId(window, out owner); - if (owner == processId && IsWindowVisible(window) && - String.Equals(ClassOf(window), "#32768", StringComparison.Ordinal) && - PopupMenuHandle(window) == menu) { - result = window; - return false; - } - return true; - }, IntPtr.Zero); - return result; - } - public static bool HoverPopupMenuItem(IntPtr owner, IntPtr menu, int position) { - RECT item; - if (menu == IntPtr.Zero || !GetMenuItemRect(owner, menu, (uint)position, out item)) return false; - int x = item.Right - Math.Min(12, (item.Right - item.Left) / 2); - int y = (item.Top + item.Bottom) / 2; - if (!SetCursorPos(x, y)) - throw new InvalidOperationException(String.Format("SetCursorPos for submenu failed: Win32Error={0}", Marshal.GetLastWin32Error())); - ScreenPoint at; - if (!GetCursorPos(out at)) return false; - uint ownerProcess; - GetWindowThreadProcessId(owner, out ownerProcess); - IntPtr popup = FindPopupForMenu(ownerProcess, menu); - if (popup == IntPtr.Zero) return false; - var client = new ScreenPoint { X = x, Y = y }; - if (!ScreenToClient(popup, ref client)) return false; - SendMessage(popup, 0x0200, UIntPtr.Zero, MouseLParam(client.X, client.Y)); - bool hilite = false; - for (int attempt = 0; attempt < 50 && !hilite; attempt++) { - hilite = (GetMenuState(menu, (uint)position, 0x0400) & 0x0080) != 0; - if (!hilite) Sleep(10); - } - return at.X == x && at.Y == y && hilite; - } - // Keyboard reveal of a real submenu: hilite the parent item (MN_SELECTITEM) - // and post VK_RIGHT, the same navigation a keyboard user performs. Unlike a - // WM_COMMAND posted to the shell (which TrackPopupMenu/TPM_RETURNCMD menus - // never route), this opens the shell's own submenu popup. - public static bool RevealSubmenuByKeyboard(IntPtr popup, IntPtr menu, int position) { - if (popup == IntPtr.Zero || menu == IntPtr.Zero || !IsWindowVisible(popup)) return false; - SendMessage(popup, 0x01E5, (UIntPtr)position, IntPtr.Zero); - if ((GetMenuState(menu, (uint)position, 0x0400) & 0x0080) == 0) return false; - return PostMessage(popup, 0x0100, (UIntPtr)0x27, IntPtr.Zero) && - PostMessage(popup, 0x0101, (UIntPtr)0x27, IntPtr.Zero); - } - public static int PopupMenuItemCount(IntPtr menu) { - if (menu == IntPtr.Zero) return -1; - return GetMenuItemCount(menu); - } - public static uint PopupMenuItemId(IntPtr menu, int position) { - if (menu == IntPtr.Zero) return 0; - return GetMenuItemID(menu, position); - } - public static string PopupMenuItemText(IntPtr menu, int position) { - if (menu == IntPtr.Zero) return ""; - var text = new StringBuilder(512); - GetMenuString(menu, (uint)position, text, text.Capacity, 0x0400); - return text.ToString(); - } - public static uint PopupMenuItemState(IntPtr menu, int position) { - if (menu == IntPtr.Zero) return 0xFFFFFFFF; - return GetMenuState(menu, (uint)position, 0x0400); - } - // MainWindow.wm_uia_context_menu (WM_APP + 44). - public static bool PostContextMenu(IntPtr window, uint target) { - return PostMessage(window, 0x802C, (UIntPtr)target, IntPtr.Zero); - } - // MainWindow.wm_uia_present_form (WM_APP + 45). - public static bool PostPresentForm(IntPtr window, uint form) { - return PostMessage(window, 0x802D, (UIntPtr)form, IntPtr.Zero); - } - public static bool DismissPopupMenu(IntPtr popup, IntPtr owner) { - bool posted = popup != IntPtr.Zero && - PostMessage(popup, 0x0100, (UIntPtr)0x1B, IntPtr.Zero); - if (!posted && owner != IntPtr.Zero) { - SendMessage(owner, 0x001F, UIntPtr.Zero, IntPtr.Zero); - } - return posted; - } - public static void CancelPopupMenu(IntPtr owner) { - if (owner != IntPtr.Zero) PostMessage(owner, 0x001F, UIntPtr.Zero, IntPtr.Zero); - } - public static IntPtr FindChild(IntPtr parent, string className) { - return FindWindowEx(parent, IntPtr.Zero, className, null); - } - public static IntPtr FindTopLevel(string className, uint processId) { - IntPtr result = IntPtr.Zero; - EnumWindows(delegate(IntPtr window, IntPtr parameter) { - uint owner; - GetWindowThreadProcessId(window, out owner); - if (owner != processId) return true; - var actualClass = new StringBuilder(256); - GetClassName(window, actualClass, actualClass.Capacity); - if (!String.Equals(actualClass.ToString(), className, StringComparison.Ordinal)) return true; - result = window; - return false; - }, IntPtr.Zero); - return result; - } - public static string[] GetListItems(IntPtr list) { - if (list == IntPtr.Zero) return new string[0]; - int count = (int)SendMessage(list, 0x018B, UIntPtr.Zero, IntPtr.Zero); - var result = new string[count]; - for (int index = 0; index < count; index++) { - int length = (int)SendMessage(list, 0x018A, (UIntPtr)index, IntPtr.Zero); - var text = new StringBuilder(length + 1); - SendMessageText(list, 0x0189, (UIntPtr)index, text); - result[index] = text.ToString(); - } - return result; - } - public static bool PostFixtureMutation(IntPtr window, uint mutation) { - return PostMessage(window, 0x802A, (UIntPtr)mutation, IntPtr.Zero); - } - public static bool PostPaletteRefresh(IntPtr window) { - return PostMessage(window, 0x802B, UIntPtr.Zero, IntPtr.Zero); - } - public static bool PostTaggedExitCollision(IntPtr window) { - return PostMessage(window, 0x0111, new UIntPtr(0x8000000000001008UL), IntPtr.Zero); - } - public static bool PostKeyboard(IntPtr window, uint key) { - return PostMessage(window, 0x0100, (UIntPtr)key, IntPtr.Zero) && - PostMessage(window, 0x0101, (UIntPtr)key, IntPtr.Zero); - } - public static bool SendReturn(IntPtr window) { - if (window == IntPtr.Zero) return false; - SendMessage(window, 0x0100, (UIntPtr)0x0D, IntPtr.Zero); - return true; - } - public static bool SendCommand(IntPtr window, uint command) { - if (window == IntPtr.Zero) return false; - UIntPtr result; - return SendMessageTimeout( - window, 0x0111, (UIntPtr)command, IntPtr.Zero, - 0x0001 | 0x0002, 5000, out result - ) != IntPtr.Zero; - } - public static bool ClickButton(IntPtr button) { - if (button == IntPtr.Zero) return false; - SendMessage(button, 0x00F5, UIntPtr.Zero, IntPtr.Zero); - return true; - } - public static int GetCheckState(IntPtr window) { - if (window == IntPtr.Zero) return -1; - return (int)SendMessage(window, 0x00F0, UIntPtr.Zero, IntPtr.Zero); - } - public static bool FocusControl(IntPtr parent, IntPtr control) { - if (parent == IntPtr.Zero || control == IntPtr.Zero) return false; - ActivateWindow(parent); - uint ignoredProcessId; - uint parentThread = GetWindowThreadProcessId(parent, out ignoredProcessId); - uint currentThread = GetCurrentThreadId(); - bool attached = currentThread != parentThread && - AttachThreadInput(currentThread, parentThread, true); - try { - SetFocus(control); - return IsForegroundWindow(parent) && GetFocus() == control; - } finally { - if (attached) AttachThreadInput(currentThread, parentThread, false); - } - } - public static IntPtr FocusedControlInDialog(IntPtr parent) { - if (parent == IntPtr.Zero) return IntPtr.Zero; - uint ignoredProcessId; - uint parentThread = GetWindowThreadProcessId(parent, out ignoredProcessId); - uint currentThread = GetCurrentThreadId(); - bool attached = currentThread != parentThread && - AttachThreadInput(currentThread, parentThread, true); - try { - return GetFocus(); - } finally { - if (attached) AttachThreadInput(currentThread, parentThread, false); - } - } - public static bool IsControlOwnedBy(IntPtr parent, IntPtr control, int controlId) { - return parent != IntPtr.Zero && control != IntPtr.Zero && - GetDlgCtrlID(control) == controlId && GetAncestor(control, 2) == parent; - } - public static int ControlIdOf(IntPtr control) { - return control == IntPtr.Zero ? 0 : GetDlgCtrlID(control); - } - public static bool HasVisibleBounds(IntPtr window) { - RECT rect; - return window != IntPtr.Zero && IsWindowVisible(window) && - GetWindowRect(window, out rect) && rect.Right > rect.Left && rect.Bottom > rect.Top; - } - public static string LastActivationDiagnostic = "not attempted"; - public static bool ActivateWindow(IntPtr window) { - if (window == IntPtr.Zero) { - LastActivationDiagnostic = "invalid target HWND"; - return false; - } - IntPtr foreground = GetForegroundWindow(); - uint ignoredForegroundProcessId; - uint foregroundThread = foreground == IntPtr.Zero ? 0 : - GetWindowThreadProcessId(foreground, out ignoredForegroundProcessId); - uint ignoredTargetProcessId; - uint targetThread = GetWindowThreadProcessId(window, out ignoredTargetProcessId); - uint currentThread = GetCurrentThreadId(); - bool needsForegroundAttach = foregroundThread != 0 && currentThread != foregroundThread; - bool attachForeground = !needsForegroundAttach || - AttachThreadInput(currentThread, foregroundThread, true); int foregroundAttachError = Marshal.GetLastWin32Error(); - bool needsTargetAttach = currentThread != targetThread; - bool attachTarget = !needsTargetAttach || - AttachThreadInput(currentThread, targetThread, true); int targetAttachError = Marshal.GetLastWin32Error(); - try { - ShowWindow(window, 9); - bool broughtToTop = BringWindowToTop(window); - int bringError = Marshal.GetLastWin32Error(); - keybd_event(0x12, 0, 0, UIntPtr.Zero); - keybd_event(0x12, 0, 0x0002, UIntPtr.Zero); - SetActiveWindow(window); - bool foregroundSet = SetForegroundWindow(window); int foregroundError = Marshal.GetLastWin32Error(); - bool observed = IsForegroundWindow(window); - LastActivationDiagnostic = String.Format( - "AttachThreadInput(foreground)={0} GetLastError={1} AttachThreadInput(target)={2} GetLastError={3} BringWindowToTop={4} GetLastError={5} SetForegroundWindow={6} GetLastError={7} (FALSE can leave no meaningful last error) observedForeground={8}", - attachForeground, needsForegroundAttach && !attachForeground ? foregroundAttachError : 0, - attachTarget, needsTargetAttach && !attachTarget ? targetAttachError : 0, - broughtToTop, broughtToTop ? 0 : bringError, - foregroundSet, foregroundSet ? 0 : foregroundError, observed - ); - return observed; - } finally { - if (needsTargetAttach && attachTarget) AttachThreadInput(currentThread, targetThread, false); - if (needsForegroundAttach && attachForeground) AttachThreadInput(currentThread, foregroundThread, false); - } - } - public static bool IsForegroundWindow(IntPtr window) { - return window != IntPtr.Zero && GetForegroundWindow() == window; - } - public static IntPtr CurrentForegroundWindow() { - return GetForegroundWindow(); - } - public static uint WindowProcessId(IntPtr window) { - if (window == IntPtr.Zero) return 0; - uint processId; - GetWindowThreadProcessId(window, out processId); - return processId; - } - public static string WindowClass(IntPtr window) { - if (window == IntPtr.Zero) return ""; - var text = new StringBuilder(256); - GetClassName(window, text, text.Capacity); - return text.ToString(); - } - public static string WindowTitle(IntPtr window) { - if (window == IntPtr.Zero) return ""; - int length = GetWindowTextLength(window); - var text = new StringBuilder(length + 1); - GetWindowText(window, text, text.Capacity); - return text.ToString(); - } - public static void HideWindow(IntPtr window) { - if (window != IntPtr.Zero) ShowWindow(window, 0); - } - public static void HideProcessWindows(uint processId) { - EnumWindows(delegate(IntPtr window, IntPtr parameter) { - uint owner; - GetWindowThreadProcessId(window, out owner); - if (owner == processId) HideWindow(window); - return true; - }, IntPtr.Zero); - } - // Diagnostic only (Tools/windows/uia-live-gate.ps1's Get-DirectChildren - // exhaustion path): enumerates every top-level window currently owned by - // processId with its class, visibility, and title, so a failure that - // reports MainWindowHandle == 0 can be told apart as window-destroyed - // (empty result), window-hidden (a result exists but is not visible), or - // handle-churn (a visible, healthy-looking window exists under a handle - // Process.MainWindowHandle no longer reports). Does not filter or assert - // anything - it is read verbatim into a log line. - public static string[] DescribeTopLevelWindows(uint processId) { - var results = new System.Collections.ArrayList(); - EnumWindows(delegate(IntPtr window, IntPtr parameter) { - uint owner; - GetWindowThreadProcessId(window, out owner); - if (owner != processId) return true; - var classText = new StringBuilder(256); - GetClassName(window, classText, classText.Capacity); - int titleLength = GetWindowTextLength(window); - var titleText = new StringBuilder(titleLength + 1); - GetWindowText(window, titleText, titleText.Capacity); - bool visible = IsWindowVisible(window); - results.Add(window.ToInt64() + ":" + classText.ToString() + ":" + - (visible ? "visible" : "hidden") + ":" + titleText.ToString()); - return true; - }, IntPtr.Zero); - return (string[])results.ToArray(typeof(string)); - } - public static bool PostMouseClick(IntPtr window) { - return PostMessage(window, 0x0201, UIntPtr.Zero, IntPtr.Zero); - } - // A modal disables its owner for as long as it is up. Win32 refuses a caption - // close against a WS_DISABLED window, so the owner must be observed enabled - // again before a close can be attributed to the shell rather than to a modal - // that has not finished tearing down. - public static bool WindowIsEnabled(IntPtr window) { - return IsWindowEnabled(window); - } - public static bool WindowIsVisible(IntPtr window) { - return window != IntPtr.Zero && IsWindowVisible(window); - } - public static IntPtr WindowOwner(IntPtr window) { - return window == IntPtr.Zero ? IntPtr.Zero : GetWindow(window, 4); - } - public static bool IsCaptionlessSheet(IntPtr window) { - if (window == IntPtr.Zero || - !String.Equals(WindowClass(window), "GraphCodeSheetHost", StringComparison.Ordinal)) - return false; - ulong style = unchecked((ulong)GetWindowLongPtr(window, -16).ToInt64()); - return (style & 0x80000000UL) != 0 && (style & 0x00C00000UL) == 0; - } - public static string SheetContentClass(IntPtr window) { - if (window == IntPtr.Zero || - !String.Equals(WindowClass(window), "GraphCodeSheetHost", StringComparison.Ordinal)) - return ""; - IntPtr viewport = FindWindowEx(window, IntPtr.Zero, "GraphCodeSheetViewport", null); - if (viewport == IntPtr.Zero) return ""; - string[] contentClasses = new[] { - "GraphCodeWindowsDialogContent", - "GraphCodeNativeForm", - "GraphCodeProductSettings", - "GraphCodeWorkspaceManager", - "GraphCodeRepositoryIngressDialog", - "GraphCodeRepositoryOperationDialog", - "GraphCodeCodespaceIngressDialog", - "GraphCodeUpdateOffer", - "GraphCodeUpdateInstall" - }; - foreach (string contentClass in contentClasses) - if (FindWindowEx(viewport, IntPtr.Zero, contentClass, null) != IntPtr.Zero) - return contentClass; - return ""; - } - public static IntPtr FindVisibleProcessWindow(uint processId, string title) { - IntPtr result = IntPtr.Zero; - EnumWindows(delegate(IntPtr window, IntPtr parameter) { - uint owner; - GetWindowThreadProcessId(window, out owner); - if (owner == processId && IsWindowVisible(window) && - String.Equals(WindowTitle(window), title, StringComparison.Ordinal)) { - result = window; - return false; - } - return true; - }, IntPtr.Zero); - return result; - } - // Real client-coordinate mouse messages posted directly to the target window, - // matching the same WM_MOUSEMOVE/WM_LBUTTONDOWN/WM_LBUTTONUP messages the OS - // delivers for genuine mouse input, without moving the shared desktop's real - // cursor (multiple fleet sessions share this desktop). PostMouseButtonAt sets - // the MK_LBUTTON flag in wParam, matching the wParam a real WM_LBUTTONDOWN/UP - // carries; ClickAt below (and its existing sidebar-update-banner caller) keep - // working unchanged since App.zig's click handling only decodes the lParam - // x/y, not the button-state bits in wParam. - private static IntPtr MouseLParam(int x, int y) { - return (IntPtr)(((y & 0xFFFF) << 16) | (x & 0xFFFF)); - } - public static bool PostMouseButtonAt(IntPtr window, uint message, int x, int y) { - return PostMessage(window, message, (UIntPtr)0x0001, MouseLParam(x, y)); - } - public static bool ClickAt(IntPtr window, int x, int y) { - return PostMouseButtonAt(window, 0x0201, x, y) && PostMouseButtonAt(window, 0x0202, x, y); - } - public static bool PostMouseMoveAt(IntPtr window, int clientX, int clientY) { - return PostMessage(window, 0x0200, UIntPtr.Zero, MouseLParam(clientX, clientY)); - } - public static bool PostMouseDragMoveAt(IntPtr window, int clientX, int clientY) { - return PostMessage(window, 0x0200, (UIntPtr)0x0001, MouseLParam(clientX, clientY)); - } - public static IntPtr SendMouseButtonAt(IntPtr window, uint message, int clientX, int clientY) { - return SendMessage(window, message, (UIntPtr)0x0001, MouseLParam(clientX, clientY)); - } - public static bool PostMouseClickAt(IntPtr window, int clientX, int clientY) { - return PostMouseButtonAt(window, 0x0201, clientX, clientY) && - PostMouseButtonAt(window, 0x0202, clientX, clientY); - } - public static bool PostRightClickAt(IntPtr window, int clientX, int clientY) { - IntPtr point = MouseLParam(clientX, clientY); - return PostMessage(window, 0x0204, (UIntPtr)0x0002, point) && - PostMessage(window, 0x0205, UIntPtr.Zero, point); - } - public static int[] ClickOwnedScreenRectangle(IntPtr owner, int left, int top, int right, int bottom, bool rightClick) { - if (!WindowIsVisible(owner) || right <= left || bottom <= top) - throw new InvalidOperationException("Owned rectangle is not visibly hittable"); - var point = new ScreenPoint { X = (left + right) / 2, Y = (top + bottom) / 2 }; - IntPtr hit = WindowFromPoint(point); - if (hit == IntPtr.Zero || GetAncestor(hit, 2) != owner) - throw new InvalidOperationException("Live rectangle is covered by another top-level window"); - if (!SetCursorPos(point.X, point.Y)) - throw new InvalidOperationException("Unable to move cursor into owned live rectangle"); - ScreenPoint actual; - if (!GetCursorPos(out actual) || actual.X != point.X || actual.Y != point.Y) - throw new InvalidOperationException("Owned live-rectangle cursor position differs"); - var inputs = new Input[] { - new Input { Type = 0, Mouse = new MouseInput { Flags = rightClick ? 0x0008u : 0x0002u } }, - new Input { Type = 0, Mouse = new MouseInput { Flags = rightClick ? 0x0010u : 0x0004u } } - }; - uint sent = SendInput((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(Input))); - if (sent != inputs.Length) - throw new InvalidOperationException(String.Format("Owned rectangle SendInput sent {0}/{1}", sent, inputs.Length)); - return new int[] { left, top, right, bottom, actual.X, actual.Y, (int)sent, inputs.Length }; - } - public static bool PostOwnedScreenPoint(IntPtr owner, int screenX, int screenY, bool rightClick) { - if (!WindowIsVisible(owner)) return false; - var point = new ScreenPoint { X = screenX, Y = screenY }; - IntPtr hit = WindowFromPoint(point); - if (hit == IntPtr.Zero || GetAncestor(hit, 2) != owner || !ScreenToClient(owner, ref point)) - return false; - if (rightClick) { - return PostMessage(owner, 0x0204, (UIntPtr)0x0002, MouseLParam(point.X, point.Y)) && - PostMessage(owner, 0x0205, UIntPtr.Zero, MouseLParam(point.X, point.Y)); - } - return PostMessage(owner, 0x0201, (UIntPtr)0x0001, MouseLParam(point.X, point.Y)) && - PostMessage(owner, 0x0202, UIntPtr.Zero, MouseLParam(point.X, point.Y)); - } - public sealed class PopupItemHit { - public int Position, ItemId, Left, Top, Right, Bottom; - public int ScreenX, ScreenY, ClientX, ClientY, CursorBeforeX, CursorBeforeY; - public int CursorAtX, CursorAtY; - public bool Hilite, UsedKeyboardFallback, UsedSelectItemFallback; - public bool HiliteAtEnterFallback, HiliteBeforeEnter; - } - public static PopupItemHit ClickPopupMenuItem(IntPtr popup, IntPtr owner, int position, int commandId) { - if (popup == IntPtr.Zero || position < 0) return null; - IntPtr menu = PopupMenuHandle(popup); - RECT item; - if (menu == IntPtr.Zero || !GetMenuItemRect(owner, menu, (uint)position, out item) || - item.Right <= item.Left || item.Bottom <= item.Top || - PopupMenuItemId(menu, position) != (uint)commandId) return null; - int screenX = (item.Left + item.Right) / 2; - int screenY = (item.Top + item.Bottom) / 2; - if (screenX < item.Left || screenX >= item.Right || - screenY < item.Top || screenY >= item.Bottom) return null; - var point = new ScreenPoint { - X = screenX, - Y = screenY - }; - if (!ScreenToClient(popup, ref point)) return null; - ScreenPoint before, at; - if (!GetCursorPos(out before)) - throw new InvalidOperationException(String.Format("GetCursorPos before popup click failed: Win32Error={0}", Marshal.GetLastWin32Error())); - if (!SetCursorPos(screenX, screenY)) - throw new InvalidOperationException(String.Format("SetCursorPos for popup click failed: Win32Error={0}", Marshal.GetLastWin32Error())); - if (!GetCursorPos(out at)) - throw new InvalidOperationException(String.Format("GetCursorPos after popup move failed: Win32Error={0}", Marshal.GetLastWin32Error())); - if (at.X != screenX || at.Y != screenY) - throw new InvalidOperationException(String.Format("Popup cursor landed at ({0},{1}), expected ({2},{3})", at.X, at.Y, screenX, screenY)); - SendMessage(popup, 0x0200, UIntPtr.Zero, MouseLParam(point.X, point.Y)); - bool hilite = false; - for (int attempt = 0; attempt < 50 && !hilite; attempt++) { - hilite = (GetMenuState(menu, (uint)position, 0x0400) & 0x0080) != 0; - if (!hilite) Sleep(10); - } - bool selectItemFallback = false; - if (!hilite) { - SendMessage(popup, 0x01E5, (UIntPtr)position, IntPtr.Zero); - selectItemFallback = true; - for (int attempt = 0; attempt < 50 && !hilite; attempt++) { - hilite = (GetMenuState(menu, (uint)position, 0x0400) & 0x0080) != 0; - if (!hilite) Sleep(10); - } - } - if (!hilite) - throw new InvalidOperationException("Popup item did not acknowledge hover before click"); - var inputs = new[] { - new Input { Type = 0, Mouse = new MouseInput { Flags = 0x0002 } }, - new Input { Type = 0, Mouse = new MouseInput { Flags = 0x0004 } } - }; - uint sent = SendInput((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(Input))); - if (sent != inputs.Length) - throw new InvalidOperationException(String.Format("SendInput injected {0} of {1} popup mouse events: Win32Error={2}", sent, inputs.Length, Marshal.GetLastWin32Error())); - for (int attempt = 0; attempt < 20 && IsWindowVisible(popup); attempt++) Sleep(10); - bool keyboardFallback = false; - bool hiliteAtEnterFallback = false; - bool hiliteBeforeEnter = false; - if (IsWindowVisible(popup)) { - // Injected clicks can be dropped (or held) by the desktop's input stack, - // and the menu can lose its hover hilite meanwhile. Enter with no hilited - // item ends TrackPopupMenu with command 0: the popup closes exactly as if - // the item had been chosen, but no command reaches the shell. Re-assert - // the intended item and prove its hilite immediately before Enter. - hiliteAtEnterFallback = (GetMenuState(menu, (uint)position, 0x0400) & 0x0080) != 0; - SendMessage(popup, 0x01E5, (UIntPtr)position, IntPtr.Zero); - hiliteBeforeEnter = (GetMenuState(menu, (uint)position, 0x0400) & 0x0080) != 0; - } - if (IsWindowVisible(popup)) { - if (!hiliteBeforeEnter) - throw new InvalidOperationException(String.Format("Popup item {0} was not hilited before the Enter fallback (hilite at fallback={1})", commandId, hiliteAtEnterFallback)); - keyboardFallback = - PostMessage(popup, 0x0100, (UIntPtr)0x0D, IntPtr.Zero) && - PostMessage(popup, 0x0101, (UIntPtr)0x0D, IntPtr.Zero); - if (!keyboardFallback) - throw new InvalidOperationException(String.Format("Popup Enter fallback was rejected: Win32Error={0}", Marshal.GetLastWin32Error())); - } - return new PopupItemHit { - Position = position, ItemId = commandId, Left = item.Left, Top = item.Top, - Right = item.Right, Bottom = item.Bottom, - ScreenX = screenX, ScreenY = screenY, ClientX = point.X, ClientY = point.Y, - CursorBeforeX = before.X, CursorBeforeY = before.Y, - CursorAtX = at.X, CursorAtY = at.Y, Hilite = hilite, - UsedKeyboardFallback = keyboardFallback, - UsedSelectItemFallback = selectItemFallback, - HiliteAtEnterFallback = hiliteAtEnterFallback, - HiliteBeforeEnter = hiliteBeforeEnter - }; - } - // Sidebar.updateBannerRect/updateBannerAt are pixel-only hit-test geometry with - // no UIA identity of their own, so a genuine click requires the real live client - // height rather than an assumed window size. - public static int ClientHeight(IntPtr window) { - RECT rect; - if (window == IntPtr.Zero || !GetClientRect(window, out rect)) return 0; - return rect.Bottom - rect.Top; - } - // Posts the WM_MOUSEWHEEL the OS delivers to the window under the cursor. Its - // lParam is in screen coordinates; delta is in wheel units (negative scrolls - // content up, so the sidebar advances). - [DllImport("user32.dll")] - private static extern bool ClientToScreen(IntPtr window, ref ScreenPoint point); - public static bool WheelAt(IntPtr window, int clientX, int clientY, int delta) { - var point = new ScreenPoint { X = clientX, Y = clientY }; - if (window == IntPtr.Zero || !ClientToScreen(window, ref point)) return false; - return PostMessage(window, 0x020A, new UIntPtr((uint)((delta & 0xFFFF) << 16)), MouseLParam(point.X, point.Y)); - } - [StructLayout(LayoutKind.Sequential)] - private struct RECT { public int Left; public int Top; public int Right; public int Bottom; } - [DllImport("user32.dll")] - private static extern bool GetClientRect(IntPtr window, out RECT rect); - [DllImport("user32.dll")] - private static extern IntPtr GetMenu(IntPtr window); - [DllImport("user32.dll")] - private static extern IntPtr GetSubMenu(IntPtr menu, int position); - public static IntPtr NativeMenu(IntPtr window) { - return window == IntPtr.Zero ? IntPtr.Zero : GetMenu(window); - } - public static IntPtr NativeSubMenu(IntPtr menu, int position) { - return menu == IntPtr.Zero ? IntPtr.Zero : GetSubMenu(menu, position); - } - // App.zig only rebuilds recent_folders (and the rest of the native chrome) - // from the live GraphModel on WM_INITMENUPOPUP - the same message real - // Windows sends right before a menu bar popup is displayed. Reading the - // HMENU without first sending this leaves it holding whatever was current - // the last time a menu was actually opened (or, at startup, an empty - // "No recent folders" placeholder installed before the fixture loaded). - // Sending it here reproduces the exact real trigger, not a shortcut. - public static void RefreshNativeMenuFromLiveModel(IntPtr window, IntPtr menu) { - SendMessage(window, 0x0117, (UIntPtr)(ulong)menu.ToInt64(), IntPtr.Zero); - } - [StructLayout(LayoutKind.Sequential)] - public struct ScreenPoint { public int X; public int Y; } - [DllImport("user32.dll")] - private static extern bool ScreenToClient(IntPtr window, ref ScreenPoint point); - [DllImport("user32.dll")] - private static extern bool IsIconic(IntPtr window); - public static bool IsWindowMinimized(IntPtr window) { return IsIconic(window); } - public static bool ScreenToClientPoint(IntPtr window, int screenX, int screenY, out int clientX, out int clientY) { - var point = new ScreenPoint { X = screenX, Y = screenY }; - bool ok = ScreenToClient(window, ref point); - clientX = point.X; - clientY = point.Y; - return ok; - } - public static int[] ClientScreenBounds(IntPtr window) { - RECT rect; - if (window == IntPtr.Zero || !GetClientRect(window, out rect)) return null; - var topLeft = new ScreenPoint { X = rect.Left, Y = rect.Top }; - var bottomRight = new ScreenPoint { X = rect.Right, Y = rect.Bottom }; - if (!ClientToScreen(window, ref topLeft) || !ClientToScreen(window, ref bottomRight)) - return null; - return new[] { topLeft.X, topLeft.Y, bottomRight.X, bottomRight.Y }; - } - public static bool PostCommand(IntPtr window, uint command) { - return PostMessage(window, 0x0111, (UIntPtr)command, IntPtr.Zero); - } - public static bool PostClose(IntPtr window) { - return PostMessage(window, 0x0010, UIntPtr.Zero, IntPtr.Zero); - } - public static bool SetFirstEditText(IntPtr parent, string text) { - var edit = FindWindowEx(parent, IntPtr.Zero, "Edit", null); - if (edit == IntPtr.Zero || !SetWindowText(edit, text)) return false; - ulong command = ((ulong)0x0300 << 16) | (uint)GetDlgCtrlID(edit); - SendMessage(parent, 0x0111, (UIntPtr)command, edit); - return true; - } - public static bool SetEditTextById(IntPtr parent, int controlId, string text) { - var edit = ControlById(parent, controlId); - // Cross-process SetWindowText only updates the stored caption; WM_SETTEXT reaches the EDIT buffer. - if (edit == IntPtr.Zero || SendMessageString(edit, 0x000C, UIntPtr.Zero, text) == IntPtr.Zero) return false; - ulong command = ((ulong)0x0300 << 16) | (uint)controlId; - SendMessage(parent, 0x0111, (UIntPtr)command, edit); - return true; - } - public static string EditTextById(IntPtr parent, int controlId) { - var edit = ControlById(parent, controlId); - if (edit == IntPtr.Zero) return null; - return EditBufferText(edit); - } - private static string EditBufferText(IntPtr edit) { - // Cross-process GetWindowText returns the stored caption; WM_GETTEXT reads the EDIT buffer. - int length = (int)SendMessage(edit, 0x000E, UIntPtr.Zero, IntPtr.Zero); - var text = new StringBuilder(length + 1); - SendMessageText(edit, 0x000D, (UIntPtr)text.Capacity, text); - return text.ToString(); - } - public static string FirstEditText(IntPtr parent) { - var edit = ControlById(parent, 9100); - if (edit == IntPtr.Zero) edit = ControlById(parent, 9904); - if (edit == IntPtr.Zero) edit = FindWindowEx(parent, IntPtr.Zero, "Edit", null); - if (edit == IntPtr.Zero) return null; - return EditBufferText(edit); - } - // Node creation sheet helpers. Every click below is a real cursor move plus - // SendInput at the live window rectangle of the target control, and the - // window under that point is checked first so a covered or mis-scaled target - // fails instead of silently clicking something else. - [DllImport("user32.dll")] - private static extern bool GetWindowRect(IntPtr window, out RECT rect); - [DllImport("user32.dll")] - private static extern IntPtr WindowFromPoint(ScreenPoint point); - private delegate bool EnumChildProc(IntPtr window, IntPtr parameter); - [DllImport("user32.dll")] - private static extern bool EnumChildWindows(IntPtr parent, EnumChildProc callback, IntPtr parameter); - [StructLayout(LayoutKind.Sequential)] - private struct KeybdInput { - public ushort VirtualKey, ScanCode; - public uint Flags, Time; - public UIntPtr ExtraInfo; - } - // INPUT is a union sized by MOUSEINPUT; the trailing padding keeps this - // keyboard record the same size so SendInput accepts cbSize. - [StructLayout(LayoutKind.Sequential)] - private struct KeyInputRecord { - public uint Type; - public KeybdInput Key; - public uint Pad0, Pad1; - } - [DllImport("user32.dll", SetLastError = true, EntryPoint = "SendInput")] - private static extern uint SendKeyInputs(uint count, KeyInputRecord[] inputs, int size); - [DllImport("user32.dll", SetLastError = true, EntryPoint = "SystemParametersInfoW")] - private static extern bool SystemParametersInfoRect(uint action, uint param, out RECT rect, uint winIni); - [DllImport("user32.dll")] - private static extern IntPtr GetAncestor(IntPtr window, uint flags); - [DllImport("user32.dll")] - 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")] - private static extern IntPtr GetWindowLongPtr(IntPtr window, int index); - public sealed class ControlClick { - public int ControlId, Left, Top, Right, Bottom, ScreenX, ScreenY; - public int CursorBeforeX, CursorBeforeY, CursorAtX, CursorAtY; - public bool HitTarget; - public int WindowAtPointId; - public string WindowAtPointClass, WindowAtPointRootClass, WindowAtPointText; - public int WindowAtPointProcessId; - public int RealChildId; - public string RealChildClass, RealChildText; - public bool SameTopLevel; - public bool VisibleEmpty; - public int WorkLeft, WorkTop, WorkRight, WorkBottom; - public bool OutsideWorkArea; - public int CenterX, CenterY; - public bool CenterHitTarget; - public string WindowAtCenterClass, WindowAtCenterRootClass; - public bool ScanUsed; - public int ScannedPoints, CoveredPoints; - public int CoveringId, CoveringLeft, CoveringTop, CoveringRight, CoveringBottom; - public string CoveringClass, CoveringText; - public string[] Samples; - public string[] CoveringWindows; - public int[][] UncoveredRectangles; - public int[] ChosenUncoveredRectangle; - public bool NarrowUncovered; - } - public static int[][] SubtractCoveredRectangles(int[] visible, int[][] covers) { - var remaining = new System.Collections.ArrayList(); - if (visible[2] > visible[0] && visible[3] > visible[1]) remaining.Add(visible); - foreach (int[] cover in covers) { - var next = new System.Collections.ArrayList(); - foreach (int[] piece in remaining) { - int left = Math.Max(piece[0], cover[0]), top = Math.Max(piece[1], cover[1]); - int right = Math.Min(piece[2], cover[2]), bottom = Math.Min(piece[3], cover[3]); - if (left >= right || top >= bottom) { next.Add(piece); continue; } - if (piece[0] < left) next.Add(new[] { piece[0], piece[1], left, piece[3] }); - if (right < piece[2]) next.Add(new[] { right, piece[1], piece[2], piece[3] }); - if (piece[1] < top) next.Add(new[] { left, piece[1], right, top }); - if (bottom < piece[3]) next.Add(new[] { left, bottom, right, piece[3] }); - } - remaining = next; - } - var result = new int[remaining.Count][]; - remaining.CopyTo(result); - Array.Sort(result, delegate(int[] a, int[] b) { - long areaA = (long)(a[2] - a[0]) * (a[3] - a[1]); - long areaB = (long)(b[2] - b[0]) * (b[3] - b[1]); - int byArea = areaB.CompareTo(areaA); - if (byArea != 0) return byArea; - int byTop = a[1].CompareTo(b[1]); - return byTop != 0 ? byTop : a[0].CompareTo(b[0]); - }); - return result; - } - private static IntPtr ResolveChild(IntPtr dialog, ScreenPoint point) { - var clientPoint = point; - if (!ScreenToClient(dialog, ref clientPoint)) return IntPtr.Zero; - return RealChildWindowFromPoint(dialog, clientPoint); - } - private static bool ResolvesToControl(IntPtr target, IntPtr dialog, ScreenPoint point) { - IntPtr atPoint = WindowFromPoint(point); - if (atPoint == IntPtr.Zero || GetAncestor(atPoint, 2) != GetAncestor(target, 2)) return false; - var clientPoint = point; - if (!ScreenToClient(dialog, ref clientPoint)) return false; - return RealChildWindowFromPoint(dialog, clientPoint) == target; - } - private static string ClassOf(IntPtr window) { - if (window == IntPtr.Zero) return ""; - var name = new StringBuilder(128); - GetClassName(window, name, name.Capacity); - return name.ToString(); - } - public static int[] WindowBounds(IntPtr window) { - RECT rect; - if (window == IntPtr.Zero || !GetWindowRect(window, out rect)) return null; - return new[] { rect.Left, rect.Top, rect.Right, rect.Bottom }; - } - public static IntPtr ControlById(IntPtr parent, int controlId) { - if (parent == IntPtr.Zero) return IntPtr.Zero; - IntPtr direct = GetDlgItem(parent, controlId); - if (direct != IntPtr.Zero) return direct; - if (!String.Equals(ClassOf(parent), "GraphCodeSheetHost", StringComparison.Ordinal)) - return IntPtr.Zero; - IntPtr viewport = FindWindowEx(parent, IntPtr.Zero, "GraphCodeSheetViewport", null); - if (viewport == IntPtr.Zero) return IntPtr.Zero; - string[] contentClasses = new[] { - "GraphCodeWindowsDialogContent", - "GraphCodeNativeForm", - "GraphCodeProductSettings", - "GraphCodeWorkspaceManager", - "GraphCodeRepositoryIngressDialog", - "GraphCodeRepositoryOperationDialog", - "GraphCodeCodespaceIngressDialog", - "GraphCodeUpdateOffer", - "GraphCodeUpdateInstall" - }; - foreach (string contentClass in contentClasses) { - IntPtr content = FindWindowEx(viewport, IntPtr.Zero, contentClass, null); - if (content == IntPtr.Zero) continue; - IntPtr nested = GetDlgItem(content, controlId); - if (nested != IntPtr.Zero) return nested; - } - return IntPtr.Zero; - } - public static ControlClick ClickScreenPoint(IntPtr target) { - RECT rect; - if (target == IntPtr.Zero || !GetWindowRect(target, out rect) || - rect.Right <= rect.Left || rect.Bottom <= rect.Top) return null; - RECT work; - if (!SystemParametersInfoRect(0x0030, 0, out work, 0)) - throw new InvalidOperationException(String.Format("SPI_GETWORKAREA failed: Win32Error={0}", Marshal.GetLastWin32Error())); - var rectCenter = new ScreenPoint { X = (rect.Left + rect.Right) / 2, Y = (rect.Top + rect.Bottom) / 2 }; - IntPtr atRectCenter = WindowFromPoint(rectCenter); - // A user clicks the part of a control they can see. When the control lies - // inside the work area this is exactly its centre; only when it crosses the - // work-area edge does the point move into the visible portion. A control with - // no visible portion is reported as VisibleEmpty and never clicked. - int visibleLeft = Math.Max(rect.Left, work.Left), visibleTop = Math.Max(rect.Top, work.Top); - int visibleRight = Math.Min(rect.Right, work.Right), visibleBottom = Math.Min(rect.Bottom, work.Bottom); - var center = new ScreenPoint { X = (visibleLeft + visibleRight) / 2, Y = (visibleTop + visibleBottom) / 2 }; - bool visibleEmpty = visibleRight <= visibleLeft || visibleBottom <= visibleTop; - IntPtr atPoint = visibleEmpty ? IntPtr.Zero : WindowFromPoint(center); - IntPtr rootAtPoint = atPoint == IntPtr.Zero ? IntPtr.Zero : GetAncestor(atPoint, 2); - uint ownerProcess = 0; - if (rootAtPoint != IntPtr.Zero) GetWindowThreadProcessId(rootAtPoint, out ownerProcess); - // Real mouse input skips HTTRANSPARENT children such as plain static text; - // cross-process WindowFromPoint does not. RealChildWindowFromPoint applies - // the same rule within the dialog, and the top-level check still rejects - // any other window (for example the taskbar) covering the point. - IntPtr dialog = GetAncestor(target, 1); - IntPtr realChild = IntPtr.Zero; - if (!visibleEmpty && dialog != IntPtr.Zero) { - var clientPoint = center; - if (ScreenToClient(dialog, ref clientPoint)) realChild = RealChildWindowFromPoint(dialog, clientPoint); - } - bool sameTopLevel = rootAtPoint != IntPtr.Zero && rootAtPoint == GetAncestor(target, 2); - var hit = new ControlClick { - ControlId = GetDlgCtrlID(target), Left = rect.Left, Top = rect.Top, Right = rect.Right, - Bottom = rect.Bottom, ScreenX = center.X, ScreenY = center.Y, - HitTarget = !visibleEmpty && sameTopLevel && realChild == target, - VisibleEmpty = visibleEmpty, - WindowAtPointId = atPoint == IntPtr.Zero ? 0 : GetDlgCtrlID(atPoint), - WindowAtPointClass = ClassOf(atPoint), - WindowAtPointText = atPoint == IntPtr.Zero ? "" : EditBufferText(atPoint), - WindowAtPointRootClass = ClassOf(rootAtPoint), - WindowAtPointProcessId = (int)ownerProcess, - RealChildId = realChild == IntPtr.Zero ? 0 : GetDlgCtrlID(realChild), - RealChildClass = ClassOf(realChild), - RealChildText = realChild == IntPtr.Zero ? "" : EditBufferText(realChild), - SameTopLevel = sameTopLevel, - WorkLeft = work.Left, WorkTop = work.Top, WorkRight = work.Right, WorkBottom = work.Bottom, - OutsideWorkArea = rect.Left < work.Left || rect.Top < work.Top || rect.Right > work.Right || rect.Bottom > work.Bottom, - CenterX = rectCenter.X, CenterY = rectCenter.Y, - CenterHitTarget = atRectCenter == target, - WindowAtCenterClass = ClassOf(atRectCenter), - WindowAtCenterRootClass = ClassOf(atRectCenter == IntPtr.Zero ? IntPtr.Zero : GetAncestor(atRectCenter, 2)) - }; - // Subtract visible siblings above the target in Z order. A narrow uncovered - // strip can fall between grid rows, so try the largest residual rectangle - // before scanning the fixed grid as a diagnostic fallback. - if (!hit.HitTarget && !visibleEmpty && dialog != IntPtr.Zero) { - IntPtr covering = realChild != IntPtr.Zero && realChild != target && realChild != dialog ? realChild : atPoint; - RECT coveringRect; - if (covering != IntPtr.Zero && GetWindowRect(covering, out coveringRect)) { - hit.CoveringLeft = coveringRect.Left; hit.CoveringTop = coveringRect.Top; - hit.CoveringRight = coveringRect.Right; hit.CoveringBottom = coveringRect.Bottom; - } - hit.CoveringId = covering == IntPtr.Zero ? 0 : GetDlgCtrlID(covering); - hit.CoveringClass = ClassOf(covering); - hit.CoveringText = covering == IntPtr.Zero ? "" : EditBufferText(covering); - var covers = new System.Collections.ArrayList(); - var coveringWindows = new System.Collections.ArrayList(); - for (IntPtr sibling = GetWindow(target, 3); sibling != IntPtr.Zero; sibling = GetWindow(sibling, 3)) { - RECT siblingRect; - if (!IsWindowVisible(sibling) || !GetWindowRect(sibling, out siblingRect)) continue; - int left = Math.Max(visibleLeft, siblingRect.Left), top = Math.Max(visibleTop, siblingRect.Top); - int right = Math.Min(visibleRight, siblingRect.Right), bottom = Math.Min(visibleBottom, siblingRect.Bottom); - if (left >= right || top >= bottom) continue; - var overlapCenter = new ScreenPoint { X = (left + right) / 2, Y = (top + bottom) / 2 }; - if (ResolveChild(dialog, overlapCenter) != sibling) continue; - covers.Add(new[] { siblingRect.Left, siblingRect.Top, siblingRect.Right, siblingRect.Bottom }); - coveringWindows.Add(String.Format("{0}|{1}|{2}|{3},{4},{5},{6}", GetDlgCtrlID(sibling), - ClassOf(sibling), EditBufferText(sibling), siblingRect.Left, siblingRect.Top, siblingRect.Right, siblingRect.Bottom)); - } - hit.CoveringWindows = (string[])coveringWindows.ToArray(typeof(string)); - hit.UncoveredRectangles = SubtractCoveredRectangles(new[] { visibleLeft, visibleTop, visibleRight, visibleBottom }, - (int[][])covers.ToArray(typeof(int[]))); - foreach (int[] piece in hit.UncoveredRectangles) { - var candidate = new ScreenPoint { X = (piece[0] + piece[2]) / 2, Y = (piece[1] + piece[3]) / 2 }; - if (!ResolvesToControl(target, dialog, candidate)) continue; - if (piece[2] - piece[0] < 3 || piece[3] - piece[1] < 3) { - hit.NarrowUncovered = true; - continue; - } - center = candidate; - hit.ScreenX = candidate.X; hit.ScreenY = candidate.Y; - hit.HitTarget = true; - hit.ScanUsed = true; - hit.ChosenUncoveredRectangle = piece; - break; - } - int width = visibleRight - visibleLeft, height = visibleBottom - visibleTop; - var samples = new string[15]; - for (int row = 1; row <= 3; row++) { - for (int column = 1; column <= 5; column++) { - var candidate = new ScreenPoint { X = visibleLeft + column * width / 6, Y = visibleTop + row * height / 4 }; - IntPtr resolved = ResolveChild(dialog, candidate); - bool uncovered = ResolvesToControl(target, dialog, candidate); - if (uncovered && hit.NarrowUncovered) uncovered = false; - samples[hit.ScannedPoints++] = String.Format("{0},{1}|{2}|{3}|{4}|{5}", candidate.X, candidate.Y, - resolved == IntPtr.Zero ? 0 : GetDlgCtrlID(resolved), ClassOf(resolved), uncovered ? "control" : "covered", - resolved == IntPtr.Zero ? "" : EditBufferText(resolved)); - if (!uncovered) { - hit.CoveredPoints++; - } else if (!hit.HitTarget) { - center = candidate; - hit.ScreenX = candidate.X; hit.ScreenY = candidate.Y; - hit.HitTarget = true; - hit.ScanUsed = true; - } - } - } - hit.Samples = samples; - } - if (!hit.HitTarget) return hit; - ScreenPoint before, at; - if (!GetCursorPos(out before)) - throw new InvalidOperationException(String.Format("GetCursorPos before control click failed: Win32Error={0}", Marshal.GetLastWin32Error())); - if (!SetCursorPos(center.X, center.Y)) - throw new InvalidOperationException(String.Format("SetCursorPos for control click failed: Win32Error={0}", Marshal.GetLastWin32Error())); - if (!GetCursorPos(out at)) - throw new InvalidOperationException(String.Format("GetCursorPos after control move failed: Win32Error={0}", Marshal.GetLastWin32Error())); - if (at.X != center.X || at.Y != center.Y) - throw new InvalidOperationException(String.Format("Control cursor landed at ({0},{1}), expected ({2},{3})", at.X, at.Y, center.X, center.Y)); - hit.CursorBeforeX = before.X; hit.CursorBeforeY = before.Y; - hit.CursorAtX = at.X; hit.CursorAtY = at.Y; - var inputs = new[] { - new Input { Type = 0, Mouse = new MouseInput { Flags = 0x0002 } }, - new Input { Type = 0, Mouse = new MouseInput { Flags = 0x0004 } } - }; - uint sent = SendInput((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(Input))); - if (sent != inputs.Length) - throw new InvalidOperationException(String.Format("SendInput injected {0} of {1} control mouse events: Win32Error={2}", sent, inputs.Length, Marshal.GetLastWin32Error())); - return hit; - } - public static int SendKeyInput(ushort virtualKey, int count) { - if (count <= 0) return 0; - var inputs = new KeyInputRecord[count * 2]; - for (int index = 0; index < count; index++) { - inputs[index * 2] = new KeyInputRecord { Type = 1, Key = new KeybdInput { VirtualKey = virtualKey } }; - inputs[index * 2 + 1] = new KeyInputRecord { Type = 1, Key = new KeybdInput { VirtualKey = virtualKey, Flags = 0x0002 } }; - } - uint sent = SendKeyInputs((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(KeyInputRecord))); - if (sent != inputs.Length) - throw new InvalidOperationException(String.Format("SendInput injected {0} of {1} key events (cbSize={2}): Win32Error={3}", sent, inputs.Length, Marshal.SizeOf(typeof(KeyInputRecord)), Marshal.GetLastWin32Error())); - return count; - } - public static bool TypeEditTextById(IntPtr parent, int controlId, string text) { - LastEditClearExpected = LastEditClearSent = 0; - LastEditTextExpected = LastEditTextSent = 0; - LastEditUsedMessageFallback = false; - IntPtr edit = ControlById(parent, controlId); - if (edit == IntPtr.Zero || !FocusControl(parent, edit)) return false; - SendMessage(edit, 0x00B1, UIntPtr.Zero, new IntPtr(-1)); - var clear = new KeyInputRecord[2]; - clear[0] = new KeyInputRecord { Type = 1, Key = new KeybdInput { VirtualKey = 0x2E } }; - clear[1] = new KeyInputRecord { Type = 1, Key = new KeybdInput { VirtualKey = 0x2E, Flags = 2 } }; - LastEditClearExpected = (uint)clear.Length; - LastEditClearSent = SendKeyInputs(LastEditClearExpected, clear, Marshal.SizeOf(typeof(KeyInputRecord))); - if (LastEditClearSent != LastEditClearExpected) return false; - bool cleared = String.IsNullOrEmpty(EditBufferText(edit)); - var records = new KeyInputRecord[text.Length * 2]; - int offset = 0; - foreach (char character in text) { - records[offset++] = new KeyInputRecord { Type = 1, Key = new KeybdInput { ScanCode = character, Flags = 4 } }; - records[offset++] = new KeyInputRecord { Type = 1, Key = new KeybdInput { ScanCode = character, Flags = 6 } }; - } - LastEditTextExpected = (uint)records.Length; - if (records.Length > 0) { - LastEditTextSent = SendKeyInputs(LastEditTextExpected, records, Marshal.SizeOf(typeof(KeyInputRecord))); - if (LastEditTextSent != LastEditTextExpected) return false; - } - bool matched = cleared && String.Equals(EditTextById(parent, controlId), text, StringComparison.Ordinal); - if (!matched) { - SendMessageString(edit, 0x000C, UIntPtr.Zero, text); - LastEditUsedMessageFallback = true; - matched = String.Equals(EditTextById(parent, controlId), text, StringComparison.Ordinal); - } - return matched; - } - public static int[] VisibleChildIds(IntPtr parent, int first, int last) { - var buffer = new int[Math.Max(0, last - first + 1)]; - int found = 0; - for (int id = first; id <= last; id++) { - IntPtr child = ControlById(parent, id); - if (child != IntPtr.Zero && IsWindowVisible(child)) buffer[found++] = id; - } - var result = new int[found]; - Array.Copy(buffer, result, found); - return result; - } - public static string[] VisibleStaticTexts(IntPtr parent) { - var buffer = new string[256]; - int found = 0; - if (parent == IntPtr.Zero) return new string[0]; - EnumChildWindows(parent, delegate(IntPtr window, IntPtr parameter) { - var actualClass = new StringBuilder(64); - GetClassName(window, actualClass, actualClass.Capacity); - if (String.Equals(actualClass.ToString(), "Static", StringComparison.OrdinalIgnoreCase) && - IsWindowVisible(window) && found < buffer.Length) { - buffer[found++] = EditBufferText(window); - } - return true; - }, IntPtr.Zero); - var result = new string[found]; - Array.Copy(buffer, result, found); - return result; - } - public static string WindowTextOf(IntPtr window) { - return window == IntPtr.Zero ? null : EditBufferText(window); - } - public static string ComboSelection(IntPtr parent, int controlId) { - IntPtr combo = ControlById(parent, controlId); - if (combo == IntPtr.Zero) return null; - int index = (int)SendMessage(combo, 0x0147, UIntPtr.Zero, IntPtr.Zero); - if (index < 0) return index + "|"; - int length = (int)SendMessage(combo, 0x0149, (UIntPtr)index, IntPtr.Zero); - var text = new StringBuilder(Math.Max(length, 0) + 1); - SendMessageText(combo, 0x0148, (UIntPtr)index, text); - return index + "|" + text.ToString(); - } -} -"@ -ReferencedAssemblies @( - [System.Windows.Automation.AutomationElement].Assembly.Location, - [System.Windows.Automation.AutomationEventArgs].Assembly.Location -) - -Add-Type -TypeDefinition @" -using System; -using System.Diagnostics; -using System.Runtime.InteropServices; -using System.Text; -public static class GraphCodeUiaHostInfo { - private const int UOI_NAME = 2; - [DllImport("user32.dll")] - private static extern IntPtr GetProcessWindowStation(); - [DllImport("user32.dll")] - private static extern IntPtr GetThreadDesktop(uint threadId); - [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern bool GetUserObjectInformation( - IntPtr handle, int index, StringBuilder info, uint length, out uint needed - ); - [DllImport("kernel32.dll")] - private static extern uint GetCurrentThreadId(); - [DllImport("kernel32.dll", SetLastError = true)] - private static extern bool ProcessIdToSessionId(uint processId, out uint sessionId); - [DllImport("user32.dll")] - private static extern uint GetGuiResources(IntPtr process, uint flags); - private static string ObjectName(IntPtr handle) { - if (handle == IntPtr.Zero) return null; - var name = new StringBuilder(256); - uint needed; - if (!GetUserObjectInformation(handle, UOI_NAME, name, (uint)(name.Capacity * 2), out needed)) - return null; - return name.ToString(); - } - public static string CurrentWindowStationName() { - return ObjectName(GetProcessWindowStation()); - } - public static string CurrentDesktopName() { - return ObjectName(GetThreadDesktop(GetCurrentThreadId())); - } - public static uint CurrentSessionId() { - uint sessionId; - if (!ProcessIdToSessionId((uint)Process.GetCurrentProcess().Id, out sessionId)) - throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error()); - return sessionId; - } - public static uint GuiResourceCount(int processId, uint flags) { - using (var process = Process.GetProcessById(processId)) - return GetGuiResources(process.Handle, flags); - } -} -"@ - -# Wait-timeout attribution. Reads only the foreground window's owning process -# id/name and class (never another application's title or content), every -# top-level window owned by the shell process, and a PrintWindow capture of the -# shell's own window (not a screen copy, so no other application is captured). -Add-Type -TypeDefinition @" -using System; -using System.Collections.Generic; -using System.Runtime.InteropServices; -using System.Text; -public static class GraphCodeUiaAttribution { - private delegate bool EnumWindowsProc(IntPtr window, IntPtr parameter); - [DllImport("user32.dll")] - private static extern bool EnumWindows(EnumWindowsProc callback, IntPtr parameter); - [DllImport("user32.dll")] - private static extern IntPtr GetForegroundWindow(); - [DllImport("user32.dll")] - private static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId); - [DllImport("user32.dll", CharSet = CharSet.Unicode)] - private static extern int GetClassName(IntPtr window, StringBuilder className, int capacity); - [DllImport("user32.dll", CharSet = CharSet.Unicode)] - private static extern int GetWindowText(IntPtr window, StringBuilder text, int count); - [DllImport("user32.dll")] - private static extern bool IsWindowVisible(IntPtr window); - [DllImport("user32.dll")] - private static extern bool IsWindowEnabled(IntPtr window); - [DllImport("user32.dll")] - private static extern bool IsHungAppWindow(IntPtr window); - [DllImport("user32.dll")] - private static extern IntPtr GetWindow(IntPtr window, uint command); - [StructLayout(LayoutKind.Sequential)] - public struct Rect { public int Left, Top, Right, Bottom; } - [DllImport("user32.dll")] - private static extern bool GetWindowRect(IntPtr window, out Rect rect); - [DllImport("user32.dll")] - public static extern bool PrintWindow(IntPtr window, IntPtr hdc, uint flags); - public sealed class WindowRecord { - public long Handle; public long Owner; public string ClassName; public string Title; - public bool Visible, Enabled, Hung; public int Left, Top, Right, Bottom; - } - private static string ClassOf(IntPtr window) { - var text = new StringBuilder(256); - GetClassName(window, text, text.Capacity); - return text.ToString(); - } - public static long ForegroundHandle() { return GetForegroundWindow().ToInt64(); } - public static uint ProcessOf(IntPtr window) { - if (window == IntPtr.Zero) return 0; - uint processId; - GetWindowThreadProcessId(window, out processId); - return processId; - } - public static string ClassName(IntPtr window) { - return window == IntPtr.Zero ? "" : ClassOf(window); - } - public static Rect Bounds(IntPtr window) { - Rect rect; - if (window == IntPtr.Zero || !GetWindowRect(window, out rect)) return new Rect(); - return rect; - } - // Titles are read only for windows owned by processId (the gate's own shell). - public static WindowRecord[] ProcessWindows(uint processId) { - var results = new List(); - EnumWindows(delegate(IntPtr window, IntPtr parameter) { - uint owner; - GetWindowThreadProcessId(window, out owner); - if (owner != processId) return true; - var title = new StringBuilder(512); - GetWindowText(window, title, title.Capacity); - Rect rect; - GetWindowRect(window, out rect); - results.Add(new WindowRecord { - Handle = window.ToInt64(), Owner = GetWindow(window, 4).ToInt64(), - ClassName = ClassOf(window), Title = title.ToString(), - Visible = IsWindowVisible(window), Enabled = IsWindowEnabled(window), - Hung = IsHungAppWindow(window), - Left = rect.Left, Top = rect.Top, Right = rect.Right, Bottom = rect.Bottom - }); - return true; - }, IntPtr.Zero); - return results.ToArray(); - } -} -"@ +Import-Module -Name (Join-Path $PSScriptRoot "ShellProcessOwnership.psm1") -ErrorAction Stop +$validationAssemblyPath = $env:GRAPHCODE_VALIDATION_ASSEMBLY +if (-not $validationAssemblyPath) { + $validationAssemblyPath = & (Join-Path $PSScriptRoot "Build-CompiledValidationRunner.ps1") +} +foreach ($requiredType in @( + "GraphCode.Windows.UiaWindowActivation", "GraphCodeUiaGateState", "GraphCodeUiaHostInfo", + "GraphCodeUiaAttribution", "GraphCodeMultiProjectEditNative", "GraphCodeMultiProjectRenameNative", + "GraphCode.Validation.Ownership.StandaloneProcessJob", "GraphCode.Validation.Ownership.UiaOwnedStreamCapture" + )) { + & (Join-Path $PSScriptRoot "Import-CompiledValidationAssembly.ps1") ` + -AssemblyPath $validationAssemblyPath -RequiredType $requiredType | Out-Null +} $script:uiaAttributionDirectory = $null $script:uiaAttributionSequence = 0 @@ -1529,6 +165,61 @@ function Require([bool] $condition, [string] $message) { if (-not $condition) { throw $message } } +function Wait-UiaUnwiredRecoveryVisibility($root, $rawWalker, $process, $wire, $mark) { + $wireId = [string]$wire.Current.AutomationId + $markId = [string]$mark.Current.AutomationId + Require ($process.Id -gt 0 -and + $wire.Current.ProcessId -eq $process.Id -and $mark.Current.ProcessId -eq $process.Id -and + $wireId.StartsWith("wire-node-", [StringComparison]::Ordinal) -and + $markId.StartsWith("mark-entry-", [StringComparison]::Ordinal) -and + $wire.Current.Name -ceq "Wire it up" -and $mark.Current.Name -ceq "Mark as entry") ` + "Unwired recovery source controls did not belong to the owned graph" + function Test-RecoveryVisibility($wire, $mark, $graph, [int] $owner, [string] $wireId, [string] $markId) { + if ($null -eq $wire -or $null -eq $mark -or $null -eq $graph -or + $graph.Current.ProcessId -ne $owner -or $graph.Current.AutomationId -cne "graph" -or + $wire.Current.ProcessId -ne $owner -or $mark.Current.ProcessId -ne $owner -or + $wire.Current.AutomationId -cne $wireId -or $mark.Current.AutomationId -cne $markId -or + $wire.Current.Name -cne "Wire it up" -or $mark.Current.Name -cne "Mark as entry" -or + $graph.Current.IsOffscreen) { return $false } + $graphBounds = $graph.Current.BoundingRectangle + if ($graphBounds.IsEmpty -or $graphBounds.Width -le 0 -or $graphBounds.Height -le 0) { return $false } + foreach ($control in @($wire, $mark)) { + if ($control.Current.IsOffscreen) { return $false } + $bounds = $control.Current.BoundingRectangle + if ($bounds.IsEmpty -or $bounds.Width -le 0 -or $bounds.Height -le 0 -or + $bounds.Left -lt $graphBounds.Left -or $bounds.Top -lt $graphBounds.Top -or + $bounds.Right -gt $graphBounds.Right -or $bounds.Bottom -gt $graphBounds.Bottom) { return $false } + } + return $true + } + $graph = Find-FragmentByIdWithRetry $root "graph" $rawWalker + Require ($null -ne $graph -and $graph.Current.ProcessId -eq $process.Id -and + $graph.Current.AutomationId -ceq "graph") "Unwired recovery graph did not belong to the owned shell" + if (Test-RecoveryVisibility $wire $mark $graph $process.Id $wireId $markId) { + return [ordered]@{ root = $root; graph = $graph; wire = $wire; mark = $mark } + } + $fitRecovery = Find-FragmentByIdWithRetry $root "fit-canvas" $rawWalker + Require ($null -ne $fitRecovery -and $fitRecovery.Current.ProcessId -eq $process.Id -and + $fitRecovery.Current.AutomationId -ceq "fit-canvas" -and $fitRecovery.Current.Name -ceq "Fit canvas" -and + $fitRecovery.Current.ControlType -eq [System.Windows.Automation.ControlType]::Button -and + $fitRecovery.Current.IsEnabled -and -not $fitRecovery.Current.IsOffscreen) ` + "Unwired recovery could not acquire the owned visible Fit canvas action" + $fitRecovery.GetCurrentPattern( + [System.Windows.Automation.InvokePattern]::Pattern + ).Invoke() + for ($attempt = 0; $attempt -lt 40; $attempt++) { + $root = Wait-ForRootReconnect $process @($rawWalker) + $graph = Find-FragmentById $root "graph" $rawWalker + $wire = Find-FragmentById $root $wireId $rawWalker + $mark = Find-FragmentById $root $markId $rawWalker + if (Test-RecoveryVisibility $wire $mark $graph $process.Id $wireId $markId) { + return [ordered]@{ root = $root; graph = $graph; wire = $wire; mark = $mark } + } + if ($attempt -lt 39) { Start-Sleep -Milliseconds 100 } + } + Require $false "Unwired recovery actions remained unavailable after Fit canvas" +} + function Test-MultiProjectOwnedSheetIdentity( $identity, [int] $expectedProcessId, @@ -1577,6 +268,156 @@ function Test-UpdateBannerClickTarget( $shellStatus -ceq "Update offer deferred" } +function Get-DeleteTargetDiagnosticValue([hashtable] $budget, [scriptblock] $readValue) { + if ($budget.reads -ge 11 -or $budget.clock.ElapsedMilliseconds -ge 500) { + return [ordered]@{ state = "unavailable"; reason = "diagnostic-budget" } + } + $budget.reads++ + try { + $value = & $readValue + if ($budget.clock.ElapsedMilliseconds -ge 500) { + return [ordered]@{ state = "unavailable"; reason = "diagnostic-budget" } + } + if ($null -eq $value) { return [ordered]@{ state = "unavailable" } } + return [ordered]@{ state = "available"; value = $value } + } catch { + return [ordered]@{ state = "unavailable"; hresult = $_.Exception.HResult } + } +} + +function Get-DeleteTargetIdentityHash([string] $identity) { + if ([string]::IsNullOrEmpty($identity) -or $identity.Length -gt 4096) { + throw [ArgumentException]::new("Diagnostic identity is absent or exceeds its bound") + } + return [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData( + [Text.Encoding]::UTF8.GetBytes($identity))) +} + +function Write-DeleteTargetSelectFailure( + $target, $selection, [string] $expectedIdentity, [int] $expectedProcessId, + [IntPtr] $expectedWindow, [Management.Automation.ErrorRecord] $failure, + [string] $receiptPath +) { + $budget = @{ reads = 0; clock = [Diagnostics.Stopwatch]::StartNew() } + $untrusted = [ordered]@{ state = "unavailable"; reason = "untrusted-identity" } + $processId = Get-DeleteTargetDiagnosticValue $budget { + $value = $target.Current.ProcessId + if ($value -isnot [int]) { throw "Process identity is unavailable" } + $value + } + $owned = $processId.state -ceq "available" -and $expectedProcessId -gt 0 -and $processId.value -eq $expectedProcessId + $identity = if ($owned) { + Get-DeleteTargetDiagnosticValue $budget { Get-DeleteTargetIdentityHash $target.Current.AutomationId } + } else { $untrusted } + $expectedHash = try { + [ordered]@{ state = "available"; value = Get-DeleteTargetIdentityHash $expectedIdentity } + } catch { [ordered]@{ state = "unavailable" } } + $matches = $identity.state -ceq "available" -and $expectedHash.state -ceq "available" -and + $identity.value -ceq $expectedHash.value + $record = [ordered]@{ + expectedProcessId = $expectedProcessId; expectedWindow = $expectedWindow.ToInt64() + automationIdHash = $identity; expectedAutomationIdHash = $expectedHash + identityMatches = if ($identity.state -ceq "available" -and $expectedHash.state -ceq "available") { + $identity.value -ceq $expectedHash.value + } else { $null } + processId = $processId + ownedProcess = if ($processId.state -ceq "available") { $processId.value -eq $expectedProcessId } else { $null } + runtimeIdHash = $untrusted; runtimeComparison = "unavailable-no-preselect-snapshot" + bounds = $untrusted; offscreen = $untrusted; enabled = $untrusted; selected = $untrusted + parent = $untrusted + retired = @{ state = "unavailable"; reason = "not-exposed" } + active = @{ state = "unavailable"; reason = "not-exposed" } + invokable = @{ state = "unavailable"; reason = "not-exposed" } + providerBranch = @{ state = "unavailable"; reason = "not-exposed" } + exceptionHResults = @(); exceptionChainTruncated = $false + } + if ($owned -and $matches) { + $record.runtimeIdHash = Get-DeleteTargetDiagnosticValue $budget { + $runtime = @($target.GetRuntimeId()) + if ($runtime.Count -eq 0 -or $runtime.Count -gt 64) { throw "Runtime identity exceeds diagnostic bound" } + foreach ($part in $runtime) { + if ($part -isnot [int]) { throw "Runtime identity is not an integer vector" } + } + Get-DeleteTargetIdentityHash ($runtime -join ",") + } + $record.bounds = Get-DeleteTargetDiagnosticValue $budget { + $bounds = $target.Current.BoundingRectangle + $values = @($bounds.Left, $bounds.Top, $bounds.Width, $bounds.Height) + foreach ($value in $values) { + if ($value -isnot [double] -or [double]::IsNaN($value) -or [double]::IsInfinity($value)) { + throw "Bounds are unavailable or nonfinite" + } + } + [ordered]@{ left = [double]$values[0]; top = [double]$values[1]; width = [double]$values[2]; height = [double]$values[3] } + } + foreach ($field in @("offscreen", "enabled", "selected")) { + $record[$field] = Get-DeleteTargetDiagnosticValue $budget { + $value = switch ($field) { + "offscreen" { $target.Current.IsOffscreen } + "enabled" { $target.Current.IsEnabled } + "selected" { $selection.Current.IsSelected } + } + if ($value -isnot [bool]) { throw "Boolean property is unavailable" } + $value + } + } + # This provider returns row.parent through the held selection pattern. + $container = Get-DeleteTargetDiagnosticValue $budget { $selection.Current.SelectionContainer } + if ($container.state -ceq "available") { + $record.parent = [ordered]@{ + state = "available"; classification = "selection-container" + processId = Get-DeleteTargetDiagnosticValue $budget { + $value = $container.value.Current.ProcessId + if ($value -isnot [int]) { throw "Parent process identity is unavailable" } + $value + } + controlTypeId = $untrusted; classNameHash = $untrusted + } + if ($record.parent.processId.state -ceq "available" -and + $record.parent.processId.value -eq $expectedProcessId) { + $record.parent.controlTypeId = Get-DeleteTargetDiagnosticValue $budget { + $value = $container.value.Current.ControlType.Id + if ($value -isnot [int]) { throw "Parent role is unavailable" } + $value + } + $record.parent.classNameHash = Get-DeleteTargetDiagnosticValue $budget { + Get-DeleteTargetIdentityHash $container.value.Current.ClassName + } + } + } else { + $record.parent = [ordered]@{ state = "unavailable"; reason = "selection-container-unavailable" } + } + } + $exception = $failure.Exception + for ($index = 0; $null -ne $exception -and $index -lt 4; $index++) { + $record.exceptionHResults += [int]$exception.HResult + $exception = $exception.InnerException + } + $record.exceptionChainTruncated = $null -ne $exception + $record.readCount = $budget.reads + $record.readLimit = 11 + $record.budgetMilliseconds = 500 + $record.elapsedMilliseconds = $budget.clock.ElapsedMilliseconds + $json = $record | ConvertTo-Json -Depth 6 -Compress + $bytes = [Text.UTF8Encoding]::new($false).GetBytes($json) + if ($bytes.Length -gt 8192) { throw "Delete Select receipt exceeds 8192 bytes" } + Write-Host ("UIA_DELETE_TARGET_SELECT_FAILURE=" + $json) + if ([string]::IsNullOrWhiteSpace($receiptPath)) { + Write-Host "UIA_DELETE_TARGET_SELECT_TELEMETRY_FAILURE=receipt-path-unconfigured" + return + } + $stream = $null + try { + $stream = [IO.FileStream]::new($receiptPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::Read) + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + } finally { + if ($null -ne $stream) { $stream.Dispose() } + } + Write-Host ("UIA_DELETE_TARGET_SELECT_RECEIPT=flushed bytes={0} sha256={1}" -f + $bytes.Length, [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes))) +} + function Get-UiaOwnedSheetIdentity($element) { if ($null -eq $element) { return $null } $handle = [IntPtr]$element.Current.NativeWindowHandle @@ -1868,6 +709,89 @@ function ConvertTo-PopupMenuEvidence($items) { }) } +function Restore-DeleteLoopCanvas( + $process, + $root, + [IntPtr] $ownerWindow, + [string] $automationId, + $walker +) { + Require (Ensure-ShellForeground $ownerWindow "Delete Loop Show in Graph") ` + "Delete Loop could not acquire its owned foreground before Show in Graph" + $workspace = Wait-ForGraphChildren $root $walker ` + { $_.Current.AutomationId -match '^workspace-show-graph-' } ` + { param($items) @($items).Count -eq 1 } + $showGraph = @($workspace.Items | Where-Object { + $_.Current.AutomationId -match '^workspace-show-graph-' -and + $_.Current.Name -ceq "Show in Graph" -and + $_.Current.ProcessId -eq $process.Id -and -not $_.Current.IsOffscreen + }) + Require ($showGraph.Count -eq 1) "Delete Loop omitted its visible owned Show in Graph action" + $showGraph[0].GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + $root = Wait-ForRootReconnect $process @($walker) + $card = Find-FragmentByIdWithRetry $root $automationId $walker + Require ($null -ne $card -and $card.Current.AutomationId -ceq $automationId -and + $card.Current.ProcessId -eq $process.Id) "Delete Loop lost its same owned card after Show in Graph" + $fit = Find-FragmentByIdWithRetry $root "fit-canvas" $walker + Require ($null -ne $fit -and $fit.Current.AutomationId -ceq "fit-canvas" -and + $fit.Current.Name -ceq "Fit canvas" -and $fit.Current.ProcessId -eq $process.Id) ` + "Delete Loop graph reentry omitted the exact owned Fit Canvas action" + $fit.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + $visible = Wait-ForGraphChildren $root $walker ` + { $_.Current.AutomationId -ceq $automationId -or $_.Current.AutomationId -match '^workspace-show-graph-' } ` + { param($items) + @($items | Where-Object { $_.Current.AutomationId -match '^workspace-show-graph-' }).Count -eq 0 -and + @($items | Where-Object { $_.Current.AutomationId -ceq $automationId -and + $_.Current.ProcessId -eq $process.Id -and -not $_.Current.IsOffscreen }).Count -eq 1 } + $visibleCard = @($visible.Items | Where-Object { $_.Current.AutomationId -ceq $automationId }) + Require ($null -ne $visible.Graph -and $visible.Graph.Current.ProcessId -eq $process.Id -and + $visibleCard.Count -eq 1 -and $visibleCard[0].Current.AutomationId -ceq $automationId -and + $visibleCard[0].Current.ProcessId -eq $process.Id -and -not $visibleCard[0].Current.IsOffscreen -and + @($visible.Items | Where-Object { $_.Current.AutomationId -match '^workspace-show-graph-' }).Count -eq 0) ` + "Delete Loop same card did not return to its visible canvas after Show in Graph and Fit Canvas" + Require ($visibleCard[0].GetCurrentPattern( + [System.Windows.Automation.SelectionItemPattern]::Pattern).Current.IsSelected) ` + "Delete Loop Show in Graph changed the selected card identity" + return (Wait-ForRootReconnect $process @($walker)) +} + +function Open-CanvasCardContextMenu( + $process, + $root, + [IntPtr] $ownerWindow, + [string] $automationId, + $walker, + [string] $label +) { + Require (Ensure-ShellForeground $ownerWindow "$label context menu") ` + "GraphCode shell did not reacquire foreground before the $label context menu" + $card = Find-FragmentByIdWithRetry $root $automationId $walker + Require ($null -ne $card) "$label context menu lost its stable card" + Require ($card.Current.AutomationId -ceq $automationId -and + $card.Current.ProcessId -eq $process.Id -and -not $card.Current.IsOffscreen) ` + "$label context menu card identity, process or visibility changed" + $canvas = Find-FragmentByIdWithRetry $root "graph" $walker + Require ($null -ne $canvas) "$label context menu lost its canvas" + $bounds = $card.Current.BoundingRectangle + $canvasBounds = $canvas.Current.BoundingRectangle + $cardRect = @($bounds.Left, $bounds.Top, $bounds.Right, $bounds.Bottom) + $canvasRect = @($canvasBounds.Left, $canvasBounds.Top, $canvasBounds.Right, $canvasBounds.Bottom) + Require (@(@($cardRect) + @($canvasRect) | Where-Object { + [double]::IsNaN($_) -or [double]::IsInfinity($_) + }).Count -eq 0) "$label context menu requires finite measured bounds" + $visible = Get-MultiProjectClippedRectangle $cardRect $canvasRect + $x = [int](($visible[0] + $visible[2]) / 2) + $y = [int](($visible[1] + $visible[3]) / 2) + Require ($x -ge $visible[0] -and $x -lt $visible[2] -and + $y -ge $visible[1] -and $y -lt $visible[3]) ` + "$label context menu card has no interior screen point" + Require ([GraphCodeUiaGateState]::PostOwnedScreenPoint($ownerWindow, $x, $y, $true)) ` + "$label context menu stable-card right-click was rejected" + $popup = Wait-ForPopupMenu $process $ownerWindow $label + Require ($popup -ne [IntPtr]::Zero) "$label context menu did not open" + return $popup +} + function Read-CanvasContextMenu( [System.Diagnostics.Process] $process, [IntPtr] $ownerWindow, @@ -2024,25 +948,6 @@ function Wait-ForDesktopElementGone( return $null -eq $element } -function Hide-TestProviderZmxWindows { - if (-not $env:GRAPHCODE_ZMX) { return } - $providerZmx = [IO.Path]::GetFullPath($env:GRAPHCODE_ZMX) - foreach ($process in @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | - Where-Object { - $_.Name -match "(?i)^zmx(?:\.exe)?$" -and - (([string]$_.ExecutablePath) -eq $providerZmx -or - ([string]$_.CommandLine) -like "*$providerZmx*") - })) { - [GraphCodeUiaGateState]::HideProcessWindows([uint32]$process.ProcessId) - } - $foreground = [GraphCodeUiaGateState]::CurrentForegroundWindow() - $foregroundTitle = [GraphCodeUiaGateState]::WindowTitle($foreground) - if ($foregroundTitle -eq $providerZmx -or - $foregroundTitle -like "*\zmx.exe") { - [GraphCodeUiaGateState]::HideWindow($foreground) - } -} - function Test-FocusedElementIdentity( [System.Windows.Automation.AutomationElement] $candidate, [System.Windows.Automation.AutomationElement] $expected, @@ -2072,11 +977,9 @@ function Retain-FocusWithRetry( [int] $Attempts = 600 ) { $candidate = $null - Hide-TestProviderZmxWindows Start-Sleep -Milliseconds 250 Write-Host "UIA_FOCUS_DIAGNOSTICS phase=$label $(Get-FocusDiagnostics $window)" for ($index = 0; $index -lt $Attempts; $index++) { - Hide-TestProviderZmxWindows $activated = [GraphCodeUiaGateState]::ActivateWindow($window) Start-Sleep -Milliseconds 50 try { @@ -2113,12 +1016,8 @@ function Ensure-ShellForeground( # command/UIA path that depends on the shell being foreground, retrying # within a bounded deadline instead of assuming a prior activation holds. # - # Only fall back to ActivateWindow's invasive Alt-tap foreground-lock - # bypass (keybd_event + SetForegroundWindow) when the shell does not - # already hold true foreground ownership: that trick injects a global - # synthetic Alt key press/release, and issuing it when it is not needed - # (the shell is already foreground) races the very next native window - # this gate creates and can desynchronize its subsequent close handling. + # Activation never injects keys or attaches another foreground input queue. + # A denied foreground request remains an explicit bounded failure. if ([GraphCodeUiaGateState]::IsForegroundWindow($window)) { return $true } @@ -2126,7 +1025,6 @@ function Ensure-ShellForeground( $deadline = $startedUtc.AddMilliseconds($TimeoutMilliseconds) $acquired = $false do { - Hide-TestProviderZmxWindows $activated = [GraphCodeUiaGateState]::ActivateWindow($window) $acquired = $activated -and [GraphCodeUiaGateState]::IsForegroundWindow($window) if (-not $acquired) { @@ -3265,67 +2163,66 @@ function Get-UiaOwnedProcessDescendants([int[]] $rootProcessIds) { return @($descendants | Sort-Object Depth -Descending) } +function Invoke-UiaCleanupActions([scriptblock[]] $actions) { + $errors = [Collections.Generic.List[Exception]]::new() + foreach ($action in $actions) { + try { & $action } + catch { $errors.Add($_.Exception) } + } + if ($errors.Count) { + $failure = [AggregateException]::new("UIA cleanup actions failed", $errors.ToArray()) + if (@($errors | Where-Object { $_.Data["GraphCodeValidationRootRetentionRequired"] -eq $true }).Count) { + $tag = New-UiaOwnershipFailure $failure.Message + foreach ($key in $tag.Data.Keys) { $failure.Data[$key] = $tag.Data[$key] } + $failure.Data["GraphCodeValidationRetainedRoots"] = @( + @($tag.Data["GraphCodeValidationRetainedRoots"]) + + @($errors | ForEach-Object { $_.Data["GraphCodeValidationRetainedRoots"] }) | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique + ) + } + throw $failure + } +} + function Stop-UiaOwnedProcessTrees([Diagnostics.Process[]] $rootProcesses) { $roots = @($rootProcesses | Where-Object { $null -ne $_ }) - if ($roots.Count -eq 0) { return } - $rootIds = @($roots | ForEach-Object { $_.Id }) - $descendants = @(Get-UiaOwnedProcessDescendants $rootIds) - Write-Host ("UIA_OWNED_PROCESS_TREE=" + (@($descendants | ForEach-Object { - [ordered]@{ - processId = $_.ProcessId - parentProcessId = $_.ParentProcessId - name = $_.Name - executablePath = Protect-UiaStartupDiagnosticText $_.ExecutablePath - depth = $_.Depth - } - } | ConvertTo-Json -Compress -Depth 4))) - foreach ($descendant in $descendants) { - $current = Get-CimInstance Win32_Process -Filter ` - "ProcessId = $($descendant.ProcessId)" -ErrorAction Stop - if ($null -eq $current) { continue } - if ([string]$current.Name -cne $descendant.Name -or - [string]$current.CreationDate -cne [string]$descendant.CreationDate) { - continue - } - $child = Get-Process -Id $descendant.ProcessId -ErrorAction SilentlyContinue - if ($child) { - try { - if (-not $child.HasExited) { $child.Kill() } - } catch [InvalidOperationException] { - if (-not $child.HasExited) { throw } - } - if (-not $child.WaitForExit(5000)) { - throw "Owned descendant process $($descendant.ProcessId) did not exit after termination" - } - $child.Dispose() - } + $captures = @($script:UiaGateOwnedCaptures | Where-Object { $null -ne $_ }) + if ($roots.Count -eq 0 -and $captures.Count -eq 0) { + Write-Host "UIA_PROCESS_TREE_CLEANUP=not_needed no owned launch was registered" + return } + $errors = [Collections.Generic.List[Exception]]::new() foreach ($root in $roots) { - try { - if (-not $root.HasExited) { $root.Kill() } - } catch [InvalidOperationException] { - if (-not $root.HasExited) { throw } - } - if (-not $root.HasExited) { - if (-not $root.WaitForExit(5000)) { - throw "Owned UIA shell process $($root.Id) did not exit after termination" - } + $owned = @($captures | Where-Object { [Object]::ReferenceEquals($_.process, $root) }) + if ($owned.Count -ne 1) { + $errors.Add((New-UiaOwnershipFailure ( + "UIA root $($root.Id) has no unique pre-launch owned-job lease. " + + "No PID/CIM-discovered descendant was terminated; complete tree cleanup cannot be verified."))) } } - $remaining = @() - foreach ($descendant in $descendants) { - $current = Get-CimInstance Win32_Process -Filter ` - "ProcessId = $($descendant.ProcessId)" -ErrorAction Stop - if ($null -ne $current -and - [string]$current.Name -ceq $descendant.Name -and - [string]$current.CreationDate -ceq [string]$descendant.CreationDate) { - $remaining += $descendant.ProcessId - } + foreach ($capture in $captures) { + if ($capture.cleaned) { continue } + try { $null = Complete-MultiProjectCapture $capture $capture.logDirectory } + catch { $errors.Add($_.Exception) } } - if ($remaining.Count -gt 0) { - throw "Owned UIA descendants remained after teardown: $($remaining -join ',')" + if ($errors.Count) { + $failure = [AggregateException]::new("UIA owned-job cleanup failed", $errors.ToArray()) + if (@($errors | Where-Object { $_.Data["GraphCodeValidationRootRetentionRequired"] -eq $true }).Count) { + $tag = New-UiaOwnershipFailure $failure.Message + foreach ($key in $tag.Data.Keys) { $failure.Data[$key] = $tag.Data[$key] } + } + throw $failure } - Write-Host "UIA_PROCESS_TREE_CLEANUP=verified roots=$($rootIds -join ',') descendants=$($descendants.Count)" + Write-Host "UIA_PROCESS_TREE_CLEANUP=verified ownedJobs=$($captures.Count)" +} + +function New-UiaOwnershipFailure([string] $message) { + $failure = [InvalidOperationException]::new($message) + $root = [Environment]::GetEnvironmentVariable("GRAPHCODE_VALIDATION_ROOT", "Process") + $roots = @( + if (-not [string]::IsNullOrWhiteSpace($root)) { $root } + ) + return (ShellProcessOwnership\Set-ShellOwnershipUnproven -Exception $failure -RetainedRoots $roots) } function Stop-UiaOwnedProviderProcesses( @@ -3337,7 +2234,7 @@ function Stop-UiaOwnedProviderProcesses( return } $expected = [IO.Path]::GetFullPath($providerPath) - $owned = @( + $observed = @( Get-CimInstance Win32_Process -Filter "Name = 'zmx.exe'" -ErrorAction Stop | Where-Object { $_.ExecutablePath -and @@ -3345,36 +2242,12 @@ function Stop-UiaOwnedProviderProcesses( -not $baseline.Contains("$([int]$_.ProcessId)|$([string]$_.CreationDate)") } ) - foreach ($candidate in $owned) { - $current = Get-CimInstance Win32_Process -Filter ` - "ProcessId = $([int]$candidate.ProcessId)" -ErrorAction Stop - if ($null -eq $current -or - [string]$current.CreationDate -cne [string]$candidate.CreationDate -or - -not $current.ExecutablePath -or - [IO.Path]::GetFullPath([string]$current.ExecutablePath) -ine $expected) { - continue - } - $process = Get-Process -Id ([int]$candidate.ProcessId) -ErrorAction SilentlyContinue - if ($process) { - if (-not $process.HasExited) { $process.Kill() } - if (-not $process.WaitForExit(5000)) { - throw "Owned provider process $($candidate.ProcessId) did not exit after termination" - } - $process.Dispose() - } + if ($observed.Count -gt 0) { + throw (New-UiaOwnershipFailure ( + "UIA provider ownership is unproven for observed PIDs $($observed.ProcessId -join ','); " + + "path, creation time, and baseline absence are advisory only. No discovered process was terminated.")) } - $remaining = @( - Get-CimInstance Win32_Process -Filter "Name = 'zmx.exe'" -ErrorAction Stop | - Where-Object { - $_.ExecutablePath -and - [IO.Path]::GetFullPath([string]$_.ExecutablePath) -ieq $expected -and - -not $baseline.Contains("$([int]$_.ProcessId)|$([string]$_.CreationDate)") - } - ) - if ($remaining.Count -gt 0) { - throw "Owned provider processes remained after teardown: $($remaining.ProcessId -join ',')" - } - Write-Host "UIA_PROVIDER_PROCESS_CLEANUP=verified terminated=$($owned.Count)" + Write-Host "UIA_PROVIDER_PROCESS_CLEANUP=no_candidates_observed terminated=0" } function Get-UiaStartupImageHash([string] $path, [scriptblock] $openRead) { @@ -4461,83 +3334,7 @@ function Get-MultiProjectRenameTitle([string] $loopTitle) { } function New-MultiProjectEditNativeApi([string] $expectedTitle = "") { - if(-not("GraphCodeMultiProjectEditNative" -as [type])){ - Add-Type -TypeDefinition @' -using System; -using System.Text; -using System.Diagnostics; -using System.ComponentModel; -using System.Runtime.InteropServices; -public static class GraphCodeMultiProjectEditNative { - [StructLayout(LayoutKind.Sequential)] private struct Key { public ushort Vk, Scan; public uint Flags, Time; public UIntPtr Extra; } - [StructLayout(LayoutKind.Sequential)] private struct Input { public uint Type; public Key Key; public uint Pad0, Pad1; } - [StructLayout(LayoutKind.Sequential)] private struct Rect {public int Left,Top,Right,Bottom;} - [StructLayout(LayoutKind.Sequential)] private struct GuiInfo { - public int Size; public uint Flags; public IntPtr Active,Focus,Capture,MenuOwner,MoveSize,Caret; public Rect CaretRect; - } - [DllImport("user32.dll", SetLastError=true)] private static extern uint SendInput(uint count, Input[] inputs, int size); - [DllImport("user32.dll", EntryPoint="SendMessageTimeoutW", SetLastError=true)] - private static extern IntPtr Message(IntPtr window, uint message, UIntPtr wparam, IntPtr lparam, uint flags, uint timeout, out UIntPtr result); - [DllImport("user32.dll", EntryPoint="SendMessageTimeoutW", CharSet=CharSet.Unicode, SetLastError=true)] - private static extern IntPtr TextMessage(IntPtr window, uint message, UIntPtr wparam, StringBuilder text, uint flags, uint timeout, out UIntPtr result); - [DllImport("user32.dll", EntryPoint="SendMessageTimeoutW", CharSet=CharSet.Unicode, SetLastError=true)] - private static extern IntPtr SetTextMessage(IntPtr window, uint message, UIntPtr wparam, string text, uint flags, uint timeout, out UIntPtr result); - [DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr window, out uint pid); - [DllImport("user32.dll",SetLastError=true)] private static extern bool GetGUIThreadInfo(uint thread,ref GuiInfo info); - private static uint Remaining(int budget, Stopwatch watch) { - long value=budget-watch.ElapsedMilliseconds; - if(value<=0) throw new TimeoutException("N3e edit transaction deadline expired"); - return (uint)value; - } - private static UIntPtr ReadMessage(IntPtr window,uint message,UIntPtr first,IntPtr last,int budget,Stopwatch watch) { - UIntPtr result; - if(Message(window,message,first,last,0x23,Remaining(budget,watch),out result)==IntPtr.Zero) - throw new Win32Exception(Marshal.GetLastWin32Error(),"N3e edit message unavailable/timed out"); - return result; - } - public static string Buffer(IntPtr edit,int budget) { - var watch=Stopwatch.StartNew(); - int length=checked((int)ReadMessage(edit,0x000E,UIntPtr.Zero,IntPtr.Zero,budget,watch).ToUInt64()); - if(length<0 || length>65536) throw new InvalidOperationException("N3e edit buffer exceeds bound"); - var text=new StringBuilder(length+1); UIntPtr result; - if(TextMessage(edit,0x000D,(UIntPtr)text.Capacity,text,0x23,Remaining(budget,watch),out result)==IntPtr.Zero) - throw new Win32Exception(Marshal.GetLastWin32Error(),"N3e WM_GETTEXT unavailable/timed out"); - return text.ToString(); - } - public static int[] Selection(IntPtr edit,int budget) { - var watch=Stopwatch.StartNew(); IntPtr memory=Marshal.AllocHGlobal(8); - try { - Marshal.WriteInt32(memory,-1); Marshal.WriteInt32(memory,4,-1); - ReadMessage(edit,0x00B0,(UIntPtr)memory.ToInt64(),IntPtr.Add(memory,4),budget,watch); - return new[]{Marshal.ReadInt32(memory),Marshal.ReadInt32(memory,4)}; - } finally {Marshal.FreeHGlobal(memory);} - } - public static void SelectAll(IntPtr edit,int budget) { - ReadMessage(edit,0x00B1,UIntPtr.Zero,new IntPtr(-1),budget,Stopwatch.StartNew()); - } - public static void SetText(IntPtr edit,string text,int budget) { - UIntPtr result; - if(SetTextMessage(edit,0x000C,UIntPtr.Zero,text,0x23,(uint)budget,out result)==IntPtr.Zero) - throw new Win32Exception(Marshal.GetLastWin32Error(),"N3e WM_SETTEXT unavailable/timed out"); - } - public static IntPtr FocusOf(IntPtr modal) { - uint pid; uint thread=GetWindowThreadProcessId(modal,out pid); - var info=new GuiInfo{Size=Marshal.SizeOf(typeof(GuiInfo))}; - if(thread==0 || !GetGUIThreadInfo(thread,ref info)) throw new Win32Exception(Marshal.GetLastWin32Error(),"N3e GetGUIThreadInfo"); - return info.Focus; - } - public static int[] Unicode(string text) { - var inputs=new Input[text.Length*2]; int index=0; - foreach(char value in text){ - inputs[index++]=new Input{Type=1,Key=new Key{Scan=value,Flags=4}}; - inputs[index++]=new Input{Type=1,Key=new Key{Scan=value,Flags=6}}; - } - uint sent=SendInput((uint)inputs.Length,inputs,Marshal.SizeOf(typeof(Input))); - return new[]{checked((int)sent),inputs.Length}; - } -} -'@ - } + & (Join-Path $PSScriptRoot "Import-CompiledValidationAssembly.ps1") -AssemblyPath $validationAssemblyPath -RequiredType "GraphCodeMultiProjectEditNative" | Out-Null $api=New-MultiProjectRenameNativeApi $expectedTitle $api | Add-Member NoteProperty Watch ([Diagnostics.Stopwatch]::StartNew()) $api | Add-Member NoteProperty LastFocusPostedFallback $false @@ -4545,6 +3342,7 @@ public static class GraphCodeMultiProjectEditNative { $api | Add-Member ScriptMethod Clock {return $this.Watch.ElapsedMilliseconds} $api | Add-Member ScriptMethod Title {param($modal,$remaining) [GraphCodeMultiProjectEditNative]::Buffer($modal,$remaining)} -Force $api | Add-Member ScriptMethod FocusHandle {param($modal) [GraphCodeMultiProjectEditNative]::FocusOf($modal)} + $api | Add-Member ScriptMethod FocusControl {param($modal,$edit) [GraphCodeUiaGateState]::FocusControl($modal,$edit)} $api | Add-Member ScriptMethod FocusInsertion { param($modal,$point) $receipt = @([GraphCodeUiaGateState]::ClickOwnedScreenRectangle( @@ -4585,11 +3383,9 @@ public static class GraphCodeMultiProjectEditNative { } $null=Get-MultiProjectEditRemaining $this $started $receipt = @($this.FocusInsertion($modal,$point)) - if ("GraphCodeUiaGateState" -as [type]) { - $this.LastFocusControlFallback = [GraphCodeUiaGateState]::FocusControl($modal,$edit) - if (-not $this.LastFocusControlFallback) { - throw "MULTIPROJECT_EDIT_FOCUS: direct control focus fallback failed" - } + $this.LastFocusControlFallback = $this.FocusControl($modal,$edit) + if (-not $this.LastFocusControlFallback) { + throw "MULTIPROJECT_EDIT_FOCUS: direct control focus fallback failed" } return ,$receipt } @@ -4597,8 +3393,14 @@ public static class GraphCodeMultiProjectEditNative { $api | Add-Member ScriptMethod Selection {param($edit,$remaining) [GraphCodeMultiProjectEditNative]::Selection($edit,$remaining)} $api | Add-Member ScriptMethod SelectAll {param($edit,$remaining) [GraphCodeMultiProjectEditNative]::SelectAll($edit,$remaining)} $api | Add-Member ScriptMethod SetText {param($edit,$text,$remaining) [GraphCodeMultiProjectEditNative]::SetText($edit,$text,$remaining)} - $api | Add-Member ScriptMethod Delete {return ,@(([GraphCodeUiaGateState]::SendKeyInput(0x2E,1)*2),2)} - $api | Add-Member ScriptMethod Unicode {param($text) return ,@([GraphCodeMultiProjectEditNative]::Unicode($text))} + $api | Add-Member ScriptMethod Delete { + param($modal,$edit,$expectedProcessId) + return ,@(([GraphCodeUiaGateState]::SendKeyInput($modal,$expectedProcessId,$edit,0x2E,1)*2),2) + } + $api | Add-Member ScriptMethod Unicode { + param($text,$modal,$edit,$expectedProcessId) + return ,@([GraphCodeUiaGateState]::SendUnicodeInput($modal,$expectedProcessId,$edit,$text)) + } $api | Add-Member ScriptMethod Observe {param($remaining) Start-Sleep -Milliseconds ([Math]::Min(25,$remaining))} return $api } @@ -4731,7 +3533,7 @@ function Invoke-MultiProjectSequencedEdit( $null=Assert-MultiProjectEditOwned $api $modal $processId $started $field -Focused $null=Get-MultiProjectEditRemaining $api $started $record.phase="clear";$record.clearBatches++ - $record.clear=@($api.Delete()) + $record.clear=@($api.Delete($modal,$edit,$processId)) if($record.clear.Count -ne 2 -or $record.clear[0] -isnot [int] -or $record.clear[1] -isnot [int] -or $record.clear[0] -ne 2 -or $record.clear[1] -ne 2){throw "MULTIPROJECT_EDIT_COUNTS: clear insertion receipt not2/2"} if($null -ne $api.PSObject.Methods["SetText"]){ @@ -4765,7 +3567,7 @@ function Invoke-MultiProjectSequencedEdit( $null=Assert-MultiProjectEditOwned $api $modal $processId $started $field -Focused $null=Get-MultiProjectEditRemaining $api $started $record.phase="unicode";$record.unicodeBatches++ - $record.typed=@($api.Unicode($text)) + $record.typed=@($api.Unicode($text,$modal,$edit,$processId)) if($record.typed.Count -ne 2 -or $record.typed[0] -isnot [int] -or $record.typed[1] -isnot [int] -or $record.typed[0] -ne 2*$text.Length -or $record.typed[1] -ne 2*$text.Length){ throw "MULTIPROJECT_EDIT_COUNTS: single Unicode insertion receipt incomplete" @@ -4804,86 +3606,7 @@ function Invoke-MultiProjectSequencedEdit( } function New-MultiProjectRenameNativeApi([string] $expectedTitle = "") { - if (-not ("GraphCodeMultiProjectRenameNative" -as [type])) { - Add-Type -TypeDefinition @' -using System; -using System.ComponentModel; -using System.Runtime.InteropServices; -public static class GraphCodeMultiProjectRenameNative { - [StructLayout(LayoutKind.Sequential)] public struct Point { public int X, Y; } - [StructLayout(LayoutKind.Sequential)] public struct Rect { public int Left, Top, Right, Bottom; } - [StructLayout(LayoutKind.Sequential)] private struct MonitorInfo { - public int Size; public Rect Monitor, Work; public uint Flags; - } - [DllImport("user32.dll")] public static extern IntPtr GetAncestor(IntPtr window, uint flags); - [DllImport("user32.dll")] public static extern IntPtr WindowFromPoint(Point point); - [DllImport("user32.dll")] private static extern IntPtr RealChildWindowFromPoint(IntPtr window, Point point); - [DllImport("user32.dll", CharSet=CharSet.Unicode)] - private static extern IntPtr FindWindowEx(IntPtr parent, IntPtr childAfter, string className, string title); - [DllImport("user32.dll", CharSet=CharSet.Unicode, EntryPoint="GetClassNameW")] - private static extern int GetClassName(IntPtr window, System.Text.StringBuilder name, int capacity); - [DllImport("user32.dll", SetLastError=true)] private static extern bool GetClientRect(IntPtr window, out Rect rect); - [DllImport("user32.dll", SetLastError=true)] private static extern bool ClientToScreen(IntPtr window, ref Point point); - [DllImport("user32.dll", SetLastError=true)] private static extern bool ScreenToClient(IntPtr window, ref Point point); - [DllImport("user32.dll")] private static extern IntPtr MonitorFromWindow(IntPtr window, uint flags); - [DllImport("user32.dll", CharSet=CharSet.Unicode, SetLastError=true)] - private static extern bool GetMonitorInfo(IntPtr monitor, ref MonitorInfo info); - public static int[] ClientBounds(IntPtr window) { - Rect rect; - if (!GetClientRect(window, out rect)) throw new Win32Exception(Marshal.GetLastWin32Error(), "Rename GetClientRect"); - var first = new Point { X=rect.Left, Y=rect.Top }; - var last = new Point { X=rect.Right, Y=rect.Bottom }; - if (!ClientToScreen(window, ref first) || !ClientToScreen(window, ref last)) - throw new Win32Exception(Marshal.GetLastWin32Error(), "Rename ClientToScreen"); - return new[] { first.X, first.Y, last.X, last.Y }; - } - public static int[] WorkBounds(IntPtr window) { - var info = new MonitorInfo { Size=Marshal.SizeOf(typeof(MonitorInfo)) }; - if (!GetMonitorInfo(MonitorFromWindow(window, 2), ref info)) - throw new Win32Exception(Marshal.GetLastWin32Error(), "Rename GetMonitorInfo"); - return new[] { info.Work.Left, info.Work.Top, info.Work.Right, info.Work.Bottom }; - } - private static bool ContainsScreenPoint(IntPtr window, int x, int y) { - Rect rect; - if (!GetClientRect(window, out rect)) return false; - var first = new Point { X=rect.Left, Y=rect.Top }; - var last = new Point { X=rect.Right, Y=rect.Bottom }; - if (!ClientToScreen(window, ref first) || !ClientToScreen(window, ref last)) return false; - return x >= first.X && x < last.X && y >= first.Y && y < last.Y; - } - public static IntPtr ChildAt(IntPtr window, int x, int y) { - var point = new Point { X=x, Y=y }; - if (!ScreenToClient(window, ref point)) throw new Win32Exception(Marshal.GetLastWin32Error(), "Rename ScreenToClient"); - IntPtr direct = RealChildWindowFromPoint(window, point); - var rootClass = new System.Text.StringBuilder(128); - var childClass = new System.Text.StringBuilder(128); - GetClassName(window, rootClass, rootClass.Capacity); - if (direct == IntPtr.Zero && - String.Equals(rootClass.ToString(), "GraphCodeSheetHost", StringComparison.Ordinal)) { - IntPtr viewport = FindWindowEx(window, IntPtr.Zero, "GraphCodeSheetViewport", null); - if (ContainsScreenPoint(viewport, x, y)) direct = viewport; - } - GetClassName(direct, childClass, childClass.Capacity); - if (!String.Equals(rootClass.ToString(), "GraphCodeSheetHost", StringComparison.Ordinal) || - !String.Equals(childClass.ToString(), "GraphCodeSheetViewport", StringComparison.Ordinal)) - return direct; - IntPtr content = FindWindowEx(direct, IntPtr.Zero, "GraphCodeWindowsDialogContent", null); - if (content == IntPtr.Zero) return direct; - var viewportPoint = new Point { X=x, Y=y }; - if (!ScreenToClient(direct, ref viewportPoint)) - throw new Win32Exception(Marshal.GetLastWin32Error(), "Rename viewport ScreenToClient"); - IntPtr viewportChild = RealChildWindowFromPoint(direct, viewportPoint); - if (viewportChild != content && - !(viewportChild == IntPtr.Zero && ContainsScreenPoint(content, x, y))) return direct; - var contentPoint = new Point { X=x, Y=y }; - if (!ScreenToClient(content, ref contentPoint)) - throw new Win32Exception(Marshal.GetLastWin32Error(), "Rename content ScreenToClient"); - IntPtr nested = RealChildWindowFromPoint(content, contentPoint); - return nested == IntPtr.Zero ? content : nested; - } -} -'@ - } + & (Join-Path $PSScriptRoot "Import-CompiledValidationAssembly.ps1") -AssemblyPath $validationAssemblyPath -RequiredType "GraphCodeMultiProjectRenameNative" | Out-Null $api = New-Object PSObject $api | Add-Member NoteProperty ExpectedTitle $expectedTitle $api | Add-Member ScriptMethod ProcessId { param($window) [GraphCodeUiaGateState]::WindowProcessId($window) } @@ -5418,17 +4141,7 @@ function Test-MultiProjectRenameReceipt($before, $after, [string] $path, [string } function Initialize-MultiProjectProcessJob([string] $sourcePath) { - if ("StandaloneProcessJob" -as [type]) { return } - $errors = $null - $ast = [Management.Automation.Language.Parser]::ParseFile($sourcePath, [ref]$null, [ref]$errors) - if ($errors.Count -ne 0) { throw "MULTIPROJECT_JOB: approved standalone ownership source does not parse" } - $definitions = @($ast.FindAll({ - param($node) - $node -is [Management.Automation.Language.StringConstantExpressionAst] -and - $node.Value.Contains("public static class StandaloneProcessJob") - }, $true)) - if ($definitions.Count -ne 1) { throw "MULTIPROJECT_JOB: exact approved job ownership definition missing" } - Add-Type $definitions[0].Value + & (Join-Path $PSScriptRoot "Import-CompiledValidationAssembly.ps1") -AssemblyPath $validationAssemblyPath -RequiredType "GraphCode.Validation.Ownership.StandaloneProcessJob" | Out-Null } function Wait-MultiProjectCapture($capture, [int] $timeoutMilliseconds = 5000) { @@ -5458,51 +4171,61 @@ function Complete-MultiProjectCapture($capture, [string] $logDirectory, [int] $t $drained = $false try { if ($capture.assigned) { - [StandaloneProcessJob]::Terminate($capture.job) + [GraphCode.Validation.Ownership.StandaloneProcessJob]::Terminate($capture.job) $remaining = [Math]::Max(0, $timeoutMilliseconds - [int]$clock.ElapsedMilliseconds) - [StandaloneProcessJob]::WaitForEmpty($capture.job, $remaining) - $empty = [StandaloneProcessJob]::Active($capture.job) -eq 0 + [GraphCode.Validation.Ownership.StandaloneProcessJob]::WaitForEmpty($capture.job, $remaining) + $empty = [GraphCode.Validation.Ownership.StandaloneProcessJob]::Active($capture.job) -eq 0 if (-not $empty) { throw "owned job census is not empty" } - } elseif ($capture.rootProcess -and -not $capture.rootProcess.HasExited) { - if ($capture.rootProcess.StartTime.ToUniversalTime().Ticks -ne $capture.rootStartUtcTicks) { - throw "unassigned root identity changed" - } - Stop-Process -Id $capture.rootPID -Force + } else { + # The assignment barrier was never released; only the retained launcher exists. + if ($capture.rootProcess -and -not $capture.rootProcess.HasExited) { $capture.rootProcess.Kill() } + $empty = $true } $remaining = [Math]::Max(0, $timeoutMilliseconds - [int]$clock.ElapsedMilliseconds) - $exited = $capture.rootProcess.WaitForExit($remaining) + $exited = -not $capture.rootProcess -or $capture.rootProcess.WaitForExit($remaining) if (-not $exited) { throw "root did not exit within shared teardown deadline" } if ($capture.process -and $capture.process -ne $capture.rootProcess) { $remaining = [Math]::Max(0, $timeoutMilliseconds - [int]$clock.ElapsedMilliseconds) - if (-not $capture.process.WaitForExit($remaining)) { throw "retained target did not exit within shared teardown deadline" } + if (-not $capture.process.WaitForExit($remaining)) { + $exited = $false + throw "retained target did not exit within shared teardown deadline" + } } } catch { $errors.Add("owned exit/quiescence: $($_.Exception.Message)") } try { - if ($capture.stdout -and $capture.stderr) { + $readers = @(@($capture.stdout, $capture.stderr) | Where-Object { $null -ne $_ }) + if ($readers.Count) { $remaining = [Math]::Max(0, $timeoutMilliseconds - [int]$clock.ElapsedMilliseconds) - $drained = [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($capture.stdout, $capture.stderr), $remaining) + $drained = [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]$readers, $remaining) if (-not $drained) { throw "stdout=$($capture.stdout.Status) stderr=$($capture.stderr.Status) shared pipe-drain deadline exceeded" } foreach ($stream in @("stdout", "stderr")) { $task = $capture[$stream] + if ($null -eq $task) { continue } if ($task.Status -ne [Threading.Tasks.TaskStatus]::RanToCompletion) { throw "$stream reader did not successfully complete" } try { - $task.GetAwaiter().GetResult() | Set-Content -LiteralPath (Join-Path $logDirectory ($capture.stem + "-$stream.log")) + if (-not $capture.streamingLogs) { + $task.GetAwaiter().GetResult() | Set-Content -LiteralPath (Join-Path $logDirectory ($capture.stem + "-$stream.log")) + } } catch { $errors.Add("$stream capture persistence: $($_.Exception.Message)") } } - } + } else { $drained = $true } } catch { $errors.Add("redirected pipe drain: $($_.Exception.Message)") } - foreach ($stream in @("StandardOutput", "StandardError")) { - try { if ($capture.rootProcess) { $capture.rootProcess.$stream.Dispose() } } - catch { $errors.Add("disposing $stream reader: $($_.Exception.Message)") } + $release = $exited -and $empty -and $drained + if ($release) { + foreach ($stream in @("StandardOutput", "StandardError")) { + try { if ($capture.rootProcess) { $capture.rootProcess.$stream.Dispose() } } + catch { $errors.Add("disposing $stream reader: $($_.Exception.Message)") } + } + try { + if ($capture.job -ne [IntPtr]::Zero) { [GraphCode.Validation.Ownership.StandaloneProcessJob]::Close($capture.job) } + } catch { $errors.Add("closing owned job: $($_.Exception.Message)") } + try { + if ($capture.process -and $capture.process -ne $capture.rootProcess) { $capture.process.Dispose() } + } catch { $errors.Add("disposing retained target: $($_.Exception.Message)") } + try { if ($capture.rootProcess) { $capture.rootProcess.Dispose() } } + catch { $errors.Add("disposing retained root: $($_.Exception.Message)") } + $capture.cleaned = $true } - try { - if ($capture.job -ne [IntPtr]::Zero) { [StandaloneProcessJob]::Close($capture.job) } - } catch { $errors.Add("closing owned job: $($_.Exception.Message)") } - try { - if ($capture.process -and $capture.process -ne $capture.rootProcess) { $capture.process.Dispose() } - } catch { $errors.Add("disposing retained target: $($_.Exception.Message)") } - try { if ($capture.rootProcess) { $capture.rootProcess.Dispose() } } - catch { $errors.Add("disposing retained root: $($_.Exception.Message)") } $diagnostic = [ordered]@{ identity = $identity; rootExited = $exited; ownedJobEmpty = $empty redirectedReadersDrained = $drained; deadlineMilliseconds = $timeoutMilliseconds @@ -5514,23 +4237,52 @@ function Complete-MultiProjectCapture($capture, [string] $logDirectory, [int] $t Write-Host ("MULTIPROJECT_CAPTURE_TEARDOWN=" + ($diagnostic | ConvertTo-Json -Depth 8 -Compress)) if ($errors.Count -gt 0) { $message = "MULTIPROJECT_TEARDOWN: $identity; $($errors -join '; ')" + $failure = if (-not $release) { New-UiaOwnershipFailure $message } else { [InvalidOperationException]::new($message) } + $failure.Data["UiaRetainedCapture"] = $capture if ($primaryError) { + foreach ($key in $failure.Data.Keys) { $primaryError.Exception.Data[$key] = $failure.Data[$key] } $primaryError.Exception.Data["MultiProjectTeardown:$($capture.stem)"] = $message Write-Warning $message -WarningAction Continue - } else { throw $message } + } else { throw $failure } } return $diagnostic } +function Initialize-UiaOwnedStreamCapture() { + & (Join-Path $PSScriptRoot "Import-CompiledValidationAssembly.ps1") -AssemblyPath $validationAssemblyPath -RequiredType "GraphCode.Validation.Ownership.UiaOwnedStreamCapture" | Out-Null +} + +function Start-UiaOwnedProcess( + [string] $file, [string[]] $arguments, [string] $standardOutputPath, + [string] $standardErrorPath, [switch] $Hidden +) { + $environment = @{} + foreach ($entry in [Environment]::GetEnvironmentVariables("Process").GetEnumerator()) { + $environment[$entry.Key] = $entry.Value + } + $directory = Split-Path -Parent $standardErrorPath + $stem = [IO.Path]::GetFileNameWithoutExtension($standardErrorPath) -replace "-stderr$", "" + if (-not $standardOutputPath) { $standardOutputPath = Join-Path $directory "$stem-stdout.log" } + $capture = Start-MultiProjectOwnedProcess -file $file -arguments $arguments -environment $environment ` + -stem $stem -Visible:(-not $Hidden) -RegisterForUia -standardOutputPath $standardOutputPath ` + -standardErrorPath $standardErrorPath -logDirectory $directory -targetWorkingDirectory (Get-Location).ProviderPath + return $capture.process +} + function Start-MultiProjectOwnedProcess( [string] $file, [string[]] $arguments, [hashtable] $environment, [string] $stem, [string] $jobSourcePath = (Join-Path $PSScriptRoot "Tests\Packaging.Standalone.Tests.ps1"), - [switch] $Visible + [switch] $Visible, [switch] $RegisterForUia, + [string] $standardOutputPath, [string] $standardErrorPath, + [string] $logDirectory = $environment.TEMP, + [string] $targetWorkingDirectory = $environment.TEMP ) { Initialize-MultiProjectProcessJob $jobSourcePath $gate = Join-Path $environment.TEMP ("mp-start-" + [guid]::NewGuid().ToString("N")) $identityPath = $gate + ".identity" - $launch = @{ gate = $gate; identity = $identityPath; file = $file; arguments = @($arguments); visible = [bool]$Visible } | + $launch = @{ gate = $gate; identity = $identityPath; file = $file; arguments = @($arguments) + visible = [bool]$Visible; workingDirectory = $targetWorkingDirectory + lifetimeMilliseconds = $(if ($RegisterForUia) { -1 } else { 600000 }) } | ConvertTo-Json -Depth 8 -Compress $payload = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($launch)) $command = @' @@ -5544,15 +4296,18 @@ while (-not [IO.File]::Exists($launch.gate)) { $start = [Diagnostics.ProcessStartInfo]::new($launch.file) $start.UseShellExecute = $false $start.CreateNoWindow = -not $launch.visible +$start.RedirectStandardInput = $true +$start.WorkingDirectory = $launch.workingDirectory foreach ($argument in $launch.arguments) { $start.ArgumentList.Add([string]$argument) } $target = [Diagnostics.Process]::Start($start) +$target.StandardInput.Close() try { $null = $target.Handle $temporaryIdentity = $launch.identity + ".pending" @{ pid = $target.Id; startUtcTicks = $target.StartTime.ToUniversalTime().Ticks } | ConvertTo-Json -Compress | Set-Content -LiteralPath $temporaryIdentity [IO.File]::Move($temporaryIdentity, $launch.identity) - if (-not $target.WaitForExit(600000)) { throw "Owned target exceeded ten-minute launch lifetime" } + if (-not $target.WaitForExit($launch.lifetimeMilliseconds)) { throw "Owned target exceeded its launch lifetime" } exit $target.ExitCode } finally { $target.Dispose() } '@ @@ -5572,17 +4327,29 @@ try { foreach ($argument in @("-NoProfile", "-NonInteractive", "-EncodedCommand", [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)))) { $start.ArgumentList.Add($argument) } $capture = [ordered]@{ process = $null; rootProcess = $null; rootPID = $null; rootStartUtcTicks = $null - targetPID = $null; targetStartUtcTicks = $null; job = [StandaloneProcessJob]::Create() - assigned = $false; stdout = $null; stderr = $null; stem = $stem } + targetPID = $null; targetStartUtcTicks = $null; job = [GraphCode.Validation.Ownership.StandaloneProcessJob]::Create() + assigned = $false; stdout = $null; stderr = $null; stem = $stem + cleaned = $false; streamingLogs = [bool]$standardOutputPath; logDirectory = $logDirectory } + if ($RegisterForUia) { + if ($null -eq $script:UiaGateOwnedCaptures) { $script:UiaGateOwnedCaptures = [Collections.Generic.List[object]]::new() } + $script:UiaGateOwnedCaptures.Add($capture) + } try { $root = [Diagnostics.Process]::Start($start) $capture.rootProcess = $root $capture.rootPID = $root.Id $capture.rootStartUtcTicks = $root.StartTime.ToUniversalTime().Ticks $handle = $root.Handle - $capture.stdout = $root.StandardOutput.ReadToEndAsync() - $capture.stderr = $root.StandardError.ReadToEndAsync() - [StandaloneProcessJob]::Assign($capture.job, $handle) + if ($standardOutputPath) { + Initialize-UiaOwnedStreamCapture + $capture.stdout = [GraphCode.Validation.Ownership.UiaOwnedStreamCapture]::Read($root.StandardOutput, $standardOutputPath) + $capture.stderr = [GraphCode.Validation.Ownership.UiaOwnedStreamCapture]::Read($root.StandardError, $standardErrorPath) + if ($capture.stdout.IsFaulted -or $capture.stderr.IsFaulted) { throw "UIA owned stream could not open its exact log paths" } + } else { + $capture.stdout = $root.StandardOutput.ReadToEndAsync() + $capture.stderr = $root.StandardError.ReadToEndAsync() + } + [GraphCode.Validation.Ownership.StandaloneProcessJob]::Assign($capture.job, $handle) $capture.assigned = $true [IO.File]::WriteAllText($gate, "assigned") $deadline = [DateTime]::UtcNow.AddSeconds(10) @@ -5602,10 +4369,13 @@ try { $capture.process = $target $capture.targetPID = $target.Id $capture.targetStartUtcTicks = $identity.startUtcTicks + Initialize-UiaOwnedStreamCapture + [GraphCode.Validation.Ownership.UiaOwnedStreamCapture]::RequireJobMember($target.Handle, $capture.job) + if ("GraphCodeUiaGateState" -as [type]) { [GraphCodeUiaGateState]::RetainProcess($target) } return $capture } catch { $primary = $_ - $null = Complete-MultiProjectCapture $capture $environment.TEMP 5000 $primary + $null = Complete-MultiProjectCapture $capture $logDirectory 5000 $primary throw } } @@ -6064,7 +4834,7 @@ function Invoke-MultiProjectRenamePhase { $rename = @(Get-PopupMenuItems $popup | Where-Object { $_.Text -like "Rename...*" -and $_.Enabled }) } Require ($rename.Count -eq 1) "multi-project node popup lacks one enabled Rename" - $click = [GraphCodeUiaGateState]::ClickPopupMenuItem($popup, $multiWindow, [int]$rename[0].Position, $rename[0].Id) + $click = [GraphCodeUiaGateState]::ClickPopupMenuItem($popup, $multiWindow, [int]$rename[0].Position, $rename[0].Id, [uint32]$multiProcess.Id) Require ($null -ne $click) "multi-project native Rename item was not hit" $renameProcess = $multiProcess $renameShellWindow = $multiWindow @@ -6224,7 +4994,7 @@ function Invoke-MultiProjectRenamePhase { $primaryError = $_ throw } finally { - $cleanupFailures = [Collections.Generic.List[string]]::new() + $cleanupFailures = [Collections.Generic.List[Exception]]::new() $failureChildren = [Collections.Generic.List[object]]::new() foreach ($child in @($peerChild, $shellChild)) { if ($null -ne $child) { @@ -6245,7 +5015,7 @@ function Invoke-MultiProjectRenamePhase { $failureChildren.Add($observed) } try { $null = Complete-MultiProjectCapture $child $logDirectory 5000 $primaryError } - catch { $cleanupFailures.Add($_.Exception.Message) } + catch { $cleanupFailures.Add($_.Exception) } } } if ($primaryError) { @@ -6253,11 +5023,20 @@ function Invoke-MultiProjectRenamePhase { $failureChildren.ToArray() $phaseStartedUtc $phaseClock.ElapsedMilliseconds } if ($cleanupFailures.Count -gt 0) { - $message = "MULTIPROJECT_PHASE_TEARDOWN: $($cleanupFailures -join '; ')" - if ($primaryError) { - $primaryError.Exception.Data["MultiProjectPhaseTeardown"] = $message - Write-Warning $message -WarningAction Continue - } else { throw $message } + $message = "MULTIPROJECT_PHASE_TEARDOWN: $($cleanupFailures.Message -join '; ')" + $actions = @($cleanupFailures | ForEach-Object { + $cleanupFailure = $_ + { throw $cleanupFailure }.GetNewClosure() + }) + try { Invoke-UiaCleanupActions $actions } + catch { + if ($primaryError) { + $primaryError.Exception.Data["MultiProjectPhaseTeardown"] = $message + $primaryError.Exception.Data["MultiProjectCleanupFailure"] = $_.Exception + foreach ($key in $_.Exception.Data.Keys) { $primaryError.Exception.Data[$key] = $_.Exception.Data[$key] } + Write-Warning $message -WarningAction Continue + } else { throw } + } } } } @@ -6402,13 +5181,7 @@ try { [Text.UTF8Encoding]::new($false) ) Write-Host ("UIA_PRELAUNCH_DIAGNOSTICS=" + $prelaunchJson) - if ($ArgumentList.Count -gt 0) { - $process = Start-Process -FilePath $Shell -ArgumentList $ArgumentList -PassThru -WindowStyle Normal ` - -RedirectStandardOutput $shellOutputPath -RedirectStandardError $shellErrorPath - } else { - $process = Start-Process -FilePath $Shell -PassThru -WindowStyle Normal ` - -RedirectStandardOutput $shellOutputPath -RedirectStandardError $shellErrorPath - } + $process = Start-UiaOwnedProcess $Shell $ArgumentList $shellOutputPath $shellErrorPath $root = $null for ($i = 0; $i -lt 160; $i++) { @@ -8970,6 +7743,7 @@ try { Require ($concurrencyStressEvidence.completedReads -eq $concurrencyStressEvidence.requestedReads) ` "UIA read stress completed $($concurrencyStressEvidence.completedReads) of $($concurrencyStressEvidence.requestedReads) reads during fixture reorder/removal" Remove-Job -Job $stressJob -Force + $stressJob = $null $remainingRows = @(Get-DirectChildren $worktrees $rawWalker) $remainingRowIds = @($remainingRows | ForEach-Object { $_.Current.AutomationId }) Assert-Ids $remainingRowIds @($unsafeRow.Current.AutomationId) "removed Worktrees" @@ -9062,22 +7836,82 @@ try { Write-Host "UIA_RENAME_OUTCOME graph='$renameGraphTitle' sidebar='$renameSidebarTitle' expected='UIA renamed loop' reason=gate-forces-daemon-connection-failure" $deleteLoopTitle = 'Delete “UIA loop A”?' + $fitDeleteCanvas = Find-FragmentByIdWithRetry $root "fit-canvas" $rawWalker + Require (($null -ne $fitDeleteCanvas) -and + ($fitDeleteCanvas.Current.ProcessId -eq $process.Id) -and + ($fitDeleteCanvas.Current.Name -ceq "Fit canvas")) ` + "Delete Loop visibility normalization omitted the exact owned Fit Canvas action" + $fitDeleteCanvas.GetCurrentPattern( + [System.Windows.Automation.InvokePattern]::Pattern + ).Invoke() + $root = Wait-ForRootReconnect $process @($rawWalker, $controlWalker) + $deleteTargetVisible = $false + $deleteTargetBounds = $null + $deleteTargetOffscreen = $true + for ($deleteVisibilityAttempt = 0; $deleteVisibilityAttempt -lt 40; $deleteVisibilityAttempt++) { + $deleteTargetVisible = $false + $deleteCandidate = Find-FragmentById $root $renameGraphIdentity $rawWalker + $deleteGraph = Find-FragmentById $root "graph" $rawWalker + if (($null -ne $deleteCandidate) -and ($null -ne $deleteGraph) -and + ($deleteCandidate.Current.ProcessId -eq $process.Id) -and + ($deleteCandidate.Current.AutomationId -ceq $renameGraphIdentity) -and + ($deleteGraph.Current.ProcessId -eq $process.Id)) { + $deleteTargetOffscreen = $deleteCandidate.Current.IsOffscreen + if (-not $deleteTargetOffscreen) { + $deleteTargetBounds = $deleteCandidate.Current.BoundingRectangle + $deleteGraphBounds = $deleteGraph.Current.BoundingRectangle + $deleteTargetVisible = -not $deleteTargetOffscreen -and + -not $deleteTargetBounds.IsEmpty -and + $deleteTargetBounds.Width -gt 0 -and $deleteTargetBounds.Height -gt 0 -and + -not $deleteGraphBounds.IsEmpty -and + $deleteTargetBounds.Left -ge $deleteGraphBounds.Left -and + $deleteTargetBounds.Top -ge $deleteGraphBounds.Top -and + $deleteTargetBounds.Right -le $deleteGraphBounds.Right -and + $deleteTargetBounds.Bottom -le $deleteGraphBounds.Bottom + if ($deleteTargetVisible) { break } + } + } + Start-Sleep -Milliseconds 100 + $root = Wait-ForRootReconnect $process @($rawWalker, $controlWalker) + } + Require ($deleteTargetVisible) ` + "Delete Loop same-identity target did not become visible with positive bounds inside the owned graph after 40 bounded Fit Canvas observations" + Write-Host ("UIA_DELETE_TARGET_VISIBLE=" + ([ordered]@{ + attempts = $deleteVisibilityAttempt + 1 + bounds = @( + [double]$deleteTargetBounds.Left, + [double]$deleteTargetBounds.Top, + [double]$deleteTargetBounds.Right, + [double]$deleteTargetBounds.Bottom + ) + offscreen = $deleteTargetOffscreen + } | ConvertTo-Json -Compress)) $deleteTarget = Find-FragmentByIdWithRetry $root $renameGraphIdentity $rawWalker Require ($null -ne $deleteTarget) "Delete Loop could not reacquire the renamed fixture loop identity" $deleteTargetSelection = $deleteTarget.GetCurrentPattern( [System.Windows.Automation.SelectionItemPattern]::Pattern ) - $deleteTargetSelection.Select() + try { + $deleteTargetSelection.Select() + } catch { + try { + try { + Write-DeleteTargetSelectFailure $deleteTarget $deleteTargetSelection $renameGraphIdentity ` + $process.Id $shellWindow $_ $DeleteSelectFailureReceipt + } catch { + Write-Host ("UIA_DELETE_TARGET_SELECT_TELEMETRY_FAILURE=hresult:{0}" -f $_.Exception.HResult) + } + } finally { + throw + } + } Require $deleteTargetSelection.Current.IsSelected ` "Delete Loop could not select the fixture loop before invoking the selection-dependent command" Write-Host ("UIA_DELETE_LOOP_TARGET automationId='$($deleteTarget.Current.AutomationId)' " + "name='$($deleteTarget.Current.Name)' selected=$($deleteTargetSelection.Current.IsSelected)") - Require (Ensure-ShellForeground $shellWindow "Delete Loop context menu") ` - "GraphCode shell did not reacquire foreground before the Delete Loop context menu" - Require ([GraphCodeUiaGateState]::PostContextMenu($shellWindow, 3)) ` - "Delete Loop context menu request was rejected" - $deleteLoopPopup = Wait-ForPopupMenu $process $shellWindow "Delete Loop" - Require ($deleteLoopPopup -ne [IntPtr]::Zero) "Delete Loop context menu did not open" + $root = Restore-DeleteLoopCanvas $process $root $shellWindow $renameGraphIdentity $rawWalker + $deleteLoopPopup = Open-CanvasCardContextMenu $process $root $shellWindow ` + $renameGraphIdentity $rawWalker "Delete Loop" $deleteLoopItems = @(Get-PopupMenuItems $deleteLoopPopup) $deleteLoopItem = @($deleteLoopItems | Where-Object { $_.Id -eq 5103 -and $_.Enabled @@ -9085,7 +7919,7 @@ try { Require ($null -ne $deleteLoopItem) ` "Delete Loop context menu omitted its enabled Delete action: $(Format-PopupMenuItems $deleteLoopItems)" $deleteLoopClick = [GraphCodeUiaGateState]::ClickPopupMenuItem( - $deleteLoopPopup, $shellWindow, [int]$deleteLoopItem.Position, [int]$deleteLoopItem.Id + $deleteLoopPopup, $shellWindow, [int]$deleteLoopItem.Position, [int]$deleteLoopItem.Id, [uint32]$process.Id ) Require ($null -ne $deleteLoopClick) "Delete Loop context-menu action could not be clicked" Write-Host ("UIA_DELETE_LOOP_MENU command=$($deleteLoopItem.Id) " + @@ -9840,6 +8674,11 @@ try { "unwired canvas card did not expose exactly one stable Wire it up control" Require ($markEntryControls.Count -eq 1) ` "unwired canvas card did not expose exactly one stable Mark as entry control" + $recoveryVisibility = Wait-UiaUnwiredRecoveryVisibility $root $rawWalker $process $wireControls[0] $markEntryControls[0] + $root = $recoveryVisibility.root + $graph = $recoveryVisibility.graph + $wireControls = @($recoveryVisibility.wire) + $markEntryControls = @($recoveryVisibility.mark) foreach ($control in @($wireControls[0], $markEntryControls[0])) { Require (($control.Current.BoundingRectangle.Width -gt 0) -and ($control.Current.BoundingRectangle.Height -gt 0)) ` @@ -10057,7 +8896,7 @@ try { Require ($null -ne $editEdgeMenuItem) ` "canvas edge action menu omitted Edit Edge: $(Format-PopupMenuItems $edgeActionItems)" $editEdgeClick = [GraphCodeUiaGateState]::ClickPopupMenuItem( - $edgeActionPopup, $shellWindow, [int]$editEdgeMenuItem.Position, [int]$editEdgeMenuItem.Id + $edgeActionPopup, $shellWindow, [int]$editEdgeMenuItem.Position, [int]$editEdgeMenuItem.Id, [uint32]$process.Id ) Require ($null -ne $editEdgeClick) "canvas edge Edit Edge item could not be clicked" $editEdgeClickEvidence = [ordered]@{ @@ -10454,13 +9293,7 @@ try { } Require $retainedProviderSafe "retained status provider was unsafe after teardown" $env:GRAPHCODE_UIA_RESET_SIDEBAR = "0" - if ($ArgumentList.Count -gt 0) { - $settingsProcess = Start-Process -FilePath $Shell -ArgumentList $ArgumentList -PassThru ` - -WindowStyle Normal -RedirectStandardError $settingsErrorPath - } else { - $settingsProcess = Start-Process -FilePath $Shell -PassThru -WindowStyle Normal ` - -RedirectStandardError $settingsErrorPath - } + $settingsProcess = Start-UiaOwnedProcess -file $Shell -arguments $ArgumentList -standardErrorPath $settingsErrorPath for ($index = 0; $index -lt 160 -and $settingsProcess.MainWindowHandle -eq 0; $index++) { Start-Sleep -Milliseconds 250 $settingsProcess.Refresh() @@ -10747,22 +9580,19 @@ try { $renameCommandLogPath = Assert-UiaSandboxPath $sandboxPath (Join-Path $logDirectory "rename-daemon-command.json") $renameShellErrorPath = Assert-UiaSandboxPath $sandboxPath (Join-Path $logDirectory "rename-shell-stderr.log") $renameShellOutputPath = Assert-UiaSandboxPath $sandboxPath (Join-Path $logDirectory "rename-shell-stdout.log") - # Start-Process joins ArgumentList with spaces and quotes nothing, so any value - # that contains a space has to carry its own quotes or pwsh binds the remainder - # to the next positional parameter. - $renameStubProcess = Start-Process -FilePath "pwsh" -WindowStyle Hidden -PassThru ` - -RedirectStandardError $renameStubErrorPath -ArgumentList @( + $renameStubProcess = Start-UiaOwnedProcess -file (Get-Command pwsh).Source -Hidden ` + -standardErrorPath $renameStubErrorPath -arguments @( "-NoProfile", "-File", - ('"' + (Join-Path $PSScriptRoot "Stub-Daemon.ps1") + '"'), + (Join-Path $PSScriptRoot "Stub-Daemon.ps1"), "-PipeName", - ('"' + $renamePipeName + '"'), + $renamePipeName, "-ResultPath", - ('"' + $renameStubResultPath + '"'), + $renameStubResultPath, "-NodeAId", - ('"' + $renameNodeId + '"'), + $renameNodeId, "-NodeATitle", - ('"' + $renameInitialTitle + '"'), + $renameInitialTitle, "-ApplyGraphCommands", "-SeedSketches" ) @@ -10796,14 +9626,7 @@ try { Remove-Item Env:GRAPHCODE_UIA_FIXTURE_ROWS -ErrorAction SilentlyContinue Remove-Item Env:GRAPHCODE_UIA_UPDATE_AVAILABLE -ErrorAction SilentlyContinue Remove-Item Env:GRAPHCODE_UIA_SHOW_UPDATE -ErrorAction SilentlyContinue - if ($ArgumentList.Count -gt 0) { - $renameProcess = Start-Process -FilePath $Shell -ArgumentList $ArgumentList -PassThru ` - -WindowStyle Normal -RedirectStandardOutput $renameShellOutputPath ` - -RedirectStandardError $renameShellErrorPath - } else { - $renameProcess = Start-Process -FilePath $Shell -PassThru -WindowStyle Normal ` - -RedirectStandardOutput $renameShellOutputPath -RedirectStandardError $renameShellErrorPath - } + $renameProcess = Start-UiaOwnedProcess $Shell $ArgumentList $renameShellOutputPath $renameShellErrorPath $renameRoot = $null for ($index = 0; $index -lt 160 -and $null -eq $renameRoot; $index++) { Start-Sleep -Milliseconds 250 @@ -11152,7 +9975,7 @@ try { Start-Sleep -Milliseconds 100 } Require ($null -ne $item) "edge workflow menu lacks enabled $command after republish: $lastMenu; stub=$(Read-UiaTextFile $renameStubResultPath)" - $click = [GraphCodeUiaGateState]::ClickPopupMenuItem($popup, $renameShellWindow, [int]$item.Position, $command) + $click = [GraphCodeUiaGateState]::ClickPopupMenuItem($popup, $renameShellWindow, [int]$item.Position, $command, [uint32]$renameProcess.Id) if ($null -ne $click) { Write-Host "UIA_EDGE_MENU_CLICK command=$command hilite=$($click.Hilite) selectItemFallback=$($click.UsedSelectItemFallback) enterFallback=$($click.UsedKeyboardFallback) hiliteAtEnterFallback=$($click.HiliteAtEnterFallback) hiliteBeforeEnter=$($click.HiliteBeforeEnter) cursor=$($click.CursorAtX),$($click.CursorAtY) item=$($click.Left),$($click.Top),$($click.Right),$($click.Bottom)" } @@ -11255,6 +10078,7 @@ try { $delta = $index - [int]($after.Split("|")[0]) if ($delta -ne 0) { $keys = [GraphCodeUiaGateState]::SendKeyInput( + $edgeWorkflowWindow, [uint32]$renameProcess.Id, $control, [uint16]$(if ($delta -gt 0) { 0x28 } else { 0x26 }), [Math]::Abs($delta)) Require ($keys -eq [Math]::Abs($delta)) "edge workflow combo $id keyboard injection failed" } @@ -11405,7 +10229,7 @@ try { $edit = [GraphCodeUiaGateState]::ControlById($edgeWorkflowWindow, 9105) Require ($edit -ne [IntPtr]::Zero -and [GraphCodeUiaGateState]::FocusControl($edgeWorkflowWindow, $edit) -and - [GraphCodeUiaGateState]::SendKeyInput(0x0D, 1) -eq 1) ` + [GraphCodeUiaGateState]::SendKeyInput($edgeWorkflowWindow, [uint32]$renameProcess.Id, $edit, 0x0D, 1) -eq 1) ` "edge workflow $title measured-occluded keyboard submit failed" $method = "keyboardEnter" } @@ -11764,7 +10588,7 @@ try { Require ($goalEdit -ne [IntPtr]::Zero -and [GraphCodeUiaGateState]::FocusControl($nodeSheetWindow, $goalEdit)) ` "node creation sheet $label could not focus the Goal field before Enter" - Require ([GraphCodeUiaGateState]::SendKeyInput(0x0D, 1) -eq 1) ` + Require ([GraphCodeUiaGateState]::SendKeyInput($nodeSheetWindow, [uint32]$renameProcess.Id, $goalEdit, 0x0D, 1) -eq 1) ` "node creation sheet $label could not send a native Enter key" $method = "keyboardEnter" $mouseSubmitUnavailable = $true @@ -11989,7 +10813,8 @@ try { $delta = $comboCase.Index - $currentIndex $keys = 0 if ($delta -ne 0) { - $keys = [GraphCodeUiaGateState]::SendKeyInput([uint16]$(if ($delta -gt 0) { 0x28 } else { 0x26 }), [Math]::Abs($delta)) + $keys = [GraphCodeUiaGateState]::SendKeyInput($nodeSheetWindow, [uint32]$renameProcess.Id, $combo, + [uint16]$(if ($delta -gt 0) { 0x28 } else { 0x26 }), [Math]::Abs($delta)) } $after = $before for ($attempt = 0; $attempt -lt 40 -and $after -ne "$($comboCase.Index)|$($comboCase.Text)"; $attempt++) { @@ -12319,7 +11144,7 @@ try { $focus = Assert-SketchControl $focusId Require ([GraphCodeUiaGateState]::WindowIsVisible($script:edgeWorkflowWindow) -and [GraphCodeUiaGateState]::FocusControl($script:edgeWorkflowWindow, $focus) -and - [GraphCodeUiaGateState]::SendKeyInput(0x0D, 1) -eq 1) ` + [GraphCodeUiaGateState]::SendKeyInput($script:edgeWorkflowWindow, [uint32]$renameProcess.Id, $focus, 0x0D, 1) -eq 1) ` "sketch/custody submit '$label' measured-occluded Enter injection failed" $method = "keyboardEnter" } @@ -12347,7 +11172,7 @@ try { function Sketch-Cancel([string] $label) { Require (Ensure-ShellForeground $script:edgeWorkflowWindow "$label cancel") ` "sketch/custody '$label' lost foreground before cancel" - Require ([GraphCodeUiaGateState]::SendKeyInput(0x1B, 1) -eq 1) ` + Require ([GraphCodeUiaGateState]::SendKeyInput($script:edgeWorkflowWindow, [uint32]$renameProcess.Id, [IntPtr]::Zero, 0x1B, 1) -eq 1) ` "sketch/custody '$label' native Escape injection failed" Start-Sleep -Milliseconds 200 $keyboardFallback = $false @@ -12602,7 +11427,7 @@ try { $item = @($menu.items | Where-Object { $_.Id -eq $id -and $_.Enabled }) Require ($item.Count -eq 1) "sketch/custody popup missing enabled ${id}: $(Format-PopupMenuItems $menu.items)" $click = [GraphCodeUiaGateState]::ClickPopupMenuItem($menu.popup, $renameShellWindow, - [int]$item[0].Position, $id) + [int]$item[0].Position, $id, [uint32]$renameProcess.Id) if ($null -ne $click) { Write-Host "UIA_SKETCH_MENU_CLICK command=$id hilite=$($click.Hilite) selectItemFallback=$($click.UsedSelectItemFallback) enterFallback=$($click.UsedKeyboardFallback) hiliteAtEnterFallback=$($click.HiliteAtEnterFallback) hiliteBeforeEnter=$($click.HiliteBeforeEnter)" } @@ -13253,26 +12078,26 @@ try { throw } finally { try { - if ($stressJob) { - Remove-Job -Job $stressJob -Force -ErrorAction SilentlyContinue - } - if ($propertyEventRegistered) { - [System.Windows.Automation.Automation]::RemoveAutomationPropertyChangedEventHandler( - $status, $propertyHandler - ) - } - if ($liveEventRegistered) { - [System.Windows.Automation.Automation]::RemoveAutomationEventHandler( - $liveRegionEvent, $status, $eventHandler - ) - } - if ($focusEventRegistered) { - [System.Windows.Automation.Automation]::RemoveAutomationFocusChangedEventHandler($focusHandler) - } - Stop-UiaOwnedProcessTrees @($process, $settingsProcess, $renameProcess, $renameStubProcess) - Stop-UiaOwnedProviderProcesses $providerZmxPath $providerZmxBaseline + Invoke-UiaCleanupActions @( + { if ($stressJob) { Remove-Job -Job $stressJob -Force -ErrorAction Stop } }, + { if ($propertyEventRegistered) { + [System.Windows.Automation.Automation]::RemoveAutomationPropertyChangedEventHandler($status, $propertyHandler) + } }, + { if ($liveEventRegistered) { + [System.Windows.Automation.Automation]::RemoveAutomationEventHandler($liveRegionEvent, $status, $eventHandler) + } }, + { if ($focusEventRegistered) { + [System.Windows.Automation.Automation]::RemoveAutomationFocusChangedEventHandler($focusHandler) + } }, + { Stop-UiaOwnedProcessTrees @($process, $settingsProcess, $renameProcess, $renameStubProcess) }, + { Stop-UiaOwnedProviderProcesses $providerZmxPath $providerZmxBaseline } + ) } catch { $sandboxCleanupError = $_ + if ($gateFailure) { + $gateFailure.Exception.Data["UiaCleanupFailure"] = $_.Exception + foreach ($key in $_.Exception.Data.Keys) { $gateFailure.Exception.Data[$key] = $_.Exception.Data[$key] } + } if ($sandboxCreated) { Write-Host "UIA_FAILED_SANDBOX_RETAINED=$sandboxPath" } Write-Host "UIA_CLEANUP_ERROR=$($_.Exception.Message)" } finally { @@ -13324,5 +12149,15 @@ try { } } } + $ownershipUnknown = ($gateFailure -and (ShellProcessOwnership\Test-ShellOwnershipUnproven $gateFailure)) -or + ($sandboxCleanupError -and (ShellProcessOwnership\Test-ShellOwnershipUnproven $sandboxCleanupError)) + if ($ownershipUnknown) { + try { + [Console]::Out.WriteLine("GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven") + [Console]::Out.Flush() + } finally { + [Threading.Thread]::Sleep([Threading.Timeout]::Infinite) + } + } if ($sandboxCleanupError -and $null -eq $gateFailure) { throw $sandboxCleanupError } } diff --git a/Tools/windows/validate.ps1 b/Tools/windows/validate.ps1 index ac74fe6c..3a60818a 100644 --- a/Tools/windows/validate.ps1 +++ b/Tools/windows/validate.ps1 @@ -262,7 +262,23 @@ function Install-AtomicRuntimePackage([string] $sourceDirectory, [string] $desti function Invoke-Native([string] $description, [scriptblock] $command) { Write-Host "==> $description" - & $command + $ownershipUnproven = $false + try { + & $command 2>&1 | ForEach-Object { + if ($_ -is [string] -and $_ -ceq "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven") { + $ownershipUnproven = $true + } + $_ + } + } catch { + if ($ownershipUnproven) { $_.Exception.Data["GraphCodeValidationRootRetentionRequired"] = $true } + throw + } + if ($ownershipUnproven) { + $failure = [InvalidOperationException]::new("$description reported unproven process ownership (exit code $LASTEXITCODE).") + $failure.Data["GraphCodeValidationRootRetentionRequired"] = $true + throw $failure + } if ($LASTEXITCODE -ne 0) { throw "$description failed with exit code $LASTEXITCODE" } @@ -375,7 +391,10 @@ function Invoke-WindowsShellValidationIsolation( TMP = Join-Path $validationRoot "temp" } $savedEnvironment = @{} + $savedEnvironmentMembership = @{} + $processEnvironment = [Environment]::GetEnvironmentVariables([EnvironmentVariableTarget]::Process) foreach ($name in $ownedEnvironment.Keys) { + $savedEnvironmentMembership[$name] = $processEnvironment.Contains($name) $savedEnvironment[$name] = [Environment]::GetEnvironmentVariable( $name, [EnvironmentVariableTarget]::Process @@ -383,6 +402,7 @@ function Invoke-WindowsShellValidationIsolation( } $primaryError = $null + $actionStarted = $false $cleanupErrors = [Collections.Generic.List[Exception]]::new() try { New-Item -ItemType Directory -Force -Path @( @@ -398,25 +418,87 @@ function Invoke-WindowsShellValidationIsolation( ) } Write-Host "WINDOWS_SHELL_VALIDATION_ISOLATION_ROOT=$validationRoot" + $actionStarted = $true & $Action } catch { $primaryError = $_ } finally { foreach ($entry in $savedEnvironment.GetEnumerator()) { try { - [Environment]::SetEnvironmentVariable( - $entry.Key, - $entry.Value, - [EnvironmentVariableTarget]::Process - ) + if ($savedEnvironmentMembership[$entry.Key]) { + [Environment]::SetEnvironmentVariable( + $entry.Key, + $entry.Value, + [EnvironmentVariableTarget]::Process + ) + } elseif (Test-Path -LiteralPath "Env:\$($entry.Key)") { + Remove-Item -LiteralPath "Env:\$($entry.Key)" -ErrorAction Stop + } } catch { $cleanupErrors.Add($_.Exception) } } + $retentionReceipt = Join-Path $validationRoot "ownership-unproven.json" + if ([IO.File]::Exists($retentionReceipt)) { + if ($null -eq $primaryError) { + $primaryError = [Management.Automation.ErrorRecord]::new( + [InvalidOperationException]::new("A child reported unproven ownership despite workload completion."), + "WindowsShellOwnershipUnproven", [Management.Automation.ErrorCategory]::InvalidResult, $validationRoot) + } + $primaryError.Exception.Data["GraphCodeValidationRootRetentionRequired"] = $true + $receiptStream = $null + try { + if ([IO.File]::GetAttributes($retentionReceipt) -band [IO.FileAttributes]::ReparsePoint) { + throw [IO.InvalidDataException]::new("Ownership retention receipt must not be a reparse point.") + } + $receiptStream = [IO.FileStream]::new($retentionReceipt, [IO.FileMode]::Open, + [IO.FileAccess]::Read, [IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete) + $bytes = [byte[]]::new(4097) + $count = 0 + while ($count -lt $bytes.Length) { + $read = $receiptStream.Read($bytes, $count, $bytes.Length - $count) + if ($read -eq 0) { break } + $count += $read + } + if ($count -gt 4096) { throw [IO.InvalidDataException]::new("Ownership retention receipt exceeds its 4096-byte limit.") } + $receipt = [Text.UTF8Encoding]::new($false, $true).GetString($bytes, 0, $count) | + ConvertFrom-Json -Depth 4 -ErrorAction Stop + if ($receipt.schemaVersion -ne 1 -or $receipt.reason -cne "ownership_unproven" -or + $receipt.validationRoot -isnot [string] -or + [IO.Path]::GetFullPath($receipt.validationRoot) -ine [IO.Path]::GetFullPath($validationRoot)) { + throw [IO.InvalidDataException]::new("Ownership retention receipt schema, reason or validation root is invalid.") + } + } catch { + $cleanupErrors.Add($_.Exception) + } finally { + if ($receiptStream) { $receiptStream.Dispose() } + } + } + if ($actionStarted -and $null -ne $primaryError) { + if ($primaryError.Exception.Data["GraphCodeValidationRootRetentionRequired"] -isnot [bool] -or + -not $primaryError.Exception.Data["GraphCodeValidationRootRetentionRequired"]) { + $primaryError.Exception.Data["GraphCodeValidationRootRetentionReason"] = "workload_failed_without_cleanup_proof" + } + $primaryError.Exception.Data["GraphCodeValidationRootRetentionRequired"] = $true + } + $retainRoot = $null -ne $primaryError -and + $primaryError.Exception.Data["GraphCodeValidationRootRetentionRequired"] -is [bool] -and + $primaryError.Exception.Data["GraphCodeValidationRootRetentionRequired"] try { - Remove-Item -LiteralPath $validationRoot -Recurse -Force -ErrorAction Stop + if ($retainRoot) { + if (-not [IO.Directory]::Exists($validationRoot)) { + throw [IO.IOException]::new("Requested validation root retention failed: $validationRoot no longer exists.") + } + $retainedRoots = if ($primaryError.Exception.Data.Contains("GraphCodeValidationRetainedRoots")) { + @($primaryError.Exception.Data["GraphCodeValidationRetainedRoots"]) + } else { @() } + $primaryError.Exception.Data["GraphCodeValidationRetainedRoots"] = @($retainedRoots) + @($validationRoot) + Write-Host "WINDOWS_SHELL_VALIDATION_ISOLATION_ROOT_RETAINED=$validationRoot" + } else { + Remove-Item -LiteralPath $validationRoot -Recurse -Force -ErrorAction Stop + } } catch { - if (-not ( + if ($retainRoot -or -not ( $_.Exception -is [Management.Automation.ItemNotFoundException] -or $_.Exception -is [IO.DirectoryNotFoundException] )) { @@ -447,10 +529,15 @@ function Invoke-WindowsShellValidationIsolation( foreach ($cleanupError in $cleanupErrors) { $failures.Add($cleanupError) } - throw [AggregateException]::new( + $aggregate = [AggregateException]::new( "Windows shell validation failed and isolation cleanup detected additional errors.", $failures.ToArray() ) + if ($retainRoot) { + $aggregate.Data["GraphCodeValidationRootRetentionRequired"] = $true + $aggregate.Data["GraphCodeValidationRetainedRoots"] = $primaryError.Exception.Data["GraphCodeValidationRetainedRoots"] + } + throw $aggregate } if ($cleanupErrors.Count -eq 1) { throw $cleanupErrors[0] @@ -742,6 +829,38 @@ function Invoke-ProviderBuild([object] $roots) { } } +function Invoke-WithCompiledValidationAssembly( + [Parameter(Mandatory)][scriptblock] $Action, + [string] $WorkspaceRoot = $repoRoot +) { + $variable = "GRAPHCODE_VALIDATION_ASSEMBLY" + $present = [Environment]::GetEnvironmentVariables("Process").Contains($variable) + $published = $false + try { + $assembly = if ($present) { + [Environment]::GetEnvironmentVariable($variable, "Process") + } else { + & (Join-Path $WorkspaceRoot "Tools\windows\Build-CompiledValidationRunner.ps1") + } + if ($assembly -isnot [string] -or [string]::IsNullOrEmpty($assembly) -or + -not [IO.Path]::IsPathFullyQualified($assembly) -or + -not (Test-Path -LiteralPath $assembly -PathType Leaf)) { + throw "GRAPHCODE_VALIDATION_ASSEMBLY must identify an existing current-worktree compiled assembly; an explicitly empty value is not a bootstrap request" + } + & (Join-Path $WorkspaceRoot "Tools\windows\Import-CompiledValidationAssembly.ps1") ` + -AssemblyPath $assembly -RequiredType "ShellOwnedLaunch" | Out-Null + if (-not $present) { + [Environment]::SetEnvironmentVariable($variable, $assembly, "Process") + $published = $true + } + & $Action + } finally { + if ($published -and [Environment]::GetEnvironmentVariables("Process").Contains($variable)) { + Remove-Item -LiteralPath "Env:\$variable" -ErrorAction Stop + } + } +} + function Invoke-Task([string] $name) { if ($DryRun) { $detail = "" @@ -1108,7 +1227,10 @@ function Invoke-Task([string] $name) { Write-Host "==> Windows shell validation isolation contract" . (Join-Path $repoRoot "Tools\windows\Import-PinnedPester.ps1") $isolationResults = Invoke-Pester ` - -Path (Join-Path $repoRoot "Tools\windows\Tests\ValidationIsolation.Tests.ps1") ` + -Path @( + (Join-Path $repoRoot "Tools\windows\Tests\ValidationIsolation.Tests.ps1"), + (Join-Path $repoRoot "Tools\windows\Tests\CompiledValidationEnvironment.Tests.ps1") + ) ` -PassThru if ($isolationResults.TotalCount -le 0 -or $isolationResults.PassedCount -ne $isolationResults.TotalCount -or @@ -1210,6 +1332,13 @@ function Invoke-Task([string] $name) { -SkipTrayLive:$SkipTrayLive ` -Stress } + Invoke-Native "Native shell smoke exit regression" { + & pwsh -NoProfile -File ` + (Join-Path $repoRoot "Tools\windows\Tests\ShellSmokeExit.Live.Tests.ps1") ` + -AppPath (Join-Path $repoRoot "graphcode-windows\zig-out\bin\graphcode-windows.exe") ` + -WinghosttyRoot $winghosttyRoot -ZmxRoot $zmxRoot -Zig0152 $zig0152 -Zig0160 $zig0160 ` + -ArtifactRoot (Join-Path $env:TEMP "shell-smoke-exit") + } Invoke-Native "Scrubbed production shell startup" { & pwsh -NoProfile -File ` (Join-Path $repoRoot "Tools\windows\Tests\ScrubbedShellStartup.Live.Tests.ps1") ` @@ -1253,43 +1382,21 @@ function Invoke-Task([string] $name) { (ConvertTo-Json -InputObject $preUiaSnapshot -Compress -Depth 4)) $repositoryPrefix = ([IO.Path]::GetFullPath($repoRoot)).TrimEnd('\', '/') + [IO.Path]::DirectorySeparatorChar - $ownedSurvivors = @($preUiaProcesses | Where-Object { + $unexpectedCandidates = @($preUiaProcesses | Where-Object { $identity = "$([int]$_.ProcessId)|$($_.CreationDate.ToUniversalTime().ToString('o'))" $_.ExecutablePath -and $_.ExecutablePath.StartsWith($repositoryPrefix, [StringComparison]::OrdinalIgnoreCase) -and -not $uiaProductProcessBaseline.Contains($identity) }) - foreach ($survivor in $ownedSurvivors) { - $current = Get-CimInstance Win32_Process -Filter ` - "ProcessId = $([int]$survivor.ProcessId)" -ErrorAction Stop - if ($null -eq $current -or - [string]$current.CreationDate -cne [string]$survivor.CreationDate) { - continue - } - Stop-Process -Id ([int]$survivor.ProcessId) -Force -ErrorAction Stop - } - $remainingSurvivors = @() - foreach ($survivor in $ownedSurvivors) { - for ($attempt = 0; $attempt -lt 30; $attempt++) { - $current = Get-CimInstance Win32_Process -Filter ` - "ProcessId = $([int]$survivor.ProcessId)" -ErrorAction Stop - if ($null -eq $current -or - [string]$current.CreationDate -cne [string]$survivor.CreationDate) { - break - } - Start-Sleep -Milliseconds 100 - } - $current = Get-CimInstance Win32_Process -Filter ` - "ProcessId = $([int]$survivor.ProcessId)" -ErrorAction Stop - if ($null -ne $current -and - [string]$current.CreationDate -ceq [string]$survivor.CreationDate) { - $remainingSurvivors += [int]$survivor.ProcessId - } - } - if ($remainingSurvivors.Count -gt 0) { - throw "Owned GraphCode processes survived pre-UIA cleanup: $($remainingSurvivors -join ',')" - } - Write-Host "WINDOWS_SHELL_PRE_UIA_CLEANUP=verified terminated=$($ownedSurvivors.Count)" + if ($unexpectedCandidates.Count -gt 0) { + $ownershipFailure = [InvalidOperationException]::new("Unexpected GraphCode process candidates before UIA: " + + (($unexpectedCandidates | ForEach-Object { [int]$_.ProcessId }) -join ',') + + ". These candidates are not attested as run-owned; no processes were terminated. " + + "Review the advisory snapshot and stop them through their owning session before rerunning validation.") + $ownershipFailure.Data["GraphCodeValidationRootRetentionRequired"] = $true + throw $ownershipFailure + } + Write-Host "WINDOWS_SHELL_PRE_UIA_CLEANUP=no_candidates_observed terminated=0" Invoke-Native "Native UI Automation live gate" { & (Join-Path $repoRoot "Tools\windows\uia-live-gate.ps1") ` -Shell (Join-Path $repoRoot "graphcode-windows\zig-out\bin\graphcode-windows.exe") ` @@ -1343,7 +1450,9 @@ try { foreach ($name in $selected) { if ($name -eq "windows-shell" -and -not $DryRun) { Invoke-WindowsShellValidationIsolation -ValidationRootParent $ShellValidationRoot -Action { - Invoke-Task "windows-shell" + Invoke-WithCompiledValidationAssembly -WorkspaceRoot $repoRoot -Action { + Invoke-Task "windows-shell" + } } } else { Invoke-Task $name diff --git a/Tools/windows/validation-matrix.md b/Tools/windows/validation-matrix.md index 0594bf77..6bbe5fb1 100644 --- a/Tools/windows/validation-matrix.md +++ b/Tools/windows/validation-matrix.md @@ -7,7 +7,7 @@ The Windows port must have runnable commands before implementation fleets begin. | Surface | Command | |---|---| | Windows investigation spikes | `pwsh Tools/windows/validate.ps1 -Task all` | -| Validation runner contract | `pwsh Tools/windows/Tests/ValidationRunner.Tests.ps1` | +| 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)) | | 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` | | TDD evidence contract | `pwsh Tools/tdd/Tests/TddEvidence.Tests.ps1` | @@ -83,7 +83,8 @@ helper and preserve its existing source/IL/reference policy. The macOS shared-regression, Windows shell, Windows port, and Windows hardening workflows run on every pull request, including documentation-only changes. Workflow-level path/branch filters must not prevent their required check contexts -from being reported. `ValidationRunner.Tests.ps1` guards the unfiltered triggers. +from being reported. The compiled runner's CI contracts guard the unfiltered +triggers and require positive full coverage from the normal executable. This deliberately trades additional CI usage for reliable branch protection. Full-pinned and owned-environment hardening remain dispatch/schedule-gated as @@ -137,6 +138,26 @@ part. | `windows-spikes` | remaining tasks | `validate.ps1 -Task all -SkipTrayLive -SkipWslRemoteE2E -SkipTask ,windows-shell,packaging,hardening` | | `Deterministic hardening (...)` | hardening | `validate.ps1 -Task hardening` | +The integration part also requires six native smoke-exit controls: failing +nonreading-daemon smoke must emit its transport diagnostic and exit nonzero +through both the original stderr-file producer and owned capture; successful +requested smoke exits must remain zero through both producers. Requested +workspace actions must refuse when activation or the focus effect is missing. +All controls run +inside assigned owned jobs and retain evidence under the validation scratch root. + +Run local GUI, keyboard, and pointer gates serially on one desktop; CI runs the +shards and integration job on separate VM desktops. Use a short, exclusively +owned `-ShellValidationRoot` for UIA lease paths. Keep Zig caches in stable owned +directories outside per-invocation `LOCALAPPDATA`, which isolation removes after +a successful run. + +The real `windows-shell` task builds and validates the current compiled helper +assembly once when `GRAPHCODE_VALIDATION_ASSEMBLY` is absent, publishes it to its +child callers, and removes that process-only publication on exit. A supplied +assembly remains subject to current-worktree provenance; an explicitly empty +entry is refused rather than treated as a bootstrap request. + `windows-spikes` no longer re-runs `windows-shell`, `packaging` or `hardening`. Those tasks already run in the `windows-shell` and `Deterministic hardening` required checks on every Windows-relevant pull request. diff --git a/Tools/windows/windows-shell.ps1 b/Tools/windows/windows-shell.ps1 index 7e7578fa..576ce901 100644 --- a/Tools/windows/windows-shell.ps1 +++ b/Tools/windows/windows-shell.ps1 @@ -11,12 +11,16 @@ param( [switch] $SkipTrayLive, [switch] $Stress, [switch] $UseStubDaemon, + [ValidateNotNullOrEmpty()] + [string] $WorkspaceActions = "1", [ValidateRange(0, 1000)] [int] $StubResponseDelayMilliseconds = 0, [string] $Version ) $ErrorActionPreference = "Stop" +Import-Module (Join-Path $PSScriptRoot "ShellProcessOwnership.psm1") +$processScope = $null $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..") $shellRoot = Join-Path $repoRoot "graphcode-windows" $packageManifest = Get-Content (Join-Path $shellRoot "build.zig.zon") -Raw @@ -58,6 +62,7 @@ $shellProcess = $null $handoffStage = $null $bareZmxStage = $null $bareZmxProject = $null +$bareProcessScope = $null $resourceRole = "graphcode-windows" $metricSequence = 0 @@ -75,6 +80,19 @@ function Assert-Equal([string] $actual, [string] $expected, [string] $label) { } } +function Start-SmokeProcess([string] $file, [string[]] $arguments, [string] $directory = "", + $scope = $processScope, [switch] $CaptureOutput, [switch] $CaptureError) { + $start = [Diagnostics.ProcessStartInfo]::new() + $start.FileName = (Get-Command $file -ErrorAction Stop).Source + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $start.WorkingDirectory = if ($directory) { $directory } else { [string]$repoRoot } + $start.RedirectStandardOutput = $CaptureOutput + $start.RedirectStandardError = $CaptureError + foreach ($argument in $arguments) { $start.ArgumentList.Add($argument) } + Start-ShellOwnedProcess $scope $start +} + function New-SmokeZmxRoot([string] $Path) { if (Test-Path -LiteralPath $Path) { throw "Smoke zmx root already exists: $Path" } New-Item -ItemType Directory -Path $Path | Out-Null @@ -119,6 +137,7 @@ function Get-ZmxSessionRecords { function Record-TestOwnedSessions { $records = @(Get-ZmxSessionRecords) foreach ($record in $records) { + # These CIM observations are telemetry only, never cleanup authority. [void] $ownedSessionNames.Add($record.Name) [void] $ownedProcessIds.Add($record.Pid) } @@ -161,15 +180,18 @@ function Invoke-ZmxProbe([string[]] $Arguments) { $start.RedirectStandardOutput = $true $start.RedirectStandardError = $true foreach ($argument in $Arguments) { $start.ArgumentList.Add($argument) } - $process = [Diagnostics.Process]::Start($start) - $output = $process.StandardOutput.ReadToEndAsync() - $errors = $process.StandardError.ReadToEndAsync() + $process = Start-ShellOwnedProcess $processScope $start try { if (-not $process.WaitForExit(5000)) { - $process.Kill() + Stop-ShellOwnedProcess $processScope $process throw "zmx probe timed out: $($Arguments -join ' ')" } - return [pscustomobject]@{ ExitCode = $process.ExitCode; Output = $output.Result; Error = $errors.Result } + Complete-ShellOwnedProcess $processScope $process + return [pscustomobject]@{ + ExitCode = $process.ExitCode + Output = Read-ShellOwnedOutput $processScope $process + Error = Read-ShellOwnedOutput $processScope $process -StandardError + } } finally { $process.Dispose() } @@ -193,15 +215,15 @@ function Start-StubLoopSessions { $start.WorkingDirectory = [string] $repoRoot $start.RedirectStandardError = $true foreach ($argument in @("--daemon", $name)) { $start.ArgumentList.Add($argument) } - $hostProcess = [Diagnostics.Process]::Start($start) - $hostError = $hostProcess.StandardError.ReadToEndAsync() + $hostProcess = Start-ShellOwnedProcess $processScope $start $stubSessionHosts[$name] = $hostProcess [void] $ownedProcessIds.Add($hostProcess.Id) [void] $ownedSessionNames.Add($name) $deadline = [DateTime]::UtcNow.AddSeconds(10) do { if ($hostProcess.HasExited) { - throw "zmx stub host exited ($($hostProcess.ExitCode)): $name; $($hostError.Result)" + Complete-ShellOwnedProcess $processScope $hostProcess + throw "zmx stub host exited ($($hostProcess.ExitCode)): $name; $(Read-ShellOwnedOutput $processScope $hostProcess -StandardError)" } $listing = Invoke-ZmxProbe @("ls") if ($listing.ExitCode -eq 0 -and $listing.Output -match $livePattern) { break } @@ -217,14 +239,16 @@ function Start-StubLoopSessions { function Invoke-ShellProcess([string[]] $arguments, [string] $phase) { Start-StubLoopSessions - $script:shellProcess = Start-Process -FilePath $app -ArgumentList $arguments -PassThru -WindowStyle Hidden + $script:shellProcess = Start-SmokeProcess $app $arguments [void] $ownedProcessIds.Add($script:shellProcess.Id) Start-Sleep -Milliseconds 250 Record-TestOwnedSessions Write-OwnedResourceMetrics $phase $script:shellProcess.WaitForExit() + Record-TestOwnedSessions + Complete-ShellOwnedProcess $processScope $script:shellProcess if ($script:shellProcess.ExitCode -ne 0) { - throw "GraphCode Windows shell exited with code $($script:shellProcess.ExitCode)" + throw "GraphCode Windows shell exited with code $($script:shellProcess.ExitCode): $(Read-ShellOwnedOutput $processScope $script:shellProcess -StandardError)" } $script:shellProcess.Dispose() $script:shellProcess = $null @@ -246,6 +270,31 @@ function Get-StageZmxProcesses([string] $stageZmx) { # that directory off PATH, no GRAPHCODE_ZMX - and require the scripted workspace actions # (New Tab, Split Right, among others) to pass through the same smoke contract, with # attach children observed running the staged zmx. +function New-ShellCleanupFailure($PrimaryFailure, [Exception[]] $CleanupErrors, + [string[]] $RetainedRoots) { + $primary = if ($PrimaryFailure -is [Management.Automation.ErrorRecord]) { + $PrimaryFailure.Exception + } else { $PrimaryFailure } + if (-not $CleanupErrors.Count -and -not (Test-ShellOwnershipUnproven $primary)) { return $null } + $messages = @($CleanupErrors | ForEach-Object Message) + if (-not $messages.Count) { $messages = @("Earlier ownership refusal requires retained validation roots") } + $roots = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($root in $RetainedRoots) { if ($root) { [void] $roots.Add($root) } } + foreach ($exception in @($primary) + @($CleanupErrors)) { + while ($exception) { + foreach ($root in @($exception.Data["GraphCodeValidationRetainedRoots"])) { + if ($root) { [void] $roots.Add($root) } + } + $exception = $exception.InnerException + } + } + $message = $messages -join "; " + $failure = if ($primary) { + [InvalidOperationException]::new("Shell failed: $($primary.Message); cleanup unproved: $message", $primary) + } else { [InvalidOperationException]::new($message) } + Set-ShellOwnershipUnproven $failure @($roots) +} + function Invoke-BareNameZmxShell { $stage = Join-Path $shellRoot "bare-zmx-installed-$PID" # A real folder that is not the shell's directory: the loop's project. Terminals start @@ -253,23 +302,27 @@ function Invoke-BareNameZmxShell { $project = Join-Path $shellRoot "bare-zmx-project-$PID" $stageShell = Join-Path $stage "graphcode-windows.exe" $stageZmx = Join-Path $stage "zmx.exe" - $script:bareZmxStage = $stage - $script:bareZmxProject = $project foreach ($path in @($stage, $project)) { if (Test-Path -LiteralPath $path) { throw "Bare-name zmx fixture already exists: $path" } } - New-Item -ItemType Directory -Path $stage, $project | Out-Null + New-Item -ItemType Directory -Path $stage | Out-Null + $script:bareZmxStage = $stage + New-Item -ItemType Directory -Path $project | Out-Null + $script:bareZmxProject = $project $process = $null $stub = $null + $barePrimaryFailure = $null $bareStubResult = Join-Path $shellRoot "bare-zmx-stub-result-$PID.json" try { + $script:bareProcessScope = New-ShellProcessScope Copy-Item -LiteralPath $app -Destination $stageShell Copy-Item -LiteralPath $env:GRAPHCODE_ZMX -Destination $stageZmx Start-StubLoopSessions $barePipe = "graphcode-shell-barezmx-$PID" - $stub = Start-Process -FilePath "pwsh" -WindowStyle Hidden -PassThru -ArgumentList @( + $stub = Start-SmokeProcess "pwsh" @( "-NoProfile", "-File", (Join-Path $repoRoot "Tools\windows\Stub-Daemon.ps1"), - "-PipeName", $barePipe, "-ResultPath", $bareStubResult, "-StubProjectPath", $project) + "-PipeName", $barePipe, "-ResultPath", $bareStubResult, "-StubProjectPath", $project) ` + -scope $bareProcessScope $start = [Diagnostics.ProcessStartInfo]::new($stageShell) $start.WorkingDirectory = $stage @@ -299,11 +352,8 @@ function Invoke-BareNameZmxShell { throw "Bare-name zmx shell has its own directory on PATH" } - $process = [Diagnostics.Process]::new() - $process.StartInfo = $start - [void] $process.Start() + $process = Start-ShellOwnedProcess $bareProcessScope $start [void] $ownedProcessIds.Add($process.Id) - $stderr = $process.StandardError.ReadToEndAsync() $attachSessions = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) $shellSessions = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) $stageProcessIds = [Collections.Generic.HashSet[int]]::new() @@ -323,7 +373,7 @@ function Invoke-BareNameZmxShell { $deadline = [DateTime]::UtcNow.AddSeconds(90) while (-not $process.HasExited) { if ([DateTime]::UtcNow -ge $deadline) { - Stop-Process -Id $process.Id -Force + Stop-ShellOwnedProcess $bareProcessScope $process throw "Bare-name zmx smoke did not finish within 90 seconds" } & $sample @@ -334,6 +384,8 @@ function Invoke-BareNameZmxShell { # daemon outlives the shell, so one last look settles what a sample could miss. & $sample Record-TestOwnedSessions + Complete-ShellOwnedProcess $bareProcessScope $process + $stderr = Read-ShellOwnedOutput $bareProcessScope $process -StandardError Write-Host ("BARE_NAME_ZMX_EVIDENCE_JSON=" + (@{ exitCode = $process.ExitCode stageProcessCount = $stageProcessIds.Count @@ -341,7 +393,7 @@ function Invoke-BareNameZmxShell { shellSessions = @($shellSessions | Sort-Object) } | ConvertTo-Json -Compress)) if ($process.ExitCode -ne 0) { - throw "Bare-name zmx shell smoke exited with code $($process.ExitCode): $($stderr.Result)" + throw "Bare-name zmx shell smoke exited with code $($process.ExitCode): $stderr" } if ($stageProcessIds.Count -eq 0) { throw "Bare-name zmx shell never ran the staged zmx.exe; the layout was not exercised" @@ -361,28 +413,36 @@ function Invoke-BareNameZmxShell { if (@($bareEvidence.commands) -notcontains "openProject" -or [bool] $bareEvidence.error) { throw "Bare-name zmx stub did not serve the folder project: $($bareEvidence.error)" } + } catch { + $barePrimaryFailure = $_ + throw } finally { - if ($process) { - if (-not $process.HasExited) { Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue } - $process.Dispose() + $bareCleanupErrors = [Collections.Generic.List[Exception]]::new() + if ($bareProcessScope) { + try { Close-ShellProcessScope $bareProcessScope } catch { $bareCleanupErrors.Add($_.Exception) } } - if ($stub -and -not $stub.HasExited) { Stop-Process -Id $stub.Id -Force -ErrorAction SilentlyContinue } + if (-not $bareCleanupErrors.Count -and -not (Test-ShellOwnershipUnproven $barePrimaryFailure)) { + try { Remove-BareNameZmxStage } catch { $bareCleanupErrors.Add($_.Exception) } + } + $bareCleanupFailure = New-ShellCleanupFailure $barePrimaryFailure $bareCleanupErrors.ToArray() @($bareZmxStage, $bareZmxProject) + if ($bareCleanupFailure) { throw $bareCleanupFailure } Remove-Item -LiteralPath $bareStubResult -Force -ErrorAction SilentlyContinue - Remove-BareNameZmxStage } $global:LASTEXITCODE = 0 } function Remove-BareNameZmxStage { $stage = $script:bareZmxStage - if ($script:bareZmxProject) { - Remove-Item -LiteralPath $script:bareZmxProject -Recurse -Force -ErrorAction SilentlyContinue - } if (-not $stage -or -not (Test-Path -LiteralPath $stage)) { return } $stageZmx = Join-Path $stage "zmx.exe" - Record-TestOwnedSessions - foreach ($zmxProcess in @(Get-StageZmxProcesses $stageZmx)) { - Stop-Process -Id ([int] $zmxProcess.ProcessId) -Force -ErrorAction SilentlyContinue + if ($bareProcessScope -and -not $bareProcessScope.Closed) { throw "Bare-name stage cleanup requires verified owned-job exit" } + $survivors = @(Get-StageZmxProcesses $stageZmx) + if ($survivors.Count) { + throw (Set-ShellOwnershipUnproven ([InvalidOperationException]::new( + "UNATTESTED_SURVIVORS: staged zmx paths observed after job cleanup; no termination attempted: $($survivors.ProcessId -join ',')")) @($stage, $bareZmxProject)) + } + if ($script:bareZmxProject) { + Remove-Item -LiteralPath $script:bareZmxProject -Recurse -Force -ErrorAction SilentlyContinue } for ($attempt = 0; $attempt -lt 20; $attempt++) { Remove-Item -LiteralPath $stage -Recurse -Force -ErrorAction SilentlyContinue @@ -429,19 +489,20 @@ function Assert-PinnedCleanWorktree( function Assert-NoOrphanShellProcesses { $processes = @( - Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Get-CimInstance Win32_Process -ErrorAction Stop | Where-Object { $_.CommandLine -and (Test-TestSessionProcess $_) } ) if ($processes.Count -ne 0) { - throw "Windows shell cleanup left orphan processes: $($processes.ProcessId -join ', ')" + throw "UNATTESTED_SURVIVORS: shell session observations after owned-job cleanup; no termination attempted: $($processes.ProcessId -join ', ')" } } Assert-PinnedCleanWorktree $WinghosttyRoot $pins.winghostty.sha "Winghostty" Assert-PinnedCleanWorktree $ZmxRoot $pins.zmx.sha "zmx" +$primaryFailure = $null try { if (-not $SkipBuild) { Invoke-Native "Pinned provider artifacts" { @@ -466,13 +527,20 @@ try { if (-not (Test-Path -LiteralPath $app)) { throw "GraphCode Windows shell executable is missing: $app" } - $reportedVersion = (& $app --version 2>$null | Select-Object -First 1).Trim() + $processScope = New-ShellProcessScope + $versionProcess = Start-SmokeProcess $app @("--version") -CaptureOutput -CaptureError + if (-not $versionProcess.WaitForExit(5000)) { + Stop-ShellOwnedProcess $processScope $versionProcess + throw "Shell version query timed out" + } + Complete-ShellOwnedProcess $processScope $versionProcess + $reportedVersion = ((Read-ShellOwnedOutput $processScope $versionProcess) -split "\r?\n" | Select-Object -First 1).Trim() if ($reportedVersion -ne $Version) { throw "GraphCode Windows shell version mismatch: expected $Version, executable reports $reportedVersion" } $env:GRAPHCODE_ZMX = Join-Path $ZmxRoot "zig-out\bin\zmx.exe" $env:GRAPHCODE_GATE_CWD = $repoRoot - $env:GRAPHCODE_SHELL_WORKSPACE_ACTIONS = "1" + $env:GRAPHCODE_SHELL_WORKSPACE_ACTIONS = $WorkspaceActions New-SmokeZmxRoot $smokeZmxDir $env:ZMX_DIR = $smokeZmxDir $env:GRAPHCODE_WORKSPACE_LAYOUT = $workspaceLayoutBase @@ -483,7 +551,7 @@ try { Remove-Item -LiteralPath $stubResult -Force -ErrorAction SilentlyContinue $pipeName = "graphcode-shell-stub-$PID" $stubScript = Join-Path $repoRoot "Tools\windows\Stub-Daemon.ps1" - $stubProcess = Start-Process -FilePath "pwsh" -WindowStyle Hidden -PassThru -ArgumentList @( + $stubProcess = Start-SmokeProcess "pwsh" @( "-NoProfile", "-File", $stubScript, @@ -554,7 +622,7 @@ try { } Remove-Item -LiteralPath $busyResult,$busyError -Force -ErrorAction SilentlyContinue $busyPipeName = "graphcode-shell-busy-$PID" - $busyStubProcess = Start-Process -FilePath "pwsh" -WindowStyle Hidden -PassThru -ArgumentList @( + $busyStubProcess = Start-SmokeProcess "pwsh" @( "-NoProfile", "-File", $stubScript, @@ -566,21 +634,22 @@ try { ) $env:GRAPHCODE_DAEMON_PIPE = "\\.\pipe\$busyPipeName" $env:GRAPHCODE_SHELL_EXPECT_TRANSPORT_ERROR = "1" - $busyApp = Start-Process -FilePath $app -ArgumentList @("--smoke") -PassThru ` - -RedirectStandardError $busyError + $busyApp = Start-SmokeProcess $app @("--smoke") -CaptureError [void] $busyApp.Handle if (-not $busyApp.WaitForExit(8000)) { - Stop-Process -Id $busyApp.Id -Force + Stop-ShellOwnedProcess $processScope $busyApp throw "Busy daemon smoke blocked the UI beyond the bounded timeout" } $busyApp.WaitForExit() $busyApp.Refresh() Record-TestOwnedSessions + Complete-ShellOwnedProcess $processScope $busyApp $busyEvidence = Get-Content -LiteralPath $busyResult -Raw | ConvertFrom-Json if (-not [bool] $busyEvidence.busyObserved) { throw "Busy daemon smoke did not accept a non-reading connection" } - $busyErrorText = Get-Content -LiteralPath $busyError -Raw + $busyErrorText = Read-ShellOwnedOutput $processScope $busyApp -StandardError + [IO.File]::WriteAllText($busyError, $busyErrorText) if ($busyErrorText -notmatch "(?i)Smoke daemon status: .*daemon") { throw "Busy daemon smoke did not post a daemon transport error" } @@ -593,8 +662,7 @@ try { Remove-Item -LiteralPath $inputError -Force -ErrorAction SilentlyContinue Start-StubLoopSessions $inputDeadline = [DateTime]::UtcNow.AddSeconds(8) - $inputApp = Start-Process -FilePath $app -ArgumentList @("--smoke") -PassThru ` - -RedirectStandardError $inputError + $inputApp = Start-SmokeProcess $app @("--smoke") -CaptureError [void] $inputApp.Handle [void] $ownedProcessIds.Add($inputApp.Id) $shellProcess = $inputApp @@ -619,15 +687,17 @@ try { $remainingMilliseconds = [Math]::Max( 0, [int]($inputDeadline - [DateTime]::UtcNow).TotalMilliseconds) if ([DateTime]::UtcNow -ge $inputDeadline -or -not $inputApp.WaitForExit($remainingMilliseconds)) { - if (-not $inputApp.HasExited) { Stop-Process -Id $inputApp.Id -Force } + if (-not $inputApp.HasExited) { Stop-ShellOwnedProcess $processScope $inputApp } throw "Large paste/non-reading attach smoke blocked the UI beyond the bounded timeout" } $inputApp.WaitForExit() $inputApp.Refresh() + Record-TestOwnedSessions + Complete-ShellOwnedProcess $processScope $inputApp $inputExitCode = $inputApp.ExitCode + [IO.File]::WriteAllText($inputError, (Read-ShellOwnedOutput $processScope $inputApp -StandardError)) $inputApp.Dispose() $shellProcess = $null - Record-TestOwnedSessions if ($null -eq $inputExitCode) { throw "Large paste/non-reading attach smoke completed without an observable exit code" } @@ -648,8 +718,10 @@ try { throw "Daemon handoff runtime is missing $name" } } - $handoffStage = Join-Path $shellRoot "daemon-handoff-live-$PID" - New-Item -ItemType Directory -Force -Path $handoffStage | Out-Null + $stage = Join-Path $shellRoot "daemon-handoff-live-$PID" + if (Test-Path -LiteralPath $stage) { throw "Handoff stage already exists: $stage" } + New-Item -ItemType Directory -Path $stage | Out-Null + $handoffStage = $stage Copy-Item -LiteralPath $app -Destination (Join-Path $handoffStage "graphcode-windows.exe") -Force Get-ChildItem -LiteralPath $runtime -File | Where-Object { $_.Name -in @("graphcoded.exe", "graphcode.exe") -or $_.Extension -ieq ".dll" } | @@ -660,38 +732,25 @@ try { } } Record-TestOwnedSessions - Write-Host "Windows shell smoke/stress: PASS" +} catch { + $primaryFailure = $_ + throw } finally { - if ($stubProcess -and -not $stubProcess.HasExited) { - Stop-Process -Id $stubProcess.Id -Force + $cleanupFailure = $null + $cleanupErrors = [Collections.Generic.List[Exception]]::new() + foreach ($scope in @($bareProcessScope, $processScope)) { + if ($scope) { + try { Close-ShellProcessScope $scope } catch { $cleanupErrors.Add($_.Exception) } + } } - if ($busyStubProcess -and -not $busyStubProcess.HasExited) { - Stop-Process -Id $busyStubProcess.Id -Force + try { Assert-NoOrphanShellProcesses } catch { $cleanupErrors.Add($_.Exception) } + if (-not $cleanupErrors.Count -and -not (Test-ShellOwnershipUnproven $primaryFailure)) { + try { Remove-BareNameZmxStage } catch { $cleanupErrors.Add($_.Exception) } } - if ($env:GRAPHCODE_ZMX -and (Test-Path -LiteralPath $env:GRAPHCODE_ZMX)) { - $treeProcessIds = Get-ProcessTreeIds @($ownedProcessIds) - foreach ($processId in $treeProcessIds) { - [void] $ownedProcessIds.Add($processId) - } - foreach ($session in @($ownedSessionNames)) { - & $env:GRAPHCODE_ZMX kill --force $session *> $null - } - foreach ($processId in @($ownedProcessIds)) { - if (Get-Process -Id $processId -ErrorAction SilentlyContinue) { - Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue - } - } - $orphanTestDaemons = @( - Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | - Where-Object { - $_.Name -match "(?i)^zmx(?:\.exe)?$" -and - (Test-TestSessionProcess $_) - } - ) - foreach ($process in $orphanTestDaemons) { - Stop-Process -Id $process.ProcessId -Force -ErrorAction SilentlyContinue - } + $cleanupFailure = New-ShellCleanupFailure $primaryFailure $cleanupErrors.ToArray() @($smokeZmxDir, $bareZmxStage, $bareZmxProject, $handoffStage) + if ($cleanupFailure) { + Write-Host "SHELL_CLEANUP_UNPROVEN=$($cleanupFailure.Message); retained=$smokeZmxDir,$bareZmxStage,$handoffStage" } Remove-Item Env:GRAPHCODE_ZMX -ErrorAction SilentlyContinue Remove-Item Env:GRAPHCODE_GATE_CWD -ErrorAction SilentlyContinue @@ -701,7 +760,7 @@ finally { $env:ZMX_DIR = $oldZmxDir } foreach ($hostProcess in $stubSessionHosts.Values) { $hostProcess.Dispose() } - if (Test-Path -LiteralPath $smokeZmxDir) { + if (-not $cleanupFailure -and (Test-Path -LiteralPath $smokeZmxDir)) { Remove-Item -LiteralPath $smokeZmxDir -Recurse -Force } if ($null -eq $oldSessionPrefix) { @@ -749,16 +808,20 @@ finally { } else { $env:GRAPHCODE_WORKSPACE_LAYOUT = $oldWorkspaceLayout } - $layoutStem = [IO.Path]::GetFileNameWithoutExtension($workspaceLayoutBase) - Get-ChildItem -LiteralPath $shellRoot -Filter "$layoutStem*.json" -ErrorAction SilentlyContinue | - Remove-Item -Force -ErrorAction SilentlyContinue - Remove-Item -LiteralPath $stubResult -Force -ErrorAction SilentlyContinue - Remove-Item -LiteralPath $busyResult,$busyError,$inputError -Force -ErrorAction SilentlyContinue - if ($handoffStage) { - Remove-Item -LiteralPath $handoffStage -Recurse -Force -ErrorAction SilentlyContinue + if (-not $cleanupFailure) { + $layoutStem = [IO.Path]::GetFileNameWithoutExtension($workspaceLayoutBase) + Get-ChildItem -LiteralPath $shellRoot -Filter "$layoutStem*.json" -ErrorAction SilentlyContinue | + Remove-Item -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $stubResult -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $busyResult,$busyError,$inputError -Force -ErrorAction SilentlyContinue + if ($handoffStage) { + Remove-Item -LiteralPath $handoffStage -Recurse -Force -ErrorAction SilentlyContinue + } + } + if ($cleanupFailure) { + throw $cleanupFailure } - Remove-BareNameZmxStage - Assert-NoOrphanShellProcesses } +Write-Host "Windows shell smoke/stress: PASS" exit 0 diff --git a/graphcode-windows/README.md b/graphcode-windows/README.md index 4a4d663e..23b7c8c4 100644 --- a/graphcode-windows/README.md +++ b/graphcode-windows/README.md @@ -870,6 +870,16 @@ this checkout by default. - PowerShell 7 (`pwsh`), Git, and `winget`. - A short checkout path when possible, such as `C:\src\GraphCode`. +The compiled Windows validator requires **.NET SDK 10.0.403**, pinned by +`Tools\windows\ValidationRunner\global.json` with SDK roll-forward disabled. +Bootstrap first reuses a verified installed exact SDK (global or private). +It checks the .NETCore and WindowsDesktop 10.0 runtimes and the SDK-declared +reference packs, including UI Automation references. If none is usable, it +downloads that exact Windows SDK ZIP from official Microsoft release metadata, +verifies its SHA-512 checksum before extraction, and installs it privately under +`ToolRoot\dotnet\10.0.403`. It does not install a global SDK or change user/machine +environment variables. + The bootstrap installs Swift 6.3.3 through `winget` when it is absent. The current `Swift.Toolchain` 6.3.3 package declares Python 3.10 and the x64 Visual C++ redistributable as dependencies. It also downloads Zig 0.15.2 for the @@ -888,6 +898,13 @@ pwsh -NoProfile -File Tools\windows\bootstrap.ps1 ` Load `environment.ps1` again in each new shell. The explicit roots keep every download and provider checkout owned by this worktree and are the locations `Tools\windows\dev.ps1` expects. +The generated environment exports `GRAPHCODE_DOTNET10403` as the verified full +`dotnet.exe` path and process-only `DOTNET_ROOT`, so the compiled validator's +apphost also works with a private-only runtime installation. Build the validator +with `pwsh -NoProfile -File Tools\windows\Build-CompiledValidationRunner.ps1`; +that wrapper honors the exported SDK, resolves the pin from the validator +directory, and restores its temporary process environment. A build never +downloads an SDK implicitly; rerun bootstrap for a missing prerequisite. Reference timings from one Windows x64 worktree on 2026-10-01 are not performance guarantees: the initial bootstrap took **34 minutes 24 seconds** @@ -953,6 +970,11 @@ mismatch rather than terminating a reused PID or an unrelated process. Running different Zig from `PATH` is not a supported workaround. - **Missing or invalid pinned environment:** rerun bootstrap and reload `.graphcode-tools\environment.ps1`; do not hand-build an environment. +- **SDK or WindowsDesktop/reference-pack prerequisite refused:** rerun bootstrap + and reload its environment; do not substitute another SDK or weaken + `global.json`. An explicitly invalid `GRAPHCODE_DOTNET10403` is rejected, not + silently replaced. An invalid existing private SDK directory is preserved; + select a fresh owned `-ToolRoot` rather than overwriting unknown contents. - **An existing `run.json` blocks build or launch:** run `pwsh -NoProfile -File Tools\windows\dev.ps1 -Stop`. If stop reports an identity mismatch, preserve the message and inspect the record instead of diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index a7d479e0..1378aea3 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -2066,7 +2066,21 @@ pub const App = struct { self.client.connect(); } try self.window.messageLoop(); - if (self.exit_requested) std.process.exit(0); + if (self.smoke_failure or + (self.smoke and self.require_smoke_contract and self.smoke_workspace_actions.len > 0)) + { + std.debug.print("Smoke contract {s}: scripted={} failed={} create={} split={} select={} focus={} close={} restart={}\n", .{ + if (self.smoke_failure) "failed" else "passed", + self.smoke_workspace_actions_ran, + self.smoke_workspace_action_failed, + self.smoke_workspace_create_observed, + self.smoke_workspace_split_observed, + self.smoke_workspace_select_observed, + self.smoke_workspace_focus_observed, + self.smoke_workspace_close_observed, + self.smoke_workspace_restart_observed, + }); + } if (self.smoke_failure) { if (std.process.getEnvVarOwned(self.allocator, "GRAPHCODE_SHELL_EXPECT_TRANSPORT_ERROR")) |value| { defer self.allocator.free(value); @@ -2074,8 +2088,10 @@ pub const App = struct { std.debug.print("Smoke daemon status: {s}\n", .{self.client.statusText()}); } } else |_| {} + if (self.exit_requested) std.process.exit(1); return error.SmokeContractFailed; } + if (self.exit_requested) std.process.exit(0); } pub fn configureArgs(self: *App, args: []const []const u8) void { @@ -26687,7 +26703,12 @@ test "Worktrees reclaim action returns before owned provider removal completes" fn runSmokeWorkspaceActions(self: *App) void { const script = self.smoke_workspace_actions; if (script.len == 0) return; + if (self.workspace) |workspace| { + if (workspace.collapsed) self.openSelectedNode(); + } const workspace = if (self.workspace) |value| value else return; + if (workspace.collapsed or + (self.surface != .workspace and !self.workspace_controls.panel_visible)) return; if (workspace.firstLiveSurface() == null) return; const default_script = "create,split,select,focus,close,restart"; const actions = if (std.mem.eql(u8, script, "1")) default_script else script; @@ -26776,6 +26797,7 @@ fn workspacePaneCount(workspace: anytype) usize { fn smokeContractPassed(self: *const App) bool { const scripted_actions = self.smoke_workspace_actions_ran; + if (self.smoke_workspace_actions.len > 0 and !scripted_actions) return false; if (!scripted_actions and self.client.connectionState() != .connected) return false; if (scripted_actions) { return !self.smoke_workspace_action_failed and