merge: Campaign LA LA9 - verified installer review-closed

This commit is contained in:
Erik 2026-08-14 21:06:26 +02:00
commit 2198a0cc8e
45 changed files with 5143 additions and 132 deletions

View file

@ -19,5 +19,11 @@
<ItemGroup>
<ProjectReference Include="..\..\src\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
<ProjectReference Include="..\..\src\AcDream.Platform\AcDream.Platform.csproj" />
<!-- Build ordering only. The crash-recovery test launches this fixture in
a separate process so the OS owns and releases the install lease. -->
<ProjectReference Include="..\AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder\AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder.csproj">
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
</ProjectReference>
</ItemGroup>
</Project>

View file

@ -0,0 +1,46 @@
using AcDream.Launcher.Core.Installation;
using AcDream.Platform;
namespace AcDream.Launcher.Core.Tests.Installation;
public sealed class BakeProcessRunnerTests
{
[Fact]
public void PublicationNonceIsEnvironmentOnlyAndVisibleArgumentsStayPinned()
{
string nonce = Guid.Parse("01234567-89ab-cdef-0123-456789abcdef")
.ToString("N");
var request = new BakeProcessRequest(
"acdream-bake",
"retail-dats",
"data/pak/acdream.pak",
7,
nonce);
System.Diagnostics.ProcessStartInfo startInfo =
SystemBakeProcessRunner.CreateStartInfo(request);
Assert.Equal(request.Arguments, startInfo.ArgumentList);
Assert.DoesNotContain(nonce, startInfo.ArgumentList);
Assert.Equal(
nonce,
startInfo.Environment[
BakePublicationGuardPaths.NonceEnvironmentVariable]);
}
[Fact]
public void UnguardedRequestExplicitlyRemovesInheritedAuthorization()
{
var request = new BakeProcessRequest(
"acdream-bake",
"retail-dats",
"data/pak/acdream.pak",
1);
System.Diagnostics.ProcessStartInfo startInfo =
SystemBakeProcessRunner.CreateStartInfo(request);
Assert.False(startInfo.Environment.ContainsKey(
BakePublicationGuardPaths.NonceEnvironmentVariable));
}
}

View file

@ -0,0 +1,57 @@
using AcDream.Launcher.Core.Installation;
namespace AcDream.Launcher.Core.Tests.Installation;
public sealed class BakeProgressJsonlParserTests
{
[Fact]
public void PartialChunksAreBufferedUntilTheJsonLineIsComplete()
{
var parser = new BakeProgressJsonlParser();
Assert.IsType<BakeHumanOutputEvent>(Assert.Single(
parser.Append("human startup text\n{\"v\":1,\"e\":\"pro")));
IReadOnlyList<BakeProgressEvent> events = parser.Append(
"gress\",\"phase\":\"mesh\",\"completed\":4,\"total\":10,"
+ "\"failures\":0,\"elapsedSeconds\":5,\"etaSeconds\":7}\n");
Assert.Single(events);
BakeWorkProgressEvent progress =
Assert.IsType<BakeWorkProgressEvent>(events[0]);
Assert.Equal("mesh", progress.Phase);
Assert.Equal(4, progress.Completed);
Assert.Equal(10, progress.Total);
}
[Fact]
public void MalformedKnownPayloadAndTruncatedFinalLineNeverThrow()
{
var parser = new BakeProgressJsonlParser();
IReadOnlyList<BakeProgressEvent> first = parser.Append(
"{\"v\":1,\"e\":\"progress\",\"phase\":\"mesh\"}\n"
+ "{not-json");
Assert.IsType<MalformedBakeProgressEvent>(Assert.Single(first));
MalformedBakeProgressEvent final = Assert.IsType<MalformedBakeProgressEvent>(
Assert.Single(parser.Complete()));
Assert.False(string.IsNullOrWhiteSpace(final.Reason));
}
[Fact]
public void UnknownKindsAndFutureVersionsRemainTypedAndFutureSafe()
{
var parser = new BakeProgressJsonlParser();
IReadOnlyList<BakeProgressEvent> events = parser.Append(
"{\"v\":1,\"e\":\"newMetric\",\"value\":9}\n"
+ "{\"v\":2,\"e\":\"progress\",\"newShape\":true}\n"
+ "{\"v\":1,\"e\":\"started\",\"bakeToolVersion\":4,"
+ "\"outputPath\":\"pak\",\"futureField\":42}\n");
Assert.IsType<UnknownBakeProgressEvent>(events[0]);
FutureBakeProgressEvent future =
Assert.IsType<FutureBakeProgressEvent>(events[1]);
Assert.Equal(2, future.Version);
BakeStartedEvent started = Assert.IsType<BakeStartedEvent>(events[2]);
Assert.Equal(4u, started.BakeToolVersion);
}
}

