Merge branch 'main' into feat/ui-improvements

This commit is contained in:
2026-04-15 09:28:18 +00:00
65 changed files with 6489 additions and 0 deletions

View File

@@ -0,0 +1,106 @@
using System.IO;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Tests;
public sealed class ChecksumVerifierTests : IDisposable
{
private readonly string _tempDir;
public ChecksumVerifierTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDoChecksum-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempDir);
}
public void Dispose()
{
try { Directory.Delete(_tempDir, recursive: true); } catch { }
}
[Fact]
public void ComputeSha256_KnownVector_EmptyFile()
{
var path = Path.Combine(_tempDir, "empty.bin");
File.WriteAllBytes(path, Array.Empty<byte>());
var hash = ChecksumVerifier.ComputeSha256(path);
Assert.Equal("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", hash);
}
[Fact]
public void ComputeSha256_KnownVector_Hello()
{
var path = Path.Combine(_tempDir, "hello.bin");
File.WriteAllText(path, "hello");
var hash = ChecksumVerifier.ComputeSha256(path);
Assert.Equal("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", hash);
}
[Fact]
public void Verify_ReturnsTrue_WhenHashMatches()
{
var path = Path.Combine(_tempDir, "x.bin");
File.WriteAllText(path, "hello");
Assert.True(ChecksumVerifier.Verify(path,
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"));
}
[Fact]
public void Verify_IsCaseInsensitive()
{
var path = Path.Combine(_tempDir, "x.bin");
File.WriteAllText(path, "hello");
Assert.True(ChecksumVerifier.Verify(path,
"2CF24DBA5FB0A30E26E83B2AC5B9E29E1B161E5C1FA7425E73043362938B9824"));
}
[Fact]
public void Verify_ReturnsFalse_OnMismatch()
{
var path = Path.Combine(_tempDir, "x.bin");
File.WriteAllText(path, "hello");
Assert.False(ChecksumVerifier.Verify(path, new string('0', 64)));
}
[Fact]
public void ParseChecksumsFile_ReadsTwoLines()
{
var content = """
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ClaudeDo-0.2.0-win-x64.zip
2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 ClaudeDo.Installer-0.2.0.exe
""";
var map = ChecksumVerifier.ParseChecksumsFile(content);
Assert.Equal(2, map.Count);
Assert.Equal(
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
map["ClaudeDo-0.2.0-win-x64.zip"]);
Assert.Equal(
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
map["ClaudeDo.Installer-0.2.0.exe"]);
}
[Fact]
public void ParseChecksumsFile_SkipsBlankAndMalformedLines()
{
var content = """
not a line
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 file.zip
""";
var map = ChecksumVerifier.ParseChecksumsFile(content);
Assert.Single(map);
Assert.True(map.ContainsKey("file.zip"));
}
}

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ClaudeDo.Installer\ClaudeDo.Installer.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,148 @@
using System.IO;
using System.IO.Compression;
using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Steps;
namespace ClaudeDo.Installer.Tests;
public sealed class DownloadAndExtractStepTests : IDisposable
{
private readonly string _tempDir;
private readonly string _installDir;
public DownloadAndExtractStepTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDoDownloadStep-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempDir);
_installDir = Path.Combine(_tempDir, "install");
Directory.CreateDirectory(_installDir);
}
public void Dispose()
{
try { Directory.Delete(_tempDir, recursive: true); } catch { }
}
private sealed class FileCopyReleaseClient : IReleaseClient
{
private readonly Dictionary<string, string> _urlToSourceFile;
public GiteaRelease? Release { get; set; }
public FileCopyReleaseClient(Dictionary<string, string> urlToSourceFile)
=> _urlToSourceFile = urlToSourceFile;
public Task<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct) => Task.FromResult(Release);
public Task DownloadAsync(string url, string destPath, IProgress<long> progress, CancellationToken ct)
{
File.Copy(_urlToSourceFile[url], destPath, overwrite: true);
progress.Report(new FileInfo(destPath).Length);
return Task.CompletedTask;
}
}
[Fact]
public async Task Extracts_Zip_Into_InstallDir_App_And_Worker()
{
var zipPath = Path.Combine(_tempDir, "release.zip");
using (var fs = File.Create(zipPath))
using (var zip = new ZipArchive(fs, ZipArchiveMode.Create))
{
var a = zip.CreateEntry("app/a.txt");
using (var w = new StreamWriter(a.Open())) w.Write("hello-app");
var b = zip.CreateEntry("worker/b.txt");
using (var w = new StreamWriter(b.Open())) w.Write("hello-worker");
}
var zipHash = ChecksumVerifier.ComputeSha256(zipPath);
var checksumsPath = Path.Combine(_tempDir, "checksums.txt");
File.WriteAllText(checksumsPath, $"{zipHash} ClaudeDo-0.1.0-win-x64.zip\n");
var release = new GiteaRelease("v0.1.0", "v0.1.0", new[]
{
new ReleaseAsset("ClaudeDo-0.1.0-win-x64.zip", "fake://zip", new FileInfo(zipPath).Length),
new ReleaseAsset("checksums.txt", "fake://checksums", new FileInfo(checksumsPath).Length),
});
var client = new FileCopyReleaseClient(new()
{
["fake://zip"] = zipPath,
["fake://checksums"] = checksumsPath,
}) { Release = release };
var step = new DownloadAndExtractStep(client);
var ctx = new InstallContext { InstallDirectory = _installDir };
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
Assert.True(result.Success, result.ErrorMessage);
Assert.Equal("hello-app", File.ReadAllText(Path.Combine(_installDir, "app", "a.txt")));
Assert.Equal("hello-worker", File.ReadAllText(Path.Combine(_installDir, "worker", "b.txt")));
Assert.Equal("0.1.0", ctx.InstalledVersion);
}
[Fact]
public async Task Fails_On_ChecksumMismatch_Without_Overwriting_InstallDir()
{
var zipPath = Path.Combine(_tempDir, "release.zip");
using (var fs = File.Create(zipPath))
using (var zip = new ZipArchive(fs, ZipArchiveMode.Create))
{
var a = zip.CreateEntry("app/a.txt");
using (var w = new StreamWriter(a.Open())) w.Write("x");
}
var checksumsPath = Path.Combine(_tempDir, "checksums.txt");
File.WriteAllText(checksumsPath, $"{new string('0', 64)} ClaudeDo-0.1.0-win-x64.zip\n");
File.WriteAllText(Path.Combine(_installDir, "marker.txt"), "untouched");
var release = new GiteaRelease("v0.1.0", "v0.1.0", new[]
{
new ReleaseAsset("ClaudeDo-0.1.0-win-x64.zip", "fake://zip", 0),
new ReleaseAsset("checksums.txt", "fake://checksums", 0),
});
var client = new FileCopyReleaseClient(new()
{
["fake://zip"] = zipPath,
["fake://checksums"] = checksumsPath,
}) { Release = release };
var step = new DownloadAndExtractStep(client);
var ctx = new InstallContext { InstallDirectory = _installDir };
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
Assert.False(result.Success);
Assert.Contains("checksum", result.ErrorMessage!, StringComparison.OrdinalIgnoreCase);
Assert.True(File.Exists(Path.Combine(_installDir, "marker.txt")));
Assert.False(Directory.Exists(Path.Combine(_installDir, "app")));
}
[Fact]
public async Task Fails_When_Release_Has_No_Zip_Asset()
{
var release = new GiteaRelease("v0.1.0", "v0.1.0", Array.Empty<ReleaseAsset>());
var client = new FileCopyReleaseClient(new()) { Release = release };
var step = new DownloadAndExtractStep(client);
var ctx = new InstallContext { InstallDirectory = _installDir };
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
Assert.False(result.Success);
Assert.Contains("not found", result.ErrorMessage!, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Fails_When_ReleaseClient_Returns_Null()
{
var client = new FileCopyReleaseClient(new()) { Release = null };
var step = new DownloadAndExtractStep(client);
var ctx = new InstallContext { InstallDirectory = _installDir };
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
Assert.False(result.Success);
}
}

View File

@@ -0,0 +1,32 @@
using System.Net;
using System.Net.Http;
namespace ClaudeDo.Installer.Tests;
internal sealed class FakeHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _handler;
private readonly object _lock = new();
private readonly List<HttpRequestMessage> _requests = new();
public FakeHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> handler)
{
_handler = handler;
}
public FakeHttpMessageHandler(HttpStatusCode status, string body)
: this(_ => new HttpResponseMessage(status) { Content = new StringContent(body) })
{
}
public IReadOnlyList<HttpRequestMessage> Requests
{
get { lock (_lock) return _requests.ToArray(); }
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
lock (_lock) _requests.Add(request);
return Task.FromResult(_handler(request));
}
}

