Agent was stopped for token budget partway through the LA1 review fix round. Landed here: F1 best-effort SessionStatusWriter, F2 App reader tolerance (paths/mode), F5 argument-parsing hardening, plus new tests. NOT DONE: F4 shared-fixture production shape (was the next step), F3 reconnect disconnected edge + recorded limitation, F6 exited idempotency/reasons, F7 structural redaction test, F8 platform-guard test + comment fix, optional RuntimeOptions PrintMembers redaction. Build/test state UNVERIFIED at this commit. Next session: finish the remaining findings, run the suites, then narrow re-review. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
292 lines
12 KiB
C#
292 lines
12 KiB
C#
using System.Text.Json;
|
|
using AcDream.Runtime.Session;
|
|
|
|
namespace AcDream.Runtime.Tests.Session;
|
|
|
|
/// <summary>
|
|
/// Campaign LA slice LA1: pins the exact JSONL status-stream contract both
|
|
/// the App and Headless hosts write into, and the launcher (a process we
|
|
/// don't own) reads — see <c>docs/plans/2026-08-14-launcher-campaign.md</c>
|
|
/// LA1 and <c>docs/superpowers/specs/2026-08-14-launcher-campaign-design.md</c>
|
|
/// §6.
|
|
/// </summary>
|
|
public sealed class SessionStatusWriterTests
|
|
{
|
|
[Fact]
|
|
public void EachEventWritesTheExactPinnedShapeInOrder()
|
|
{
|
|
using TemporaryFile file = TemporaryFile.Create();
|
|
var writer = new SessionStatusWriter(file.Path);
|
|
|
|
writer.Started("s1");
|
|
writer.Connected("s1");
|
|
writer.CharacterList(
|
|
"s1",
|
|
new LiveSessionRosterReport(
|
|
"account",
|
|
11,
|
|
[
|
|
new LiveSessionRosterEntry(0x50000001u, "Ready", 0u),
|
|
new LiveSessionRosterEntry(0x50000002u, "Grey", 10u),
|
|
]));
|
|
writer.EnteredWorld("s1", 0x50000001u, "Ready");
|
|
writer.Disconnected("s1", "stopped");
|
|
writer.Exited("s1", 0, "disposed");
|
|
|
|
string[] lines = File.ReadAllLines(file.Path);
|
|
Assert.Equal(6, lines.Length);
|
|
|
|
JsonElement started = Parse(lines[0]);
|
|
Assert.Equal(1, started.GetProperty("v").GetInt32());
|
|
Assert.Equal("started", started.GetProperty("e").GetString());
|
|
Assert.True(started.TryGetProperty("t", out _));
|
|
Assert.Equal("s1", started.GetProperty("sessionId").GetString());
|
|
|
|
JsonElement connected = Parse(lines[1]);
|
|
Assert.Equal("connected", connected.GetProperty("e").GetString());
|
|
Assert.Equal("s1", connected.GetProperty("sessionId").GetString());
|
|
|
|
JsonElement characterList = Parse(lines[2]);
|
|
Assert.Equal("characterList", characterList.GetProperty("e").GetString());
|
|
Assert.Equal("account", characterList.GetProperty("accountName").GetString());
|
|
Assert.Equal(11, characterList.GetProperty("slotCount").GetInt32());
|
|
JsonElement characters = characterList.GetProperty("characters");
|
|
Assert.Equal(2, characters.GetArrayLength());
|
|
JsonElement first = characters[0];
|
|
Assert.Equal(0x50000001u, first.GetProperty("id").GetUInt32());
|
|
Assert.Equal("Ready", first.GetProperty("name").GetString());
|
|
Assert.Equal(0u, first.GetProperty("secondsGreyedOut").GetUInt32());
|
|
|
|
JsonElement enteredWorld = Parse(lines[3]);
|
|
Assert.Equal("enteredWorld", enteredWorld.GetProperty("e").GetString());
|
|
Assert.Equal(0x50000001u, enteredWorld.GetProperty("characterId").GetUInt32());
|
|
Assert.Equal("Ready", enteredWorld.GetProperty("characterName").GetString());
|
|
|
|
JsonElement disconnected = Parse(lines[4]);
|
|
Assert.Equal("disconnected", disconnected.GetProperty("e").GetString());
|
|
Assert.Equal("stopped", disconnected.GetProperty("reason").GetString());
|
|
|
|
JsonElement exited = Parse(lines[5]);
|
|
Assert.Equal("exited", exited.GetProperty("e").GetString());
|
|
Assert.Equal(0, exited.GetProperty("code").GetInt32());
|
|
Assert.Equal("disposed", exited.GetProperty("reason").GetString());
|
|
}
|
|
|
|
[Fact]
|
|
public void NoOpWriterNeverCreatesAFile()
|
|
{
|
|
using TemporaryFile file = TemporaryFile.Reserve();
|
|
var writer = new SessionStatusWriter(null);
|
|
|
|
writer.Started("s1");
|
|
writer.Connected("s1");
|
|
writer.Disconnected("s1", "stopped");
|
|
writer.Exited("s1", 0, "disposed");
|
|
|
|
Assert.False(writer.IsEnabled);
|
|
Assert.False(File.Exists(file.Path));
|
|
}
|
|
|
|
[Fact]
|
|
public void BlankPathIsTreatedAsAbsent()
|
|
{
|
|
var writer = new SessionStatusWriter(" ");
|
|
|
|
Assert.False(writer.IsEnabled);
|
|
// Must not throw even though there is no real path behind it.
|
|
writer.Started("s1");
|
|
}
|
|
|
|
/// <summary>
|
|
/// F7 (Campaign LA LA1 review fix round): replaces the earlier
|
|
/// "DoesNotContain 'hunter2'/'password'" assertion, which could never
|
|
/// actually fail — no writer method below accepts a credential-shaped
|
|
/// parameter in the first place, so the absence of those literal strings
|
|
/// proved nothing about the SHAPE of what gets serialized. This test
|
|
/// asserts the structural claim that actually backs the "never write
|
|
/// credential material into this stream" contract: each event kind
|
|
/// serializes EXACTLY its pinned property set — the shared envelope
|
|
/// (<c>v</c>/<c>e</c>/<c>t</c>/<c>sessionId</c>) plus that event's own
|
|
/// named fields, nothing else. An extra property (a smuggled password,
|
|
/// or any other accidental field) fails this test by construction,
|
|
/// regardless of what value it carries.
|
|
/// </summary>
|
|
[Fact]
|
|
public void EachEventSerializesExactlyItsPinnedPropertySetAndNothingElse()
|
|
{
|
|
using TemporaryFile file = TemporaryFile.Create();
|
|
var writer = new SessionStatusWriter(file.Path);
|
|
|
|
writer.Started("bot");
|
|
writer.Connected("bot");
|
|
writer.CharacterList(
|
|
"bot",
|
|
new LiveSessionRosterReport(
|
|
"account-name",
|
|
11,
|
|
[new LiveSessionRosterEntry(0x50000001u, "Ready", 0u)]));
|
|
writer.EnteredWorld("bot", 0x50000001u, "Ready");
|
|
writer.Disconnected("bot", "stopped");
|
|
writer.Exited("bot", 0, "disposed");
|
|
|
|
string[] lines = File.ReadAllLines(file.Path);
|
|
Assert.Equal(6, lines.Length);
|
|
|
|
AssertExactProperties(lines[0], "v", "e", "t", "sessionId");
|
|
AssertExactProperties(lines[1], "v", "e", "t", "sessionId");
|
|
AssertExactProperties(
|
|
lines[2],
|
|
"v", "e", "t", "sessionId", "accountName", "slotCount", "characters");
|
|
AssertExactProperties(
|
|
lines[3], "v", "e", "t", "sessionId", "characterId", "characterName");
|
|
AssertExactProperties(lines[4], "v", "e", "t", "sessionId", "reason");
|
|
AssertExactProperties(lines[5], "v", "e", "t", "sessionId", "code", "reason");
|
|
|
|
// The nested characters[] entries are exact too — the exact shape a
|
|
// password could otherwise be smuggled through.
|
|
JsonElement character = Parse(lines[2]).GetProperty("characters")[0];
|
|
AssertExactProperties(character, "id", "name", "secondsGreyedOut");
|
|
}
|
|
|
|
private static void AssertExactProperties(string line, params string[] expected) =>
|
|
AssertExactProperties(Parse(line), expected);
|
|
|
|
private static void AssertExactProperties(JsonElement element, params string[] expected)
|
|
{
|
|
string[] actual = element.EnumerateObject()
|
|
.Select(static property => property.Name)
|
|
.OrderBy(static name => name, StringComparer.Ordinal)
|
|
.ToArray();
|
|
string[] sortedExpected = expected
|
|
.OrderBy(static name => name, StringComparer.Ordinal)
|
|
.ToArray();
|
|
Assert.Equal(sortedExpected, actual);
|
|
}
|
|
|
|
/// <summary>
|
|
/// F1 (Campaign LA LA1 review fix round): a status file whose parent
|
|
/// directory does not exist yet — the expected first-run shape of
|
|
/// <c>.../launcher/sessions/<id>/status.jsonl</c> on a fresh cache
|
|
/// dir — must be created lazily rather than throwing
|
|
/// <see cref="DirectoryNotFoundException"/> out of the transaction the
|
|
/// writer is merely observing.
|
|
/// </summary>
|
|
[Fact]
|
|
public void MissingParentDirectoryIsCreatedAndEventsFlow()
|
|
{
|
|
string root = Path.Combine(
|
|
Path.GetTempPath(),
|
|
$"acdream-status-root-{Guid.NewGuid():N}");
|
|
string path = Path.Combine(root, "nested", "sessions", "s1", "status.jsonl");
|
|
try
|
|
{
|
|
Assert.False(Directory.Exists(Path.GetDirectoryName(path)));
|
|
var writer = new SessionStatusWriter(path);
|
|
|
|
writer.Started("s1");
|
|
writer.Connected("s1");
|
|
|
|
Assert.True(writer.IsEnabled);
|
|
string[] lines = File.ReadAllLines(path);
|
|
Assert.Equal(2, lines.Length);
|
|
Assert.Contains("\"started\"", lines[0]);
|
|
Assert.Contains("\"connected\"", lines[1]);
|
|
}
|
|
finally
|
|
{
|
|
if (Directory.Exists(root))
|
|
Directory.Delete(root, recursive: true);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// F1: a path whose PARENT SEGMENT already exists as an ordinary file
|
|
/// (so <see cref="Directory.CreateDirectory"/> cannot turn it into a
|
|
/// directory) is exactly the "unwritable path" case the review asked
|
|
/// for — the writer must latch itself off instead of throwing, and every
|
|
/// subsequent call must stay a cheap no-op.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ParentSegmentIsAFileLatchesTheWriterInsteadOfThrowing()
|
|
{
|
|
string blocker = Path.Combine(
|
|
Path.GetTempPath(),
|
|
$"acdream-status-blocker-{Guid.NewGuid():N}");
|
|
File.WriteAllText(blocker, "not a directory");
|
|
string path = Path.Combine(blocker, "status.jsonl");
|
|
try
|
|
{
|
|
var writer = new SessionStatusWriter(path);
|
|
Assert.True(writer.IsEnabled);
|
|
|
|
// Must not throw — the writer swallows its own I/O failure and
|
|
// latches off instead of failing the caller's transaction.
|
|
writer.Started("s1");
|
|
Assert.False(writer.IsEnabled);
|
|
|
|
// Latched-off calls stay cheap no-ops — no exception, no retry.
|
|
writer.Connected("s1");
|
|
writer.Exited("s1", 0, "disposed");
|
|
}
|
|
finally
|
|
{
|
|
if (File.Exists(blocker))
|
|
File.Delete(blocker);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void FileIsOpenedShareReadSoAConcurrentTailerCanReadWhileAppending()
|
|
{
|
|
using TemporaryFile file = TemporaryFile.Create();
|
|
var writer = new SessionStatusWriter(file.Path);
|
|
writer.Started("s1");
|
|
|
|
// A concurrent reader (the launcher's tailer) must be able to open
|
|
// the file for read while the writer holds it — FileShare.Read on
|
|
// the writer side is what this test is pinning.
|
|
using FileStream tailer = new(
|
|
file.Path,
|
|
FileMode.Open,
|
|
FileAccess.Read,
|
|
FileShare.ReadWrite);
|
|
using var tailerReader = new StreamReader(tailer);
|
|
string? firstLine = tailerReader.ReadLine();
|
|
Assert.NotNull(firstLine);
|
|
Assert.Contains("\"started\"", firstLine);
|
|
|
|
// The writer keeps working while the tailer's handle is still open.
|
|
writer.Connected("s1");
|
|
string? secondLine = tailerReader.ReadLine();
|
|
Assert.NotNull(secondLine);
|
|
Assert.Contains("\"connected\"", secondLine);
|
|
}
|
|
|
|
private static JsonElement Parse(string line) =>
|
|
JsonDocument.Parse(line).RootElement;
|
|
|
|
private sealed class TemporaryFile : IDisposable
|
|
{
|
|
private TemporaryFile(string path) => Path = path;
|
|
|
|
internal string Path { get; }
|
|
|
|
internal static TemporaryFile Create()
|
|
{
|
|
string path = System.IO.Path.Combine(
|
|
System.IO.Path.GetTempPath(),
|
|
$"acdream-status-{Guid.NewGuid():N}.jsonl");
|
|
return new TemporaryFile(path);
|
|
}
|
|
|
|
/// <summary>A path that is never actually created — used by the
|
|
/// no-op test to assert the writer truly never touches disk.</summary>
|
|
internal static TemporaryFile Reserve() => Create();
|
|
|
|
public void Dispose()
|
|
{
|
|
if (File.Exists(Path))
|
|
File.Delete(Path);
|
|
}
|
|
}
|
|
}
|