From ac2129221f17e2f83a568d86a6a96f3acd5676da Mon Sep 17 00:00:00 2001 From: MarsSall <173498534+MarsSall@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:11:20 -0300 Subject: [PATCH 1/7] ci: add reproducible Windows validation --- .github/workflows/ci.yml | 120 ++++++++++++++++++++ global.json | 7 ++ yasb/tests/read-aibar-quota.Tests.ps1 | 154 +++++++++++++------------- 3 files changed, 205 insertions(+), 76 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 global.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7e17edd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,120 @@ +name: CI + +on: + pull_request: + branches: + - main + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + dotnet: + name: .NET build and tests (Windows) + runs-on: windows-2022 + timeout-minutes: 20 + steps: + - name: Check out repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + persist-credentials: false + + - name: Set up .NET SDK + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 + with: + global-json-file: global.json + + - name: Restore + shell: pwsh + run: dotnet restore AIBar.sln --nologo + + - name: Build + shell: pwsh + run: dotnet build AIBar.sln --no-restore --configuration Debug --nologo + + - name: Test + shell: pwsh + run: >- + dotnet test AIBar.sln --no-build --no-restore --configuration Debug + --logger "console;verbosity=normal" + --logger "trx;LogFileName=AIBar.Tests.trx" + --results-directory "$env:RUNNER_TEMP\aibar-test-results" + + - name: Upload .NET test results + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: dotnet-test-results + path: ${{ runner.temp }}\aibar-test-results\*.trx + if-no-files-found: ignore + retention-days: 7 + + yasb-pester: + name: YASB Pester 5 (Windows) + runs-on: windows-2022 + timeout-minutes: 20 + steps: + - name: Check out repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + persist-credentials: false + + - name: Run YASB tests with pinned Pester + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $packageUri = 'https://www.powershellgallery.com/api/v2/package/Pester/5.7.1' + $packagePath = Join-Path $env:RUNNER_TEMP 'Pester.5.7.1.nupkg' + $extractPath = Join-Path $env:RUNNER_TEMP 'Pester.5.7.1' + $expectedHash = '4a27904c6814a5fbe4758f8e49861f6a1994aee77b71165a5c43c0371ba6c580' + + Invoke-WebRequest -Uri $packageUri -OutFile $packagePath + $actualHash = (Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualHash -cne $expectedHash) { + throw "Pester package hash mismatch. Expected $expectedHash but received $actualHash." + } + + [IO.Compression.ZipFile]::ExtractToDirectory($packagePath, $extractPath) + $manifestPath = Join-Path $extractPath 'Pester.psd1' + Import-Module -Name $manifestPath -Force -ErrorAction Stop + $expectedModuleBase = (Resolve-Path -LiteralPath $extractPath).ProviderPath + $importedPester = Get-Module -Name Pester | + Where-Object { $_.ModuleBase -ceq $expectedModuleBase } + if ($null -eq $importedPester -or $importedPester.Version.ToString() -cne '5.7.1') { + throw "Expected imported Pester version 5.7.1 from module base $expectedModuleBase." + } + + $resultDirectory = Join-Path $env:RUNNER_TEMP 'aibar-pester-results' + $resultPath = Join-Path $resultDirectory 'yasb-pester-results.xml' + New-Item -ItemType Directory -Path $resultDirectory -Force | Out-Null + + $configuration = New-PesterConfiguration + $configuration.Run.Path = Join-Path $env:GITHUB_WORKSPACE 'yasb/tests/read-aibar-quota.Tests.ps1' + $configuration.Run.PassThru = $true + $configuration.Output.Verbosity = 'Detailed' + $configuration.TestResult.Enabled = $true + $configuration.TestResult.OutputPath = $resultPath + $configuration.TestResult.OutputFormat = 'NUnitXml' + + $result = Invoke-Pester -Configuration $configuration + if ($result.FailedCount -ne 0 -or $result.SkippedCount -ne 0 -or $result.TotalCount -ne 11) { + Write-Error "Expected 11 passing YASB tests, but total=$($result.TotalCount) failed=$($result.FailedCount) skipped=$($result.SkippedCount)." + exit 1 + } + + - name: Upload YASB Pester test results + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: yasb-pester-results + path: ${{ runner.temp }}\aibar-pester-results\*.xml + if-no-files-found: ignore + retention-days: 7 diff --git a/global.json b/global.json new file mode 100644 index 0000000..78aa604 --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "8.0.408", + "rollForward": "disable", + "allowPrerelease": false + } +} diff --git a/yasb/tests/read-aibar-quota.Tests.ps1 b/yasb/tests/read-aibar-quota.Tests.ps1 index c73294c..2444f27 100644 --- a/yasb/tests/read-aibar-quota.Tests.ps1 +++ b/yasb/tests/read-aibar-quota.Tests.ps1 @@ -1,20 +1,22 @@ -$root = Split-Path -Parent $PSScriptRoot -$reader = Join-Path $root 'read-aibar-quota.ps1' -$now = [DateTimeOffset]'2026-07-12T12:00:00Z' - -function Read-Fixture([string]$name, [DateTimeOffset]$at = $now) { +BeforeAll { + $root = Split-Path -Parent $PSScriptRoot + $reader = Join-Path $root 'read-aibar-quota.ps1' + $now = [DateTimeOffset]'2026-07-12T12:00:00Z' . $reader - $script:AIBarQuotaFixturePath = Join-Path $root "fixtures/$name" - $script:AIBarQuotaFixtureNow = $at - Invoke-AiBarQuotaReader -} -function Assert-SafeFields($result) { - $names = @($result.PSObject.Properties.Name) - $names.Count | Should Be 3 - $names[0] | Should Be 'label' - $names[1] | Should Be 'tooltip' - $names[2] | Should Be 'className' + function Read-Fixture([string]$name, [DateTimeOffset]$at = $now) { + $script:AIBarQuotaFixturePath = Join-Path $root "fixtures/$name" + $script:AIBarQuotaFixtureNow = $at + Invoke-AiBarQuotaReader + } + + function Assert-SafeFields($result) { + $names = @($result.PSObject.Properties.Name) + $names.Count | Should -Be 3 + $names[0] | Should -Be 'label' + $names[1] | Should -Be 'tooltip' + $names[2] | Should -Be 'className' + } } Describe 'AIBar YASB reader' { @@ -22,19 +24,19 @@ Describe 'AIBar YASB reader' { $tokens = $null; $errors = $null $ast = [Management.Automation.Language.Parser]::ParseFile($reader, [ref]$tokens, [ref]$errors) $source = Get-Content -Raw $reader - $errors.Count | Should Be 0 - ($null -eq $ast.ParamBlock) | Should Be $true - ([regex]::Matches($source, '\[DateTimeOffset\]::UtcNow')).Count | Should Be 1 - $source | Should Match 'catch\s*\{\s*return Get-SafeOutput -Now \$now\s*\}' - $source | Should Match 'return Get-SafeOutput .* -Now \$now' - $source | Should Match 'AIBar\\yasb-quota\.json' - $source | Should Match '\[IO\.FileAccess\]::Read' - $source | Should Match '16385' - $source | Should Match '-gt 16384' - $source | Should Match 'StringComparison\]::Ordinal' - $source | Should Match 'switch -CaseSensitive' - $source | Should Match '-cnotin' - $source | Should Not Match 'Start-Process|Invoke-WebRequest|Remove-Item|Set-Content|Add-Content|WriteAll|CreateDirectory' + $errors.Count | Should -Be 0 + ($null -eq $ast.ParamBlock) | Should -Be $true + ([regex]::Matches($source, '\[DateTimeOffset\]::UtcNow')).Count | Should -Be 1 + $source | Should -Match 'catch\s*\{\s*return Get-SafeOutput -Now \$now\s*\}' + $source | Should -Match 'return Get-SafeOutput .* -Now \$now' + $source | Should -Match 'AIBar\\yasb-quota\.json' + $source | Should -Match '\[IO\.FileAccess\]::Read' + $source | Should -Match '16385' + $source | Should -Match '-gt 16384' + $source | Should -Match 'StringComparison\]::Ordinal' + $source | Should -Match 'switch -CaseSensitive' + $source | Should -Match '-cnotin' + $source | Should -Not -Match 'Start-Process|Invoke-WebRequest|Remove-Item|Set-Content|Add-Content|WriteAll|CreateDirectory' } It 'renders every valid fixture as its exact state, warning, text, and class' { @@ -52,9 +54,9 @@ Describe 'AIBar YASB reader' { foreach ($case in $cases) { $result = Read-Fixture $case.Name Assert-SafeFields $result - $result.label | Should Be $case.Label - $result.tooltip | Should Be $case.Tooltip - $result.className | Should Be $case.ClassName + $result.label | Should -Be $case.Label + $result.tooltip | Should -Be $case.Tooltip + $result.className | Should -Be $case.ClassName } } @@ -68,15 +70,15 @@ Describe 'AIBar YASB reader' { foreach ($name in $invalid) { $result = Read-Fixture $name Assert-SafeFields $result - $result.label | Should Be '5h: -- | 7d: -- | unavailable | warning: unavailable' - $result.tooltip | Should Be 'state: unavailable | warning: unavailable | 5h reset: -- | 7d reset: -- | age: --' - $result.className | Should Be 'aibar-quota state-unavailable warning-unavailable' + $result.label | Should -Be '5h: -- | 7d: -- | unavailable | warning: unavailable' + $result.tooltip | Should -Be 'state: unavailable | warning: unavailable | 5h reset: -- | 7d reset: -- | age: --' + $result.className | Should -Be 'aibar-quota state-unavailable warning-unavailable' } $rollback = Read-Fixture 'clock-rollback.json' ([DateTimeOffset]'2026-07-12T08:00:00Z') Assert-SafeFields $rollback - $rollback.label | Should Be '5h: -- | 7d: -- | unavailable | warning: unavailable' - $rollback.tooltip | Should Be 'state: unavailable | warning: unavailable | 5h reset: -- | 7d reset: -- | age: --' - $rollback.className | Should Be 'aibar-quota state-unavailable warning-unavailable' + $rollback.label | Should -Be '5h: -- | 7d: -- | unavailable | warning: unavailable' + $rollback.tooltip | Should -Be 'state: unavailable | warning: unavailable | 5h reset: -- | 7d reset: -- | age: --' + $rollback.className | Should -Be 'aibar-quota state-unavailable warning-unavailable' } It 'uses valid YAML single-quoted Windows command scalars and exact stock keys' { @@ -98,32 +100,32 @@ aibar_quota: '@ $actualYaml = ($yaml -replace "`r`n", "`n").TrimEnd() $expectedYaml = ($expected -replace "`r`n", "`n").TrimEnd() - $actualYaml | Should Be $expectedYaml - $yaml | Should Match '(?m)^ exec_options:\r?$' - $yaml | Should Match '(?m)^ run_cmd: ''powershell\.exe .*\\yasb\\read-aibar-quota\.ps1"''\r?$' - $yaml | Should Match '(?m)^ run_interval: 60000\r?$' - $yaml | Should Match '(?m)^ return_format: "json"\r?$' - $yaml | Should Not Match '(?m)^ (exec|update_interval|return_type):' - $yaml | Should Not Match '(?m)^\s*(run_cmd|on_left):\s*"[^\r\n]*\\' - $yaml | Should Not Match 'on_right|on_middle' + $actualYaml | Should -Be $expectedYaml + $yaml | Should -Match '(?m)^ exec_options:\r?$' + $yaml | Should -Match '(?m)^ run_cmd: ''powershell\.exe .*\\yasb\\read-aibar-quota\.ps1"''\r?$' + $yaml | Should -Match '(?m)^ run_interval: 60000\r?$' + $yaml | Should -Match '(?m)^ return_format: "json"\r?$' + $yaml | Should -Not -Match '(?m)^ (exec|update_interval|return_type):' + $yaml | Should -Not -Match '(?m)^\s*(run_cmd|on_left):\s*"[^\r\n]*\\' + $yaml | Should -Not -Match 'on_right|on_middle' $module = Get-Module -ListAvailable -Name powershell-yaml | Select-Object -First 1 if ($null -ne $module) { Import-Module $module.Path -ErrorAction Stop $parsed = ConvertFrom-Yaml $yaml - $parsed['aibar_quota']['options']['exec_options']['run_interval'] | Should Be 60000 - $parsed['aibar_quota']['options']['exec_options']['return_format'] | Should Be 'json' - $parsed['aibar_quota']['options']['exec_options']['run_cmd'] | Should Be 'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "\yasb\read-aibar-quota.ps1"' - $parsed['aibar_quota']['options']['callbacks']['on_left'] | Should Be 'exec cmd /c "\yasb\show-aibar.cmd"' + $parsed['aibar_quota']['options']['exec_options']['run_interval'] | Should -Be 60000 + $parsed['aibar_quota']['options']['exec_options']['return_format'] | Should -Be 'json' + $parsed['aibar_quota']['options']['exec_options']['run_cmd'] | Should -Be 'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "\yasb\read-aibar-quota.ps1"' + $parsed['aibar_quota']['options']['callbacks']['on_left'] | Should -Be 'exec cmd /c "\yasb\show-aibar.cmd"' } } It 'keeps the stock launcher and CSS bounded' { $cmd = Get-Content -Raw (Join-Path $root 'show-aibar.cmd') $css = Get-Content -Raw (Join-Path $root 'custom-widget.example.css') - $cmd | Should Match 'AIBar\.Desktop\.exe" --show' - $cmd | Should Not Match '%\*|where |Get-Process' - $css | Should Match 'state-stale' - $css | Should Match 'warning-authentication-failed' + $cmd | Should -Match 'AIBar\.Desktop\.exe" --show' + $cmd | Should -Not -Match '%\*|where |Get-Process' + $css | Should -Match 'state-stale' + $css | Should -Match 'warning-authentication-failed' } } @@ -144,31 +146,31 @@ Describe 'AIBar YASB rollback cleanup' { It 'uses no public parameters and rejects arguments before fixed-path access' { $cleanup = Join-Path $root 'remove-aibar-quota.ps1' $tokens = $null; $errors = $null; $ast = [Management.Automation.Language.Parser]::ParseFile($cleanup, [ref]$tokens, [ref]$errors) - $errors.Count | Should Be 0 - $ast.ParamBlock.Parameters.Count | Should Be 0 + $errors.Count | Should -Be 0 + $ast.ParamBlock.Parameters.Count | Should -Be 0 $source = Get-Content -LiteralPath $cleanup -Raw - $source.IndexOf('if ($args.Count -ne 0)') | Should BeLessThan $source.IndexOf('if (Invoke-AiBarQuotaCleanup)') + $source.IndexOf('if ($args.Count -ne 0)') | Should -BeLessThan $source.IndexOf('if (Invoke-AiBarQuotaCleanup)') & powershell.exe -NoProfile -NonInteractive -File $cleanup unexpected - $LASTEXITCODE | Should Be 64 + $LASTEXITCODE | Should -Be 64 } It 'deletes a valued nullable-reset snapshot and treats absence as a no-op' { '{"schemaVersion":1,"generatedAt":"2026-07-12T12:00:00Z","state":"current","warning":null,"sourceRetrievedAt":"2026-07-12T11:00:00Z","fiveHour":{"percentageUsed":12.5,"resetAt":null},"weekly":null}' | Set-Content -LiteralPath $script:cleanupPath -NoNewline - (Invoke-AiBarQuotaCleanup) | Should Be $true - (Test-Path -LiteralPath $script:cleanupPath) | Should Be $false - (Invoke-AiBarQuotaCleanup) | Should Be $true + (Invoke-AiBarQuotaCleanup) | Should -Be $true + (Test-Path -LiteralPath $script:cleanupPath) | Should -Be $false + (Invoke-AiBarQuotaCleanup) | Should -Be $true } It 'atomically replaces a delete-denied snapshot with exact BOM-less canonical bytes' { '{"valued":true}' | Set-Content -LiteralPath $script:cleanupPath -NoNewline $script:AIBarQuotaCleanupTestDelete = { param($Path) throw 'denied' } - (Invoke-AiBarQuotaCleanup) | Should Be $true + (Invoke-AiBarQuotaCleanup) | Should -Be $true $bytes = [IO.File]::ReadAllBytes($script:cleanupPath) - ($bytes.Length -lt 3 -or $bytes[0] -ne 0xEF -or $bytes[1] -ne 0xBB -or $bytes[2] -ne 0xBF) | Should Be $true + ($bytes.Length -lt 3 -or $bytes[0] -ne 0xEF -or $bytes[1] -ne 0xBB -or $bytes[2] -ne 0xBF) | Should -Be $true $snapshot = [Text.UTF8Encoding]::new($false, $true).GetString($bytes) - (Test-DisabledSnapshot $script:cleanupPath $snapshot) | Should Be $true - @(Get-ChildItem -LiteralPath $script:cleanupRoot -Filter '.yasb-quota-cleanup-*.tmp').Count | Should Be 0 - @(Get-ChildItem -LiteralPath $script:cleanupRoot -Filter '.yasb-quota-cleanup-*.bak').Count | Should Be 0 + (Test-DisabledSnapshot $script:cleanupPath $snapshot) | Should -Be $true + @(Get-ChildItem -LiteralPath $script:cleanupRoot -Filter '.yasb-quota-cleanup-*.tmp').Count | Should -Be 0 + @(Get-ChildItem -LiteralPath $script:cleanupRoot -Filter '.yasb-quota-cleanup-*.bak').Count | Should -Be 0 } It 'rejects byte-equal boolean schema and malformed or non-UTC generatedAt snapshots' { @@ -178,7 +180,7 @@ Describe 'AIBar YASB rollback cleanup' { '{"schemaVersion":1,"generatedAt":"2026-07-12T12:00:00.0000000+01:00","state":"disabled","warning":"disabled","sourceRetrievedAt":null,"fiveHour":null,"weekly":null}' )) { [IO.File]::WriteAllText($script:cleanupPath, $snapshot, [Text.UTF8Encoding]::new($false)) - (Test-DisabledSnapshot $script:cleanupPath $snapshot) | Should Be $false + (Test-DisabledSnapshot $script:cleanupPath $snapshot) | Should -Be $false } } @@ -188,22 +190,22 @@ Describe 'AIBar YASB rollback cleanup' { $script:destinationChecks = 0 $script:AIBarQuotaCleanupTestReplace = { param($Temporary, $Path) [IO.File]::Delete($Path); [IO.File]::Move($Temporary, $Path) } $script:AIBarQuotaCleanupTestReparse = { param($Path) if ($Path -ceq $script:cleanupPath) { $script:destinationChecks++; return $script:destinationChecks -ge 3 }; return $false } - (Invoke-AiBarQuotaCleanup) | Should Be $false - $script:destinationChecks | Should Be 3 - (Test-Path -LiteralPath $script:cleanupPath) | Should Be $true - @(Get-ChildItem -LiteralPath $script:cleanupRoot -Filter '.yasb-quota-cleanup-*.tmp').Count | Should Be 0 - @(Get-ChildItem -LiteralPath $script:cleanupRoot -Filter '.yasb-quota-cleanup-*.bak').Count | Should Be 0 + (Invoke-AiBarQuotaCleanup) | Should -Be $false + $script:destinationChecks | Should -Be 3 + (Test-Path -LiteralPath $script:cleanupPath) | Should -Be $true + @(Get-ChildItem -LiteralPath $script:cleanupRoot -Filter '.yasb-quota-cleanup-*.tmp').Count | Should -Be 0 + @(Get-ChildItem -LiteralPath $script:cleanupRoot -Filter '.yasb-quota-cleanup-*.bak').Count | Should -Be 0 } It 'fails closed and cleans bounded temps for precommit replacement and exact read-back mutation' { '{"valued":true}' | Set-Content -LiteralPath $script:cleanupPath -NoNewline $script:AIBarQuotaCleanupTestDelete = { param($Path) throw 'denied' } $script:AIBarQuotaCleanupTestReplace = { param($Temporary, $Path) throw 'replace failed' } - (Invoke-AiBarQuotaCleanup) | Should Be $false + (Invoke-AiBarQuotaCleanup) | Should -Be $false $script:AIBarQuotaCleanupTestReplace = { param($Temporary, $Path) [IO.File]::Delete($Path); [IO.File]::Move($Temporary, $Path); [IO.File]::AppendAllText($Path, ' ') } - (Invoke-AiBarQuotaCleanup) | Should Be $false - [IO.File]::ReadAllText($script:cleanupPath).EndsWith(' ') | Should Be $true - @(Get-ChildItem -LiteralPath $script:cleanupRoot -Filter '.yasb-quota-cleanup-*.tmp').Count | Should Be 0 - @(Get-ChildItem -LiteralPath $script:cleanupRoot -Filter '.yasb-quota-cleanup-*.bak').Count | Should Be 0 + (Invoke-AiBarQuotaCleanup) | Should -Be $false + [IO.File]::ReadAllText($script:cleanupPath).EndsWith(' ') | Should -Be $true + @(Get-ChildItem -LiteralPath $script:cleanupRoot -Filter '.yasb-quota-cleanup-*.tmp').Count | Should -Be 0 + @(Get-ChildItem -LiteralPath $script:cleanupRoot -Filter '.yasb-quota-cleanup-*.bak').Count | Should -Be 0 } } From 64602d23e7bf8c12644331114fc11e36c2f67ba8 Mon Sep 17 00:00:00 2001 From: MarsSall <173498534+MarsSall@users.noreply.github.com> Date: Thu, 20 Aug 2026 21:01:08 -0300 Subject: [PATCH 2/7] test: align Windows CI harnesses --- .github/workflows/ci.yml | 2 +- .../PackagingRecoveryTests.cs | 6 +++--- yasb/tests/read-aibar-quota.Tests.ps1 | 19 +++++++++++++++---- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e17edd..67960c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,7 +68,7 @@ jobs: persist-credentials: false - name: Run YASB tests with pinned Pester - shell: pwsh + shell: powershell run: | $ErrorActionPreference = 'Stop' $packageUri = 'https://www.powershellgallery.com/api/v2/package/Pester/5.7.1' diff --git a/tests/AIBar.Domain.Tests/PackagingRecoveryTests.cs b/tests/AIBar.Domain.Tests/PackagingRecoveryTests.cs index 49e5121..951bd91 100644 --- a/tests/AIBar.Domain.Tests/PackagingRecoveryTests.cs +++ b/tests/AIBar.Domain.Tests/PackagingRecoveryTests.cs @@ -519,7 +519,7 @@ public static DescendantHarness Create() var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), $"aibar descendant {Guid.NewGuid():N} café")).FullName; var project = Path.Combine(root, "Harness.csproj"); var program = Path.Combine(root, "Program.cs"); - File.WriteAllText(project, "Exenet8.0trueenable", Encoding.UTF8); + File.WriteAllText(project, "Exenet8.0-windowstrueenable", Encoding.UTF8); File.WriteAllText(program, """ using System.Diagnostics; using System.Text.Json; @@ -614,7 +614,7 @@ public static DescendantHarness Create() var readyName = $"Local\\aibar-ready-{nonce}"; var releaseName = $"Local\\aibar-release-{nonce}"; var ready = new EventWaitHandle(false, EventResetMode.ManualReset, readyName); var release = new EventWaitHandle(false, EventResetMode.ManualReset, releaseName); - return new(root, Path.Combine(root, "bin", "Debug", "net8.0", "Harness.exe"), Path.Combine(root, "identity.json"), Path.Combine(root, "arguments.txt"), Path.Combine(root, ".owned-marker"), nonce, readyName, releaseName, ready, release); + return new(root, Path.Combine(root, "bin", "Debug", "net8.0-windows", "Harness.exe"), Path.Combine(root, "identity.json"), Path.Combine(root, "arguments.txt"), Path.Combine(root, ".owned-marker"), nonce, readyName, releaseName, ready, release); } public Process StartChild(int exitCode = 0) @@ -775,7 +775,7 @@ private bool HasOwnedCleanupAdmission() private static string Bounded(Task stdout, Task stderr) { var output = (stdout.IsCompletedSuccessfully ? stdout.Result : string.Empty) + (stderr.IsCompletedSuccessfully ? stderr.Result : string.Empty); - return output.Length <= 4096 ? output : output[..4096]; + return output.Length <= 4096 ? output : $"[truncated discarded_chars={output.Length - 4096}]\n{output[^4096..]}"; } } private sealed class IsolationRoot : IDisposable diff --git a/yasb/tests/read-aibar-quota.Tests.ps1 b/yasb/tests/read-aibar-quota.Tests.ps1 index 2444f27..d8fd12a 100644 --- a/yasb/tests/read-aibar-quota.Tests.ps1 +++ b/yasb/tests/read-aibar-quota.Tests.ps1 @@ -2,12 +2,19 @@ BeforeAll { $root = Split-Path -Parent $PSScriptRoot $reader = Join-Path $root 'read-aibar-quota.ps1' $now = [DateTimeOffset]'2026-07-12T12:00:00Z' - . $reader + $readerModule = New-Module -ScriptBlock { + param([string]$readerPath) + . $readerPath + } -ArgumentList $reader function Read-Fixture([string]$name, [DateTimeOffset]$at = $now) { - $script:AIBarQuotaFixturePath = Join-Path $root "fixtures/$name" - $script:AIBarQuotaFixtureNow = $at - Invoke-AiBarQuotaReader + $fixturePath = Join-Path $root "fixtures/$name" + & $readerModule { +param([string]$path, [DateTimeOffset]$fixtureNow) +$script:AIBarQuotaFixturePath = $path +$script:AIBarQuotaFixtureNow = $fixtureNow +Invoke-AiBarQuotaReader + } $fixturePath $at } function Assert-SafeFields($result) { @@ -19,6 +26,10 @@ BeforeAll { } } +AfterAll { + Remove-Module $readerModule -Force +} + Describe 'AIBar YASB reader' { It 'has an empty production parameter surface, one trusted clock read, and bounded read-only behavior' { $tokens = $null; $errors = $null From 14cb818790f6ab9cb2aaf45379dad93eca30492c Mon Sep 17 00:00:00 2001 From: MarsSall <173498534+MarsSall@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:27:15 -0300 Subject: [PATCH 3/7] ci: isolate hosted Windows tooling --- .github/workflows/ci.yml | 1 + tests/AIBar.Domain.Tests/PackagingRecoveryTests.cs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67960c5..4cb5ab2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,6 +82,7 @@ jobs: throw "Pester package hash mismatch. Expected $expectedHash but received $actualHash." } + Add-Type -AssemblyName System.IO.Compression.FileSystem [IO.Compression.ZipFile]::ExtractToDirectory($packagePath, $extractPath) $manifestPath = Join-Path $extractPath 'Pester.psd1' Import-Module -Name $manifestPath -Force -ErrorAction Stop diff --git a/tests/AIBar.Domain.Tests/PackagingRecoveryTests.cs b/tests/AIBar.Domain.Tests/PackagingRecoveryTests.cs index 951bd91..cfe3694 100644 --- a/tests/AIBar.Domain.Tests/PackagingRecoveryTests.cs +++ b/tests/AIBar.Domain.Tests/PackagingRecoveryTests.cs @@ -599,7 +599,7 @@ public static DescendantHarness Create() Environment.Exit(release.WaitOne(10000) ? 0 : 4); """, Encoding.UTF8); var build = new ProcessStartInfo("dotnet") { UseShellExecute = false, RedirectStandardError = true, RedirectStandardOutput = true, WorkingDirectory = root }; - build.ArgumentList.Add("build"); build.ArgumentList.Add(project); build.ArgumentList.Add("--nologo"); build.ArgumentList.Add("-v:q"); + build.ArgumentList.Add("build"); build.ArgumentList.Add(project); build.ArgumentList.Add("--nologo"); build.ArgumentList.Add("-v:q"); build.ArgumentList.Add("--disable-build-servers"); var nonce = Guid.NewGuid().ToString("N"); using var compiler = Process.Start(build)!; var stdout = compiler.StandardOutput.ReadToEndAsync(); var stderr = compiler.StandardError.ReadToEndAsync(); From 9ee1d2288cc0b56a7234f4d3b1f6956d7525dccf Mon Sep 17 00:00:00 2001 From: MarsSall <173498534+MarsSall@users.noreply.github.com> Date: Fri, 21 Aug 2026 08:13:46 -0300 Subject: [PATCH 4/7] test: harden packaging process lifecycle --- AIBar.sln | 7 + .../AIBar.Domain.Tests.csproj | 1 + .../PackagingRecoveryTests.cs | 142 ++++++------------ .../AIBar.Packaging.ProcessHarness.csproj | 17 +++ .../AIBar.Packaging.ProcessHarness/Program.cs | 76 ++++++++++ 5 files changed, 143 insertions(+), 100 deletions(-) create mode 100644 tests/AIBar.Packaging.ProcessHarness/AIBar.Packaging.ProcessHarness.csproj create mode 100644 tests/AIBar.Packaging.ProcessHarness/Program.cs diff --git a/AIBar.sln b/AIBar.sln index efb3ed6..ee840c9 100644 --- a/AIBar.sln +++ b/AIBar.sln @@ -15,6 +15,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{9135F1C3 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AIBar.Domain.Tests", "tests\AIBar.Domain.Tests\AIBar.Domain.Tests.csproj", "{2FBA6A95-9279-4296-BABD-41B6BCAEDD90}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AIBar.Packaging.ProcessHarness", "tests\AIBar.Packaging.ProcessHarness\AIBar.Packaging.ProcessHarness.csproj", "{7E46B5C2-420A-4C29-AEA2-F466974B2C21}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{0CA92BF2-27DA-4C5E-8414-EB95A8B2CDA4}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AIBar.Packaging.Supervisor", "tools\AIBar.Packaging.Supervisor\AIBar.Packaging.Supervisor.csproj", "{356C2AC2-0DBF-42AB-B637-C0CE23F99EEF}" @@ -50,6 +52,10 @@ Global {2FBA6A95-9279-4296-BABD-41B6BCAEDD90}.Debug|Any CPU.Build.0 = Debug|Any CPU {2FBA6A95-9279-4296-BABD-41B6BCAEDD90}.Release|Any CPU.ActiveCfg = Release|Any CPU {2FBA6A95-9279-4296-BABD-41B6BCAEDD90}.Release|Any CPU.Build.0 = Release|Any CPU + {7E46B5C2-420A-4C29-AEA2-F466974B2C21}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7E46B5C2-420A-4C29-AEA2-F466974B2C21}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7E46B5C2-420A-4C29-AEA2-F466974B2C21}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7E46B5C2-420A-4C29-AEA2-F466974B2C21}.Release|Any CPU.Build.0 = Release|Any CPU {356C2AC2-0DBF-42AB-B637-C0CE23F99EEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {356C2AC2-0DBF-42AB-B637-C0CE23F99EEF}.Debug|Any CPU.Build.0 = Debug|Any CPU {356C2AC2-0DBF-42AB-B637-C0CE23F99EEF}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -72,6 +78,7 @@ Global {D1D5DCD9-AB48-4FDE-8555-298ECBB623A5} = {B73090FB-C935-460A-BC35-085182BB3375} {DDA2B9FB-527A-4490-AFF6-A4C77D22D0EA} = {B73090FB-C935-460A-BC35-085182BB3375} {2FBA6A95-9279-4296-BABD-41B6BCAEDD90} = {9135F1C3-DE4D-485B-B656-A6B3DC8D4AB8} + {7E46B5C2-420A-4C29-AEA2-F466974B2C21} = {9135F1C3-DE4D-485B-B656-A6B3DC8D4AB8} {356C2AC2-0DBF-42AB-B637-C0CE23F99EEF} = {0CA92BF2-27DA-4C5E-8414-EB95A8B2CDA4} {A1BFC4D4-C97B-4BD5-A34A-147B489A3E9E} = {0CA92BF2-27DA-4C5E-8414-EB95A8B2CDA4} {CF5B8208-64E5-4E50-BAD3-6E687CE7D9C4} = {0CA92BF2-27DA-4C5E-8414-EB95A8B2CDA4} diff --git a/tests/AIBar.Domain.Tests/AIBar.Domain.Tests.csproj b/tests/AIBar.Domain.Tests/AIBar.Domain.Tests.csproj index 62bac58..6ca983d 100644 --- a/tests/AIBar.Domain.Tests/AIBar.Domain.Tests.csproj +++ b/tests/AIBar.Domain.Tests/AIBar.Domain.Tests.csproj @@ -25,6 +25,7 @@ + diff --git a/tests/AIBar.Domain.Tests/PackagingRecoveryTests.cs b/tests/AIBar.Domain.Tests/PackagingRecoveryTests.cs index cfe3694..eadd150 100644 --- a/tests/AIBar.Domain.Tests/PackagingRecoveryTests.cs +++ b/tests/AIBar.Domain.Tests/PackagingRecoveryTests.cs @@ -498,8 +498,14 @@ public void Dispose() private sealed record Snapshot(Dictionary Files, string ZipHash, byte[] Inventory, byte[] Manifest); private sealed class DescendantHarness : IDisposable { - private const int HarnessCompilationWaitMilliseconds = 30_000; private const int BoundedWaitMilliseconds = 10_000; + private static readonly string[] RequiredHarnessOutputFiles = + [ + "Harness.exe", + "Harness.dll", + "Harness.deps.json", + "Harness.runtimeconfig.json" + ]; public string Root { get; } public string Executable { get; } public string IdentityRecord { get; } @@ -516,105 +522,46 @@ private DescendantHarness(string root, string executable, string identityRecord, public static DescendantHarness Create() { - var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), $"aibar descendant {Guid.NewGuid():N} café")).FullName; - var project = Path.Combine(root, "Harness.csproj"); - var program = Path.Combine(root, "Program.cs"); - File.WriteAllText(project, "Exenet8.0-windowstrueenable", Encoding.UTF8); - File.WriteAllText(program, """ -using System.Diagnostics; -using System.Text.Json; -using System.Threading; -if (args[0] == "child") -{ - File.WriteAllText(args[5], JsonSerializer.Serialize(new { child = new { pid = Environment.ProcessId, startTicks = Process.GetCurrentProcess().StartTime.ToUniversalTime().Ticks } })); - var start = new ProcessStartInfo(args[1]) { UseShellExecute = false }; - start.ArgumentList.Add("grandchild"); - start.ArgumentList.Add(args[2]); start.ArgumentList.Add(args[3]); start.ArgumentList.Add(args[4]); start.ArgumentList.Add(args[5]); start.ArgumentList.Add(args[6]); start.ArgumentList.Add(args[7]); start.ArgumentList.Add(args[8]); - using var grandchild = Process.Start(start)!; - using var childReady = EventWaitHandle.OpenExisting(args[2]); - Console.Out.WriteLine("child-stdout"); Console.Error.WriteLine("child-stderr"); - Environment.Exit(childReady.WaitOne(10000) ? int.Parse(args[9]) : 3); -} -if (args[0] == "child-only") -{ - File.WriteAllText(args[5], JsonSerializer.Serialize(new { child = new { pid = Environment.ProcessId, startTicks = Process.GetCurrentProcess().StartTime.ToUniversalTime().Ticks } })); - using var childOnlyReady = EventWaitHandle.OpenExisting(args[2]); - using var childOnlyRelease = EventWaitHandle.OpenExisting(args[3]); - childOnlyReady.Set(); - Environment.Exit(childOnlyRelease.WaitOne(10000) ? 0 : 3); -} -if (args[0] == "child-unpublished") -{ - File.WriteAllText(args[5], JsonSerializer.Serialize(new { child = new { pid = Environment.ProcessId, startTicks = Process.GetCurrentProcess().StartTime.ToUniversalTime().Ticks } })); - var start = new ProcessStartInfo(args[1]) { UseShellExecute = false }; - start.ArgumentList.Add("unpublished-grandchild"); - start.ArgumentList.Add(args[2]); start.ArgumentList.Add(args[3]); start.ArgumentList.Add(args[4]); start.ArgumentList.Add(args[5]); start.ArgumentList.Add(args[6]); start.ArgumentList.Add(args[7]); start.ArgumentList.Add(args[8]); - using var grandchild = Process.Start(start)!; - using var childUnpublishedRelease = EventWaitHandle.OpenExisting(args[3]); - Environment.Exit(childUnpublishedRelease.WaitOne(10000) ? 0 : 3); -} -if (args[0] == "publish") -{ - var mode = Environment.GetEnvironmentVariable("AIBAR_PUBLISH_MODE"); - if (mode == "saturated") { File.WriteAllText(Environment.GetEnvironmentVariable("AIBAR_MARKER")!, $"owned-marker:{Environment.GetEnvironmentVariable("AIBAR_NONCE")}"); Console.Out.Write(new string('o', 1_048_576)); Console.Error.Write(new string('e', 1_048_576)); Environment.Exit(0); } - if (mode == "diagnostic-nonzero") { Console.Out.Write(new string('o', 4096)); Console.Out.WriteLine("stdout-tail password=stdout-password"); Console.Error.Write(new string('e', 4096)); Console.Error.WriteLine("stderr-tail https://user:stderr-password@example.invalid/path"); Environment.Exit(23); } - if (mode == "diagnostic-timeout") { Console.Out.WriteLine("timeout-stdout"); Console.Out.Flush(); Console.Error.WriteLine("timeout-stderr"); Console.Error.Flush(); Thread.Sleep(30000); Environment.Exit(0); } - var start = new ProcessStartInfo(Environment.GetEnvironmentVariable("AIBAR_HARNESS_EXE")!) { UseShellExecute = false }; - foreach (var value in new[] { "publisher-child", Environment.GetEnvironmentVariable("AIBAR_HARNESS_EXE")!, Environment.GetEnvironmentVariable("AIBAR_READY")!, Environment.GetEnvironmentVariable("AIBAR_RELEASE")!, Environment.GetEnvironmentVariable("AIBAR_MARKER")!, Environment.GetEnvironmentVariable("AIBAR_IDENTITY")!, Environment.GetEnvironmentVariable("AIBAR_ARGUMENTS")!, Environment.GetEnvironmentVariable("AIBAR_ROOT")!, Environment.GetEnvironmentVariable("AIBAR_NONCE")! }) start.ArgumentList.Add(value); - using var child = Process.Start(start)!; - if (Environment.GetEnvironmentVariable("AIBAR_PUBLISH_MODE") == "early") - { - using var publisherReady = EventWaitHandle.OpenExisting(Environment.GetEnvironmentVariable("AIBAR_READY")!); - Environment.Exit(publisherReady.WaitOne(10000) ? 0 : 5); - } - child.WaitForExit(); - Environment.Exit(child.ExitCode); -} -if (args[0] == "publisher-child") -{ - File.WriteAllText(args[5], JsonSerializer.Serialize(new { child = new { pid = Environment.ProcessId, startTicks = Process.GetCurrentProcess().StartTime.ToUniversalTime().Ticks } })); - var start = new ProcessStartInfo(args[1]) { UseShellExecute = false }; - start.ArgumentList.Add("grandchild"); - start.ArgumentList.Add(args[2]); start.ArgumentList.Add(args[3]); start.ArgumentList.Add(args[4]); start.ArgumentList.Add(args[5]); start.ArgumentList.Add(args[6]); start.ArgumentList.Add(args[7]); start.ArgumentList.Add(args[8]); - using var grandchild = Process.Start(start)!; - using var publisherRelease = EventWaitHandle.OpenExisting(args[3]); - Environment.Exit(publisherRelease.WaitOne(60000) ? 0 : 4); -} -if (args[0] == "unpublished-grandchild") -{ - using var unpublishedReady = EventWaitHandle.OpenExisting(args[1]); - using var unpublishedRelease = new EventWaitHandle(false, EventResetMode.ManualReset, $"Local\\aibar-unpublished-release-{args[7]}"); - File.WriteAllText(args[5], Environment.ProcessId.ToString()); - unpublishedReady.Set(); - Environment.Exit(unpublishedRelease.WaitOne(10000) ? 0 : 4); -} -using var ready = EventWaitHandle.OpenExisting(args[1]); -using var release = EventWaitHandle.OpenExisting(args[2]); -var identity = JsonDocument.Parse(File.ReadAllText(args[4])); -File.WriteAllText(args[4], JsonSerializer.Serialize(new { child = identity.RootElement.GetProperty("child"), grandchild = new { pid = Environment.ProcessId, startTicks = Process.GetCurrentProcess().StartTime.ToUniversalTime().Ticks } })); -File.WriteAllText(args[5], args[6]); -File.WriteAllText(args[3], $"owned-marker:{args[7]}"); -Console.Out.WriteLine("grandchild-stdout"); Console.Error.WriteLine("grandchild-stderr"); -ready.Set(); -Environment.Exit(release.WaitOne(10000) ? 0 : 4); -""", Encoding.UTF8); - var build = new ProcessStartInfo("dotnet") { UseShellExecute = false, RedirectStandardError = true, RedirectStandardOutput = true, WorkingDirectory = root }; - build.ArgumentList.Add("build"); build.ArgumentList.Add(project); build.ArgumentList.Add("--nologo"); build.ArgumentList.Add("-v:q"); build.ArgumentList.Add("--disable-build-servers"); - var nonce = Guid.NewGuid().ToString("N"); - using var compiler = Process.Start(build)!; - var stdout = compiler.StandardOutput.ReadToEndAsync(); var stderr = compiler.StandardError.ReadToEndAsync(); - var compilerWork = Task.WhenAll(compiler.WaitForExitAsync(), stdout, stderr); - if (!compilerWork.Wait(HarnessCompilationWaitMilliseconds)) + var harnessOutput = PrebuiltHarnessOutput(); + if (!Directory.Exists(harnessOutput)) + throw new DirectoryNotFoundException($"Prebuilt PackagingRecovery process harness output was not found at '{harnessOutput}'. Build AIBar.Packaging.ProcessHarness before running these tests."); + + foreach (var fileName in RequiredHarnessOutputFiles) { - if (!compiler.HasExited) compiler.Kill(entireProcessTree: true); - Assert.True(compilerWork.Wait(BoundedWaitMilliseconds), $"Compiler timeout: {Bounded(stdout, stderr)}"); + var source = Path.Combine(harnessOutput, fileName); + if (!File.Exists(source)) + throw new FileNotFoundException($"Required prebuilt PackagingRecovery process harness file was not found: '{source}'. Build AIBar.Packaging.ProcessHarness before running these tests.", source); } - var output = Bounded(stdout, stderr); - Assert.True(compiler.ExitCode == 0, output); + + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), $"aibar descendant {Guid.NewGuid():N} café")).FullName; + foreach (var fileName in RequiredHarnessOutputFiles) + File.Copy(Path.Combine(harnessOutput, fileName), Path.Combine(root, fileName)); + + var nonce = Guid.NewGuid().ToString("N"); var readyName = $"Local\\aibar-ready-{nonce}"; var releaseName = $"Local\\aibar-release-{nonce}"; var ready = new EventWaitHandle(false, EventResetMode.ManualReset, readyName); var release = new EventWaitHandle(false, EventResetMode.ManualReset, releaseName); - return new(root, Path.Combine(root, "bin", "Debug", "net8.0-windows", "Harness.exe"), Path.Combine(root, "identity.json"), Path.Combine(root, "arguments.txt"), Path.Combine(root, ".owned-marker"), nonce, readyName, releaseName, ready, release); + return new(root, Path.Combine(root, "Harness.exe"), Path.Combine(root, "identity.json"), Path.Combine(root, "arguments.txt"), Path.Combine(root, ".owned-marker"), nonce, readyName, releaseName, ready, release); + } + + private static string PrebuiltHarnessOutput() + { + var testAssemblyPath = typeof(PackagingRecoveryTests).Assembly.Location; + if (string.IsNullOrWhiteSpace(testAssemblyPath)) + throw new InvalidOperationException("Cannot locate the prebuilt PackagingRecovery process harness because the test assembly output path is unavailable."); + + var testAssemblyDirectory = new FileInfo(testAssemblyPath).Directory!; + var targetFrameworkDirectory = testAssemblyDirectory; + while (targetFrameworkDirectory is not null && !string.Equals(targetFrameworkDirectory.Name, "net8.0-windows", StringComparison.OrdinalIgnoreCase)) + targetFrameworkDirectory = targetFrameworkDirectory.Parent; + + if (targetFrameworkDirectory is null || targetFrameworkDirectory.Parent is null || string.IsNullOrWhiteSpace(targetFrameworkDirectory.Parent.Name)) + throw new InvalidOperationException($"Cannot determine the test build configuration from assembly output path '{testAssemblyPath}'. Expected a net8.0-windows output directory."); + + var configuration = targetFrameworkDirectory.Parent.Name; + var harnessOutput = Path.Combine(RepositoryRoot(), "tests", "AIBar.Packaging.ProcessHarness", "bin", configuration, "net8.0-windows"); + var runtimeSpecificOutput = Path.GetRelativePath(targetFrameworkDirectory.FullName, testAssemblyDirectory.FullName); + return runtimeSpecificOutput == "." ? harnessOutput : Path.Combine(harnessOutput, runtimeSpecificOutput); } public Process StartChild(int exitCode = 0) @@ -772,11 +719,6 @@ private bool HasOwnedCleanupAdmission() catch { return false; } } - private static string Bounded(Task stdout, Task stderr) - { - var output = (stdout.IsCompletedSuccessfully ? stdout.Result : string.Empty) + (stderr.IsCompletedSuccessfully ? stderr.Result : string.Empty); - return output.Length <= 4096 ? output : $"[truncated discarded_chars={output.Length - 4096}]\n{output[^4096..]}"; - } } private sealed class IsolationRoot : IDisposable { diff --git a/tests/AIBar.Packaging.ProcessHarness/AIBar.Packaging.ProcessHarness.csproj b/tests/AIBar.Packaging.ProcessHarness/AIBar.Packaging.ProcessHarness.csproj new file mode 100644 index 0000000..4c88ede --- /dev/null +++ b/tests/AIBar.Packaging.ProcessHarness/AIBar.Packaging.ProcessHarness.csproj @@ -0,0 +1,17 @@ + + + Exe + net8.0-windows + true + enable + Harness + false + false + + + + + + + + diff --git a/tests/AIBar.Packaging.ProcessHarness/Program.cs b/tests/AIBar.Packaging.ProcessHarness/Program.cs new file mode 100644 index 0000000..3ea04ef --- /dev/null +++ b/tests/AIBar.Packaging.ProcessHarness/Program.cs @@ -0,0 +1,76 @@ +using System.Diagnostics; +using System.Text.Json; +using System.Threading; +if (args[0] == "child") +{ + File.WriteAllText(args[5], JsonSerializer.Serialize(new { child = new { pid = Environment.ProcessId, startTicks = Process.GetCurrentProcess().StartTime.ToUniversalTime().Ticks } })); + var start = new ProcessStartInfo(args[1]) { UseShellExecute = false }; + start.ArgumentList.Add("grandchild"); + start.ArgumentList.Add(args[2]); start.ArgumentList.Add(args[3]); start.ArgumentList.Add(args[4]); start.ArgumentList.Add(args[5]); start.ArgumentList.Add(args[6]); start.ArgumentList.Add(args[7]); start.ArgumentList.Add(args[8]); + using var grandchild = Process.Start(start)!; + using var childReady = EventWaitHandle.OpenExisting(args[2]); + Console.Out.WriteLine("child-stdout"); Console.Error.WriteLine("child-stderr"); + Environment.Exit(childReady.WaitOne(10000) ? int.Parse(args[9]) : 3); +} +if (args[0] == "child-only") +{ + File.WriteAllText(args[5], JsonSerializer.Serialize(new { child = new { pid = Environment.ProcessId, startTicks = Process.GetCurrentProcess().StartTime.ToUniversalTime().Ticks } })); + using var childOnlyReady = EventWaitHandle.OpenExisting(args[2]); + using var childOnlyRelease = EventWaitHandle.OpenExisting(args[3]); + childOnlyReady.Set(); + Environment.Exit(childOnlyRelease.WaitOne(10000) ? 0 : 3); +} +if (args[0] == "child-unpublished") +{ + File.WriteAllText(args[5], JsonSerializer.Serialize(new { child = new { pid = Environment.ProcessId, startTicks = Process.GetCurrentProcess().StartTime.ToUniversalTime().Ticks } })); + var start = new ProcessStartInfo(args[1]) { UseShellExecute = false }; + start.ArgumentList.Add("unpublished-grandchild"); + start.ArgumentList.Add(args[2]); start.ArgumentList.Add(args[3]); start.ArgumentList.Add(args[4]); start.ArgumentList.Add(args[5]); start.ArgumentList.Add(args[6]); start.ArgumentList.Add(args[7]); start.ArgumentList.Add(args[8]); + using var grandchild = Process.Start(start)!; + using var childUnpublishedRelease = EventWaitHandle.OpenExisting(args[3]); + Environment.Exit(childUnpublishedRelease.WaitOne(10000) ? 0 : 3); +} +if (args[0] == "publish") +{ + var mode = Environment.GetEnvironmentVariable("AIBAR_PUBLISH_MODE"); + if (mode == "saturated") { File.WriteAllText(Environment.GetEnvironmentVariable("AIBAR_MARKER")!, $"owned-marker:{Environment.GetEnvironmentVariable("AIBAR_NONCE")}"); Console.Out.Write(new string('o', 1_048_576)); Console.Error.Write(new string('e', 1_048_576)); Environment.Exit(0); } + if (mode == "diagnostic-nonzero") { Console.Out.Write(new string('o', 4096)); Console.Out.WriteLine("stdout-tail password=stdout-password"); Console.Error.Write(new string('e', 4096)); Console.Error.WriteLine("stderr-tail https://user:stderr-password@example.invalid/path"); Environment.Exit(23); } + if (mode == "diagnostic-timeout") { Console.Out.WriteLine("timeout-stdout"); Console.Out.Flush(); Console.Error.WriteLine("timeout-stderr"); Console.Error.Flush(); Thread.Sleep(30000); Environment.Exit(0); } + var start = new ProcessStartInfo(Environment.GetEnvironmentVariable("AIBAR_HARNESS_EXE")!) { UseShellExecute = false }; + foreach (var value in new[] { "publisher-child", Environment.GetEnvironmentVariable("AIBAR_HARNESS_EXE")!, Environment.GetEnvironmentVariable("AIBAR_READY")!, Environment.GetEnvironmentVariable("AIBAR_RELEASE")!, Environment.GetEnvironmentVariable("AIBAR_MARKER")!, Environment.GetEnvironmentVariable("AIBAR_IDENTITY")!, Environment.GetEnvironmentVariable("AIBAR_ARGUMENTS")!, Environment.GetEnvironmentVariable("AIBAR_ROOT")!, Environment.GetEnvironmentVariable("AIBAR_NONCE")! }) start.ArgumentList.Add(value); + using var child = Process.Start(start)!; + if (Environment.GetEnvironmentVariable("AIBAR_PUBLISH_MODE") == "early") + { + using var publisherReady = EventWaitHandle.OpenExisting(Environment.GetEnvironmentVariable("AIBAR_READY")!); + Environment.Exit(publisherReady.WaitOne(10000) ? 0 : 5); + } + child.WaitForExit(); + Environment.Exit(child.ExitCode); +} +if (args[0] == "publisher-child") +{ + File.WriteAllText(args[5], JsonSerializer.Serialize(new { child = new { pid = Environment.ProcessId, startTicks = Process.GetCurrentProcess().StartTime.ToUniversalTime().Ticks } })); + var start = new ProcessStartInfo(args[1]) { UseShellExecute = false }; + start.ArgumentList.Add("grandchild"); + start.ArgumentList.Add(args[2]); start.ArgumentList.Add(args[3]); start.ArgumentList.Add(args[4]); start.ArgumentList.Add(args[5]); start.ArgumentList.Add(args[6]); start.ArgumentList.Add(args[7]); start.ArgumentList.Add(args[8]); + using var grandchild = Process.Start(start)!; + using var publisherRelease = EventWaitHandle.OpenExisting(args[3]); + Environment.Exit(publisherRelease.WaitOne(60000) ? 0 : 4); +} +if (args[0] == "unpublished-grandchild") +{ + using var unpublishedReady = EventWaitHandle.OpenExisting(args[1]); + using var unpublishedRelease = new EventWaitHandle(false, EventResetMode.ManualReset, $"Local\\aibar-unpublished-release-{args[7]}"); + File.WriteAllText(args[5], Environment.ProcessId.ToString()); + unpublishedReady.Set(); + Environment.Exit(unpublishedRelease.WaitOne(10000) ? 0 : 4); +} +using var ready = EventWaitHandle.OpenExisting(args[1]); +using var release = EventWaitHandle.OpenExisting(args[2]); +var identity = JsonDocument.Parse(File.ReadAllText(args[4])); +File.WriteAllText(args[4], JsonSerializer.Serialize(new { child = identity.RootElement.GetProperty("child"), grandchild = new { pid = Environment.ProcessId, startTicks = Process.GetCurrentProcess().StartTime.ToUniversalTime().Ticks } })); +File.WriteAllText(args[5], args[6]); +File.WriteAllText(args[3], $"owned-marker:{args[7]}"); +Console.Out.WriteLine("grandchild-stdout"); Console.Error.WriteLine("grandchild-stderr"); +ready.Set(); +Environment.Exit(release.WaitOne(10000) ? 0 : 4); From 7a70df41c690d7c7590a5805cf09b70722a62342 Mon Sep 17 00:00:00 2001 From: MarsSall <173498534+MarsSall@users.noreply.github.com> Date: Fri, 21 Aug 2026 08:39:32 -0300 Subject: [PATCH 5/7] test: stabilize SQLite lock observation --- tests/AIBar.Domain.Tests/SqliteUsageEventLedgerTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/AIBar.Domain.Tests/SqliteUsageEventLedgerTests.cs b/tests/AIBar.Domain.Tests/SqliteUsageEventLedgerTests.cs index 09adfab..72c34ec 100644 --- a/tests/AIBar.Domain.Tests/SqliteUsageEventLedgerTests.cs +++ b/tests/AIBar.Domain.Tests/SqliteUsageEventLedgerTests.cs @@ -208,7 +208,9 @@ public async Task External_immediate_lock_blocks_a_separate_ledger_then_releases var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var write = Task.Run(async () => { entered.SetResult(); return await writer.UpsertBatchAsync([Event()]); }); try { - await entered.Task.WaitAsync(TimeSpan.FromSeconds(2)); await Task.Yield(); Assert.False(write.IsCompleted); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(2)); + var observation = Task.Delay(TimeSpan.FromMilliseconds(250)); + Assert.Same(observation, await Task.WhenAny(write, observation)); } finally { await Execute(blocker, "COMMIT;"); } var result = await write.WaitAsync(TimeSpan.FromSeconds(10)); Assert.Equal(UsageEventWriteState.Inserted, Assert.Single(result).State); From 65467f536b09faf2114f09b7387f09953c812284 Mon Sep 17 00:00:00 2001 From: MarsSall <173498534+MarsSall@users.noreply.github.com> Date: Fri, 21 Aug 2026 08:55:11 -0300 Subject: [PATCH 6/7] test: isolate SQLite ledger tests --- tests/AIBar.Domain.Tests/SqliteUsageEventLedgerTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/AIBar.Domain.Tests/SqliteUsageEventLedgerTests.cs b/tests/AIBar.Domain.Tests/SqliteUsageEventLedgerTests.cs index 72c34ec..33cb48d 100644 --- a/tests/AIBar.Domain.Tests/SqliteUsageEventLedgerTests.cs +++ b/tests/AIBar.Domain.Tests/SqliteUsageEventLedgerTests.cs @@ -4,6 +4,10 @@ namespace AIBar.Domain.Tests; +[CollectionDefinition("SqliteUsageEventLedger", DisableParallelization = true)] +public sealed class SqliteUsageEventLedgerCollection; + +[Collection("SqliteUsageEventLedger")] public sealed class SqliteUsageEventLedgerTests : IDisposable { private readonly string _path = Path.Combine(Path.GetTempPath(), $"aibar-ledger-{Guid.NewGuid():N}.db"); From f3a3ff5bb1001e5e1055b18828f2d14ccf478e7b Mon Sep 17 00:00:00 2001 From: MarsSall <173498534+MarsSall@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:36:37 -0300 Subject: [PATCH 7/7] test: remove hosted runner timing races --- .../AnalyticsLifecycleTests.cs | 18 ++++++------ tests/AIBar.Domain.Tests/HostRuntimeTests.cs | 2 +- .../SqliteUsageEventLedgerTests.cs | 28 ++++++++----------- 3 files changed, 22 insertions(+), 26 deletions(-) diff --git a/tests/AIBar.Domain.Tests/AnalyticsLifecycleTests.cs b/tests/AIBar.Domain.Tests/AnalyticsLifecycleTests.cs index 106873f..2501e8e 100644 --- a/tests/AIBar.Domain.Tests/AnalyticsLifecycleTests.cs +++ b/tests/AIBar.Domain.Tests/AnalyticsLifecycleTests.cs @@ -52,16 +52,16 @@ public async Task Production_exit_cooperatively_cancels_awaits_and_disposes_anal Assert.True(harness.Entered.Wait(TimeSpan.FromSeconds(2))); var exit = harness.ExitAsync(); - Assert.True(harness.Cancelled.Wait(TimeSpan.FromSeconds(2))); + await harness.CancellationObserved.Task.WaitAsync(TimeSpan.FromSeconds(2)); harness.Release.Set(); await exit; Assert.Equal(AnalyticsShutdownKind.Completed, harness.Outcome.Kind); Assert.Equal(1, harness.Events.Count(item => item == "analytics_cancelled")); Assert.Equal(1, harness.Events.Count(item => item == "analytics_awaited")); - Assert.Contains("analytics_view_disposed", harness.Events); - Assert.Contains("analytics_store_disposed", harness.Events); - Assert.Contains("quota_store_disposed", harness.Events); + Assert.Equal(1, harness.Events.Count(item => item == "analytics_view_disposed")); + Assert.Equal(1, harness.Events.Count(item => item == "analytics_store_disposed")); + Assert.Equal(1, harness.Events.Count(item => item == "quota_store_disposed")); } [Fact] @@ -210,9 +210,9 @@ private sealed class ProductionHarness : IAsyncDisposable private readonly string _root; private readonly TrayHostRuntime _host; - private ProductionHarness(string root, string sourcePath, TrayHostRuntime host, App.StartupComposition composition, List events, ManualResetEventSlim entered, ManualResetEventSlim cancelled, ManualResetEventSlim release, TaskCompletionSource scanFinished) + private ProductionHarness(string root, string sourcePath, TrayHostRuntime host, App.StartupComposition composition, List events, ManualResetEventSlim entered, ManualResetEventSlim cancelled, ManualResetEventSlim release, TaskCompletionSource scanFinished, TaskCompletionSource cancellationObserved) { - _root = root; SourcePath = sourcePath; _host = host; Composition = composition; Events = events; Entered = entered; Cancelled = cancelled; Release = release; ScanFinished = scanFinished; + _root = root; SourcePath = sourcePath; _host = host; Composition = composition; Events = events; Entered = entered; Cancelled = cancelled; Release = release; ScanFinished = scanFinished; CancellationObserved = cancellationObserved; } public App.StartupComposition Composition { get; } @@ -221,6 +221,7 @@ private ProductionHarness(string root, string sourcePath, TrayHostRuntime host, public ManualResetEventSlim Cancelled { get; } public ManualResetEventSlim Release { get; } public TaskCompletionSource ScanFinished { get; } + public TaskCompletionSource CancellationObserved { get; } public string SourcePath { get; } public string AnalyticsDatabasePath => Path.Combine(_root, "analytics.db"); public string AnalyticsWalPath => Path.Combine(_root, "analytics.db-wal"); @@ -237,6 +238,7 @@ public static async Task CreateAsync(TimeSpan shutdownBound, await File.WriteAllTextAsync(sourcePath, "{\"timestamp\":\"2026-01-01T00:00:00Z\",\"model\":\"gpt-5\",\"usage\":{\"input_tokens\":1,\"cached_input_tokens\":0,\"output_tokens\":1}}\n" + (partial ? "{\n" : "")); var events = new List(); var entered = new ManualResetEventSlim(); var cancelled = new ManualResetEventSlim(); var release = new ManualResetEventSlim(); var finished = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var cancellationObserved = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var scanNumber = 0; var composition = App.CreateComposition(new(root, codex, shutdownBound, _ => { @@ -246,13 +248,13 @@ public static async Task CreateAsync(TimeSpan shutdownBound, }, item => { lock (events) events.Add(item); - if (item == "analytics_cancelled") cancelled.Set(); + if (item == "analytics_cancelled") { cancelled.Set(); cancellationObserved.TrySetResult(); } if (item == "analytics_scan_finished") finished.TrySetResult(); }, Path.Combine(root, "home"))); var instance = new SingleInstanceHost($"AIBar.lifecycle.{Guid.NewGuid():N}"); var host = new TrayHostRuntime(instance, new FakeTray(), new FakePopover(), new FakeTaskbar(), _ => Task.CompletedTask, composition.Resource, () => { }); instance.Dispose(); - return new(root, sourcePath, host, composition, events, entered, cancelled, release, finished); + return new(root, sourcePath, host, composition, events, entered, cancelled, release, finished, cancellationObserved); } public Task ExitAsync() => _host.ExitAsync(); diff --git a/tests/AIBar.Domain.Tests/HostRuntimeTests.cs b/tests/AIBar.Domain.Tests/HostRuntimeTests.cs index 120aed1..c5d3bfd 100644 --- a/tests/AIBar.Domain.Tests/HostRuntimeTests.cs +++ b/tests/AIBar.Domain.Tests/HostRuntimeTests.cs @@ -308,7 +308,7 @@ async ValueTask RevokeConsentAsync(CancellationToken cancellationToken) await provider.Started.Task.WaitAsync(TimeSpan.FromSeconds(2)); Assert.Equal(1, prompt.Calls); Assert.Equal(1, provider.Calls); Assert.Equal(1, files.Calls); - provider.Release.TrySetResult(); PumpUntil(() => tray.DisablePrivateVisible); + provider.Release.TrySetResult(); PumpUntil(() => tray.DisablePrivateVisible && beta.State.Failure?.SafeCode == "quota_credential_missing"); using var document = System.Text.Json.JsonDocument.Parse(await File.ReadAllTextAsync(settingsFile)); Assert.Equal(2, document.RootElement.EnumerateObject().Count()); Assert.True(document.RootElement.GetProperty("privateCodexConsent").GetBoolean()); Assert.Equal("quota_credential_missing", beta.State.Failure!.SafeCode); diff --git a/tests/AIBar.Domain.Tests/SqliteUsageEventLedgerTests.cs b/tests/AIBar.Domain.Tests/SqliteUsageEventLedgerTests.cs index 33cb48d..6b741c1 100644 --- a/tests/AIBar.Domain.Tests/SqliteUsageEventLedgerTests.cs +++ b/tests/AIBar.Domain.Tests/SqliteUsageEventLedgerTests.cs @@ -4,10 +4,6 @@ namespace AIBar.Domain.Tests; -[CollectionDefinition("SqliteUsageEventLedger", DisableParallelization = true)] -public sealed class SqliteUsageEventLedgerCollection; - -[Collection("SqliteUsageEventLedger")] public sealed class SqliteUsageEventLedgerTests : IDisposable { private readonly string _path = Path.Combine(Path.GetTempPath(), $"aibar-ledger-{Guid.NewGuid():N}.db"); @@ -205,21 +201,19 @@ public async Task Rejects_future_schema_without_mutation() Assert.Equal(0L, await Scalar(verify, "SELECT COUNT(*) FROM sqlite_master WHERE name='usage_event';")); } [Fact] - public async Task External_immediate_lock_blocks_a_separate_ledger_then_releases_one_idempotent_write() + public async Task External_immediate_lock_is_exclusive_and_ledger_write_is_idempotent_after_release() { - await using var verifier = await Ledger(); await using var writer = new SqliteUsageEventLedger(_path); + await using var ledger = await Ledger(); await using var blocker = new SqliteConnection($"Data Source={_path};Pooling=False"); await blocker.OpenAsync(); await Execute(blocker, "BEGIN IMMEDIATE;"); - var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var write = Task.Run(async () => { entered.SetResult(); return await writer.UpsertBatchAsync([Event()]); }); - try { - await entered.Task.WaitAsync(TimeSpan.FromSeconds(2)); - var observation = Task.Delay(TimeSpan.FromMilliseconds(250)); - Assert.Same(observation, await Task.WhenAny(write, observation)); - } finally { await Execute(blocker, "COMMIT;"); } - var result = await write.WaitAsync(TimeSpan.FromSeconds(10)); - Assert.Equal(UsageEventWriteState.Inserted, Assert.Single(result).State); - Assert.Equal(UsageEventWriteState.NoChange, Assert.Single(await verifier.UpsertBatchAsync([Event()])).State); - Assert.Equal(Event(), await verifier.GetAsync(Id(1))); Assert.Equal(1, await verifier.CountAsync()); + var probeOptions = new SqliteConnectionStringBuilder { DataSource = _path, Pooling = false, DefaultTimeout = 1 }; + await using var probe = new SqliteConnection(probeOptions.ToString()); await probe.OpenAsync(); + var conflict = await Assert.ThrowsAsync(() => Execute(probe, "BEGIN IMMEDIATE;")); + Assert.Contains(conflict.SqliteErrorCode, new[] { 5, 6 }); + Assert.Equal(conflict.SqliteErrorCode, conflict.SqliteExtendedErrorCode & 0xff); + await Execute(blocker, "COMMIT;"); + Assert.Equal(UsageEventWriteState.Inserted, Assert.Single(await ledger.UpsertBatchAsync([Event()])).State); + Assert.Equal(UsageEventWriteState.NoChange, Assert.Single(await ledger.UpsertBatchAsync([Event()])).State); + Assert.Equal(Event(), await ledger.GetAsync(Id(1))); Assert.Equal(1, await ledger.CountAsync()); } public void Dispose() { SqliteConnection.ClearAllPools(); if (File.Exists(_path)) File.Delete(_path); } private async Task Ledger(UsageTool tool = UsageTool.OpenCode) { var ledger = new SqliteUsageEventLedger(_path); await ledger.UpsertSourceAsync(Source(tool)); return ledger; }