View file

@ -0,0 +1,109 @@
using AcDream.Launcher.Core.Installation;
namespace AcDream.Launcher.Core.Tests.Installation;
public sealed class BakeProgressProtocolTests
{
[Fact]
public void OneStartedProgressAndCompletedSequenceIsAccepted()
{
var protocol = new BakeProgressProtocol();
Assert.True(protocol.Observe(new BakeHumanOutputEvent("human")));
Assert.True(protocol.Observe(new UnknownBakeProgressEvent(
1,
"newMetric",
"{}")));
Assert.True(protocol.Observe(new FutureBakeProgressEvent(
2,
"started",
"{}")));
Assert.True(protocol.Observe(new BakeStartedEvent(1, 4, "pak")));
Assert.True(protocol.Observe(new BakeWorkProgressEvent(
1,
"mesh",
1,
2,
0,
1,
1)));
Assert.True(protocol.Observe(new BakeCompletedEvent(1, 4, 100, 0)));
protocol.CompleteInput();
Assert.Null(protocol.Violation);
Assert.NotNull(protocol.Started);
Assert.NotNull(protocol.Completed);
Assert.Null(protocol.Error);
}
[Theory]
[MemberData(nameof(InvalidKnownSequences))]
public void OutOfOrderDuplicateAndPostTerminalKnownEventsAreRejected(
BakeProgressEvent[] events)
{
var protocol = new BakeProgressProtocol();
foreach (BakeProgressEvent progressEvent in events)
{
protocol.Observe(progressEvent);
}
protocol.CompleteInput();
Assert.NotNull(protocol.Violation);
}
[Fact]
public void ErrorTerminalCannotBeOverwrittenByContradictoryCompletion()
{
var protocol = new BakeProgressProtocol();
var failure = new BakeErrorEvent(1, "first failure");
Assert.True(protocol.Observe(new BakeStartedEvent(1, 4, null)));
Assert.True(protocol.Observe(failure));
Assert.False(protocol.Observe(new BakeCompletedEvent(1, 4, 10, 0)));
protocol.CompleteInput();
Assert.Same(failure, protocol.Error);
Assert.Null(protocol.Completed);
Assert.Contains("after", protocol.Violation, StringComparison.OrdinalIgnoreCase);
}
public static TheoryData<BakeProgressEvent[]> InvalidKnownSequences => new()
{
new BakeProgressEvent[]
{
new BakeWorkProgressEvent(1, "mesh", 0, 1, 0, 0, 0),
},
new BakeProgressEvent[]
{
new BakeCompletedEvent(1, 4, 10, 0),
},
new BakeProgressEvent[]
{
new BakeErrorEvent(1, "before start"),
},
new BakeProgressEvent[]
{
new BakeStartedEvent(1, 4, null),
new BakeStartedEvent(1, 4, null),
},
new BakeProgressEvent[]
{
new BakeStartedEvent(1, 4, null),
new BakeCompletedEvent(1, 4, 10, 0),
new BakeCompletedEvent(1, 4, 10, 0),
},
new BakeProgressEvent[]
{
new BakeStartedEvent(1, 4, null),
new BakeCompletedEvent(1, 4, 10, 0),
new BakeWorkProgressEvent(1, "mesh", 1, 1, 0, 1, 0),
},
new BakeProgressEvent[]
{
new BakeStartedEvent(1, 4, null),
},
};
}

