using System.Text.Json; using AcDream.Launcher.Core.Integrity; using AcDream.Launcher.Core.Installation; using AcDream.Launcher.Core.Launching; using AcDream.Platform; namespace AcDream.Launcher.Core.Tests.Installation; public sealed class LauncherOverlayInstallerTests : IDisposable { private readonly string _root = Path.Combine( Path.GetTempPath(), "acdream-overlay-installer-tests", Guid.NewGuid().ToString("N")); private readonly ApplicationPathSet _paths; private readonly string _dats; private readonly string _bakeExecutable; public LauncherOverlayInstallerTests() { _paths = new ApplicationPathSet( Path.Combine(_root, "config"), Path.Combine(_root, "data"), Path.Combine(_root, "cache"), null); _dats = Path.Combine(_root, "dats"); foreach (string name in DatDirectoryLocator.RequiredFileNames) { Directory.CreateDirectory(_dats); File.WriteAllText(Path.Combine(_dats, name), name); } _bakeExecutable = Path.Combine(_root, "bin", "acdream-bake"); Directory.CreateDirectory(Path.GetDirectoryName(_bakeExecutable)!); File.WriteAllText(_bakeExecutable, "fixture"); } public void Dispose() { if (Directory.Exists(_root)) { Directory.Delete(_root, recursive: true); } } [Fact] public async Task FilteredBakePublishesOneOverlayWithoutRewritingBase() { var recordStore = new LauncherInstallRecordStore(_paths); LauncherInstallRecord baseRecord = await CreateBaseRecordAsync(recordStore); byte[] baseBefore = await File.ReadAllBytesAsync( recordStore.PreparedAssetPath); BakeProcessRequest? observed = null; var runner = new FakeRunner(async (request, output, _) => { observed = request; LauncherContentStateStoreTests.WritePakHeader( request.OutputPath, LauncherInstallRecordStore.CurrentBakeToolVersion); long bytes = new FileInfo(request.OutputPath).Length; output($"{{\"v\":1,\"e\":\"started\",\"bakeToolVersion\":5}}\n"); output($"{{\"v\":1,\"e\":\"completed\",\"bakeToolVersion\":5," + $"\"outputBytes\":{bytes},\"failures\":0}}\n"); await Task.Yield(); return new BakeProcessResult(0, string.Empty); }); var installer = new LauncherInstaller( _paths, _bakeExecutable, recordStore: recordStore, processRunner: runner); var migration = new ContentMigrationPlan( 4, 5, ContentWorkKind.Overlay, "bounded fixture update", [0x01001234u, 0x02005678u], [0xAB]); LauncherInstallResult result = await installer.ApplyContentUpdateAsync( _dats, 3, migration); Assert.Equal(baseBefore, await File.ReadAllBytesAsync( recordStore.PreparedAssetPath)); Assert.NotNull(observed); Assert.Equal( new[] { "--ids", "0x01001234,0x02005678" }, observed.Arguments.SkipWhile(value => value != "--ids").Take(2)); Assert.Equal( new[] { "--landblocks", "0xAB" }, observed.Arguments.SkipWhile(value => value != "--landblocks").Take(2)); Assert.NotNull(result.Record.PreparedAssetOverlayPath); Assert.True(File.Exists(result.Record.PreparedAssetOverlayPath)); Assert.Equal(5u, result.Record.ResolvedBakeToolVersion); Assert.True(result.Record.RequiresClientCompatibilityConfirmation); Assert.False(File.Exists( new LauncherContentStateStore(_paths).OverlayCandidatePath)); var restartedInstaller = new LauncherInstaller( _paths, _bakeExecutable, recordStore: recordStore, processRunner: runner); InstallRecordVerification discovered = await restartedInstaller.LoadExistingAsync(); Assert.True(discovered.IsVerified); Assert.True(discovered.Record?.RequiresClientCompatibilityConfirmation); Assert.Equal( result.Record.PreparedAssetOverlayPath, discovered.Record?.PreparedAssetOverlayPath); restartedInstaller.ConfirmClientCompatibility(); discovered = await restartedInstaller.LoadExistingAsync(); Assert.True(discovered.IsVerified); Assert.False(discovered.Record?.RequiresClientCompatibilityConfirmation); string json = SessionConfigComposer.Serialize( SessionConfigComposer.Compose( new() { Name = "server", Host = "localhost", Port = 9000 }, new() { Account = "account", Password = "secret" }, new() { Name = "character", LaunchMode = AcDream.Launcher.Core.Profiles.LaunchMode.Gui, }, discovered.Record!, _paths, "overlay-session").Document); Assert.Contains("preparedAssetOverlayPath", json, StringComparison.Ordinal); Assert.Contains("preparedAssetBaseRecipeVersion", json, StringComparison.Ordinal); Assert.DoesNotContain(baseRecord.PreparedAssetSha256, json, StringComparison.Ordinal); } [Fact] public async Task CancellationLeavesBaseRecordAndSidecarUntouched() { var recordStore = new LauncherInstallRecordStore(_paths); LauncherInstallRecord baseRecord = await CreateBaseRecordAsync(recordStore); string recordBefore = await File.ReadAllTextAsync(recordStore.RecordPath); byte[] baseBefore = await File.ReadAllBytesAsync( recordStore.PreparedAssetPath); var entered = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); var runner = new FakeRunner(async (request, _, cancellationToken) => { LauncherContentStateStoreTests.WritePakHeader(request.OutputPath, 5); entered.SetResult(); await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); return new BakeProcessResult(0, string.Empty); }); var installer = new LauncherInstaller( _paths, _bakeExecutable, recordStore: recordStore, processRunner: runner); using var cancellation = new CancellationTokenSource(); Task operation = installer.ApplyContentUpdateAsync( _dats, 2, new ContentMigrationPlan( 4, 5, ContentWorkKind.Overlay, "bounded fixture update", [0x01001234u]), cancellationToken: cancellation.Token); await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); cancellation.Cancel(); await Assert.ThrowsAnyAsync(() => operation); Assert.Equal(baseBefore, await File.ReadAllBytesAsync( recordStore.PreparedAssetPath)); Assert.Equal(recordBefore, await File.ReadAllTextAsync(recordStore.RecordPath)); var stateStore = new LauncherContentStateStore(_paths); Assert.False(File.Exists(stateStore.StatePath)); Assert.False(File.Exists(stateStore.OverlayCandidatePath)); Assert.False(File.Exists(stateStore.ClientCompatibilityPendingPath)); Assert.Equal( InstallRecordVerificationState.ContentUpdateRequired, (await installer.LoadExistingAsync()).State); Assert.Equal(baseRecord, (await recordStore.LoadAndVerifyAsync()).Record); } private async Task CreateBaseRecordAsync( LauncherInstallRecordStore recordStore) { LauncherContentStateStoreTests.WritePakHeader( recordStore.PreparedAssetPath, recipe: 4); LauncherInstallRecord baseRecord = await LauncherContentStateStoreTests.RecordAsync( recordStore.PreparedAssetPath, recipe: 4, datDirectory: Path.GetFullPath(_dats)); Directory.CreateDirectory(_paths.DataDirectory); await File.WriteAllTextAsync( recordStore.RecordPath, JsonSerializer.Serialize(baseRecord, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, })); return baseRecord; } private sealed class FakeRunner( Func, CancellationToken, Task> handler) : IBakeProcessRunner { public Task RunAsync( BakeProcessRequest request, Action onStandardOutput, CancellationToken cancellationToken = default) => handler(request, onStandardOutput, cancellationToken); } }