using Sdt.Config; using Sdt.Core; using Sdt.Runner; using Xunit; namespace DevTool.Tests; public sealed class WorkflowExecutorTests { [Fact] public async Task MissingPrereq_UserDeclines_ReturnsUserDeclined() { var executor = new WorkflowExecutor( new WorkflowPlanner(), new FakeProbeService(isAvailable: false), new FakeInstallerService(success: true), new FakeActionRunner(success: true), new RequirementResolver()); var cfg = new DevToolConfig(); var wf = BuildSingleStepWorkflow("w", "dotnet"); var map = new Dictionary(StringComparer.OrdinalIgnoreCase) { [wf.Id] = wf }; var result = await executor.ExecuteAsync( wf, map, cfg, ".", (_, _) => Task.FromResult(false), (_, _) => { }); Assert.False(result.Success); Assert.Equal(ExecutionStopReason.UserDeclined, result.StopReason); } [Fact] public async Task MissingPrereq_InstallFails_ReturnsInstallFailed() { var executor = new WorkflowExecutor( new WorkflowPlanner(), new FakeProbeService(isAvailable: false), new FakeInstallerService(success: false), new FakeActionRunner(success: true), new RequirementResolver()); var cfg = new DevToolConfig(); var wf = BuildSingleStepWorkflow("w", "dotnet"); var map = new Dictionary(StringComparer.OrdinalIgnoreCase) { [wf.Id] = wf }; var result = await executor.ExecuteAsync( wf, map, cfg, ".", (_, _) => Task.FromResult(true), (_, _) => { }); Assert.False(result.Success); Assert.Equal(ExecutionStopReason.InstallFailed, result.StopReason); } [Fact] public async Task StepFailure_StopsImmediately() { var executor = new WorkflowExecutor( new WorkflowPlanner(), new FakeProbeService(isAvailable: true), new FakeInstallerService(success: true), new FakeActionRunner(success: false), new RequirementResolver()); var wf = new WorkflowDefinition { Id = "w", Label = "W", Steps = [ new WorkflowStep { Id = "s1", Label = "S1", Command = "dotnet", Args = ["build"] }, new WorkflowStep { Id = "s2", Label = "S2", Command = "dotnet", Args = ["build"] }, ] }; var map = new Dictionary(StringComparer.OrdinalIgnoreCase) { [wf.Id] = wf }; var result = await executor.ExecuteAsync( wf, map, new DevToolConfig(), ".", (_, _) => Task.FromResult(true), (_, _) => { }); Assert.False(result.Success); Assert.Equal(ExecutionStopReason.CommandFailed, result.StopReason); Assert.Single(result.Steps); } [Fact] public async Task LegacyPwshScriptStep_MissingPrereq_PromptsBeforeRun() { var executor = new WorkflowExecutor( new WorkflowPlanner(), new FakeProbeService(isAvailable: false), new FakeInstallerService(success: true), new FakeActionRunner(success: true), new RequirementResolver()); var wf = new WorkflowDefinition { Id = "w", Label = "W", Steps = [ new WorkflowStep { Id = "ps1", Label = "Legacy PS1", Command = "pwsh", Args = ["-NoProfile", "-File", "scripts/publish-app.ps1", "-Target", "web"], } ] }; var map = new Dictionary(StringComparer.OrdinalIgnoreCase) { [wf.Id] = wf }; var result = await executor.ExecuteAsync( wf, map, new DevToolConfig(), ".", (_, _) => Task.FromResult(false), (_, _) => { }); Assert.False(result.Success); Assert.Equal(ExecutionStopReason.UserDeclined, result.StopReason); } [Fact] public async Task TauriBuild_DoesNotRequireGlobalTauri_WhenNodeNpmCargoAvailable() { var executor = new WorkflowExecutor( new WorkflowPlanner(), new ConditionalProbeService(), new FakeInstallerService(success: true), new FakeActionRunner(success: true), new RequirementResolver()); var wf = new WorkflowDefinition { Id = "w", Label = "W", Steps = [ new WorkflowStep { Id = "tauri", Label = "Tauri Build", Action = "tauri-build", } ] }; var map = new Dictionary(StringComparer.OrdinalIgnoreCase) { [wf.Id] = wf }; var result = await executor.ExecuteAsync( wf, map, new DevToolConfig(), ".", (_, _) => Task.FromResult(true), (_, _) => { }); Assert.True(result.Success); Assert.Null(result.StopReason); Assert.Single(result.Steps); } [Fact] public async Task MissingPrereq_EmitsProbeDiagnosticsToOutput() { var executor = new WorkflowExecutor( new WorkflowPlanner(), new DetailedProbeService(), new FakeInstallerService(success: true), new FakeActionRunner(success: true), new RequirementResolver()); var wf = BuildSingleStepWorkflow("w", "dotnet"); var map = new Dictionary(StringComparer.OrdinalIgnoreCase) { [wf.Id] = wf }; var lines = new List(); var result = await executor.ExecuteAsync( wf, map, new DevToolConfig(), ".", (_, _) => Task.FromResult(false), (line, _) => lines.Add(line)); Assert.False(result.Success); Assert.Contains(lines, l => l.Contains("Probe detail [dotnet]", StringComparison.OrdinalIgnoreCase)); } [Fact] public async Task ExecuteAsync_EmitsRunEvents_ForStepLifecycle() { var executor = new WorkflowExecutor( new WorkflowPlanner(), new FakeProbeService(isAvailable: true), new FakeInstallerService(success: true), new FakeActionRunner(success: true), new RequirementResolver()); var wf = BuildSingleStepWorkflow("w", "dotnet"); var map = new Dictionary(StringComparer.OrdinalIgnoreCase) { [wf.Id] = wf }; var events = new List(); var result = await executor.ExecuteAsync( wf, map, new DevToolConfig(), ".", (_, _) => Task.FromResult(true), (_, _) => { }, events.Add); Assert.True(result.Success); Assert.Contains(events, e => e.Type == RunEventType.WorkflowStarted); Assert.Contains(events, e => e.Type == RunEventType.WorkflowStepStarted); Assert.Contains(events, e => e.Type == RunEventType.WorkflowStepCompleted); Assert.Contains(events, e => e.Type == RunEventType.WorkflowCompleted && e.Success == true); } [Fact] public void AggregatorWorkflow_ExecutesDependenciesOnly() { var planner = new WorkflowPlanner(); var dep = new WorkflowDefinition { Id = "dep", Label = "Dependency", Steps = [new WorkflowStep { Id = "s", Label = "S", Command = "dotnet", Args = ["build"] }] }; var agg = new WorkflowDefinition { Id = "agg", Label = "Aggregator", DependsOn = ["dep"], Steps = [] }; var map = new Dictionary(StringComparer.OrdinalIgnoreCase) { [dep.Id] = dep, [agg.Id] = agg }; var plan = planner.ResolvePlan(agg, map); Assert.Single(plan); Assert.Equal("dep", plan[0].Id); } private static WorkflowDefinition BuildSingleStepWorkflow(string id, string tool) { return new WorkflowDefinition { Id = id, Label = id, Steps = [ new WorkflowStep { Id = "step", Label = "step", Command = tool, Args = ["--version"], Requires = [new ToolRequirement { Tool = tool, InstallPolicy = InstallPolicy.Prompt }], } ] }; } private sealed class FakeProbeService(bool isAvailable) : IToolProbe { public Task ProbeAsync( string tool, string projectRoot, DevToolConfig? config = null, IReadOnlyDictionary? envOverrides = null, CancellationToken cancellationToken = default) => Task.FromResult(new ProbeResult(tool, isAvailable, Version: isAvailable ? "1.0.0" : null)); } private sealed class FakeInstallerService(bool success) : IPrereqInstaller { public Task GetInstallPlanAsync( string tool, string projectRoot, DevToolConfig? config = null, CancellationToken cancellationToken = default) => Task.FromResult(new InstallPlan(tool, Supported: true, "test", [new InstallCommand("echo", ["ok"])])); public Task RunInstallAsync( InstallCommand command, string projectRoot, Action onOutput, IReadOnlyDictionary? envOverrides = null, CancellationToken cancellationToken = default) => Task.FromResult(new RunResult(success ? 0 : 1, TimeSpan.FromMilliseconds(5))); } private sealed class FakeActionRunner(bool success) : IActionRunner { public Task RunStepAsync( WorkflowStep step, string projectRoot, Action onOutput, IReadOnlyDictionary? envOverrides = null, CancellationToken cancellationToken = default) => Task.FromResult(new RunResult(success ? 0 : 2, TimeSpan.FromMilliseconds(10))); } private sealed class ConditionalProbeService : IToolProbe { public Task ProbeAsync( string tool, string projectRoot, DevToolConfig? config = null, IReadOnlyDictionary? envOverrides = null, CancellationToken cancellationToken = default) { var available = tool.ToLowerInvariant() switch { "node" => true, "npm" => true, "cargo" => true, "tauri" => false, _ => true }; return Task.FromResult(new ProbeResult(tool, available, Version: available ? "1.0.0" : null)); } } private sealed class DetailedProbeService : IToolProbe { public Task ProbeAsync( string tool, string projectRoot, DevToolConfig? config = null, IReadOnlyDictionary? envOverrides = null, CancellationToken cancellationToken = default) => Task.FromResult(new ProbeResult(tool, false, Details: "Fallback: unresolved command")); } }