Implements IEnvironmentCheck for the four checks derivable without a Claude CLI probe: - GitCheck (Error) - resolves git via ExecutableResolver (handles .cmd shims), parses `git --version`. - GitIdentityCheck (Warning) - user.name/user.email presence; Unknown (not Failed) if git itself is missing, so it doesn't duplicate GitCheck's failure. - PortCheck (Warning) - loopback bind probe for SignalRPort/ExternalMcpPort; resolves the owning process via a new NetstatPortOwnerResolver and treats a port held by the running ClaudeDo.Worker (update/repair case) as Ok. Both ports are configurable, hence a warning. - WriteAccessCheck (Error) - create+delete a probe file in InstallDirectory and ~/.todo-app (walking up to the first existing parent), not an ACL read (ACLs lie on virtualized paths). Process calls go through a new IProcessRunner wrapping the existing static ProcessRunner, so checks are fakeable in tests instead of spawning real processes. DotnetRuntimeCheck was intentionally not added: per docs/explore-notes/installer-preflight.md, App/Worker publish self-contained (no preinstalled runtime needed), and the Installer's own .NET 8 Desktop Runtime requirement is self-proving - a framework-dependent apphost can't reach managed code at all if that runtime is missing, so a check running from inside the process can never observe a failure. Brings in two prerequisite commits this task builds on that hadn't reached this branch yet: the IEnvironmentCheck/EnvironmentCheckService scaffolding and the installer-preflight.md research note.
69 lines
2.3 KiB
C#
69 lines
2.3 KiB
C#
using System.Net;
|
|
using System.Net.Sockets;
|
|
using ClaudeDo.Installer.Checks;
|
|
using ClaudeDo.Installer.Checks.Interfaces;
|
|
using ClaudeDo.Installer.Core;
|
|
|
|
namespace ClaudeDo.Installer.Tests.Checks;
|
|
|
|
public sealed class PortCheckTests
|
|
{
|
|
private sealed class FakeOwnerResolver : IPortOwnerResolver
|
|
{
|
|
private readonly string? _owner;
|
|
public FakeOwnerResolver(string? owner) => _owner = owner;
|
|
public Task<string?> FindOwningProcessNameAsync(int port, CancellationToken ct) => Task.FromResult(_owner);
|
|
}
|
|
|
|
private static int GetFreePort()
|
|
{
|
|
var listener = new TcpListener(IPAddress.Loopback, 0);
|
|
listener.Start();
|
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
|
listener.Stop();
|
|
return port;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Both_ports_free_reports_ok()
|
|
{
|
|
var ctx = new InstallContext { SignalRPort = GetFreePort(), ExternalMcpPort = GetFreePort() };
|
|
var check = new PortCheck(new FakeOwnerResolver(null));
|
|
|
|
var result = await check.RunAsync(ctx, CancellationToken.None);
|
|
|
|
Assert.Equal(CheckStatus.Ok, result.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Port_in_use_by_other_process_reports_failed()
|
|
{
|
|
var port = GetFreePort();
|
|
using var occupying = new TcpListener(IPAddress.Loopback, port);
|
|
occupying.Start();
|
|
var ctx = new InstallContext { SignalRPort = port, ExternalMcpPort = GetFreePort() };
|
|
var check = new PortCheck(new FakeOwnerResolver("SomeOtherApp"));
|
|
|
|
var result = await check.RunAsync(ctx, CancellationToken.None);
|
|
|
|
Assert.Equal(CheckStatus.Failed, result.Status);
|
|
Assert.Equal(CheckSeverity.Warning, result.Severity);
|
|
Assert.Contains("SomeOtherApp", result.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Port_in_use_by_own_worker_reports_ok()
|
|
{
|
|
var port = GetFreePort();
|
|
using var occupying = new TcpListener(IPAddress.Loopback, port);
|
|
occupying.Start();
|
|
var ctx = new InstallContext { SignalRPort = port, ExternalMcpPort = GetFreePort() };
|
|
var check = new PortCheck(new FakeOwnerResolver("ClaudeDo.Worker"));
|
|
|
|
var result = await check.RunAsync(ctx, CancellationToken.None);
|
|
|
|
Assert.Equal(CheckStatus.Ok, result.Status);
|
|
Assert.Contains("ClaudeDo Worker", result.Message);
|
|
}
|
|
}
|