View File

@@ -0,0 +1,76 @@
using System.IO;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Tests;
public sealed class InstallManifestStoreTests : IDisposable
{
private readonly string _tempDir;
public InstallManifestStoreTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDoInstallerTests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempDir);
}
public void Dispose()
{
try { Directory.Delete(_tempDir, recursive: true); } catch { /* best effort */ }
}
[Fact]
public void TryRead_ReturnsNull_WhenFileMissing()
{
var result = InstallManifestStore.TryRead(_tempDir);
Assert.Null(result);
}
[Fact]
public void Write_Then_Read_RoundTripsAllFields()
{
var manifest = new InstallManifest(
Version: "0.2.0",
InstallDir: _tempDir,
WorkerDir: Path.Combine(_tempDir, "worker"),
InstalledAt: new DateTimeOffset(2026, 4, 15, 12, 34, 56, TimeSpan.Zero));
InstallManifestStore.Write(_tempDir, manifest);
var round = InstallManifestStore.TryRead(_tempDir);
Assert.NotNull(round);
Assert.Equal("0.2.0", round!.Version);
Assert.Equal(manifest.InstallDir, round.InstallDir);
Assert.Equal(manifest.WorkerDir, round.WorkerDir);
Assert.Equal(manifest.InstalledAt, round.InstalledAt);
}
[Fact]
public void Write_CreatesInstallDir_IfMissing()
{
var nested = Path.Combine(_tempDir, "nested");
Assert.False(Directory.Exists(nested));
InstallManifestStore.Write(nested, new InstallManifest(
"0.0.1", nested, Path.Combine(nested, "worker"), DateTimeOffset.UtcNow));
Assert.True(File.Exists(Path.Combine(nested, "install.json")));
}
[Fact]
public void TryRead_ReturnsNull_WhenJsonMalformed()
{
File.WriteAllText(Path.Combine(_tempDir, "install.json"), "{ not json");
var result = InstallManifestStore.TryRead(_tempDir);
Assert.Null(result);
}
[Fact]
public void TryRead_ReturnsNull_WhenJsonIsValidButShapeIsWrong()
{
// Valid JSON, but installedAt has a wrong type — causes JsonException, swallowed silently.
File.WriteAllText(Path.Combine(_tempDir, "install.json"),
"""{"version":"1.0","installDir":"x","workerDir":"y","installedAt":12345}""");
var result = InstallManifestStore.TryRead(_tempDir);
Assert.Null(result);
}
}

