using Aryx.AgentHost.Contracts; using Aryx.AgentHost.Services; namespace Aryx.AgentHost.Tests; public sealed class HookCommandRunnerTests { [Fact] public async Task RunAsync_PipesJsonIntoHookStandardInput() { HookCommandRunner runner = new(); using TestDirectory project = new(); HookCommandDefinition hook = CreatePlatformHook( OperatingSystem.IsWindows() ? "$payload = [Console]::In.ReadToEnd(); Write-Output $payload" : "payload=$(cat); printf '%s' \"$payload\""); string input = """{"toolName":"view","toolArgs":"{\"path\":\"README.md\"}"}"""; string? output = await runner.RunAsync(hook, input, project.Path, CancellationToken.None); Assert.Equal(input, output?.Trim()); } [Fact] public async Task RunAsync_ReturnsNullWhenHookTimesOut() { HookCommandRunner runner = new(); using TestDirectory project = new(); HookCommandDefinition hook = CreatePlatformHook( OperatingSystem.IsWindows() ? "Start-Sleep -Seconds 5" : "sleep 5", timeoutSec: 1); string? output = await runner.RunAsync(hook, "{}", project.Path, CancellationToken.None); Assert.Null(output); } [Fact] public async Task RunAsync_ReturnsNullWhenHookFails() { HookCommandRunner runner = new(); using TestDirectory project = new(); HookCommandDefinition hook = CreatePlatformHook( OperatingSystem.IsWindows() ? "Write-Error 'boom'; exit 1" : "echo boom >&2; exit 1"); string? output = await runner.RunAsync(hook, "{}", project.Path, CancellationToken.None); Assert.Null(output); } [Fact] public async Task RunAsync_ReturnsNullWhenCurrentPlatformCommandIsMissing() { HookCommandRunner runner = new(); using TestDirectory project = new(); HookCommandDefinition hook = OperatingSystem.IsWindows() ? new HookCommandDefinition { Type = "command", Bash = "echo unsupported" } : new HookCommandDefinition { Type = "command", PowerShell = "Write-Output unsupported" }; string? output = await runner.RunAsync(hook, "{}", project.Path, CancellationToken.None); Assert.Null(output); } [Fact] public async Task RunAsync_UsesConfiguredWorkingDirectoryAndEnvironment() { HookCommandRunner runner = new(); using TestDirectory project = new(); string hooksDirectory = Directory.CreateDirectory(Path.Combine(project.Path, "scripts")).FullName; await File.WriteAllTextAsync(Path.Combine(hooksDirectory, "cwd-marker.txt"), "marker"); HookCommandDefinition hook = CreatePlatformHook( OperatingSystem.IsWindows() ? "$null = [Console]::In.ReadToEnd(); if (Test-Path -LiteralPath './cwd-marker.txt') { $status = 'present' } else { $status = 'missing' }; Write-Output ($status + '|' + $env:HOOK_TEST_ENV)" : "cat >/dev/null; if [ -f ./cwd-marker.txt ]; then status=present; else status=missing; fi; printf '%s|%s' \"$status\" \"$HOOK_TEST_ENV\"", cwd: "scripts", env: new Dictionary { ["HOOK_TEST_ENV"] = "configured", }); string? output = await runner.RunAsync(hook, "{}", project.Path, CancellationToken.None); Assert.Equal("present|configured", output?.Trim()); } private static HookCommandDefinition CreatePlatformHook( string command, int? timeoutSec = null, string? cwd = null, IReadOnlyDictionary? env = null) { return OperatingSystem.IsWindows() ? new HookCommandDefinition { Type = "command", PowerShell = command, TimeoutSec = timeoutSec, Cwd = cwd, Env = env, } : new HookCommandDefinition { Type = "command", Bash = command, TimeoutSec = timeoutSec, Cwd = cwd, Env = env, }; } private sealed class TestDirectory : IDisposable { private readonly DirectoryInfo _directory = Directory.CreateTempSubdirectory("aryx-hooks-runner-"); public string Path => _directory.FullName; public void Dispose() { if (_directory.Exists) { _directory.Delete(recursive: true); } } } }