using System.Diagnostics; using Xunit; namespace DevTool.Tests; public sealed class HeadlessExecutionTests { [Fact] public async Task HeadlessRun_JsonMode_EmitsVersionedEventsAndSummary() { var root = CreateTempProject(""" { "name": "headless-demo", "version": "0.1.0", "workflows": [ { "id": "build", "label": "Build", "description": "Build", "group": "Build", "dependsOn": [], "steps": [ { "id": "s1", "label": "dotnet --version", "command": "dotnet", "args": ["--version"], "workingDir": ".", "requires": [] } ] } ] } """); var result = await RunSdtAsync(["run", "build", "--json", "--project-root", root]); Assert.Equal(0, result.ExitCode); Assert.Contains($"\"run_event_version\":\"{Sdt.Core.HeadlessExecutionService.ContractVersion}\"", result.StdOut, StringComparison.Ordinal); Assert.Contains("\"event_type\":\"WorkflowStarted\"", result.StdOut, StringComparison.Ordinal); Assert.Contains("\"category\":\"workflow\"", result.StdOut, StringComparison.Ordinal); Assert.Contains("\"success\":true", result.StdOut, StringComparison.Ordinal); } [Fact] public async Task HeadlessRun_NonInteractive_MissingPrereq_UsesDeterministicExitCode() { var root = CreateTempProject(""" { "name": "headless-demo", "version": "0.1.0", "workflows": [ { "id": "build", "label": "Build", "description": "Build", "group": "Build", "dependsOn": [], "steps": [ { "id": "s1", "label": "dotnet --version", "command": "dotnet", "args": ["--version"], "workingDir": ".", "requires": [ { "tool": "definitely-missing-tool", "installPolicy": "Prompt" } ] } ] } ] } """); var result = await RunSdtAsync(["run", "build", "--json", "--project-root", root, "--non-interactive"]); Assert.Equal(14, result.ExitCode); Assert.Contains("\"stopReason\":\"UserDeclined\"", result.StdOut, StringComparison.Ordinal); Assert.Contains("\"exactFixCommand\"", result.StdOut, StringComparison.Ordinal); } [Fact] public async Task HeadlessDebug_JsonMode_EmitsDebugSummary() { var root = CreateTempProject(""" { "name": "headless-demo", "version": "0.1.0", "workflows": [], "debug": { "profiles": [ { "id": "d1", "label": "Dotnet Info", "type": "dotnet", "command": "dotnet", "args": ["--info"], "workingDir": ".", "env": {}, "requires": [] } ] } } """); var result = await RunSdtAsync(["debug", "d1", "--json", "--project-root", root]); Assert.Equal(0, result.ExitCode); Assert.Contains("\"category\":\"debug\"", result.StdOut, StringComparison.Ordinal); Assert.Contains("\"event_type\":\"DebugStarted\"", result.StdOut, StringComparison.Ordinal); Assert.Contains("\"success\":true", result.StdOut, StringComparison.Ordinal); } [Fact] public async Task HeadlessRun_CommandFailure_MapsToDeterministicExitCode() { var root = CreateTempProject(""" { "name": "headless-demo", "version": "0.1.0", "workflows": [ { "id": "build", "label": "Build", "description": "Build", "group": "Build", "dependsOn": [], "steps": [ { "id": "s1", "label": "dotnet bad command", "command": "dotnet", "args": ["__definitely_invalid_command__"], "workingDir": ".", "requires": [] } ] } ] } """); var result = await RunSdtAsync(["run", "build", "--json", "--project-root", root]); Assert.Equal(12, result.ExitCode); Assert.Contains("\"stopReason\":\"CommandFailed\"", result.StdOut, StringComparison.Ordinal); Assert.Contains("\"retryInstruction\"", result.StdOut, StringComparison.Ordinal); } [Fact] public async Task HeadlessRun_AllowsGuidedAliasSelection() { var root = CreateTempProject(""" { "name": "headless-demo", "version": "0.1.0", "workflows": [ { "id": "build", "label": "Build project", "guidedName": "build app", "aliases": ["app", "compile"], "description": "Build", "group": "Build", "dependsOn": [], "steps": [ { "id": "s1", "label": "dotnet --version", "command": "dotnet", "args": ["--version"], "workingDir": ".", "requires": [] } ] } ] } """); var result = await RunSdtAsync(["run", "app", "--json", "--project-root", root]); Assert.Equal(0, result.ExitCode); Assert.Contains("\"success\":true", result.StdOut, StringComparison.Ordinal); } private static async Task<(int ExitCode, string StdOut, string StdErr)> RunSdtAsync(IReadOnlyList args) { var exe = Path.Combine(AppContext.BaseDirectory, OperatingSystem.IsWindows() ? "sdt.exe" : "sdt"); if (!File.Exists(exe)) throw new InvalidOperationException($"Could not find test runtime executable: {exe}"); var psi = new ProcessStartInfo { FileName = exe, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, }; foreach (var arg in args) psi.ArgumentList.Add(arg); using var process = new Process { StartInfo = psi }; process.Start(); var stdout = await process.StandardOutput.ReadToEndAsync(); var stderr = await process.StandardError.ReadToEndAsync(); await process.WaitForExitAsync(); return (process.ExitCode, stdout, stderr); } private static string CreateTempProject(string devtoolJson) { var root = Path.Combine(Path.GetTempPath(), "sdt-headless-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); File.WriteAllText(Path.Combine(root, "sdtconfig-headless.json"), devtoolJson); return root; } }