317 lines
11 KiB
C#
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"));
|
|
}
|
|
}
|