SDT/tests/DevTool.Tests/WorkflowExecutorTests.cs
2026-03-01 20:52:56 -06:00

317 lines
11 KiB
C#

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<string, WorkflowDefinition>(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<string, WorkflowDefinition>(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<string, WorkflowDefinition>(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<string, WorkflowDefinition>(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<string, WorkflowDefinition>(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<string, WorkflowDefinition>(StringComparer.OrdinalIgnoreCase) { [wf.Id] = wf };
var lines = new List<string>();
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<string, WorkflowDefinition>(StringComparer.OrdinalIgnoreCase) { [wf.Id] = wf };
var events = new List<RunEvent>();
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<string, WorkflowDefinition>(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<ProbeResult> ProbeAsync(
string tool,
string projectRoot,
DevToolConfig? config = null,
IReadOnlyDictionary<string, string>? 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<InstallPlan> 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<RunResult> RunInstallAsync(
InstallCommand command,
string projectRoot,
Action<string, bool> onOutput,
IReadOnlyDictionary<string, string>? envOverrides = null,
CancellationToken cancellationToken = default)
=> Task.FromResult(new RunResult(success ? 0 : 1, TimeSpan.FromMilliseconds(5)));
}
private sealed class FakeActionRunner(bool success) : IActionRunner
{
public Task<RunResult> RunStepAsync(
WorkflowStep step,
string projectRoot,
Action<string, bool> onOutput,
IReadOnlyDictionary<string, string>? envOverrides = null,
CancellationToken cancellationToken = default)
=> Task.FromResult(new RunResult(success ? 0 : 2, TimeSpan.FromMilliseconds(10)));
}
private sealed class ConditionalProbeService : IToolProbe
{
public Task<ProbeResult> ProbeAsync(
string tool,
string projectRoot,
DevToolConfig? config = null,
IReadOnlyDictionary<string, string>? 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<ProbeResult> ProbeAsync(
string tool,
string projectRoot,
DevToolConfig? config = null,
IReadOnlyDictionary<string, string>? envOverrides = null,
CancellationToken cancellationToken = default)
=> Task.FromResult(new ProbeResult(tool, false, Details: "Fallback: unresolved command"));
}
}