Merge branch 'claudedo/0c5a46e7b2d84538a54b2e13b44af01e'

This commit is contained in:
mika kuns
2026-08-06 07:56:22 +02:00
13 changed files with 761 additions and 0 deletions
@@ -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,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);
}
}
}