feat(installer): add SystemCheckPage with blocking-error gating

Adds a new wizard page (positioned right after Welcome, FreshInstall only)
that auto-runs EnvironmentCheckService on entry and shows one row per check
with status icon, localized title/message, and hint+help-link on
failure/unknown. A "Recheck" button re-runs it, guarded against re-entrancy.

IInstallerPage gets a BlocksNavigation default member; WizardViewModel's
Next button now binds to CanGoNext, which the current page can veto (used
here while a check run is in flight or a blocking Error+Failed result is
present — Warnings and Unknown results never block). The summary line names
the blocking checks so a disabled Next is self-explanatory.

Wires up DI for the check pipeline (IProcessRunner, IPortOwnerResolver,
per-run ClaudeCliLookup) and adds the checks.* / installer.systemCheck.*
locale keys in en.json + de.json.

Visual appearance is NOT verified — needs a manual pass in the running
installer.
This commit is contained in:
mika kuns
2026-08-05 20:09:52 +02:00
parent 05be07b28c
commit 5cc1ec98c0
17 changed files with 717 additions and 3 deletions
@@ -0,0 +1,35 @@
using ClaudeDo.Installer.Checks;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Tests.Pages.SystemCheckPage;
internal sealed class FakeEnvironmentCheck : IEnvironmentCheck
{
private readonly Func<InstallContext, CancellationToken, Task<CheckResult>> _run;
public FakeEnvironmentCheck(string id, CheckSeverity severity, Func<InstallContext, CancellationToken, Task<CheckResult>> run)
{
Id = id;
Severity = severity;
_run = run;
}
public static FakeEnvironmentCheck Ok(string id, CheckSeverity severity = CheckSeverity.Error) =>
new(id, severity, (_, _) => Task.FromResult(CheckResult.Ok(id, severity, $"checks.{id}.title", "ok")));
public static FakeEnvironmentCheck Fail(string id, CheckSeverity severity) =>
new(id, severity, (_, _) => Task.FromResult(CheckResult.Fail(id, severity, $"checks.{id}.title", "failed")));
public static FakeEnvironmentCheck Unknown(string id, CheckSeverity severity = CheckSeverity.Warning) =>
new(id, severity, (_, _) => Task.FromResult(CheckResult.Unknown(id, severity, $"checks.{id}.title", "unknown")));
public string Id { get; }
public CheckSeverity Severity { get; }
public int CallCount { get; private set; }
public Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
{
CallCount++;
return _run(ctx, ct);
}
}
@@ -0,0 +1,172 @@
using ClaudeDo.Installer.Checks;
using ClaudeDo.Installer.Core;
using Vm = ClaudeDo.Installer.Pages.SystemCheckPage.SystemCheckPageViewModel;
namespace ClaudeDo.Installer.Tests.Pages.SystemCheckPage;
public sealed class SystemCheckPageViewModelTests
{
private static Vm CreateViewModel(Func<EnvironmentCheckService> factory) =>
new(new InstallContext(), factory);
private static Func<EnvironmentCheckService> FactoryFor(params IEnvironmentCheck[] checks) =>
() => new EnvironmentCheckService(checks);
[Fact]
public async Task Entering_the_page_runs_checks_exactly_once()
{
var check = FakeEnvironmentCheck.Ok("a");
var vm = CreateViewModel(FactoryFor(check));
await vm.LoadAsync();
Assert.Equal(1, check.CallCount);
Assert.True(vm.HasRun);
Assert.Single(vm.Rows);
}
[Fact]
public async Task Entering_the_page_a_second_time_does_not_run_checks_again()
{
var check = FakeEnvironmentCheck.Ok("a");
var vm = CreateViewModel(FactoryFor(check));
await vm.LoadAsync();
await vm.LoadAsync();
Assert.Equal(1, check.CallCount);
}
[Fact]
public async Task Recheck_while_a_run_is_in_progress_does_not_start_a_second_run()
{
var gate = new TaskCompletionSource();
var slow = new FakeEnvironmentCheck("slow", CheckSeverity.Error, async (_, _) =>
{
await gate.Task;
return CheckResult.Ok("slow", CheckSeverity.Error, "checks.slow.title", "ok");
});
var factoryCalls = 0;
var vm = CreateViewModel(() =>
{
factoryCalls++;
return new EnvironmentCheckService(new IEnvironmentCheck[] { slow });
});
var firstRun = vm.RunChecksCommand.ExecuteAsync(null);
Assert.True(vm.IsRunning);
await vm.RunChecksCommand.ExecuteAsync(null); // recheck while the first run is still in flight
Assert.Equal(1, factoryCalls);
Assert.Equal(1, slow.CallCount);
gate.SetResult();
await firstRun;
Assert.False(vm.IsRunning);
}
[Fact]
public async Task Blocking_error_blocks_navigation_and_clears_after_a_clean_recheck()
{
var callNumber = 0;
var vm = CreateViewModel(() =>
{
callNumber++;
IEnvironmentCheck check = callNumber == 1
? FakeEnvironmentCheck.Fail("git", CheckSeverity.Error)
: FakeEnvironmentCheck.Ok("git");
return new EnvironmentCheckService(new[] { check });
});
await vm.LoadAsync();
Assert.True(vm.HasBlockingError);
Assert.True(vm.BlocksNavigation);
await vm.RunChecksCommand.ExecuteAsync(null);
Assert.False(vm.HasBlockingError);
Assert.False(vm.BlocksNavigation);
}
[Fact]
public async Task Only_warnings_does_not_block_navigation()
{
var warning = FakeEnvironmentCheck.Fail("gitIdentity", CheckSeverity.Warning);
var vm = CreateViewModel(FactoryFor(warning));
await vm.LoadAsync();
Assert.False(vm.HasBlockingError);
Assert.False(vm.BlocksNavigation);
}
[Fact]
public async Task Only_unknown_results_do_not_block_navigation()
{
var unknown = FakeEnvironmentCheck.Unknown("claudeAuth", CheckSeverity.Error);
var vm = CreateViewModel(FactoryFor(unknown));
await vm.LoadAsync();
Assert.False(vm.HasBlockingError);
Assert.False(vm.BlocksNavigation);
}
[Fact]
public async Task Summary_reports_all_ok()
{
var vm = CreateViewModel(FactoryFor(FakeEnvironmentCheck.Ok("git")));
await vm.LoadAsync();
Assert.Equal("Everything looks good.", vm.Summary);
}
[Fact]
public async Task Summary_reports_warning_count()
{
var vm = CreateViewModel(FactoryFor(
FakeEnvironmentCheck.Fail("gitIdentity", CheckSeverity.Warning),
FakeEnvironmentCheck.Fail("ports", CheckSeverity.Warning)));
await vm.LoadAsync();
Assert.Equal("2 warning(s) found.", vm.Summary);
}
[Fact]
public async Task Summary_reports_blocking_errors_by_name()
{
var vm = CreateViewModel(FactoryFor(
FakeEnvironmentCheck.Fail("git", CheckSeverity.Error),
FakeEnvironmentCheck.Fail("writeAccess", CheckSeverity.Error)));
await vm.LoadAsync();
Assert.Equal("2 problem(s) must be fixed: checks.git.title, checks.writeAccess.title", vm.Summary);
}
[Fact]
public async Task Summary_reports_running_state_while_a_check_is_in_flight()
{
var gate = new TaskCompletionSource();
var slow = new FakeEnvironmentCheck("slow", CheckSeverity.Error, async (_, _) =>
{
await gate.Task;
return CheckResult.Ok("slow", CheckSeverity.Error, "checks.slow.title", "ok");
});
var vm = CreateViewModel(FactoryFor(slow));
var run = vm.RunChecksCommand.ExecuteAsync(null);
Assert.True(vm.IsRunning);
Assert.Equal("Checking your system…", vm.Summary);
gate.SetResult();
await run;
}
}