View File

@@ -0,0 +1,124 @@
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Tests;
public sealed class InstallModeDetectorTests : IDisposable
{
private readonly string _tempDir;
public InstallModeDetectorTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDoDetector-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempDir);
}
public void Dispose()
{
try { Directory.Delete(_tempDir, recursive: true); } catch { }
}
private sealed class FakeReleaseClient : IReleaseClient
{
public GiteaRelease? Release { get; set; }
public Task<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct) => Task.FromResult(Release);
public Task DownloadAsync(string url, string destPath, IProgress<long> progress, CancellationToken ct)
=> throw new NotSupportedException();
}
[Fact]
public async Task Detect_FreshInstall_WhenManifestMissing()
{
var detector = new InstallModeDetector(new FakeReleaseClient());
var state = await detector.DetectAsync(_tempDir, CancellationToken.None);
Assert.Equal(InstallerMode.FreshInstall, state.Mode);
Assert.Null(state.Existing);
}
[Fact]
public async Task Detect_Config_WhenManifestPresent_And_Api_Unreachable()
{
InstallManifestStore.Write(_tempDir,
new InstallManifest("0.1.0", _tempDir, _tempDir, DateTimeOffset.UtcNow));
var detector = new InstallModeDetector(new FakeReleaseClient { Release = null });
var state = await detector.DetectAsync(_tempDir, CancellationToken.None);
Assert.Equal(InstallerMode.Config, state.Mode);
Assert.Equal("0.1.0", state.Existing!.Version);
}
[Fact]
public async Task Detect_Update_WhenLatest_GreaterThan_Installed()
{
InstallManifestStore.Write(_tempDir,
new InstallManifest("0.1.0", _tempDir, _tempDir, DateTimeOffset.UtcNow));
var fake = new FakeReleaseClient
{
Release = new GiteaRelease("v0.2.0", "v0.2.0", Array.Empty<ReleaseAsset>())
};
var detector = new InstallModeDetector(fake);
var state = await detector.DetectAsync(_tempDir, CancellationToken.None);
Assert.Equal(InstallerMode.Update, state.Mode);
Assert.Equal("0.1.0", state.Existing!.Version);
Assert.Equal("0.2.0", state.LatestVersion);
}
[Fact]
public async Task Detect_Config_WhenLatest_EqualsOrOlderThan_Installed()
{
InstallManifestStore.Write(_tempDir,
new InstallManifest("0.2.0", _tempDir, _tempDir, DateTimeOffset.UtcNow));
var fake = new FakeReleaseClient
{
Release = new GiteaRelease("v0.2.0", "v0.2.0", Array.Empty<ReleaseAsset>())
};
var detector = new InstallModeDetector(fake);
var state = await detector.DetectAsync(_tempDir, CancellationToken.None);
Assert.Equal(InstallerMode.Config, state.Mode);
}
[Fact]
public async Task Detect_Config_WhenInstalledIs_Newer_Than_Latest()
{
InstallManifestStore.Write(_tempDir,
new InstallManifest("0.3.0", _tempDir, _tempDir, DateTimeOffset.UtcNow));
var fake = new FakeReleaseClient
{
Release = new GiteaRelease("v0.2.0", "v0.2.0", Array.Empty<ReleaseAsset>())
};
var detector = new InstallModeDetector(fake);
var state = await detector.DetectAsync(_tempDir, CancellationToken.None);
Assert.Equal(InstallerMode.Config, state.Mode);
}
[Fact]
public async Task Detect_Config_WhenInstalledVersion_IsUnparseable()
{
// install.json has been tampered with or written by an older installer with a
// version string we can't compare. Must not crash; must land on Config (no update).
InstallManifestStore.Write(_tempDir,
new InstallManifest("garbage", _tempDir, _tempDir, DateTimeOffset.UtcNow));
var fake = new FakeReleaseClient
{
Release = new GiteaRelease("v0.2.0", "v0.2.0", Array.Empty<ReleaseAsset>())
};
var detector = new InstallModeDetector(fake);
var state = await detector.DetectAsync(_tempDir, CancellationToken.None);
Assert.Equal(InstallerMode.Config, state.Mode);
}
}

