feat(launcher): implement verified atomic updates
This commit is contained in:
parent
2198a0cc8e
commit
2d2a5b5046
34 changed files with 6755 additions and 61 deletions
292
tests/AcDream.Launcher.Tests/LauncherUpdateViewModelTests.cs
Normal file
292
tests/AcDream.Launcher.Tests/LauncherUpdateViewModelTests.cs
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
using AcDream.Launcher.Core.Updates;
|
||||
using AcDream.Launcher.ViewModels;
|
||||
|
||||
namespace AcDream.Launcher.Tests;
|
||||
|
||||
public sealed class LauncherUpdateViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task StartupPollingIsOfflineTolerantAndDoesNotOpenErrorModal()
|
||||
{
|
||||
var updater = new FakeUpdater
|
||||
{
|
||||
CheckHandler = _ => Task.FromException<LauncherUpdateCheckResult>(
|
||||
new LauncherUpdateException("fixture offline")),
|
||||
};
|
||||
using var viewModel = Create(updater);
|
||||
|
||||
await viewModel.StartupCheckAsync();
|
||||
|
||||
Assert.False(viewModel.IsOpen);
|
||||
Assert.False(viewModel.HasError);
|
||||
Assert.Contains("continuing offline", viewModel.Status, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal(LauncherUpdatePhase.Failed, viewModel.Phase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartupUpdateOpensModalAndClientInstallProjectsProgressAndRefreshesVersions()
|
||||
{
|
||||
var updater = new FakeUpdater();
|
||||
int changed = 0;
|
||||
using var viewModel = Create(updater, () => changed++);
|
||||
|
||||
await viewModel.StartupCheckAsync();
|
||||
|
||||
Assert.True(viewModel.IsOpen);
|
||||
Assert.Equal("2.0.0", viewModel.AvailableVersion);
|
||||
Assert.Equal("1.0.0", viewModel.CurrentClientVersion);
|
||||
Assert.True(viewModel.InstallClientCommand.CanExecute(null));
|
||||
await viewModel.InstallClientCommand.ExecuteAsync();
|
||||
|
||||
Assert.Equal(1, updater.InstallCalls);
|
||||
Assert.Equal(1, changed);
|
||||
Assert.Equal("2.0.0", viewModel.CurrentClientVersion);
|
||||
Assert.False(viewModel.IsClientUpdateAvailable);
|
||||
Assert.Equal(100, viewModel.ProgressPercent);
|
||||
Assert.False(viewModel.HasError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ManualCheckShowsErrorsAndCanRetrySuccessfully()
|
||||
{
|
||||
var updater = new FakeUpdater();
|
||||
int calls = 0;
|
||||
updater.CheckHandler = _ => ++calls == 1
|
||||
? Task.FromException<LauncherUpdateCheckResult>(
|
||||
new LauncherUpdateException("malformed fixture manifest"))
|
||||
: Task.FromResult(updater.CreateCheck());
|
||||
using var viewModel = Create(updater);
|
||||
|
||||
await viewModel.OpenCommand.ExecuteAsync();
|
||||
|
||||
Assert.True(viewModel.IsOpen);
|
||||
Assert.True(viewModel.HasError);
|
||||
Assert.Contains("malformed", viewModel.Error, StringComparison.Ordinal);
|
||||
await viewModel.CheckCommand.ExecuteAsync();
|
||||
Assert.False(viewModel.HasError);
|
||||
Assert.Equal(2, calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MinimumLauncherGateDisablesClientButAllowsVerifiedSelfUpdateStage()
|
||||
{
|
||||
var updater = new FakeUpdater
|
||||
{
|
||||
MinimumSatisfied = false,
|
||||
};
|
||||
using var viewModel = Create(updater);
|
||||
|
||||
await viewModel.OpenCommand.ExecuteAsync();
|
||||
|
||||
Assert.True(viewModel.IsLauncherMinimumBlocked);
|
||||
Assert.False(viewModel.InstallClientCommand.CanExecute(null));
|
||||
Assert.True(viewModel.StageLauncherCommand.CanExecute(null));
|
||||
await viewModel.StageLauncherCommand.ExecuteAsync();
|
||||
Assert.Equal(1, updater.StageCalls);
|
||||
Assert.True(viewModel.IsLauncherRestartRequired);
|
||||
Assert.Contains("next start", viewModel.LauncherRestartStatus, StringComparison.Ordinal);
|
||||
Assert.False(viewModel.StageLauncherCommand.CanExecute(null));
|
||||
Assert.False(viewModel.HasError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MutationPermissionDisablesInstallStageAndRollbackWhileSessionsRun()
|
||||
{
|
||||
var updater = new FakeUpdater();
|
||||
using var viewModel = Create(updater, canMutate: () => false);
|
||||
|
||||
await viewModel.OpenCommand.ExecuteAsync();
|
||||
|
||||
Assert.False(viewModel.InstallClientCommand.CanExecute(null));
|
||||
Assert.False(viewModel.StageLauncherCommand.CanExecute(null));
|
||||
Assert.False(viewModel.RollbackCommand.CanExecute(null));
|
||||
Assert.True(viewModel.CheckCommand.CanExecute(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancellationAndRollbackHaveExplicitSafeTerminalStates()
|
||||
{
|
||||
var updater = new FakeUpdater();
|
||||
updater.InstallHandler = async (progress, token) =>
|
||||
{
|
||||
progress?.Report(new LauncherUpdateProgress(
|
||||
LauncherUpdatePhase.DownloadingClient,
|
||||
"downloading",
|
||||
1,
|
||||
100));
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, token);
|
||||
return updater.CurrentClient;
|
||||
};
|
||||
int changed = 0;
|
||||
using var viewModel = Create(updater, () => changed++);
|
||||
await viewModel.OpenCommand.ExecuteAsync();
|
||||
|
||||
Task install = viewModel.InstallClientCommand.ExecuteAsync();
|
||||
await WaitUntilAsync(() => viewModel.IsBusy);
|
||||
Assert.True(viewModel.CancelCommand.CanExecute(null));
|
||||
viewModel.CancelCommand.Execute(null);
|
||||
await install;
|
||||
|
||||
Assert.Equal(LauncherUpdatePhase.Cancelled, viewModel.Phase);
|
||||
Assert.Contains("cancelled", viewModel.Status, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.False(viewModel.HasError);
|
||||
|
||||
await viewModel.RollbackCommand.ExecuteAsync();
|
||||
Assert.Equal(1, updater.RollbackCalls);
|
||||
Assert.Equal("0.9.0", viewModel.CurrentClientVersion);
|
||||
Assert.Equal(1, changed);
|
||||
}
|
||||
|
||||
private static LauncherUpdateViewModel Create(
|
||||
FakeUpdater updater,
|
||||
Action? changed = null,
|
||||
Func<bool>? canMutate = null) => new(
|
||||
updater,
|
||||
new ImmediateUiDispatcher(),
|
||||
changed ?? (() => { }),
|
||||
canOpen: () => true,
|
||||
canMutate: canMutate ?? (() => true));
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> condition)
|
||||
{
|
||||
DateTimeOffset deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(5);
|
||||
while (!condition())
|
||||
{
|
||||
if (DateTimeOffset.UtcNow >= deadline)
|
||||
{
|
||||
throw new TimeoutException("View model did not enter the expected state.");
|
||||
}
|
||||
|
||||
await Task.Delay(10);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeUpdater : ILauncherUpdater
|
||||
{
|
||||
private static readonly LauncherVersion One = LauncherVersion.Parse("1.0.0");
|
||||
private static readonly LauncherVersion Two = LauncherVersion.Parse("2.0.0");
|
||||
private static readonly LauncherVersion NineTenths = LauncherVersion.Parse("0.9.0");
|
||||
|
||||
public FakeUpdater()
|
||||
{
|
||||
CurrentClient = Resolution(One, "0.9.0");
|
||||
}
|
||||
|
||||
public ClientVersionResolution CurrentClient { get; private set; }
|
||||
|
||||
public bool MinimumSatisfied { get; init; } = true;
|
||||
|
||||
public int InstallCalls { get; private set; }
|
||||
|
||||
public int StageCalls { get; private set; }
|
||||
|
||||
public int RollbackCalls { get; private set; }
|
||||
|
||||
public Func<CancellationToken, Task<LauncherUpdateCheckResult>>? CheckHandler
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
public Func<
|
||||
IProgress<LauncherUpdateProgress>?,
|
||||
CancellationToken,
|
||||
Task<ClientVersionResolution>>? InstallHandler { get; set; }
|
||||
|
||||
public Task<ClientVersionResolution> InitializeAsync(
|
||||
CancellationToken cancellationToken = default) => Task.FromResult(CurrentClient);
|
||||
|
||||
public Task<LauncherUpdateCheckResult> CheckAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
CheckHandler?.Invoke(cancellationToken) ?? Task.FromResult(CreateCheck());
|
||||
|
||||
public async Task<ClientVersionResolution> InstallClientAsync(
|
||||
LauncherUpdateCheckResult check,
|
||||
IProgress<LauncherUpdateProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
InstallCalls++;
|
||||
if (InstallHandler is not null)
|
||||
{
|
||||
return await InstallHandler(progress, cancellationToken);
|
||||
}
|
||||
|
||||
progress?.Report(new LauncherUpdateProgress(
|
||||
LauncherUpdatePhase.DownloadingClient,
|
||||
"Downloading fixture.",
|
||||
5,
|
||||
10));
|
||||
CurrentClient = Resolution(Two, "1.0.0");
|
||||
progress?.Report(new LauncherUpdateProgress(
|
||||
LauncherUpdatePhase.Completed,
|
||||
"Installed fixture.",
|
||||
1,
|
||||
1));
|
||||
return CurrentClient;
|
||||
}
|
||||
|
||||
public Task<SelfUpdateStageResult> StageLauncherAsync(
|
||||
LauncherUpdateCheckResult check,
|
||||
IProgress<LauncherUpdateProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
StageCalls++;
|
||||
progress?.Report(new LauncherUpdateProgress(
|
||||
LauncherUpdatePhase.StagingLauncher,
|
||||
"Staged fixture.",
|
||||
1,
|
||||
1));
|
||||
return Task.FromResult(new SelfUpdateStageResult(
|
||||
Two,
|
||||
"pending.json",
|
||||
"Launcher staged for next start."));
|
||||
}
|
||||
|
||||
public Task<ClientVersionResolution> RollbackClientAsync(
|
||||
IProgress<LauncherUpdateProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
RollbackCalls++;
|
||||
CurrentClient = Resolution(NineTenths, "1.0.0");
|
||||
progress?.Report(new LauncherUpdateProgress(
|
||||
LauncherUpdatePhase.Completed,
|
||||
"Rolled back fixture.",
|
||||
1,
|
||||
1));
|
||||
return Task.FromResult(CurrentClient);
|
||||
}
|
||||
|
||||
public LauncherUpdateCheckResult CreateCheck()
|
||||
{
|
||||
bool available = CurrentClient.Version! < Two;
|
||||
var artifact = new ReleaseArtifact(
|
||||
new Uri("https://example.test/release.zip"),
|
||||
new string('a', 64),
|
||||
100);
|
||||
var manifest = new ReleaseManifest(
|
||||
Two,
|
||||
MinimumSatisfied ? One : Two,
|
||||
new Dictionary<string, ReleaseArtifact> { ["win-x64"] = artifact },
|
||||
new Dictionary<string, ReleaseArtifact> { ["win-x64"] = artifact });
|
||||
return new LauncherUpdateCheckResult(
|
||||
manifest,
|
||||
"win-x64",
|
||||
One,
|
||||
CurrentClient.Version,
|
||||
available,
|
||||
true,
|
||||
MinimumSatisfied,
|
||||
available ? "Fixture update available." : "Fixture is current.");
|
||||
}
|
||||
|
||||
private static ClientVersionResolution Resolution(
|
||||
LauncherVersion version,
|
||||
string? previous) => new(
|
||||
ClientVersionState.Verified,
|
||||
"Fixture client verified.",
|
||||
version,
|
||||
Path.Combine("fixture", version.Value),
|
||||
previous,
|
||||
null);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue