Merge subtask

This commit is contained in:
mika kuns
2026-08-06 07:54:20 +02:00
17 changed files with 677 additions and 0 deletions
@@ -0,0 +1,22 @@
using ClaudeDo.Installer.Core.Interfaces;
namespace ClaudeDo.Installer.Tests.Checks;
internal sealed class FakeProcessRunner : IProcessRunner
{
private readonly Queue<(int ExitCode, string Output)> _responses;
public FakeProcessRunner(params (int ExitCode, string Output)[] responses)
{
_responses = new Queue<(int, string)>(responses);
}
public List<(string FileName, string Arguments)> Calls { get; } = new();
public Task<(int ExitCode, string Output)> RunAsync(string fileName, string arguments, string? workingDirectory, CancellationToken ct)
{
Calls.Add((fileName, arguments));
var response = _responses.Count > 0 ? _responses.Dequeue() : (0, "");
return Task.FromResult(response);
}
}
@@ -0,0 +1,66 @@
using ClaudeDo.Installer.Checks;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Tests.Checks;
public sealed class GitCheckTests : IDisposable
{
private readonly string _dir;
public GitCheckTests()
{
_dir = Path.Combine(Path.GetTempPath(), $"cdgitcheck_{Guid.NewGuid():N}");
Directory.CreateDirectory(_dir);
}
public void Dispose()
{
try { Directory.Delete(_dir, recursive: true); } catch { }
}
private string MakeGitExe()
{
var path = Path.Combine(_dir, "git.exe");
File.WriteAllText(path, "");
return path;
}
[Fact]
public async Task Git_present_with_parseable_version_reports_ok()
{
MakeGitExe();
var runner = new FakeProcessRunner((0, "git version 2.43.0.windows.1\n"));
var check = new GitCheck(runner, pathOverride: _dir, pathExtOverride: ".exe");
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
Assert.Equal(CheckStatus.Ok, result.Status);
Assert.Contains("2.43.0", result.Message);
Assert.Contains(_dir, result.Message);
}
[Fact]
public async Task Git_not_found_on_path_reports_failed()
{
var runner = new FakeProcessRunner();
var check = new GitCheck(runner, pathOverride: _dir, pathExtOverride: ".exe");
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
Assert.Equal(CheckStatus.Failed, result.Status);
Assert.Equal(CheckSeverity.Error, result.Severity);
Assert.Empty(runner.Calls);
}
[Fact]
public async Task Git_nonzero_exit_reports_failed()
{
MakeGitExe();
var runner = new FakeProcessRunner((1, "some error"));
var check = new GitCheck(runner, pathOverride: _dir, pathExtOverride: ".exe");
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
Assert.Equal(CheckStatus.Failed, result.Status);
}
}
@@ -0,0 +1,88 @@
using ClaudeDo.Installer.Checks;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Tests.Checks;
public sealed class GitIdentityCheckTests : IDisposable
{
private readonly string _dir;
public GitIdentityCheckTests()
{
_dir = Path.Combine(Path.GetTempPath(), $"cdgitid_{Guid.NewGuid():N}");
Directory.CreateDirectory(_dir);
}
public void Dispose()
{
try { Directory.Delete(_dir, recursive: true); } catch { }
}
private void MakeGitExe() => File.WriteAllText(Path.Combine(_dir, "git.exe"), "");
[Fact]
public async Task Both_set_reports_ok()
{
MakeGitExe();
var runner = new FakeProcessRunner((0, "Mika Kuns\n"), (0, "mika@example.com\n"));
var check = new GitIdentityCheck(runner, pathOverride: _dir, pathExtOverride: ".exe");
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
Assert.Equal(CheckStatus.Ok, result.Status);
}
[Fact]
public async Task Only_name_set_reports_failed_naming_email()
{
MakeGitExe();
var runner = new FakeProcessRunner((0, "Mika Kuns\n"), (1, ""));
var check = new GitIdentityCheck(runner, pathOverride: _dir, pathExtOverride: ".exe");
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
Assert.Equal(CheckStatus.Failed, result.Status);
Assert.Contains("user.email", result.Message);
Assert.DoesNotContain("user.name", result.Message);
}
[Fact]
public async Task Only_email_set_reports_failed_naming_name()
{
MakeGitExe();
var runner = new FakeProcessRunner((1, ""), (0, "mika@example.com\n"));
var check = new GitIdentityCheck(runner, pathOverride: _dir, pathExtOverride: ".exe");
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
Assert.Equal(CheckStatus.Failed, result.Status);
Assert.Contains("user.name", result.Message);
Assert.DoesNotContain("user.email", result.Message);
}
[Fact]
public async Task Neither_set_reports_failed_naming_both()
{
MakeGitExe();
var runner = new FakeProcessRunner((1, ""), (1, ""));
var check = new GitIdentityCheck(runner, pathOverride: _dir, pathExtOverride: ".exe");
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
Assert.Equal(CheckStatus.Failed, result.Status);
Assert.Contains("user.name", result.Message);
Assert.Contains("user.email", result.Message);
}
[Fact]
public async Task Git_missing_reports_unknown_not_failed()
{
var runner = new FakeProcessRunner();
var check = new GitIdentityCheck(runner, pathOverride: _dir, pathExtOverride: ".exe");
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
Assert.Equal(CheckStatus.Unknown, result.Status);
Assert.Empty(runner.Calls);
}
}
@@ -0,0 +1,68 @@
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);
}
}
@@ -0,0 +1,80 @@
using System.Security.AccessControl;
using System.Security.Principal;
using ClaudeDo.Installer.Checks;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Tests.Checks;
public sealed class WriteAccessCheckTests : IDisposable
{
private readonly List<string> _cleanupDirs = new();
private string TempDir()
{
var dir = Path.Combine(Path.GetTempPath(), $"cdwriteaccess_{Guid.NewGuid():N}");
Directory.CreateDirectory(dir);
_cleanupDirs.Add(dir);
return dir;
}
public void Dispose()
{
foreach (var dir in _cleanupDirs)
{
try { Directory.Delete(dir, recursive: true); } catch { }
}
}
[Fact]
public async Task Writable_directory_reports_ok()
{
var dir = TempDir();
var check = new WriteAccessCheck();
var result = await check.RunAsync(new InstallContext { InstallDirectory = dir }, CancellationToken.None);
Assert.Equal(CheckStatus.Ok, result.Status);
}
[Fact]
public async Task NonExistent_path_under_writable_parent_reports_ok()
{
var parent = TempDir();
var target = Path.Combine(parent, "does", "not", "exist");
var check = new WriteAccessCheck();
var result = await check.RunAsync(new InstallContext { InstallDirectory = target }, CancellationToken.None);
Assert.Equal(CheckStatus.Ok, result.Status);
}
[Fact]
public async Task Unwritable_directory_reports_failed_with_no_leftover_file()
{
var dir = TempDir();
var dirInfo = new DirectoryInfo(dir);
var identity = WindowsIdentity.GetCurrent().User!;
var denyRule = new FileSystemAccessRule(identity, FileSystemRights.CreateFiles | FileSystemRights.WriteData, AccessControlType.Deny);
var acl = dirInfo.GetAccessControl();
acl.AddAccessRule(denyRule);
dirInfo.SetAccessControl(acl);
try
{
var check = new WriteAccessCheck();
var result = await check.RunAsync(new InstallContext { InstallDirectory = dir }, CancellationToken.None);
Assert.Equal(CheckStatus.Failed, result.Status);
Assert.Equal(CheckSeverity.Error, result.Severity);
Assert.Contains(dir, result.Message);
Assert.Empty(Directory.GetFiles(dir));
}
finally
{
acl.RemoveAccessRule(denyRule);
dirInfo.SetAccessControl(acl);
}
}
}
@@ -14,6 +14,7 @@
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="System.IO.FileSystem.AccessControl" Version="5.0.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>