View File

@@ -0,0 +1,109 @@
using System.Net;
using System.Net.Http;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Tests;
public sealed class ReleaseClientTests
{
private const string ApiBase = "https://git.example.test/api/v1/repos/releases/ClaudeDo";
[Fact]
public async Task GetLatestReleaseAsync_ParsesTagAndAssets()
{
const string json = """
{
"tag_name": "v0.2.0",
"name": "v0.2.0",
"assets": [
{
"name": "ClaudeDo-0.2.0-win-x64.zip",
"browser_download_url": "https://git.example.test/dl/zip",
"size": 12345
},
{
"name": "checksums.txt",
"browser_download_url": "https://git.example.test/dl/checksums",
"size": 128
}
]
}
""";
var handler = new FakeHttpMessageHandler(HttpStatusCode.OK, json);
using var http = new HttpClient(handler);
var client = new ReleaseClient(http, ApiBase);
var release = await client.GetLatestReleaseAsync(CancellationToken.None);
Assert.NotNull(release);
Assert.Equal("v0.2.0", release!.TagName);
Assert.Equal(2, release.Assets.Count);
Assert.Equal("ClaudeDo-0.2.0-win-x64.zip", release.Assets[0].Name);
Assert.Equal("https://git.example.test/dl/zip", release.Assets[0].BrowserDownloadUrl);
Assert.Equal(12345, release.Assets[0].Size);
}
[Fact]
public async Task GetLatestReleaseAsync_Returns_Null_On404()
{
var handler = new FakeHttpMessageHandler(HttpStatusCode.NotFound, "");
using var http = new HttpClient(handler);
var client = new ReleaseClient(http, ApiBase);
var release = await client.GetLatestReleaseAsync(CancellationToken.None);
Assert.Null(release);
}
[Fact]
public async Task GetLatestReleaseAsync_Returns_Null_OnNetworkError()
{
var handler = new FakeHttpMessageHandler(_ => throw new HttpRequestException("boom"));
using var http = new HttpClient(handler);
var client = new ReleaseClient(http, ApiBase);
var release = await client.GetLatestReleaseAsync(CancellationToken.None);
Assert.Null(release);
}
[Fact]
public async Task GetLatestReleaseAsync_Hits_CorrectUrl()
{
var handler = new FakeHttpMessageHandler(HttpStatusCode.OK, "{\"tag_name\":\"v0.1.0\",\"assets\":[]}");
using var http = new HttpClient(handler);
var client = new ReleaseClient(http, ApiBase);
_ = await client.GetLatestReleaseAsync(CancellationToken.None);
Assert.Single(handler.Requests);
Assert.Equal($"{ApiBase}/releases/latest", handler.Requests[0].RequestUri!.ToString());
}
[Fact]
public async Task DownloadAsync_WritesBytesToDisk()
{
var payload = new byte[] { 1, 2, 3, 4, 5 };
var handler = new FakeHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(payload)
});
using var http = new HttpClient(handler);
var client = new ReleaseClient(http, ApiBase);
var tempPath = Path.Combine(Path.GetTempPath(), "ClaudeDoDlTest-" + Guid.NewGuid().ToString("N"));
try
{
await client.DownloadAsync("https://example/foo", tempPath,
new Progress<long>(_ => { }), CancellationToken.None);
Assert.True(File.Exists(tempPath));
Assert.Equal(payload, File.ReadAllBytes(tempPath));
}
finally
{
if (File.Exists(tempPath)) File.Delete(tempPath);
}
}
}

