using System.Diagnostics; 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 ContradictoryTerminalCannotReplaceFirstFailureOrPriorInstall() { (LauncherInstaller installer, LauncherInstallRecordStore store, LauncherInstallRecord old) = await CreateInstallerWithPriorRecordAsync( async (request, output, _) => { await File.WriteAllTextAsync(request.OutputPath, "contradictory output"); long bytes = new FileInfo(request.OutputPath).Length; output("{\"v\":1,\"e\":\"started\",\"bakeToolVersion\":4}\n"); output("{\"v\":1,\"e\":\"error\",\"message\":\"first failure\"}\n"); output($"{{\"v\":1,\"e\":\"completed\",\"bakeToolVersion\":4," + $"\"outputBytes\":{bytes},\"failures\":0}}\n"); return new BakeProcessResult(0, string.Empty); }); LauncherInstallException exception = await Assert.ThrowsAsync( () => installer.InstallAsync(_dats, 2)); Assert.Contains("after", exception.Message, StringComparison.OrdinalIgnoreCase); Assert.Equal( "previous verified package", await File.ReadAllTextAsync(store.PreparedAssetPath)); Assert.Equal(old, (await store.LoadAndVerifyAsync()).Record); } [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)); } [Fact] public async Task IndependentInstallersSerializeAndWaitingCancellationTouchesNothing() { var storeA = new LauncherInstallRecordStore(_paths); LauncherInstallRecord old = await CreatePriorRecordAsync(storeA); string backupPath = LauncherInstallRecordStore.GetBackupPath( storeA.PreparedAssetPath); var childEntered = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); var releaseChild = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); var runnerA = new FakeBakeProcessRunner(async (request, _, _) => { await File.WriteAllTextAsync(request.OutputPath, "installer A in progress"); childEntered.SetResult(); await releaseChild.Task; return new BakeProcessResult(1, "fixture A failed"); }); bool runnerBEntered = false; var runnerB = new FakeBakeProcessRunner((_, _, _) => { runnerBEntered = true; return Task.FromResult(new BakeProcessResult(1, "must not run")); }); var installerA = new LauncherInstaller( _paths, _bakeExecutable, recordStore: storeA, processRunner: runnerA); var installerB = new LauncherInstaller( _paths, _bakeExecutable, recordStore: new LauncherInstallRecordStore(_paths), processRunner: runnerB); Task operationA = installerA.InstallAsync(_dats, 1); await childEntered.Task.WaitAsync(TimeSpan.FromSeconds(5)); try { using var cancellationB = new CancellationTokenSource(); Task operationB = installerB.InstallAsync( _dats, 1, cancellationToken: cancellationB.Token); await Task.Delay(150); cancellationB.Cancel(); await Assert.ThrowsAnyAsync(() => operationB); Assert.False(runnerBEntered); Assert.Equal( "installer A in progress", await File.ReadAllTextAsync(storeA.PreparedAssetPath)); Assert.Equal( "previous verified package", await File.ReadAllTextAsync(backupPath)); } finally { releaseChild.TrySetResult(); } await Assert.ThrowsAsync(() => operationA); Assert.Equal( "previous verified package", await File.ReadAllTextAsync(storeA.PreparedAssetPath)); Assert.False(File.Exists(backupPath)); Assert.Equal(old, (await storeA.LoadAndVerifyAsync()).Record); } [Fact] public void StagingCleanupDeletesOnlyExactBakeTransactionNames() { var store = new LauncherInstallRecordStore(_paths); string outputPath = store.PreparedAssetPath; string directory = Path.GetDirectoryName(outputPath)!; Directory.CreateDirectory(directory); string owned = BakeOutputStagingContract.CreateStagingPath( outputPath, Guid.Parse("01234567-89ab-cdef-0123-456789abcdef")); string canonical = outputPath; string backup = LauncherInstallRecordStore.GetBackupPath(outputPath); string oldPattern = Path.Combine( directory, $".{Path.GetFileName(outputPath)}.{Guid.NewGuid():N}.tmp"); string invalidTransaction = Path.Combine( directory, $".{Path.GetFileName(outputPath)}.acdream-bake.not-a-guid.tmp"); string unrelated = Path.Combine(directory, "unrelated.tmp"); Assert.Equal( Path.Combine( directory, ".acdream.pak.acdream-bake.0123456789abcdef0123456789abcdef.tmp"), owned); foreach (string path in new[] { owned, canonical, backup, oldPattern, invalidTransaction, unrelated, }) { File.WriteAllText(path, Path.GetFileName(path)); } BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath); Assert.False(File.Exists(owned)); Assert.True(File.Exists(canonical)); Assert.True(File.Exists(backup)); Assert.True(File.Exists(oldPattern)); Assert.True(File.Exists(invalidTransaction)); Assert.True(File.Exists(unrelated)); } [Fact] public async Task KilledProcessReleasesLeaseAndRestartReclaimsOnlyBakeStaging() { var store = new LauncherInstallRecordStore(_paths); LauncherInstallRecord old = await CreatePriorRecordAsync(store); string staging = BakeOutputStagingContract.CreateStagingPath( store.PreparedAssetPath, Guid.Parse("fedcba98-7654-3210-fedc-ba9876543210")); string ready = Path.Combine(_root, "fixture-ready"); string fixtureDll = GetInstallLeaseFixturePath(); Assert.True(File.Exists(fixtureDll), $"Missing fixture: {fixtureDll}"); var startInfo = new ProcessStartInfo("dotnet") { RedirectStandardError = true, RedirectStandardOutput = true, UseShellExecute = false, }; startInfo.ArgumentList.Add(fixtureDll); startInfo.ArgumentList.Add( InstallerTransactionLease.GetLockPath(store.DataDirectory)); startInfo.ArgumentList.Add(staging); startInfo.ArgumentList.Add(ready); using Process helper = Process.Start(startInfo) ?? throw new InvalidOperationException("Could not start lease fixture."); try { await WaitForFileAsync(ready, helper, TimeSpan.FromSeconds(10)); Assert.True(File.Exists(staging)); var blockedInstaller = new LauncherInstaller( _paths, _bakeExecutable, recordStore: new LauncherInstallRecordStore(_paths)); using var blockedCancellation = new CancellationTokenSource( TimeSpan.FromMilliseconds(200)); await Assert.ThrowsAnyAsync( () => blockedInstaller.LoadExistingAsync(blockedCancellation.Token)); Assert.True(File.Exists(staging)); } finally { if (!helper.HasExited) { helper.Kill(entireProcessTree: true); } await helper.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); } var restarted = new LauncherInstaller( _paths, _bakeExecutable, recordStore: new LauncherInstallRecordStore(_paths)); InstallRecordVerification recovered = await restarted.LoadExistingAsync(); Assert.True(recovered.IsVerified); Assert.Equal(old, recovered.Record); Assert.False(File.Exists(staging)); Assert.Equal( "previous verified package", await File.ReadAllTextAsync(store.PreparedAssetPath)); } 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 async Task CreatePriorRecordAsync( LauncherInstallRecordStore store) { 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); return old; } private static async Task WaitForFileAsync( string path, Process process, TimeSpan timeout) { using var cancellation = new CancellationTokenSource(timeout); while (!File.Exists(path)) { if (process.HasExited) { throw new InvalidOperationException( $"Lease fixture exited with {process.ExitCode}: " + await process.StandardError.ReadToEndAsync()); } await Task.Delay(25, cancellation.Token); } } private static string GetInstallLeaseFixturePath() { string root = FindRepositoryRoot(); string configuration = new DirectoryInfo(AppContext.BaseDirectory) .Parent?.Name ?? "Release"; return Path.Combine( root, "tests", "AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder", "bin", configuration, "net10.0", "AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder.dll"); } private static string FindRepositoryRoot() { foreach (string start in new[] { AppContext.BaseDirectory, Environment.CurrentDirectory, }) { for (var directory = new DirectoryInfo(start); directory is not null; directory = directory.Parent) { if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) { return directory.FullName; } } } throw new DirectoryNotFoundException("Could not locate repository root."); } 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); } }