feat(launcher): add verified first-run installer
This commit is contained in:
parent
60f627998c
commit
ff6ebb6a6a
28 changed files with 3259 additions and 125 deletions
|
|
@ -0,0 +1,336 @@
|
|||
using System.Text.Json.Nodes;
|
||||
using AcDream.Launcher.Core.Integrity;
|
||||
using AcDream.Launcher.Core.Installation;
|
||||
using AcDream.Launcher.Core.Launching;
|
||||
using AcDream.Launcher.Core.Profiles;
|
||||
using AcDream.Platform;
|
||||
|
||||
namespace AcDream.Launcher.Core.Tests.Installation;
|
||||
|
||||
public sealed class LauncherInstallerTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"acdream-installer-tests",
|
||||
Guid.NewGuid().ToString("N"));
|
||||
private readonly ApplicationPathSet _paths;
|
||||
private readonly string _dats;
|
||||
private readonly string _bakeExecutable;
|
||||
|
||||
public LauncherInstallerTests()
|
||||
{
|
||||
_paths = new ApplicationPathSet(
|
||||
Path.Combine(_root, "config"),
|
||||
Path.Combine(_root, "data"),
|
||||
Path.Combine(_root, "cache"),
|
||||
null);
|
||||
_dats = Path.Combine(_root, "retail-dats");
|
||||
_bakeExecutable = Path.Combine(_root, "bin", "acdream-bake");
|
||||
CreateCompleteDatDirectory(_dats);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(_bakeExecutable)!);
|
||||
File.WriteAllText(_bakeExecutable, "fake executable marker");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_root))
|
||||
{
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FakeChildProgressPublishesVerifiedRecordAndFeedsExactSessionContent()
|
||||
{
|
||||
BakeProcessRequest? observedRequest = null;
|
||||
var runner = new FakeBakeProcessRunner(async (request, output, _) =>
|
||||
{
|
||||
observedRequest = request;
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(request.OutputPath)!);
|
||||
await File.WriteAllTextAsync(request.OutputPath, "complete prepared package");
|
||||
long bytes = new FileInfo(request.OutputPath).Length;
|
||||
output("acdream-bake human header\n{\"v\":1,\"e\":\"star");
|
||||
output("ted\",\"bakeToolVersion\":4,\"outputPath\":\"pak\"}\n");
|
||||
output("{\"v\":1,\"e\":\"progress\",\"phase\":\"mesh\","
|
||||
+ "\"completed\":25,\"total\":100,\"failures\":0,"
|
||||
+ "\"elapsedSeconds\":5,\"etaSeconds\":15}\n");
|
||||
output("{\"v\":1,\"e\":\"newMetric\",\"value\":1}\n");
|
||||
output($"{{\"v\":1,\"e\":\"completed\",\"bakeToolVersion\":4,"
|
||||
+ $"\"outputBytes\":{bytes},\"failures\":0}}\n");
|
||||
return new BakeProcessResult(0, string.Empty);
|
||||
});
|
||||
var installer = new LauncherInstaller(
|
||||
_paths,
|
||||
_bakeExecutable,
|
||||
processRunner: runner);
|
||||
var progress = new List<LauncherInstallProgress>();
|
||||
|
||||
LauncherInstallResult result = await installer.InstallAsync(
|
||||
_dats,
|
||||
threads: 7,
|
||||
new ImmediateProgress<LauncherInstallProgress>(progress.Add));
|
||||
|
||||
Assert.NotNull(observedRequest);
|
||||
Assert.Equal(Path.GetFullPath(_bakeExecutable), observedRequest.ExecutablePath);
|
||||
Assert.Equal(Path.GetFullPath(_dats), observedRequest.DatDirectory);
|
||||
Assert.Equal(
|
||||
Path.Combine(_paths.DataDirectory, "pak", "acdream.pak"),
|
||||
observedRequest.OutputPath);
|
||||
Assert.Equal(
|
||||
[
|
||||
"--dat-dir", Path.GetFullPath(_dats),
|
||||
"--out", Path.Combine(_paths.DataDirectory, "pak", "acdream.pak"),
|
||||
"--threads", "7",
|
||||
"--progress-json",
|
||||
],
|
||||
observedRequest.Arguments);
|
||||
Assert.Equal(LauncherInstallRecordStore.CurrentBakeToolVersion,
|
||||
result.Record.BakeToolVersion);
|
||||
Assert.Equal(new FileInfo(result.Record.PreparedAssetPath).Length,
|
||||
result.Record.PreparedAssetSize);
|
||||
Assert.Equal(
|
||||
await FileIntegrity.ComputeSha256HexAsync(result.Record.PreparedAssetPath),
|
||||
result.Record.PreparedAssetSha256);
|
||||
Assert.Contains(progress, value => value.Phase == LauncherInstallPhase.BakingMeshes);
|
||||
Assert.Equal(LauncherInstallPhase.Completed, progress[^1].Phase);
|
||||
|
||||
var store = new LauncherInstallRecordStore(_paths);
|
||||
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
|
||||
Assert.True(verification.IsVerified);
|
||||
Assert.Equal(result.Record, verification.Record);
|
||||
|
||||
var server = new ServerProfile
|
||||
{
|
||||
Name = "Local ACE",
|
||||
Host = "127.0.0.1",
|
||||
Port = 9000,
|
||||
};
|
||||
var account = new AccountProfile
|
||||
{
|
||||
Account = "testaccount",
|
||||
Password = "credential-never-serialized",
|
||||
};
|
||||
var character = new CharacterProfile
|
||||
{
|
||||
Name = "+Acdream",
|
||||
Id = "0x5000000A",
|
||||
LaunchMode = LaunchMode.Gui,
|
||||
};
|
||||
ComposedSessionConfig composed = SessionConfigComposer.Compose(
|
||||
server,
|
||||
account,
|
||||
character,
|
||||
result.Record,
|
||||
_paths,
|
||||
"installed-session");
|
||||
JsonObject content = JsonNode.Parse(
|
||||
SessionConfigComposer.Serialize(composed.Document))!
|
||||
["process"]!["content"]!.AsObject();
|
||||
Assert.Equal(result.Record.DatDirectory, (string?)content["datDirectory"]);
|
||||
Assert.Equal(
|
||||
result.Record.PreparedAssetPath,
|
||||
(string?)content["preparedAssetPath"]);
|
||||
Assert.DoesNotContain(
|
||||
result.Record.PreparedAssetSha256,
|
||||
SessionConfigComposer.Serialize(composed.Document),
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FailedChildRestoresPriorVerifiedPakAndRecord()
|
||||
{
|
||||
(LauncherInstaller installer, LauncherInstallRecordStore store, LauncherInstallRecord old) =
|
||||
await CreateInstallerWithPriorRecordAsync(
|
||||
async (request, output, _) =>
|
||||
{
|
||||
await File.WriteAllTextAsync(request.OutputPath, "partial replacement");
|
||||
output("{\"v\":1,\"e\":\"started\",\"bakeToolVersion\":4}\n");
|
||||
output("{\"v\":1,\"e\":\"error\",\"message\":\"fixture failed\"}\n");
|
||||
return new BakeProcessResult(9, "human failure detail");
|
||||
},
|
||||
loadExisting: false);
|
||||
string recordBefore = await File.ReadAllTextAsync(store.RecordPath);
|
||||
var progress = new List<LauncherInstallProgress>();
|
||||
|
||||
LauncherInstallException exception = await Assert.ThrowsAsync<LauncherInstallException>(
|
||||
() => installer.InstallAsync(
|
||||
_dats,
|
||||
2,
|
||||
new ImmediateProgress<LauncherInstallProgress>(progress.Add)));
|
||||
|
||||
Assert.Contains("fixture failed", exception.Message, StringComparison.Ordinal);
|
||||
Assert.Equal("previous verified package", await File.ReadAllTextAsync(
|
||||
store.PreparedAssetPath));
|
||||
Assert.Equal(recordBefore, await File.ReadAllTextAsync(store.RecordPath));
|
||||
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
|
||||
Assert.True(verification.IsVerified);
|
||||
Assert.Equal(old, verification.Record);
|
||||
Assert.Equal(LauncherInstallPhase.Failed, progress[^1].Phase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancellationRestoresPriorInstallAndNeverPublishesPartialOutput()
|
||||
{
|
||||
var enteredChild = new TaskCompletionSource(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
(LauncherInstaller installer, LauncherInstallRecordStore store, LauncherInstallRecord old) =
|
||||
await CreateInstallerWithPriorRecordAsync(
|
||||
async (request, _, cancellationToken) =>
|
||||
{
|
||||
await File.WriteAllTextAsync(
|
||||
request.OutputPath,
|
||||
"partial replacement",
|
||||
cancellationToken);
|
||||
enteredChild.SetResult();
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||
return new BakeProcessResult(0, string.Empty);
|
||||
});
|
||||
var progress = new List<LauncherInstallProgress>();
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
|
||||
Task<LauncherInstallResult> operation = installer.InstallAsync(
|
||||
_dats,
|
||||
3,
|
||||
new ImmediateProgress<LauncherInstallProgress>(progress.Add),
|
||||
cancellation.Token);
|
||||
await enteredChild.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
cancellation.Cancel();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => operation);
|
||||
Assert.Equal("previous verified package", await File.ReadAllTextAsync(
|
||||
store.PreparedAssetPath));
|
||||
Assert.False(File.Exists(LauncherInstallRecordStore.GetBackupPath(
|
||||
store.PreparedAssetPath)));
|
||||
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
|
||||
Assert.True(verification.IsVerified);
|
||||
Assert.Equal(old, verification.Record);
|
||||
Assert.Equal(LauncherInstallPhase.Cancelled, progress[^1].Phase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FailedFirstInstallRemovesPartialPakAndCreatesNoRecord()
|
||||
{
|
||||
var runner = new FakeBakeProcessRunner(async (request, _, _) =>
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(request.OutputPath)!);
|
||||
await File.WriteAllTextAsync(request.OutputPath, "partial");
|
||||
return new BakeProcessResult(1, "failed");
|
||||
});
|
||||
var installer = new LauncherInstaller(
|
||||
_paths,
|
||||
_bakeExecutable,
|
||||
processRunner: runner);
|
||||
var store = new LauncherInstallRecordStore(_paths);
|
||||
|
||||
await Assert.ThrowsAsync<LauncherInstallException>(
|
||||
() => installer.InstallAsync(_dats, 1));
|
||||
|
||||
Assert.False(File.Exists(store.PreparedAssetPath));
|
||||
Assert.False(File.Exists(store.RecordPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancellationDuringHashRemovesUnrecordedPublishedPackage()
|
||||
{
|
||||
var hashEntered = new TaskCompletionSource(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var runner = new FakeBakeProcessRunner(async (request, output, _) =>
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(request.OutputPath)!);
|
||||
await File.WriteAllTextAsync(request.OutputPath, "complete but unverified");
|
||||
long bytes = new FileInfo(request.OutputPath).Length;
|
||||
output("{\"v\":1,\"e\":\"started\",\"bakeToolVersion\":4}\n");
|
||||
output($"{{\"v\":1,\"e\":\"completed\",\"bakeToolVersion\":4,"
|
||||
+ $"\"outputBytes\":{bytes},\"failures\":0}}\n");
|
||||
return new BakeProcessResult(0, string.Empty);
|
||||
});
|
||||
var store = new LauncherInstallRecordStore(_paths);
|
||||
var installer = new LauncherInstaller(
|
||||
_paths,
|
||||
_bakeExecutable,
|
||||
recordStore: store,
|
||||
processRunner: runner,
|
||||
computeSha256: async (_, cancellationToken) =>
|
||||
{
|
||||
hashEntered.SetResult();
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||
return new string('a', 64);
|
||||
});
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
|
||||
Task<LauncherInstallResult> operation = installer.InstallAsync(
|
||||
_dats,
|
||||
2,
|
||||
cancellationToken: cancellation.Token);
|
||||
await hashEntered.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
cancellation.Cancel();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => operation);
|
||||
Assert.False(File.Exists(store.PreparedAssetPath));
|
||||
Assert.False(File.Exists(store.RecordPath));
|
||||
}
|
||||
|
||||
private async Task<(
|
||||
LauncherInstaller Installer,
|
||||
LauncherInstallRecordStore Store,
|
||||
LauncherInstallRecord Old)> CreateInstallerWithPriorRecordAsync(
|
||||
Func<BakeProcessRequest, Action<string>, CancellationToken, Task<BakeProcessResult>> handler,
|
||||
bool loadExisting = true)
|
||||
{
|
||||
var store = new LauncherInstallRecordStore(_paths);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!);
|
||||
await File.WriteAllTextAsync(
|
||||
store.PreparedAssetPath,
|
||||
"previous verified package");
|
||||
var old = new LauncherInstallRecord(
|
||||
Path.GetFullPath(_dats),
|
||||
Path.GetFullPath(store.PreparedAssetPath),
|
||||
await FileIntegrity.ComputeSha256HexAsync(store.PreparedAssetPath),
|
||||
new FileInfo(store.PreparedAssetPath).Length,
|
||||
LauncherInstallRecordStore.CurrentBakeToolVersion);
|
||||
await store.SaveAtomicallyAsync(old);
|
||||
|
||||
var installer = new LauncherInstaller(
|
||||
_paths,
|
||||
_bakeExecutable,
|
||||
recordStore: store,
|
||||
processRunner: new FakeBakeProcessRunner(handler));
|
||||
if (loadExisting)
|
||||
{
|
||||
Assert.True((await installer.LoadExistingAsync()).IsVerified);
|
||||
}
|
||||
|
||||
return (installer, store, old);
|
||||
}
|
||||
|
||||
private static void CreateCompleteDatDirectory(string directory)
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
foreach (string fileName in DatDirectoryLocator.RequiredFileNames)
|
||||
{
|
||||
File.WriteAllText(Path.Combine(directory, fileName), "fixture");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeBakeProcessRunner(
|
||||
Func<BakeProcessRequest, Action<string>, CancellationToken, Task<BakeProcessResult>> handler)
|
||||
: IBakeProcessRunner
|
||||
{
|
||||
private readonly Func<
|
||||
BakeProcessRequest,
|
||||
Action<string>,
|
||||
CancellationToken,
|
||||
Task<BakeProcessResult>> _handler = handler;
|
||||
|
||||
public Task<BakeProcessResult> RunAsync(
|
||||
BakeProcessRequest request,
|
||||
Action<string> onStandardOutput,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
_handler(request, onStandardOutput, cancellationToken);
|
||||
}
|
||||
|
||||
private sealed class ImmediateProgress<T>(Action<T> callback) : IProgress<T>
|
||||
{
|
||||
public void Report(T value) => callback(value);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue