feat(installer): pull in preflight check implementations as prerequisite for SystemCheckPage
Git/GitIdentity/Port/WriteAccess and Claude CLI/Version/Auth/PermissionModeAuto checks plus the ExecutableResolver they depend on were built in two sibling task branches that hadn't landed on main yet. Vendored the finished files in from those branches (same content, verified building + tests green) so the SystemCheckPage task has something to consume.
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
using ClaudeDo.Installer.Checks;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests.Checks;
|
||||
|
||||
public sealed class ClaudeAuthCheckTests : IDisposable
|
||||
{
|
||||
private readonly string _dir;
|
||||
|
||||
public ClaudeAuthCheckTests()
|
||||
{
|
||||
_dir = Path.Combine(Path.GetTempPath(), $"cdclaudeauth_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_dir);
|
||||
File.WriteAllText(Path.Combine(_dir, "claude.exe"), "");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_dir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
private ClaudeCliLookup MakeLookup(FakeProcessRunner runner) =>
|
||||
new(runner, pathOverride: _dir, pathExtOverride: ".exe");
|
||||
|
||||
[Fact]
|
||||
public async Task Logged_in_reports_ok_without_sending_a_prompt()
|
||||
{
|
||||
var runner = new FakeProcessRunner(
|
||||
(0, "2.1.230 (Claude Code)"),
|
||||
(0, "{\"loggedIn\":true,\"authMethod\":\"claude.ai\"}"));
|
||||
var check = new ClaudeAuthCheck(MakeLookup(runner));
|
||||
|
||||
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(CheckStatus.Ok, result.Status);
|
||||
Assert.All(runner.Calls, call => Assert.DoesNotContain("-p ", call.Arguments));
|
||||
Assert.Contains(runner.Calls, call => call.Arguments.Contains("auth") && call.Arguments.Contains("status"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Logged_out_reports_failed()
|
||||
{
|
||||
var runner = new FakeProcessRunner(
|
||||
(0, "2.1.230 (Claude Code)"),
|
||||
(0, "{\"loggedIn\":false}"));
|
||||
var check = new ClaudeAuthCheck(MakeLookup(runner));
|
||||
|
||||
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(CheckStatus.Failed, result.Status);
|
||||
Assert.NotNull(result.HintKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unparseable_output_reports_unknown_not_failed()
|
||||
{
|
||||
var runner = new FakeProcessRunner(
|
||||
(0, "2.1.230 (Claude Code)"),
|
||||
(0, "not json"));
|
||||
var check = new ClaudeAuthCheck(MakeLookup(runner));
|
||||
|
||||
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(CheckStatus.Unknown, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cli_missing_reports_unknown()
|
||||
{
|
||||
var emptyDir = Path.Combine(Path.GetTempPath(), $"cdclaudeauth_empty_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(emptyDir);
|
||||
try
|
||||
{
|
||||
var runner = new FakeProcessRunner();
|
||||
var check = new ClaudeAuthCheck(new ClaudeCliLookup(runner, pathOverride: emptyDir, pathExtOverride: ".exe"));
|
||||
var ctx = new InstallContext { ClaudeBin = "claudedo_totally_missing_cmd_9f3a1" };
|
||||
|
||||
var result = await check.RunAsync(ctx, CancellationToken.None);
|
||||
|
||||
Assert.Equal(CheckStatus.Unknown, result.Status);
|
||||
Assert.Empty(runner.Calls);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(emptyDir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using ClaudeDo.Installer.Checks;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests.Checks;
|
||||
|
||||
public sealed class ClaudeCliCheckTests : IDisposable
|
||||
{
|
||||
private readonly string _dir;
|
||||
|
||||
public ClaudeCliCheckTests()
|
||||
{
|
||||
_dir = Path.Combine(Path.GetTempPath(), $"cdclaudecli_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_dir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_dir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Found_as_exe_reports_ok_with_path_and_version_and_no_shim_detail()
|
||||
{
|
||||
File.WriteAllText(Path.Combine(_dir, "claude.exe"), "");
|
||||
var runner = new FakeProcessRunner((0, "2.1.230 (Claude Code)"));
|
||||
var check = new ClaudeCliCheck(new ClaudeCliLookup(runner, pathOverride: _dir, pathExtOverride: ".exe"));
|
||||
|
||||
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(CheckStatus.Ok, result.Status);
|
||||
Assert.Contains(_dir, result.Message);
|
||||
Assert.Contains("2.1.230", result.Message);
|
||||
Assert.Null(result.Detail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Found_as_shim_flags_it_in_detail()
|
||||
{
|
||||
File.WriteAllText(Path.Combine(_dir, "claude.cmd"), "");
|
||||
var runner = new FakeProcessRunner((0, "2.1.230 (Claude Code)"));
|
||||
var check = new ClaudeCliCheck(new ClaudeCliLookup(runner, pathOverride: _dir, pathExtOverride: ".cmd"));
|
||||
|
||||
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(CheckStatus.Ok, result.Status);
|
||||
Assert.NotNull(result.Detail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Not_found_reports_failed_naming_searched_locations()
|
||||
{
|
||||
var runner = new FakeProcessRunner();
|
||||
var check = new ClaudeCliCheck(new ClaudeCliLookup(runner, pathOverride: _dir, pathExtOverride: ".exe"));
|
||||
// A distinctive, definitely-missing name — a plain "claude" would still resolve via
|
||||
// ExecutableResolver's fixed fallback dirs (e.g. %USERPROFILE%\.local\bin) on a machine
|
||||
// that has the real CLI installed, regardless of pathOverride.
|
||||
var ctx = new InstallContext { ClaudeBin = "claudedo_totally_missing_cmd_9f3a1" };
|
||||
|
||||
var result = await check.RunAsync(ctx, CancellationToken.None);
|
||||
|
||||
Assert.Equal(CheckStatus.Failed, result.Status);
|
||||
Assert.Equal(CheckSeverity.Error, result.Severity);
|
||||
Assert.Contains(_dir, result.Message);
|
||||
Assert.NotNull(result.HintKey);
|
||||
Assert.Empty(runner.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Nonzero_exit_reports_failed()
|
||||
{
|
||||
File.WriteAllText(Path.Combine(_dir, "claude.exe"), "");
|
||||
var runner = new FakeProcessRunner((1, "some error"));
|
||||
var check = new ClaudeCliCheck(new ClaudeCliLookup(runner, pathOverride: _dir, pathExtOverride: ".exe"));
|
||||
|
||||
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(CheckStatus.Failed, result.Status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using ClaudeDo.Installer.Checks;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests.Checks;
|
||||
|
||||
public sealed class ClaudeCliLookupTests : IDisposable
|
||||
{
|
||||
private readonly string _dir;
|
||||
|
||||
public ClaudeCliLookupTests()
|
||||
{
|
||||
_dir = Path.Combine(Path.GetTempPath(), $"cdclaudelookup_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_dir);
|
||||
File.WriteAllText(Path.Combine(_dir, "claude.exe"), "");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_dir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Version_is_executed_only_once_across_repeated_calls()
|
||||
{
|
||||
var runner = new FakeProcessRunner((0, "2.1.230 (Claude Code)"));
|
||||
var lookup = new ClaudeCliLookup(runner, pathOverride: _dir, pathExtOverride: ".exe");
|
||||
|
||||
await lookup.ResolveAsync(new InstallContext(), CancellationToken.None);
|
||||
await lookup.ResolveAsync(new InstallContext(), CancellationToken.None);
|
||||
await lookup.ResolveAsync(new InstallContext(), CancellationToken.None);
|
||||
|
||||
Assert.Single(runner.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_calls_still_run_version_only_once()
|
||||
{
|
||||
var runner = new FakeProcessRunner((0, "2.1.230 (Claude Code)"));
|
||||
var lookup = new ClaudeCliLookup(runner, pathOverride: _dir, pathExtOverride: ".exe");
|
||||
|
||||
await Task.WhenAll(Enumerable.Range(0, 8)
|
||||
.Select(_ => lookup.ResolveAsync(new InstallContext(), CancellationToken.None)));
|
||||
|
||||
Assert.Single(runner.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Version_is_executed_only_once_when_shared_across_all_four_checks()
|
||||
{
|
||||
var runner = new FakeProcessRunner((0, "2.1.230 (Claude Code)"));
|
||||
var lookup = new ClaudeCliLookup(runner, pathOverride: _dir, pathExtOverride: ".exe");
|
||||
var ctx = new InstallContext();
|
||||
|
||||
await new ClaudeCliCheck(lookup).RunAsync(ctx, CancellationToken.None);
|
||||
await new ClaudeVersionCheck(lookup).RunAsync(ctx, CancellationToken.None);
|
||||
await new ClaudeAuthCheck(lookup).RunAsync(ctx, CancellationToken.None);
|
||||
await new PermissionModeAutoCheck(lookup).RunAsync(ctx, CancellationToken.None);
|
||||
|
||||
var versionCalls = runner.Calls.Count(c => c.Arguments.Contains("--version"));
|
||||
Assert.Equal(1, versionCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cli_not_found_returns_null_resolved_without_running_a_process()
|
||||
{
|
||||
var emptyDir = Path.Combine(Path.GetTempPath(), $"cdclaudelookup_empty_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(emptyDir);
|
||||
try
|
||||
{
|
||||
var runner = new FakeProcessRunner();
|
||||
var lookup = new ClaudeCliLookup(runner, pathOverride: emptyDir, pathExtOverride: ".exe");
|
||||
var ctx = new InstallContext { ClaudeBin = "claudedo_totally_missing_cmd_9f3a1" };
|
||||
|
||||
var result = await lookup.ResolveAsync(ctx, CancellationToken.None);
|
||||
|
||||
Assert.Null(result.Resolved);
|
||||
Assert.Empty(runner.Calls);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(emptyDir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using ClaudeDo.Installer.Checks;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests.Checks;
|
||||
|
||||
public sealed class ClaudeVersionCheckTests : IDisposable
|
||||
{
|
||||
private readonly string _dir;
|
||||
|
||||
public ClaudeVersionCheckTests()
|
||||
{
|
||||
_dir = Path.Combine(Path.GetTempPath(), $"cdclaudeversion_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_dir);
|
||||
File.WriteAllText(Path.Combine(_dir, "claude.exe"), "");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_dir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
private ClaudeCliLookup MakeLookup(FakeProcessRunner runner) =>
|
||||
new(runner, pathOverride: _dir, pathExtOverride: ".exe");
|
||||
|
||||
[Theory]
|
||||
[InlineData("2.1.220 (Claude Code)", CheckStatus.Ok)] // exactly at the floor
|
||||
[InlineData("2.1.219 (Claude Code)", CheckStatus.Failed)] // one patch below the floor
|
||||
[InlineData("2.1.191", CheckStatus.Failed)]
|
||||
[InlineData("v2.1.191", CheckStatus.Failed)]
|
||||
[InlineData("2.10.0", CheckStatus.Ok)] // minor 10 > minor 1, not a lexical trap
|
||||
[InlineData("garbage output, no version here", CheckStatus.Unknown)]
|
||||
public async Task Version_parsing_and_gate(string versionOutput, CheckStatus expected)
|
||||
{
|
||||
var runner = new FakeProcessRunner((0, versionOutput));
|
||||
var check = new ClaudeVersionCheck(MakeLookup(runner));
|
||||
|
||||
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(expected, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Minor_version_compares_numerically_not_lexically()
|
||||
{
|
||||
Assert.True(new Version(2, 10, 0) > new Version(2, 9, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cli_not_found_reports_unknown_not_failed()
|
||||
{
|
||||
var emptyDir = Path.Combine(Path.GetTempPath(), $"cdclaudeversion_empty_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(emptyDir);
|
||||
try
|
||||
{
|
||||
var runner = new FakeProcessRunner();
|
||||
var check = new ClaudeVersionCheck(new ClaudeCliLookup(runner, pathOverride: emptyDir, pathExtOverride: ".exe"));
|
||||
var ctx = new InstallContext { ClaudeBin = "claudedo_totally_missing_cmd_9f3a1" };
|
||||
|
||||
var result = await check.RunAsync(ctx, CancellationToken.None);
|
||||
Assert.Empty(runner.Calls);
|
||||
|
||||
Assert.Equal(CheckStatus.Unknown, result.Status);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(emptyDir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,88 @@
|
||||
using ClaudeDo.Installer.Checks;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests.Checks;
|
||||
|
||||
public sealed class PermissionModeAutoCheckTests : IDisposable
|
||||
{
|
||||
private readonly string _dir;
|
||||
|
||||
private const string HelpWithAuto =
|
||||
"Options:\n --permission-mode <mode> Permission mode (choices: \"acceptEdits\", \"auto\", \"bypassPermissions\", \"manual\", \"dontAsk\", \"plan\")\n";
|
||||
|
||||
private const string HelpWithoutAuto =
|
||||
"Options:\n --permission-mode <mode> Permission mode (choices: \"acceptEdits\", \"bypassPermissions\", \"manual\")\n";
|
||||
|
||||
public PermissionModeAutoCheckTests()
|
||||
{
|
||||
_dir = Path.Combine(Path.GetTempPath(), $"cdpermmode_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_dir);
|
||||
File.WriteAllText(Path.Combine(_dir, "claude.exe"), "");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_dir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
private ClaudeCliLookup MakeLookup(FakeProcessRunner runner) =>
|
||||
new(runner, pathOverride: _dir, pathExtOverride: ".exe");
|
||||
|
||||
[Fact]
|
||||
public async Task Auto_listed_in_help_reports_ok()
|
||||
{
|
||||
var runner = new FakeProcessRunner((0, "2.1.230"), (0, HelpWithAuto));
|
||||
var check = new PermissionModeAutoCheck(MakeLookup(runner));
|
||||
|
||||
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(CheckStatus.Ok, result.Status);
|
||||
Assert.Equal(CheckSeverity.Warning, result.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Auto_missing_from_help_reports_failed_with_hint()
|
||||
{
|
||||
var runner = new FakeProcessRunner((0, "2.1.230"), (0, HelpWithoutAuto));
|
||||
var check = new PermissionModeAutoCheck(MakeLookup(runner));
|
||||
|
||||
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(CheckStatus.Failed, result.Status);
|
||||
Assert.Equal(CheckSeverity.Warning, result.Severity);
|
||||
Assert.NotNull(result.HintKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Help_not_determinable_reports_unknown()
|
||||
{
|
||||
var runner = new FakeProcessRunner((0, "2.1.230"), (1, ""));
|
||||
var check = new PermissionModeAutoCheck(MakeLookup(runner));
|
||||
|
||||
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(CheckStatus.Unknown, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cli_missing_reports_unknown()
|
||||
{
|
||||
var emptyDir = Path.Combine(Path.GetTempPath(), $"cdpermmode_empty_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(emptyDir);
|
||||
try
|
||||
{
|
||||
var runner = new FakeProcessRunner();
|
||||
var check = new PermissionModeAutoCheck(new ClaudeCliLookup(runner, pathOverride: emptyDir, pathExtOverride: ".exe"));
|
||||
var ctx = new InstallContext { ClaudeBin = "claudedo_totally_missing_cmd_9f3a1" };
|
||||
|
||||
var result = await check.RunAsync(ctx, CancellationToken.None);
|
||||
|
||||
Assert.Equal(CheckStatus.Unknown, result.Status);
|
||||
Assert.Empty(runner.Calls);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(emptyDir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user