feat(installer): add check abstraction and EnvironmentCheckService
Scaffolding for environment checks: IEnvironmentCheck, CheckResult/ CheckSeverity/CheckStatus, and EnvironmentCheckService that runs checks in parallel while preserving input order, never throws (a failing check becomes Unknown), and reports HasBlockingError only for Error+Failed.
This commit is contained in:
@@ -0,0 +1,25 @@
|
|||||||
|
namespace ClaudeDo.Installer.Checks;
|
||||||
|
|
||||||
|
public enum CheckSeverity { Warning, Error }
|
||||||
|
|
||||||
|
public enum CheckStatus { Ok, Failed, Unknown }
|
||||||
|
|
||||||
|
public sealed record CheckResult(
|
||||||
|
string Id,
|
||||||
|
CheckSeverity Severity,
|
||||||
|
CheckStatus Status,
|
||||||
|
string TitleKey,
|
||||||
|
string Message,
|
||||||
|
string? HintKey,
|
||||||
|
string? HelpUrl,
|
||||||
|
string? Detail)
|
||||||
|
{
|
||||||
|
public static CheckResult Ok(string id, CheckSeverity severity, string titleKey, string message, string? detail = null) =>
|
||||||
|
new(id, severity, CheckStatus.Ok, titleKey, message, HintKey: null, HelpUrl: null, detail);
|
||||||
|
|
||||||
|
public static CheckResult Fail(string id, CheckSeverity severity, string titleKey, string message, string? hintKey = null, string? helpUrl = null, string? detail = null) =>
|
||||||
|
new(id, severity, CheckStatus.Failed, titleKey, message, hintKey, helpUrl, detail);
|
||||||
|
|
||||||
|
public static CheckResult Unknown(string id, CheckSeverity severity, string titleKey, string message, string? detail = null) =>
|
||||||
|
new(id, severity, CheckStatus.Unknown, titleKey, message, HintKey: null, HelpUrl: null, detail);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using ClaudeDo.Installer.Core;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Installer.Checks;
|
||||||
|
|
||||||
|
public sealed record EnvironmentCheckReport(IReadOnlyList<CheckResult> Results)
|
||||||
|
{
|
||||||
|
public bool HasBlockingError => Results.Any(r => r.Severity == CheckSeverity.Error && r.Status == CheckStatus.Failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class EnvironmentCheckService
|
||||||
|
{
|
||||||
|
private readonly IReadOnlyList<IEnvironmentCheck> _checks;
|
||||||
|
|
||||||
|
public EnvironmentCheckService(IEnumerable<IEnvironmentCheck> checks)
|
||||||
|
{
|
||||||
|
_checks = checks.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<EnvironmentCheckReport> RunAllAsync(InstallContext ctx, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var tasks = _checks.Select(check => RunSafeAsync(check, ctx, ct)).ToArray();
|
||||||
|
var results = await Task.WhenAll(tasks);
|
||||||
|
return new EnvironmentCheckReport(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<CheckResult> RunSafeAsync(IEnvironmentCheck check, InstallContext ctx, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await check.RunAsync(ctx, ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return CheckResult.Unknown(check.Id, check.Severity, string.Empty, ex.Message, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using ClaudeDo.Installer.Core;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Installer.Checks;
|
||||||
|
|
||||||
|
public interface IEnvironmentCheck
|
||||||
|
{
|
||||||
|
string Id { get; }
|
||||||
|
CheckSeverity Severity { get; }
|
||||||
|
Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
using ClaudeDo.Installer.Checks;
|
||||||
|
using ClaudeDo.Installer.Core;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Installer.Tests;
|
||||||
|
|
||||||
|
public sealed class EnvironmentCheckServiceTests
|
||||||
|
{
|
||||||
|
private sealed class FakeCheck : IEnvironmentCheck
|
||||||
|
{
|
||||||
|
private readonly Func<InstallContext, CancellationToken, Task<CheckResult>>? _run;
|
||||||
|
|
||||||
|
public FakeCheck(string id, CheckSeverity severity, Func<InstallContext, CancellationToken, Task<CheckResult>>? run = null)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
Severity = severity;
|
||||||
|
_run = run;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Id { get; }
|
||||||
|
public CheckSeverity Severity { get; }
|
||||||
|
|
||||||
|
public Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct) =>
|
||||||
|
_run is null
|
||||||
|
? Task.FromResult(CheckResult.Ok(Id, Severity, "title", "ok"))
|
||||||
|
: _run(ctx, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ThrowingCheck : IEnvironmentCheck
|
||||||
|
{
|
||||||
|
public string Id => "throwing";
|
||||||
|
public CheckSeverity Severity => CheckSeverity.Error;
|
||||||
|
|
||||||
|
public Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct) =>
|
||||||
|
throw new InvalidOperationException("boom");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAllAsync_PreservesInputOrder_RegardlessOfCompletionOrder()
|
||||||
|
{
|
||||||
|
var slow = new FakeCheck("slow", CheckSeverity.Warning, async (_, ct) =>
|
||||||
|
{
|
||||||
|
await Task.Delay(50, ct);
|
||||||
|
return CheckResult.Ok("slow", CheckSeverity.Warning, "title", "ok");
|
||||||
|
});
|
||||||
|
var fast = new FakeCheck("fast", CheckSeverity.Warning);
|
||||||
|
var service = new EnvironmentCheckService(new IEnvironmentCheck[] { slow, fast });
|
||||||
|
|
||||||
|
var report = await service.RunAllAsync(new InstallContext(), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "slow", "fast" }, report.Results.Select(r => r.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HasBlockingError_True_WhenErrorAndFailed()
|
||||||
|
{
|
||||||
|
var check = new FakeCheck("id", CheckSeverity.Error, (_, _) =>
|
||||||
|
Task.FromResult(CheckResult.Fail("id", CheckSeverity.Error, "title", "bad")));
|
||||||
|
var service = new EnvironmentCheckService(new IEnvironmentCheck[] { check });
|
||||||
|
|
||||||
|
var report = await service.RunAllAsync(new InstallContext(), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(report.HasBlockingError);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HasBlockingError_False_WhenWarningAndFailed()
|
||||||
|
{
|
||||||
|
var check = new FakeCheck("id", CheckSeverity.Warning, (_, _) =>
|
||||||
|
Task.FromResult(CheckResult.Fail("id", CheckSeverity.Warning, "title", "bad")));
|
||||||
|
var service = new EnvironmentCheckService(new IEnvironmentCheck[] { check });
|
||||||
|
|
||||||
|
var report = await service.RunAllAsync(new InstallContext(), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(report.HasBlockingError);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HasBlockingError_False_WhenErrorAndUnknown()
|
||||||
|
{
|
||||||
|
var check = new FakeCheck("id", CheckSeverity.Error, (_, _) =>
|
||||||
|
Task.FromResult(CheckResult.Unknown("id", CheckSeverity.Error, "title", "dunno")));
|
||||||
|
var service = new EnvironmentCheckService(new IEnvironmentCheck[] { check });
|
||||||
|
|
||||||
|
var report = await service.RunAllAsync(new InstallContext(), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(report.HasBlockingError);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAllAsync_ThrowingCheck_BecomesUnknown_OthersStillRun_ServiceDoesNotThrow()
|
||||||
|
{
|
||||||
|
var throwing = new ThrowingCheck();
|
||||||
|
var ok = new FakeCheck("ok", CheckSeverity.Warning);
|
||||||
|
var service = new EnvironmentCheckService(new IEnvironmentCheck[] { throwing, ok });
|
||||||
|
|
||||||
|
var report = await service.RunAllAsync(new InstallContext(), CancellationToken.None);
|
||||||
|
|
||||||
|
var throwingResult = report.Results[0];
|
||||||
|
Assert.Equal(CheckStatus.Unknown, throwingResult.Status);
|
||||||
|
Assert.Contains("boom", throwingResult.Detail);
|
||||||
|
|
||||||
|
var okResult = report.Results[1];
|
||||||
|
Assert.Equal(CheckStatus.Ok, okResult.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAllAsync_PassesCancellationToken_ToChecks()
|
||||||
|
{
|
||||||
|
CancellationToken? seen = null;
|
||||||
|
var check = new FakeCheck("id", CheckSeverity.Warning, (_, ct) =>
|
||||||
|
{
|
||||||
|
seen = ct;
|
||||||
|
return Task.FromResult(CheckResult.Ok("id", CheckSeverity.Warning, "title", "ok"));
|
||||||
|
});
|
||||||
|
var service = new EnvironmentCheckService(new IEnvironmentCheck[] { check });
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
await service.RunAllAsync(new InstallContext(), cts.Token);
|
||||||
|
|
||||||
|
Assert.Equal(cts.Token, seen);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user