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(); LauncherInstallResult result = await installer.InstallAsync( _dats, threads: 7, new ImmediateProgress(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(); LauncherInstallException exception = await Assert.ThrowsAsync( () => installer.InstallAsync( _dats, 2, new ImmediateProgress(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(); using var cancellation = new CancellationTokenSource(); Task operation = installer.InstallAsync( _dats, 3, new ImmediateProgress(progress.Add), cancellation.Token); await enteredChild.Task.WaitAsync(TimeSpan.FromSeconds(5)); cancellation.Cancel(); await Assert.ThrowsAnyAsync(() => 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( () => 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 operation = installer.InstallAsync( _dats, 2, cancellationToken: cancellation.Token); await hashEntered.Task.WaitAsync(TimeSpan.FromSeconds(5)); cancellation.Cancel(); await Assert.ThrowsAnyAsync(() => operation); Assert.False(File.Exists(store.PreparedAssetPath)); Assert.False(File.Exists(store.RecordPath)); } private async Task<( LauncherInstaller Installer, LauncherInstallRecordStore Store, LauncherInstallRecord Old)> CreateInstallerWithPriorRecordAsync( Func, CancellationToken, Task> 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, CancellationToken, Task> handler) : IBakeProcessRunner { private readonly Func< BakeProcessRequest, Action, CancellationToken, Task> _handler = handler; public Task RunAsync( BakeProcessRequest request, Action onStandardOutput, CancellationToken cancellationToken = default) => _handler(request, onStandardOutput, cancellationToken); } private sealed class ImmediateProgress(Action callback) : IProgress { public void Report(T value) => callback(value); } }