fix(launcher): close Campaign LA LA1 review findings

This commit is contained in:
Erik 2026-08-14 17:00:09 +02:00
parent 4edc122085
commit d511e4c348
12 changed files with 413 additions and 65 deletions

View file

@ -19,12 +19,18 @@ namespace AcDream.App.Tests.Configuration;
public sealed class SessionConfigurationSharedFixtureTests
{
[Fact]
public void AppReaderAcceptsTheSharedFixtureAndParsesTheFiveNewFields()
public void AppReaderAcceptsTheProductionShapedSharedFixture()
{
(SessionConfiguration configuration, SessionDescriptor session) =
SessionConfigurationLoader.Load(SharedFixturePath());
Assert.Equal(1, configuration.Version);
Assert.Equal(
"shared-fixture-dats",
configuration.Process?.Content?.DatDirectory);
Assert.Equal(
"shared-fixture-dats/acdream.pak",
configuration.Process?.Content?.PreparedAssetPath);
Assert.Equal("shared-fixture", session.Id);
Assert.Equal("127.0.0.1", session.Endpoint.Host);
Assert.Equal(9000, session.Endpoint.Port);
@ -34,9 +40,9 @@ public sealed class SessionConfigurationSharedFixtureTests
// the pinned contract's "parsed-and-ignored" clause.
Assert.Equal("idle", session.Policy?.Id);
Assert.Equal(
SessionCredentialProviderKind.Environment,
SessionCredentialProviderKind.StandardInput,
session.Credential.Provider);
Assert.Equal("SHARED_FIXTURE_PASSWORD", session.Credential.Reference);
Assert.Equal("session", session.Credential.Reference);
Assert.Equal(["ExamplePlugin", "AnotherPlugin"], session.Plugins);
Assert.Equal(

View file

@ -87,6 +87,27 @@ public sealed class LinuxPlatformBoundaryTests
Assert.Empty(offenders);
}
[Fact]
public void RuntimePlatformGuardHasOneDefinitionAndOneApprovedConsumer()
{
string app = AppSourceRoot();
string[] files = Directory
.EnumerateFiles(app, "*.cs", SearchOption.AllDirectories)
.Where(path => File.ReadAllText(path).Contains(
"RuntimePlatformGuard",
StringComparison.Ordinal))
.Select(path => Path.GetRelativePath(app, path).Replace('\\', '/'))
.OrderBy(static path => path, StringComparer.Ordinal)
.ToArray();
Assert.Equal(
[
"Credentials/AppCredentialResolver.cs",
"Platform/GraphicalHostPlatformServices.cs",
],
files);
}
[Fact]
public void SmokePluginCopyUsesRidAwarePortableBuildAndPublishPaths()
{

View file

@ -234,6 +234,26 @@ public sealed class RuntimeOptionsTests
Assert.Equal("testpassword", realValues.LivePass);
}
[Fact]
public void RecordPrintMembersRedactsTheLivePassword()
{
RuntimeOptions options = RuntimeOptions.Parse(
AnyDatDir,
Env(new()
{
["ACDREAM_LIVE"] = "1",
["ACDREAM_TEST_USER"] = "testaccount",
["ACDREAM_TEST_PASS"] = "top-secret-value",
}));
string printed = options.ToString();
Assert.DoesNotContain("top-secret-value", printed, StringComparison.Ordinal);
Assert.Contains("LivePass = <redacted>", printed, StringComparison.Ordinal);
Assert.Contains("LiveHost = 127.0.0.1", printed, StringComparison.Ordinal);
Assert.Contains("HasLiveCredentials = True", printed, StringComparison.Ordinal);
}
[Fact]
public void HasLiveCredentials_RequiresLiveModeAndBothUserAndPass()
{

View file

@ -62,6 +62,73 @@ public sealed class HeadlessSessionHostTests
Assert.DoesNotContain("AcDream.App", diagnostics);
}
/// <summary>
/// Campaign LA LA1 review fixes F3/F6: reconnect is a visible lifecycle
/// replacement, so the retiring connection must publish disconnected
/// before the new connection publishes connected. Its reason is distinct
/// from the final host stop and from the terminal process outcome.
/// </summary>
[Fact]
public void ReconnectPublishesDisconnectedBeforeTheSecondConnectedEdge()
{
string statusPath = Path.Combine(
Path.GetTempPath(),
$"acdream-headless-reconnect-status-{Guid.NewGuid():N}.jsonl");
try
{
var operations = new FixtureSessionOperations();
using var diagnosticsOutput = new StringWriter();
using var credential = new HeadlessCredentialSecret(
"fixture",
"password");
using var host = new HeadlessSessionHost(
Descriptor(statusFile: statusPath),
credential,
new HeadlessDiagnosticWriter(diagnosticsOutput),
operations);
Assert.Equal(
RuntimeSessionStartStatus.Connected,
host.Start().Status);
Assert.Equal(
RuntimeSessionStartStatus.Connected,
host.Reconnect().Status);
host.Dispose();
JsonElement[] events = File.ReadAllLines(statusPath)
.Select(static line => JsonDocument.Parse(line).RootElement.Clone())
.ToArray();
Assert.Equal(
[
"started", "connected", "characterList", "enteredWorld",
"disconnected", "connected", "characterList", "enteredWorld",
"disconnected", "exited",
],
events.Select(static item => item.GetProperty("e").GetString()));
JsonElement[] disconnected = events
.Where(static item =>
item.GetProperty("e").GetString() == "disconnected")
.ToArray();
Assert.Equal(2, disconnected.Length);
Assert.Equal(
"reconnect",
disconnected[0].GetProperty("reason").GetString());
Assert.Equal(
"stopped",
disconnected[1].GetProperty("reason").GetString());
JsonElement exited = events[^1];
Assert.Equal(0, exited.GetProperty("code").GetInt32());
Assert.Equal("graceful", exited.GetProperty("reason").GetString());
}
finally
{
if (File.Exists(statusPath))
File.Delete(statusPath);
}
}
/// <summary>
/// Campaign LA slice LA1: proves the status-event writer fires the
/// pinned lifecycle vocabulary — started/connected/characterList/

View file

@ -18,11 +18,17 @@ namespace AcDream.Headless.Tests;
public sealed class SessionConfigurationSharedFixtureTests
{
[Fact]
public void HeadlessReaderAcceptsTheSharedFixtureAndParsesTheFiveNewFields()
public void HeadlessReaderAcceptsTheProductionShapedSharedFixture()
{
HeadlessConfiguration configuration =
HeadlessConfigurationLoader.Load(SharedFixturePath());
Assert.Equal(
"shared-fixture-dats",
configuration.Process.Content?.DatDirectory);
Assert.Equal(
"shared-fixture-dats/acdream.pak",
configuration.Process.Content?.PreparedAssetPath);
HeadlessSessionDescriptor session = Assert.Single(configuration.Sessions)!;
Assert.Equal("shared-fixture", session.Id);
Assert.Equal("127.0.0.1", session.Endpoint.Host);
@ -31,9 +37,9 @@ public sealed class SessionConfigurationSharedFixtureTests
Assert.Equal("SharedToon", session.Character.Name);
Assert.Equal("idle", session.Policy.Id);
Assert.Equal(
HeadlessCredentialProviderKind.Environment,
HeadlessCredentialProviderKind.StandardInput,
session.Credential.Provider);
Assert.Equal("SHARED_FIXTURE_PASSWORD", session.Credential.Reference);
Assert.Equal("session", session.Credential.Reference);
Assert.Equal(["ExamplePlugin", "AnotherPlugin"], session.Plugins);
Assert.Equal(

View file

@ -97,6 +97,63 @@ public sealed class SessionStatusWriterTests
writer.Started("s1");
}
/// <summary>
/// F3: both hosts share this writer but have separate reconnect command
/// adapters. The sink therefore closes an open connection before it
/// accepts another connected edge, preserving a coherent external
/// lifecycle even if a host has no reconnect-specific status hook.
/// </summary>
[Fact]
public void SecondConnectedEdgeFirstClosesTheRetiringConnection()
{
using TemporaryFile file = TemporaryFile.Create();
var writer = new SessionStatusWriter(file.Path);
writer.Connected("s1");
writer.Connected("s1");
JsonElement[] events = File.ReadAllLines(file.Path)
.Select(Parse)
.ToArray();
Assert.Equal(
["connected", "disconnected", "connected"],
events.Select(static item => item.GetProperty("e").GetString()));
Assert.Equal(
"reconnect",
events[1].GetProperty("reason").GetString());
}
/// <summary>
/// F6: exited is a terminal fact, not an append request. Repeated host
/// disposal and any late callback after disposal must not create a second
/// terminal edge or resurrect the stream. If a host exits while still
/// connected, the writer closes that connection first with a distinct,
/// truthful reason.
/// </summary>
[Fact]
public void ExitedIsIdempotentTerminalAndClosesAnOpenConnection()
{
using TemporaryFile file = TemporaryFile.Create();
var writer = new SessionStatusWriter(file.Path);
writer.Connected("s1");
writer.Exited("s1", 0, "graceful");
writer.Exited("s1", 1, "duplicate-must-not-win");
writer.Connected("s1");
JsonElement[] events = File.ReadAllLines(file.Path)
.Select(Parse)
.ToArray();
Assert.Equal(
["connected", "disconnected", "exited"],
events.Select(static item => item.GetProperty("e").GetString()));
Assert.Equal(
"process-exit",
events[1].GetProperty("reason").GetString());
Assert.Equal(0, events[2].GetProperty("code").GetInt32());
Assert.Equal("graceful", events[2].GetProperty("reason").GetString());
}
/// <summary>
/// F7 (Campaign LA LA1 review fix round): replaces the earlier
/// "DoesNotContain 'hunter2'/'password'" assertion, which could never