View file

@ -0,0 +1,90 @@
using AcDream.Launcher.Core.Installation;
namespace AcDream.Launcher.Core.Tests.Installation;
public sealed class DatDirectoryLocatorTests : IDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"acdream-dat-locator-tests",
Guid.NewGuid().ToString("N"));
public DatDirectoryLocatorTests() => Directory.CreateDirectory(_root);
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[Fact]
public void PortableValidationRequiresTheFourExactDatFileNames()
{
string directory = Path.Combine(_root, "retail");
Directory.CreateDirectory(directory);
foreach (string fileName in DatDirectoryLocator.RequiredFileNames.Take(3))
{
File.WriteAllText(Path.Combine(directory, fileName), "fixture");
}
var locator = new DatDirectoryLocator(isWindows: false);
DatDirectoryValidation incomplete = locator.Validate(directory);
Assert.False(incomplete.IsValid);
Assert.Equal(["client_local_English.dat"], incomplete.MissingFileNames);
File.WriteAllText(
Path.Combine(directory, "client_local_English.dat"),
"fixture");
DatDirectoryValidation valid = locator.Validate(directory);
Assert.True(valid.IsValid);
Assert.Equal(Path.GetFullPath(directory), valid.Directory);
Assert.Empty(valid.MissingFileNames);
}
[Fact]
public void WindowsDetectionChecksBothConventionalLocationsInOrder()
{
string documents = Path.Combine(_root, "Documents", "Asheron's Call");
string turbine = Path.Combine(_root, "Turbine", "Asheron's Call");
CreateCompleteDatDirectory(documents);
Directory.CreateDirectory(turbine);
File.WriteAllText(Path.Combine(turbine, "client_portal.dat"), "fixture");
var locator = new DatDirectoryLocator(
isWindows: true,
windowsCandidates: [documents, turbine]);
IReadOnlyList<DatDirectoryValidation> detected = locator.Detect();
Assert.Equal(2, detected.Count);
Assert.Equal(Path.GetFullPath(documents), detected[0].Directory);
Assert.True(detected[0].IsValid);
Assert.Equal(Path.GetFullPath(turbine), detected[1].Directory);
Assert.False(detected[1].IsValid);
Assert.Equal(3, detected[1].MissingFileNames.Count);
}
[Fact]
public void LinuxHasNoWindowsAutoDetectionButManualValidationStillWorks()
{
string manual = Path.Combine(_root, "linux-dats");
CreateCompleteDatDirectory(manual);
var locator = new DatDirectoryLocator(
isWindows: false,
windowsCandidates: [manual]);
Assert.Empty(locator.Detect());
Assert.True(locator.Validate(manual).IsValid);
}
private static void CreateCompleteDatDirectory(string directory)
{
Directory.CreateDirectory(directory);
foreach (string fileName in DatDirectoryLocator.RequiredFileNames)
{
File.WriteAllText(Path.Combine(directory, fileName), "fixture");
}
}
}

View file

