merge: Campaign LA LA9 - verified installer review-closed
This commit is contained in:
commit
2198a0cc8e
45 changed files with 5143 additions and 132 deletions
|
|
@ -23,6 +23,24 @@ public sealed class BakeOutputTransactionTests : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StagingPathUsesTheDocumentedLauncherRecoveryContract()
|
||||
{
|
||||
string destination = Path.Combine(_directory, "pak", "acdream.pak");
|
||||
Guid transaction = Guid.Parse("01234567-89ab-cdef-0123-456789abcdef");
|
||||
|
||||
string staging = BakeOutputTransaction.CreateStagingPath(
|
||||
destination,
|
||||
transaction);
|
||||
|
||||
Assert.Equal(
|
||||
Path.Combine(
|
||||
_directory,
|
||||
"pak",
|
||||
".acdream.pak.acdream-bake.0123456789abcdef0123456789abcdef.tmp"),
|
||||
staging);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Publish_ReplacesExistingDestinationOnlyAfterValidation()
|
||||
{
|
||||
|
|
|
|||
115
tests/AcDream.Bake.Tests/BakeProgressCliTests.cs
Normal file
115
tests/AcDream.Bake.Tests/BakeProgressCliTests.cs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
using System.Text.Json;
|
||||
using AcDream.Content.Pak;
|
||||
|
||||
namespace AcDream.Bake.Tests;
|
||||
|
||||
public sealed class BakeProgressCliTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("--help")]
|
||||
[InlineData("-h")]
|
||||
public void HelpIsAZeroDatArgumentProbe(string argument)
|
||||
{
|
||||
Assert.True(BakeCommandLine.IsHelpRequest([argument]));
|
||||
Assert.False(BakeCommandLine.IsHelpRequest([argument, "extra"]));
|
||||
Assert.Contains("--help", BakeCommandLine.Usage, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProgressJsonFlagIsOptInAndDefaultOutputRemainsInTheDatDirectory()
|
||||
{
|
||||
using var errors = new StringWriter();
|
||||
Assert.True(BakeCommandLine.TryParse(
|
||||
["--dat-dir", "retail-dats"],
|
||||
errors,
|
||||
out BakeCommandLineOptions? defaults));
|
||||
|
||||
Assert.NotNull(defaults);
|
||||
Assert.False(defaults.ProgressJson);
|
||||
Assert.Equal(
|
||||
Path.Combine("retail-dats", "acdream.pak"),
|
||||
defaults.OutputPath);
|
||||
|
||||
Assert.True(BakeCommandLine.TryParse(
|
||||
[
|
||||
"--dat-dir", "retail-dats",
|
||||
"--out", "prepared/acdream.pak",
|
||||
"--threads", "7",
|
||||
"--progress-json",
|
||||
],
|
||||
errors,
|
||||
out BakeCommandLineOptions? machine));
|
||||
|
||||
Assert.NotNull(machine);
|
||||
Assert.True(machine.ProgressJson);
|
||||
Assert.Equal("prepared/acdream.pak", machine.OutputPath);
|
||||
Assert.Equal(7, machine.Threads);
|
||||
Assert.Contains("--progress-json", BakeCommandLine.Usage, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HumanFiveSecondLineIsAlwaysWrittenAndJsonIsOnlyWrittenWhenEnabled()
|
||||
{
|
||||
using var humanOnly = new StringWriter();
|
||||
BakeProgressReporter.Write(
|
||||
humanOnly,
|
||||
machineOutput: null,
|
||||
phase: "mesh",
|
||||
completed: 1250,
|
||||
total: 5000,
|
||||
failures: 2,
|
||||
elapsed: TimeSpan.FromSeconds(5),
|
||||
etaSeconds: 15,
|
||||
privateBytes: 64L * 1024 * 1024,
|
||||
managedBytes: 16L * 1024 * 1024);
|
||||
|
||||
string defaultText = humanOnly.ToString();
|
||||
Assert.Contains("[00:00:05] extracted", defaultText, StringComparison.Ordinal);
|
||||
Assert.Contains("failures=2", defaultText, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("\"v\":", defaultText, StringComparison.Ordinal);
|
||||
|
||||
using var combined = new StringWriter();
|
||||
var json = new BakeProgressJsonWriter(combined);
|
||||
BakeProgressReporter.Write(
|
||||
combined,
|
||||
json,
|
||||
phase: "collision",
|
||||
completed: 5,
|
||||
total: 10,
|
||||
failures: 0,
|
||||
elapsed: TimeSpan.FromSeconds(10),
|
||||
etaSeconds: 10,
|
||||
privateBytes: 1,
|
||||
managedBytes: 2);
|
||||
|
||||
string[] lines = combined.ToString().Split(
|
||||
Environment.NewLine,
|
||||
StringSplitOptions.RemoveEmptyEntries);
|
||||
Assert.Equal(2, lines.Length);
|
||||
Assert.StartsWith("[00:00:10] extracted", lines[0], StringComparison.Ordinal);
|
||||
using JsonDocument document = JsonDocument.Parse(lines[1]);
|
||||
Assert.Equal(1, document.RootElement.GetProperty("v").GetInt32());
|
||||
Assert.Equal("progress", document.RootElement.GetProperty("e").GetString());
|
||||
Assert.Equal("collision", document.RootElement.GetProperty("phase").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VersionedWriterCarriesCurrentBakeVersionOnTerminalRecords()
|
||||
{
|
||||
using var output = new StringWriter();
|
||||
var writer = new BakeProgressJsonWriter(output);
|
||||
|
||||
writer.Started(PakFormat.CurrentBakeToolVersion, "prepared/acdream.pak");
|
||||
writer.Completed(PakFormat.CurrentBakeToolVersion, 1234, failures: 0);
|
||||
|
||||
JsonElement[] events = output.ToString()
|
||||
.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(line => JsonDocument.Parse(line).RootElement.Clone())
|
||||
.ToArray();
|
||||
Assert.Equal(["started", "completed"], events.Select(value =>
|
||||
value.GetProperty("e").GetString()));
|
||||
Assert.All(events, value => Assert.Equal(
|
||||
PakFormat.CurrentBakeToolVersion,
|
||||
value.GetProperty("bakeToolVersion").GetUInt32()));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\AcDream.Bake\AcDream.Bake.csproj" />
|
||||
<ProjectReference Include="..\..\src\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
|
||||
<ProjectReference Include="..\..\src\AcDream.Platform\AcDream.Platform.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using AcDream.Bake;
|
||||
using AcDream.Launcher.Core.Installation;
|
||||
using AcDream.Platform;
|
||||
|
||||
return args.FirstOrDefault() switch
|
||||
{
|
||||
"hold-install-lease" => await HoldInstallLeaseAsync(args[1..]),
|
||||
"orphan-parent" => await RunOrphanParentAsync(args[1..]),
|
||||
"orphan-child" => RunOrphanChild(args[1..]),
|
||||
_ => 2,
|
||||
};
|
||||
|
||||
static async Task<int> HoldInstallLeaseAsync(string[] arguments)
|
||||
{
|
||||
if (arguments.Length != 3)
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
string lockPath = Path.GetFullPath(arguments[0]);
|
||||
string stagingPath = Path.GetFullPath(arguments[1]);
|
||||
string readyPath = Path.GetFullPath(arguments[2]);
|
||||
Directory.CreateDirectory(
|
||||
Path.GetDirectoryName(lockPath)
|
||||
?? throw new InvalidOperationException("lock path has no parent"));
|
||||
Directory.CreateDirectory(
|
||||
Path.GetDirectoryName(stagingPath)
|
||||
?? throw new InvalidOperationException("staging path has no parent"));
|
||||
|
||||
using var lease = new FileStream(
|
||||
lockPath,
|
||||
FileMode.OpenOrCreate,
|
||||
FileAccess.ReadWrite,
|
||||
FileShare.None);
|
||||
File.WriteAllText(stagingPath, "abandoned bake staging");
|
||||
File.WriteAllText(readyPath, "ready");
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static async Task<int> RunOrphanParentAsync(string[] arguments)
|
||||
{
|
||||
if (arguments.Length != 8)
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
string dataDirectory = Path.GetFullPath(arguments[0]);
|
||||
string datDirectory = Path.GetFullPath(arguments[1]);
|
||||
string bakeMarker = Path.GetFullPath(arguments[2]);
|
||||
string schedule = arguments[3];
|
||||
string childReadyPath = Path.GetFullPath(arguments[4]);
|
||||
string childReleasePath = Path.GetFullPath(arguments[5]);
|
||||
string childPidPath = Path.GetFullPath(arguments[6]);
|
||||
string childExitPath = Path.GetFullPath(arguments[7]);
|
||||
var paths = new ApplicationPathSet(
|
||||
Path.Combine(dataDirectory, "fixture-config"),
|
||||
dataDirectory,
|
||||
Path.Combine(dataDirectory, "fixture-cache"),
|
||||
null);
|
||||
var runner = new OrphanBakeProcessRunner(
|
||||
schedule,
|
||||
childReadyPath,
|
||||
childReleasePath,
|
||||
childPidPath,
|
||||
childExitPath);
|
||||
var installer = new LauncherInstaller(
|
||||
paths,
|
||||
bakeMarker,
|
||||
processRunner: runner);
|
||||
|
||||
try
|
||||
{
|
||||
await installer.InstallAsync(datDirectory, 1);
|
||||
return 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 9;
|
||||
}
|
||||
}
|
||||
|
||||
static int RunOrphanChild(string[] arguments)
|
||||
{
|
||||
if (arguments.Length != 5)
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
string outputPath = Path.GetFullPath(arguments[0]);
|
||||
string schedule = arguments[1];
|
||||
string readyPath = Path.GetFullPath(arguments[2]);
|
||||
string releasePath = Path.GetFullPath(arguments[3]);
|
||||
string exitPath = Path.GetFullPath(arguments[4]);
|
||||
Action barrier = () =>
|
||||
{
|
||||
File.WriteAllText(readyPath, schedule);
|
||||
while (!File.Exists(releasePath))
|
||||
{
|
||||
Thread.Sleep(10);
|
||||
}
|
||||
};
|
||||
|
||||
int exitCode;
|
||||
try
|
||||
{
|
||||
BakeOutputTransaction.WriteValidateAndPublish(
|
||||
outputPath,
|
||||
temporaryPath =>
|
||||
{
|
||||
File.WriteAllText(temporaryPath, "orphan replacement");
|
||||
return 1;
|
||||
},
|
||||
(temporaryPath, _) =>
|
||||
{
|
||||
if (File.ReadAllText(temporaryPath) != "orphan replacement")
|
||||
{
|
||||
throw new InvalidDataException("staging content changed");
|
||||
}
|
||||
},
|
||||
beforePublicationLock: schedule == "late" ? barrier : null,
|
||||
beforePromotion: schedule == "holds" ? barrier : null,
|
||||
CancellationToken.None);
|
||||
exitCode = 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
File.WriteAllText(exitPath + ".error", ex.Message);
|
||||
exitCode = 17;
|
||||
}
|
||||
|
||||
File.WriteAllText(exitPath, exitCode.ToString(
|
||||
System.Globalization.CultureInfo.InvariantCulture));
|
||||
return exitCode;
|
||||
}
|
||||
|
||||
file sealed class OrphanBakeProcessRunner(
|
||||
string schedule,
|
||||
string childReadyPath,
|
||||
string childReleasePath,
|
||||
string childPidPath,
|
||||
string childExitPath) : IBakeProcessRunner
|
||||
{
|
||||
public async Task<BakeProcessResult> RunAsync(
|
||||
BakeProcessRequest request,
|
||||
Action<string> onStandardOutput,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
string dotnetHost = Environment.ProcessPath
|
||||
?? throw new InvalidOperationException("dotnet host path is unavailable");
|
||||
string fixtureDll = Assembly.GetExecutingAssembly().Location;
|
||||
var startInfo = new ProcessStartInfo(dotnetHost)
|
||||
{
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
startInfo.ArgumentList.Add(fixtureDll);
|
||||
startInfo.ArgumentList.Add("orphan-child");
|
||||
startInfo.ArgumentList.Add(request.OutputPath);
|
||||
startInfo.ArgumentList.Add(schedule);
|
||||
startInfo.ArgumentList.Add(childReadyPath);
|
||||
startInfo.ArgumentList.Add(childReleasePath);
|
||||
startInfo.ArgumentList.Add(childExitPath);
|
||||
startInfo.Environment.Remove(
|
||||
BakePublicationGuardPaths.NonceEnvironmentVariable);
|
||||
startInfo.Environment[
|
||||
BakePublicationGuardPaths.NonceEnvironmentVariable] =
|
||||
request.PublicationNonce
|
||||
?? throw new InvalidOperationException("publication nonce is missing");
|
||||
|
||||
using Process child = Process.Start(startInfo)
|
||||
?? throw new InvalidOperationException("orphan child did not start");
|
||||
File.WriteAllText(
|
||||
childPidPath,
|
||||
child.Id.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
await child.WaitForExitAsync(cancellationToken);
|
||||
if (child.ExitCode == 0)
|
||||
{
|
||||
long bytes = new FileInfo(request.OutputPath).Length;
|
||||
onStandardOutput("{\"v\":1,\"e\":\"started\",\"bakeToolVersion\":4}\n");
|
||||
onStandardOutput($"{{\"v\":1,\"e\":\"completed\","
|
||||
+ $"\"bakeToolVersion\":4,\"outputBytes\":{bytes},"
|
||||
+ "\"failures\":0}\n");
|
||||
}
|
||||
|
||||
return new BakeProcessResult(child.ExitCode, "orphan fixture child");
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -71,6 +71,25 @@ public sealed class LauncherProjectBoundaryTests
|
|||
Assert.Equal("true", EvaluateProperty(projectPath, "PublishSingleFile"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RidPublishComposesBakeWithoutAProjectReference()
|
||||
{
|
||||
string project = File.ReadAllText(Path.Combine(
|
||||
FindRepositoryRoot(),
|
||||
"src",
|
||||
"AcDream.Launcher",
|
||||
"AcDream.Launcher.csproj"));
|
||||
|
||||
Assert.DoesNotContain(
|
||||
"ProjectReference Include=\"..\\AcDream.Bake",
|
||||
project,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("PublishCoDeployedBakeTool", project, StringComparison.Ordinal);
|
||||
Assert.Contains("..\\AcDream.Bake\\AcDream.Bake.csproj", project, StringComparison.Ordinal);
|
||||
Assert.Contains("SelfContained=true", project, StringComparison.Ordinal);
|
||||
Assert.Contains("PublishSingleFile=true", project, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ModalMarkupAndCodeBehindCarryKeyboardFocusAndAccessibilityGuards()
|
||||
{
|
||||
|
|
@ -110,12 +129,18 @@ public sealed class LauncherProjectBoundaryTests
|
|||
|
||||
Assert.Contains("src/AcDream.Launcher/**", workflow, StringComparison.Ordinal);
|
||||
Assert.Contains("tests/AcDream.Launcher.Tests/**", workflow, StringComparison.Ordinal);
|
||||
Assert.Contains("src/AcDream.Bake/**", workflow, StringComparison.Ordinal);
|
||||
Assert.Contains("tests/AcDream.Bake.Tests/**", workflow, StringComparison.Ordinal);
|
||||
Assert.Contains("portable-launcher:", workflow, StringComparison.Ordinal);
|
||||
Assert.Contains("tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj", workflow, StringComparison.Ordinal);
|
||||
Assert.Contains("tests/AcDream.Bake.Tests/AcDream.Bake.Tests.csproj", workflow, StringComparison.Ordinal);
|
||||
Assert.Contains("-r linux-x64", workflow, StringComparison.Ordinal);
|
||||
Assert.Contains("-getProperty:SelfContained", workflow, StringComparison.Ordinal);
|
||||
Assert.Contains("DOTNET_ROOT", workflow, StringComparison.Ordinal);
|
||||
Assert.Contains("--verify-publish", workflow, StringComparison.Ordinal);
|
||||
Assert.Contains("acdream-bake.exe\" --help", workflow, StringComparison.Ordinal);
|
||||
Assert.Contains("\"$root/acdream-bake\" --help", workflow, StringComparison.Ordinal);
|
||||
Assert.Contains("test -x \"$root/acdream-bake\"", workflow, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"test -x src/AcDream.Headless/bin/Release/net10.0/acdream-headless",
|
||||
workflow,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using AcDream.Launcher.Core.Orchestration;
|
||||
using AcDream.Launcher.Core.Profiles;
|
||||
using AcDream.Launcher.Core.Launching;
|
||||
using AcDream.Launcher.Core.Installation;
|
||||
using AcDream.Launcher.ViewModels;
|
||||
|
||||
namespace AcDream.Launcher.Tests;
|
||||
|
|
@ -32,7 +33,7 @@ public sealed class LauncherWindowViewModelTests
|
|||
Assert.True(session.IsActive);
|
||||
|
||||
Assert.True(viewModel.IsFirstRunRequired);
|
||||
Assert.Contains("LA9", viewModel.FirstRunWizardShell.Body, StringComparison.Ordinal);
|
||||
Assert.Contains("SHA-256", viewModel.FirstRunWizardShell.Body, StringComparison.Ordinal);
|
||||
Assert.Contains("LA10", viewModel.UpdatePromptShell.Body, StringComparison.Ordinal);
|
||||
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
|
||||
Assert.True(viewModel.FirstRunWizardShell.IsOpen);
|
||||
|
|
@ -325,6 +326,92 @@ public sealed class LauncherWindowViewModelTests
|
|||
Assert.False(viewModel.EditorDialog.IsOpen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FirstRunWizardAutoDetectsValidatesAndPublishesVerifiedInstall()
|
||||
{
|
||||
using var orchestrator = new FakeLauncherOrchestrator
|
||||
{
|
||||
Session = FakeLauncherOrchestrator.CreateSession(
|
||||
LauncherActivityState.Exited,
|
||||
"Exited cleanly."),
|
||||
};
|
||||
var installer = new FakeLauncherInstaller();
|
||||
using var viewModel = CreateInitialized(orchestrator, installer);
|
||||
|
||||
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
|
||||
|
||||
Assert.Equal(installer.DetectedDirectory, viewModel.FirstRunWizardShell.DatDirectory);
|
||||
Assert.True(viewModel.FirstRunWizardShell.IsDatDirectoryValid);
|
||||
Assert.True(viewModel.FirstRunWizardShell.StartCommand.CanExecute(null));
|
||||
|
||||
viewModel.FirstRunWizardShell.SelectDatDirectory("incomplete-manual-path");
|
||||
Assert.False(viewModel.FirstRunWizardShell.IsDatDirectoryValid);
|
||||
viewModel.FirstRunWizardShell.SelectDatDirectory(installer.DetectedDirectory);
|
||||
Assert.True(viewModel.FirstRunWizardShell.IsDatDirectoryValid);
|
||||
|
||||
viewModel.FirstRunWizardShell.ThreadsText = "3";
|
||||
await viewModel.FirstRunWizardShell.StartCommand.ExecuteAsync();
|
||||
|
||||
Assert.Equal((installer.DetectedDirectory, 3), installer.InstallRequest);
|
||||
Assert.Equal(installer.Record, orchestrator.InstalledRecord);
|
||||
Assert.False(viewModel.IsFirstRunRequired);
|
||||
Assert.Equal(LauncherInstallPhase.Completed, viewModel.FirstRunWizardShell.Phase);
|
||||
Assert.Equal(100, viewModel.FirstRunWizardShell.ProgressPercent);
|
||||
Assert.False(viewModel.FirstRunWizardShell.HasError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FirstRunWizardCancellationAndFailureRemainVisibleAndPublishNothing()
|
||||
{
|
||||
using var orchestrator = new FakeLauncherOrchestrator
|
||||
{
|
||||
Session = FakeLauncherOrchestrator.CreateSession(
|
||||
LauncherActivityState.Exited,
|
||||
"Exited cleanly."),
|
||||
};
|
||||
var entered = new TaskCompletionSource(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var installer = new FakeLauncherInstaller
|
||||
{
|
||||
InstallHandler = async (_, _, progress, cancellationToken) =>
|
||||
{
|
||||
progress?.Report(new LauncherInstallProgress(
|
||||
LauncherInstallPhase.BakingMeshes,
|
||||
"Baking mesh assets.",
|
||||
1,
|
||||
10));
|
||||
entered.SetResult();
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||
throw new InvalidOperationException("unreachable");
|
||||
},
|
||||
};
|
||||
using var viewModel = CreateInitialized(orchestrator, installer);
|
||||
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
|
||||
|
||||
Task install = viewModel.FirstRunWizardShell.StartCommand.ExecuteAsync();
|
||||
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Assert.True(viewModel.FirstRunWizardShell.CancelCommand.CanExecute(null));
|
||||
Assert.False(viewModel.FirstRunWizardShell.CanEditInputs);
|
||||
viewModel.FirstRunWizardShell.CancelCommand.Execute(null);
|
||||
await install;
|
||||
|
||||
Assert.Equal(LauncherInstallPhase.Cancelled, viewModel.FirstRunWizardShell.Phase);
|
||||
Assert.Null(orchestrator.InstalledRecord);
|
||||
Assert.False(viewModel.FirstRunWizardShell.HasError);
|
||||
|
||||
installer.InstallHandler = (_, _, _, _) =>
|
||||
Task.FromException<LauncherInstallResult>(
|
||||
new LauncherInstallException("fixture bake failed"));
|
||||
await viewModel.FirstRunWizardShell.StartCommand.ExecuteAsync();
|
||||
|
||||
Assert.Equal(LauncherInstallPhase.Failed, viewModel.FirstRunWizardShell.Phase);
|
||||
Assert.Contains(
|
||||
"fixture bake failed",
|
||||
viewModel.FirstRunWizardShell.Error ?? string.Empty,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Null(orchestrator.InstalledRecord);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActiveSessionStopAndFinishedSessionClearUseCoreOwnership()
|
||||
{
|
||||
|
|
@ -345,11 +432,13 @@ public sealed class LauncherWindowViewModelTests
|
|||
}
|
||||
|
||||
private static LauncherWindowViewModel CreateInitialized(
|
||||
FakeLauncherOrchestrator orchestrator)
|
||||
FakeLauncherOrchestrator orchestrator,
|
||||
ILauncherInstaller? installer = null)
|
||||
{
|
||||
var viewModel = new LauncherWindowViewModel(
|
||||
orchestrator,
|
||||
new ImmediateUiDispatcher());
|
||||
new ImmediateUiDispatcher(),
|
||||
installer);
|
||||
viewModel.Initialize();
|
||||
return viewModel;
|
||||
}
|
||||
|
|
@ -425,14 +514,18 @@ public sealed class LauncherWindowViewModelTests
|
|||
|
||||
public string? StoppedSessionId { get; private set; }
|
||||
|
||||
public LauncherInstallRecord? InstalledRecord { get; private set; }
|
||||
|
||||
public void LoadProfiles() => LoadCalled = true;
|
||||
|
||||
public LauncherStateSnapshot GetSnapshot() => new(
|
||||
[CreateServerSnapshot()],
|
||||
[Session],
|
||||
Platform,
|
||||
IsInstallationReady: false,
|
||||
InstallationStatus: "No installed client is configured.");
|
||||
IsInstallationReady: InstalledRecord is not null,
|
||||
InstallationStatus: InstalledRecord is null
|
||||
? "No installed client is configured."
|
||||
: "Client content verified.");
|
||||
|
||||
public LauncherCapability GetLaunchCapability(LaunchMode mode) =>
|
||||
Platform.ForLaunchMode(mode);
|
||||
|
|
@ -448,6 +541,8 @@ public sealed class LauncherWindowViewModelTests
|
|||
|
||||
public void SetInstallRecord(LauncherInstallRecord? installRecord)
|
||||
{
|
||||
InstalledRecord = installRecord;
|
||||
StateChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public void AddServer(string name, string host, int port) =>
|
||||
|
|
@ -589,4 +684,83 @@ public sealed class LauncherWindowViewModelTests
|
|||
ActivityStatus: "Connected."),
|
||||
]);
|
||||
}
|
||||
|
||||
private sealed class FakeLauncherInstaller : ILauncherInstaller
|
||||
{
|
||||
public string DetectedDirectory { get; } = Path.GetFullPath("retail-dats");
|
||||
|
||||
public LauncherInstallRecord Record { get; }
|
||||
|
||||
public (string DatDirectory, int Threads)? InstallRequest { get; private set; }
|
||||
|
||||
public Func<
|
||||
string,
|
||||
int,
|
||||
IProgress<LauncherInstallProgress>?,
|
||||
CancellationToken,
|
||||
Task<LauncherInstallResult>>? InstallHandler { get; set; }
|
||||
|
||||
public FakeLauncherInstaller()
|
||||
{
|
||||
Record = new LauncherInstallRecord(
|
||||
DetectedDirectory,
|
||||
Path.GetFullPath("data/pak/acdream.pak"),
|
||||
new string('a', 64),
|
||||
123,
|
||||
LauncherInstallRecordStore.CurrentBakeToolVersion);
|
||||
}
|
||||
|
||||
public IReadOnlyList<DatDirectoryValidation> DetectDatDirectories() =>
|
||||
[
|
||||
ValidateDatDirectory(DetectedDirectory),
|
||||
];
|
||||
|
||||
public DatDirectoryValidation ValidateDatDirectory(string? directory) =>
|
||||
string.Equals(directory, DetectedDirectory, StringComparison.Ordinal)
|
||||
? new DatDirectoryValidation(
|
||||
DetectedDirectory,
|
||||
true,
|
||||
"All four required retail DAT files were found.",
|
||||
[])
|
||||
: new DatDirectoryValidation(
|
||||
directory ?? string.Empty,
|
||||
false,
|
||||
"The DAT directory is incomplete.",
|
||||
DatDirectoryLocator.RequiredFileNames);
|
||||
|
||||
public Task<InstallRecordVerification> LoadExistingAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(new InstallRecordVerification(
|
||||
InstallRecordVerificationState.Missing,
|
||||
null,
|
||||
"Client content is not installed."));
|
||||
|
||||
public Task<LauncherInstallResult> InstallAsync(
|
||||
string datDirectory,
|
||||
int threads,
|
||||
IProgress<LauncherInstallProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
InstallRequest = (datDirectory, threads);
|
||||
if (InstallHandler is not null)
|
||||
{
|
||||
return InstallHandler(
|
||||
datDirectory,
|
||||
threads,
|
||||
progress,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
progress?.Report(new LauncherInstallProgress(
|
||||
LauncherInstallPhase.BakingMeshes,
|
||||
"Baking mesh assets.",
|
||||
5,
|
||||
10));
|
||||
progress?.Report(new LauncherInstallProgress(
|
||||
LauncherInstallPhase.VerifyingPackage,
|
||||
"Verifying package."));
|
||||
return Task.FromResult(new LauncherInstallResult(Record));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue