Every update failed at "Could not replace the existing files": the app relaunches the installer via ShellExecute without a working directory, so it inherited the app's CWD - which the Start Menu shortcut sets to <InstallDir>\app. A process's current directory is locked by Windows, so the installer blocked its own `app` -> `app.bak` rename. Retries and reboots could not help. - installer moves its CWD to %TEMP% at startup, and both relaunch sites in the UI pass an explicit WorkingDirectory - cache the release zip in %TEMP%\ClaudeDo-download-cache and reuse it on a retry while its SHA-256 still matches, so a failed attempt no longer costs another full download; drop it after a successful install, delete a mismatching one, prune zips of other versions - roll back a half-done stash: a leftover app.bak was deleted as a stale stash on the next attempt, and that copy was the only one left - name the blocked path in the error message
267 lines
11 KiB
C#
267 lines
11 KiB
C#
using System.IO;
|
|
using System.IO.Compression;
|
|
using ClaudeDo.Installer.Core;
|
|
using ClaudeDo.Installer.Steps;
|
|
using ClaudeDo.Releases;
|
|
|
|
namespace ClaudeDo.Installer.Tests;
|
|
|
|
public sealed class DownloadAndExtractStepTests : IDisposable
|
|
{
|
|
private readonly string _tempDir;
|
|
private readonly string _installDir;
|
|
private readonly string _cacheDir;
|
|
|
|
public DownloadAndExtractStepTests()
|
|
{
|
|
_tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDoDownloadStep-" + Guid.NewGuid().ToString("N"));
|
|
Directory.CreateDirectory(_tempDir);
|
|
_installDir = Path.Combine(_tempDir, "install");
|
|
Directory.CreateDirectory(_installDir);
|
|
_cacheDir = Path.Combine(_tempDir, "cache");
|
|
}
|
|
|
|
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 List<string> DownloadedUrls { get; } = new();
|
|
|
|
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)
|
|
{
|
|
DownloadedUrls.Add(url);
|
|
File.Copy(_urlToSourceFile[url], destPath, overwrite: true);
|
|
progress.Report(new FileInfo(destPath).Length);
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
// Builds a valid release zip + checksums.txt and a client that serves both.
|
|
private (FileCopyReleaseClient Client, string ZipName) SetUpRelease()
|
|
{
|
|
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");
|
|
}
|
|
|
|
const string zipName = "ClaudeDo-0.1.0-win-x64.zip";
|
|
var checksumsPath = Path.Combine(_tempDir, "checksums.txt");
|
|
File.WriteAllText(checksumsPath, $"{ChecksumVerifier.ComputeSha256(zipPath)} {zipName}\n");
|
|
|
|
var release = new GiteaRelease("v0.1.0", "v0.1.0", new[]
|
|
{
|
|
new ReleaseAsset(zipName, "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 };
|
|
|
|
return (client, zipName);
|
|
}
|
|
|
|
[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, _cacheDir);
|
|
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, _cacheDir);
|
|
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, _cacheDir);
|
|
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 Reuses_Cached_Zip_Instead_Of_Downloading_It_Again()
|
|
{
|
|
var (client, zipName) = SetUpRelease();
|
|
Directory.CreateDirectory(_cacheDir);
|
|
// A previous run left a fully downloaded, still-valid zip behind.
|
|
File.Copy(Path.Combine(_tempDir, "release.zip"), Path.Combine(_cacheDir, zipName));
|
|
|
|
var step = new DownloadAndExtractStep(client, _cacheDir);
|
|
var ctx = new InstallContext { InstallDirectory = _installDir };
|
|
|
|
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
|
|
|
|
Assert.True(result.Success, result.ErrorMessage);
|
|
Assert.DoesNotContain("fake://zip", client.DownloadedUrls);
|
|
Assert.Equal("hello-app", File.ReadAllText(Path.Combine(_installDir, "app", "a.txt")));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Redownloads_When_Cached_Zip_Is_Corrupt()
|
|
{
|
|
var (client, zipName) = SetUpRelease();
|
|
Directory.CreateDirectory(_cacheDir);
|
|
// A cancelled/aborted download left a truncated file behind.
|
|
File.WriteAllText(Path.Combine(_cacheDir, zipName), "not-a-zip");
|
|
|
|
var step = new DownloadAndExtractStep(client, _cacheDir);
|
|
var ctx = new InstallContext { InstallDirectory = _installDir };
|
|
|
|
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
|
|
|
|
Assert.True(result.Success, result.ErrorMessage);
|
|
Assert.Contains("fake://zip", client.DownloadedUrls);
|
|
Assert.Equal("hello-app", File.ReadAllText(Path.Combine(_installDir, "app", "a.txt")));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Keeps_Zip_Cached_When_The_Install_Fails_And_Drops_It_On_Success()
|
|
{
|
|
var (client, zipName) = SetUpRelease();
|
|
var cachedZip = Path.Combine(_cacheDir, zipName);
|
|
|
|
// Make extraction fail: a *file* named "app" blocks the app/ entry's directory.
|
|
var blocker = Path.Combine(_installDir, "app");
|
|
File.WriteAllText(blocker, "blocked");
|
|
|
|
var step = new DownloadAndExtractStep(client, _cacheDir);
|
|
var ctx = new InstallContext { InstallDirectory = _installDir };
|
|
|
|
var failed = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
|
|
Assert.False(failed.Success);
|
|
Assert.True(File.Exists(cachedZip), "a failed install must keep the download cached");
|
|
|
|
// Retry after clearing the blocker: no second download, and the cache is dropped.
|
|
File.Delete(blocker);
|
|
client.DownloadedUrls.Clear();
|
|
|
|
var ok = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
|
|
|
|
Assert.True(ok.Success, ok.ErrorMessage);
|
|
Assert.DoesNotContain("fake://zip", client.DownloadedUrls);
|
|
Assert.False(File.Exists(cachedZip), "a successful install must not leave the zip behind");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Prunes_Cached_Zips_From_Other_Versions()
|
|
{
|
|
var (client, _) = SetUpRelease();
|
|
Directory.CreateDirectory(_cacheDir);
|
|
var stale = Path.Combine(_cacheDir, "ClaudeDo-0.0.9-win-x64.zip");
|
|
File.WriteAllText(stale, "old release");
|
|
|
|
var step = new DownloadAndExtractStep(client, _cacheDir);
|
|
var ctx = new InstallContext { InstallDirectory = _installDir };
|
|
|
|
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
|
|
|
|
Assert.True(result.Success, result.ErrorMessage);
|
|
Assert.False(File.Exists(stale));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Fails_When_ReleaseClient_Returns_Null()
|
|
{
|
|
var client = new FileCopyReleaseClient(new()) { Release = null };
|
|
var step = new DownloadAndExtractStep(client, _cacheDir);
|
|
var ctx = new InstallContext { InstallDirectory = _installDir };
|
|
|
|
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
|
|
|
|
Assert.False(result.Success);
|
|
}
|
|
}
|