@ -0,0 +1,309 @@
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;
[CollectionDefinition(WorkingDirectoryCollection.Name, DisableParallelization = true)]
public sealed class WorkingDirectoryCollection
{
public const string Name = "Launcher install-record working directory";
}
[Collection(WorkingDirectoryCollection.Name)]
public sealed class LauncherInstallRecordStoreTests : IDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"acdream-install-record-tests",
Guid.NewGuid().ToString("N"));
private readonly ApplicationPathSet _paths;
private readonly string _dats;
public LauncherInstallRecordStoreTests()
{
_paths = new ApplicationPathSet(
Path.Combine(_root, "config"),
Path.Combine(_root, "data"),
Path.Combine(_root, "cache"),
null);
_dats = Path.Combine(_root, "retail-dats");
CreateCompleteDatDirectory(_dats);
}
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[Fact]
public async Task AtomicRecordRoundTripVerifiesShaSizeAndBakeToolVersion()
{
var store = new LauncherInstallRecordStore(_paths);
Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!);
await File.WriteAllTextAsync(store.PreparedAssetPath, "verified package");
LauncherInstallRecord record = await CreateRecordAsync(store);
await store.SaveAtomicallyAsync(record);
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
Assert.True(verification.IsVerified);
Assert.Equal(record, verification.Record);
Assert.Contains("SHA-256", verification.Status, StringComparison.Ordinal);
Assert.Empty(Directory.EnumerateFiles(_paths.DataDirectory, ".install.json.*.tmp"));
}
[Fact]
public async Task SizeAndShaCorruptionDisableTheInstall()
{
var store = new LauncherInstallRecordStore(_paths);
Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!);
await File.WriteAllTextAsync(store.PreparedAssetPath, "original");
LauncherInstallRecord record = await CreateRecordAsync(store);
await store.SaveAtomicallyAsync(record);
await File.WriteAllTextAsync(store.PreparedAssetPath, "different-size");
InstallRecordVerification size = await store.LoadAndVerifyAsync();
Assert.Equal(InstallRecordVerificationState.Invalid, size.State);
Assert.Contains("size changed", size.Status, StringComparison.OrdinalIgnoreCase);
await File.WriteAllTextAsync(store.PreparedAssetPath, "tampered");
var sameSizeRecord = record with
{
PreparedAssetSize = new FileInfo(store.PreparedAssetPath).Length,
PreparedAssetSha256 = new string('0', 64),
};
await store.SaveAtomicallyAsync(sameSizeRecord);
InstallRecordVerification sha = await store.LoadAndVerifyAsync();
Assert.Equal(InstallRecordVerificationState.Invalid, sha.State);
Assert.Contains("SHA-256", sha.Status, StringComparison.Ordinal);
}
[Fact]
public async Task StaleBakeToolVersionIsRejectedBeforeHashing()
{
int hashCalls = 0;
var store = new LauncherInstallRecordStore(
_paths,
computeSha256: (_, _) =>
{
hashCalls++;
return Task.FromResult(new string('a', 64));
});
Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!);
await File.WriteAllTextAsync(store.PreparedAssetPath, "package");
var stale = new LauncherInstallRecord(
_dats,
store.PreparedAssetPath,
new string('a', 64),
new FileInfo(store.PreparedAssetPath).Length,
LauncherInstallRecordStore.CurrentBakeToolVersion - 1);
Directory.CreateDirectory(_paths.DataDirectory);
await File.WriteAllTextAsync(
store.RecordPath,
JsonSerializer.Serialize(stale, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
}));
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
Assert.Equal(InstallRecordVerificationState.Invalid, verification.State);
Assert.Contains("Bake tool version", verification.Status, StringComparison.Ordinal);
Assert.Equal(0, hashCalls);
}
[Fact]
public async Task NullIntegrityMetadataIsReportedAsInvalidInsteadOfThrowing()
{
var store = new LauncherInstallRecordStore(_paths);
Directory.CreateDirectory(_paths.DataDirectory);
await File.WriteAllTextAsync(
store.RecordPath,
JsonSerializer.Serialize(new
{
datDirectory = _dats,
preparedAssetPath = store.PreparedAssetPath,
preparedAssetSha256 = (string?)null,
preparedAssetSize = 12,
bakeToolVersion =
LauncherInstallRecordStore.CurrentBakeToolVersion,
version = LauncherInstallRecord.CurrentRecordVersion,
}));
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
Assert.Equal(InstallRecordVerificationState.Invalid, verification.State);
Assert.Contains("missing", verification.Status, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task MissingExplicitVersionIsRejectedBeforeAdmission()
{
var store = new LauncherInstallRecordStore(_paths);
Directory.CreateDirectory(_paths.DataDirectory);
await File.WriteAllTextAsync(
store.RecordPath,
JsonSerializer.Serialize(new
{
datDirectory = Path.GetFullPath(_dats),
preparedAssetPath = Path.GetFullPath(store.PreparedAssetPath),
preparedAssetSha256 = new string('a', 64),
preparedAssetSize = 12,
bakeToolVersion =
LauncherInstallRecordStore.CurrentBakeToolVersion,
}));
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
Assert.Equal(InstallRecordVerificationState.Invalid, verification.State);
Assert.Contains("explicit", verification.Status, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SaveNormalizesCanonicalAbsoluteDatAndPreparedPaths()
{
var store = new LauncherInstallRecordStore(_paths);
Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!);
await File.WriteAllTextAsync(store.PreparedAssetPath, "verified package");
var info = new FileInfo(store.PreparedAssetPath);
var nonCanonical = new LauncherInstallRecord(
Path.Combine(_dats, "..", Path.GetFileName(_dats), "."),
Path.Combine(
Path.GetDirectoryName(store.PreparedAssetPath)!,
"..",
"pak",
Path.GetFileName(store.PreparedAssetPath)),
await FileIntegrity.ComputeSha256HexAsync(store.PreparedAssetPath),
info.Length,
LauncherInstallRecordStore.CurrentBakeToolVersion);
await store.SaveAtomicallyAsync(nonCanonical);
using JsonDocument document = JsonDocument.Parse(
await File.ReadAllTextAsync(store.RecordPath));
Assert.Equal(
Path.GetFullPath(_dats),
document.RootElement.GetProperty("datDirectory").GetString());
Assert.Equal(
Path.GetFullPath(store.PreparedAssetPath),
document.RootElement.GetProperty("preparedAssetPath").GetString());
Assert.Equal(
LauncherInstallRecord.CurrentRecordVersion,
document.RootElement.GetProperty("version").GetInt32());
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
}
[Fact]
public async Task RelativeDatRecordCannotChangeMeaningWithWorkingDirectory()
{
var store = new LauncherInstallRecordStore(_paths);
Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!);
await File.WriteAllTextAsync(store.PreparedAssetPath, "verified package");
string alternateWorkingDirectory = Path.Combine(_root, "alternate-cwd");
string alternateDats = Path.Combine(alternateWorkingDirectory, "retail-dats");
CreateCompleteDatDirectory(alternateDats);
var info = new FileInfo(store.PreparedAssetPath);
var relative = new LauncherInstallRecord(
"retail-dats",
Path.GetFullPath(store.PreparedAssetPath),
await FileIntegrity.ComputeSha256HexAsync(store.PreparedAssetPath),
info.Length,
LauncherInstallRecordStore.CurrentBakeToolVersion);
Directory.CreateDirectory(_paths.DataDirectory);
await File.WriteAllTextAsync(
store.RecordPath,
JsonSerializer.Serialize(relative, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
}));
string originalWorkingDirectory = Environment.CurrentDirectory;
try
{
Environment.CurrentDirectory = alternateWorkingDirectory;
InstallRecordVerification verification =
await store.LoadAndVerifyAsync();
Assert.Equal(InstallRecordVerificationState.Invalid, verification.State);
Assert.Contains("absolute", verification.Status, StringComparison.OrdinalIgnoreCase);
}
finally
{
Environment.CurrentDirectory = originalWorkingDirectory;
}
}
[Fact]
public async Task OlderAbsoluteButNonCanonicalDatDocumentIsRejected()
{
var store = new LauncherInstallRecordStore(_paths);
Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!);
await File.WriteAllTextAsync(store.PreparedAssetPath, "verified package");
var info = new FileInfo(store.PreparedAssetPath);
var nonCanonical = new LauncherInstallRecord(
Path.Combine(_dats, "..", Path.GetFileName(_dats)),
Path.GetFullPath(store.PreparedAssetPath),
await FileIntegrity.ComputeSha256HexAsync(store.PreparedAssetPath),
info.Length,
LauncherInstallRecordStore.CurrentBakeToolVersion);
Directory.CreateDirectory(_paths.DataDirectory);
await File.WriteAllTextAsync(
store.RecordPath,
JsonSerializer.Serialize(nonCanonical, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
}));
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
Assert.Equal(InstallRecordVerificationState.Invalid, verification.State);
Assert.Contains("canonical", verification.Status, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task StartupRecoversPriorVerifiedPackageAfterInterruptedReplacement()
{
var store = new LauncherInstallRecordStore(_paths);
Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!);
await File.WriteAllTextAsync(store.PreparedAssetPath, "previous-good");
LauncherInstallRecord record = await CreateRecordAsync(store);
await store.SaveAtomicallyAsync(record);
string backup = LauncherInstallRecordStore.GetBackupPath(
store.PreparedAssetPath);
File.Move(store.PreparedAssetPath, backup);
await File.WriteAllTextAsync(store.PreparedAssetPath, "partial-new");
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
Assert.True(verification.IsVerified);
Assert.Equal("previous-good", await File.ReadAllTextAsync(store.PreparedAssetPath));
Assert.False(File.Exists(backup));
}
private async Task<LauncherInstallRecord> CreateRecordAsync(
LauncherInstallRecordStore store)
{
var info = new FileInfo(store.PreparedAssetPath);
return new LauncherInstallRecord(
Path.GetFullPath(_dats),
Path.GetFullPath(store.PreparedAssetPath),
await FileIntegrity.ComputeSha256HexAsync(store.PreparedAssetPath),
info.Length,
LauncherInstallRecordStore.CurrentBakeToolVersion);
}
private static void CreateCompleteDatDirectory(string directory)
{
Directory.CreateDirectory(directory);
foreach (string fileName in DatDirectoryLocator.RequiredFileNames)
{
File.WriteAllText(Path.Combine(directory, fileName), "fixture");
}
}
}