View File

@@ -0,0 +1,51 @@
using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Steps;
namespace ClaudeDo.Installer.Tests;
public sealed class WriteInstallManifestStepTests : IDisposable
{
private readonly string _installDir;
public WriteInstallManifestStepTests()
{
_installDir = Path.Combine(Path.GetTempPath(), "ClaudeDoWriteManifest-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_installDir);
}
public void Dispose()
{
try { Directory.Delete(_installDir, recursive: true); } catch { }
}
[Fact]
public async Task Writes_Manifest_WithAllFields()
{
var ctx = new InstallContext
{
InstallDirectory = _installDir,
InstalledVersion = "0.2.0",
};
var step = new WriteInstallManifestStep();
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
Assert.True(result.Success);
var manifest = InstallManifestStore.TryRead(_installDir);
Assert.NotNull(manifest);
Assert.Equal("0.2.0", manifest!.Version);
Assert.Equal(_installDir, manifest.InstallDir);
Assert.Equal(Path.Combine(_installDir, "worker"), manifest.WorkerDir);
}
[Fact]
public async Task Fails_When_InstalledVersion_Missing()
{
var ctx = new InstallContext { InstallDirectory = _installDir }; // no version set
var step = new WriteInstallManifestStep();
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
Assert.False(result.Success);
}
}