diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..4cb5ab2
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,121 @@
+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: powershell
+ 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."
+ }
+
+ 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
+ $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/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/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/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/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/PackagingRecoveryTests.cs b/tests/AIBar.Domain.Tests/PackagingRecoveryTests.cs
index 49e5121..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.0trueenable", 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");
- 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", "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 : output[..4096];
- }
}
private sealed class IsolationRoot : IDisposable
{
diff --git a/tests/AIBar.Domain.Tests/SqliteUsageEventLedgerTests.cs b/tests/AIBar.Domain.Tests/SqliteUsageEventLedgerTests.cs
index 09adfab..6b741c1 100644
--- a/tests/AIBar.Domain.Tests/SqliteUsageEventLedgerTests.cs
+++ b/tests/AIBar.Domain.Tests/SqliteUsageEventLedgerTests.cs
@@ -201,19 +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)); await Task.Yield(); Assert.False(write.IsCompleted);
- } 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; }
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);
diff --git a/yasb/tests/read-aibar-quota.Tests.ps1 b/yasb/tests/read-aibar-quota.Tests.ps1
index c73294c..d8fd12a 100644
--- a/yasb/tests/read-aibar-quota.Tests.ps1
+++ b/yasb/tests/read-aibar-quota.Tests.ps1
@@ -1,20 +1,33 @@
-$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) {
- . $reader
- $script:AIBarQuotaFixturePath = Join-Path $root "fixtures/$name"
- $script:AIBarQuotaFixtureNow = $at
- Invoke-AiBarQuotaReader
+BeforeAll {
+ $root = Split-Path -Parent $PSScriptRoot
+ $reader = Join-Path $root 'read-aibar-quota.ps1'
+ $now = [DateTimeOffset]'2026-07-12T12:00:00Z'
+ $readerModule = New-Module -ScriptBlock {
+ param([string]$readerPath)
+ . $readerPath
+ } -ArgumentList $reader
+
+ function Read-Fixture([string]$name, [DateTimeOffset]$at = $now) {
+ $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) {
+ $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 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'
+AfterAll {
+ Remove-Module $readerModule -Force
}
Describe 'AIBar YASB reader' {
@@ -22,19 +35,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 +65,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 +81,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 +111,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 +157,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 +191,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 +201,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
}
}