View file

@ -0,0 +1,793 @@
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<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.True(BakePublicationGuardPaths.IsValidNonce(
observedRequest.PublicationNonce));
Assert.DoesNotContain(
observedRequest.PublicationNonce!,
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);
Assert.False(File.Exists(
BakePublicationGuardPaths.GetAuthorizationPath(
result.Record.PreparedAssetPath)));
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 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<LauncherInstallException>(
() => 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<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));
}
[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<LauncherInstallResult> operationA =
installerA.InstallAsync(_dats, 1);
await childEntered.Task.WaitAsync(TimeSpan.FromSeconds(5));
try
{
using var cancellationB = new CancellationTokenSource();
Task<LauncherInstallResult> operationB = installerB.InstallAsync(
_dats,
1,
cancellationToken: cancellationB.Token);
await Task.Delay(150);
cancellationB.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => 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<LauncherInstallException>(() => 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("hold-install-lease");
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<OperationCanceledException>(
() => 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));
}
[Theory]
[InlineData("holds", 0)]
[InlineData("late", 17)]
public async Task OrphanBakeCanNeverPublishAfterRestartRecovery(
string schedule,
int expectedChildExitCode)
{
var store = new LauncherInstallRecordStore(_paths);
LauncherInstallRecord old = await CreatePriorRecordAsync(store);
string recordBefore = await File.ReadAllTextAsync(store.RecordPath);
string control = Path.Combine(_root, "orphan-" + schedule);
Directory.CreateDirectory(control);
string ready = Path.Combine(control, "child-ready");
string release = Path.Combine(control, "child-release");
string childPid = Path.Combine(control, "child-pid");
string childExit = Path.Combine(control, "child-exit");
string fixtureDll = GetInstallLeaseFixturePath();
var startInfo = new ProcessStartInfo("dotnet")
{
RedirectStandardError = true,
RedirectStandardOutput = true,
UseShellExecute = false,
};
foreach (string argument in new[]
{
fixtureDll,
"orphan-parent",
store.DataDirectory,
_dats,
_bakeExecutable,
schedule,
ready,
release,
childPid,
childExit,
})
{
startInfo.ArgumentList.Add(argument);
}
using Process parent = Process.Start(startInfo)
?? throw new InvalidOperationException("Could not start orphan parent.");
int orphanPid = 0;
try
{
await WaitForFileAsync(ready, parent, TimeSpan.FromSeconds(15));
orphanPid = int.Parse(
await File.ReadAllTextAsync(childPid),
System.Globalization.CultureInfo.InvariantCulture);
Assert.True(File.Exists(
LauncherInstallRecordStore.GetBackupPath(
store.PreparedAssetPath)));
Assert.True(File.Exists(
BakePublicationGuardPaths.GetAuthorizationPath(
store.PreparedAssetPath)));
parent.Kill(entireProcessTree: false);
await parent.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
var restarted = new LauncherInstaller(
_paths,
_bakeExecutable,
recordStore: new LauncherInstallRecordStore(_paths));
Task<InstallRecordVerification> recovery =
restarted.LoadExistingAsync();
InstallRecordVerification recovered;
if (schedule == "holds")
{
await Task.Delay(200);
Assert.False(recovery.IsCompleted);
File.WriteAllText(release, "release");
recovered = await recovery.WaitAsync(TimeSpan.FromSeconds(15));
}
else
{
recovered = await recovery.WaitAsync(TimeSpan.FromSeconds(15));
Assert.False(File.Exists(
BakePublicationGuardPaths.GetAuthorizationPath(
store.PreparedAssetPath)));
File.WriteAllText(release, "release");
}
Assert.True(recovered.IsVerified);
Assert.Equal(old, recovered.Record);
string canonicalAfterRecovery =
await File.ReadAllTextAsync(store.PreparedAssetPath);
string recordAfterRecovery =
await File.ReadAllTextAsync(store.RecordPath);
bool backupAfterRecovery = File.Exists(
LauncherInstallRecordStore.GetBackupPath(
store.PreparedAssetPath));
await WaitForFileAsync(childExit, TimeSpan.FromSeconds(15));
Assert.Equal(
expectedChildExitCode,
int.Parse(
await File.ReadAllTextAsync(childExit),
System.Globalization.CultureInfo.InvariantCulture));
if (schedule == "late")
{
Assert.Contains(
"no longer authorized",
await File.ReadAllTextAsync(childExit + ".error"),
StringComparison.OrdinalIgnoreCase);
}
await Task.Delay(200);
Assert.Equal(
canonicalAfterRecovery,
await File.ReadAllTextAsync(store.PreparedAssetPath));
Assert.Equal("previous verified package", canonicalAfterRecovery);
Assert.Equal(recordBefore, recordAfterRecovery);
Assert.Equal(recordAfterRecovery, await File.ReadAllTextAsync(store.RecordPath));
Assert.Equal(
backupAfterRecovery,
File.Exists(LauncherInstallRecordStore.GetBackupPath(
store.PreparedAssetPath)));
Assert.False(backupAfterRecovery);
Assert.False(File.Exists(
BakePublicationGuardPaths.GetAuthorizationPath(
store.PreparedAssetPath)));
}
finally
{
File.WriteAllText(release, "release");
if (!parent.HasExited)
{
parent.Kill(entireProcessTree: false);
await parent.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
}
if (orphanPid != 0 && !File.Exists(childExit))
{
TryKill(orphanPid);
}
}
}
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 async Task<LauncherInstallRecord> 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 async Task WaitForFileAsync(string path, TimeSpan timeout)
{
using var cancellation = new CancellationTokenSource(timeout);
while (!File.Exists(path))
{
await Task.Delay(25, cancellation.Token);
}
}
private static void TryKill(int processId)
{
try
{
using Process process = Process.GetProcessById(processId);
if (!process.HasExited)
{
process.Kill(entireProcessTree: true);
process.WaitForExit(5_000);
}
}
catch
{
// The orphan normally exits by itself; cleanup tolerates the
// expected race with Process.GetProcessById.
}
}
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<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);
}
}