using System.Text.Json; using AcDream.Runtime.Session; namespace AcDream.Runtime.Tests.Session; /// /// 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 docs/plans/2026-08-14-launcher-campaign.md /// LA1 and docs/superpowers/specs/2026-08-14-launcher-campaign-design.md /// §6. /// 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.PluginLoaded("s1", "acdream.good"); writer.PluginFailed("s1", "acdream.bad", "enable failed"); writer.LoginCommandFailed("s1", 2, "/version", "unsupported headless command"); writer.Disconnected("s1", "stopped"); writer.Exited("s1", 0, "disposed"); string[] lines = File.ReadAllLines(file.Path); Assert.Equal(9, 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 pluginLoaded = Parse(lines[4]); Assert.Equal("pluginLoaded", pluginLoaded.GetProperty("e").GetString()); Assert.Equal("acdream.good", pluginLoaded.GetProperty("plugin").GetString()); JsonElement pluginFailed = Parse(lines[5]); Assert.Equal("pluginFailed", pluginFailed.GetProperty("e").GetString()); Assert.Equal("acdream.bad", pluginFailed.GetProperty("plugin").GetString()); Assert.Equal("enable failed", pluginFailed.GetProperty("error").GetString()); JsonElement loginCommandFailed = Parse(lines[6]); Assert.Equal(1, loginCommandFailed.GetProperty("v").GetInt32()); Assert.Equal("loginCommandFailed", loginCommandFailed.GetProperty("e").GetString()); Assert.Equal("s1", loginCommandFailed.GetProperty("sessionId").GetString()); Assert.Equal(2, loginCommandFailed.GetProperty("commandIndex").GetInt32()); Assert.Equal("/version", loginCommandFailed.GetProperty("command").GetString()); Assert.Equal( "unsupported headless command", loginCommandFailed.GetProperty("error").GetString()); Assert.Equal( ["v", "e", "t", "sessionId", "commandIndex", "command", "error"], loginCommandFailed.EnumerateObject() .Select(static property => property.Name)); JsonElement disconnected = Parse(lines[7]); Assert.Equal("disconnected", disconnected.GetProperty("e").GetString()); Assert.Equal("stopped", disconnected.GetProperty("reason").GetString()); JsonElement exited = Parse(lines[8]); 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.PluginLoaded("s1", "acdream.good"); writer.PluginFailed("s1", "acdream.bad", "failed"); writer.LoginCommandFailed("s1", 0, "", "unknown command"); 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"); } /// /// 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. /// [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()); } /// /// 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. /// [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()); } /// /// 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 /// (v/e/t/sessionId) plus that event's own /// named fields, nothing else. An extra credential-shaped or otherwise /// accidental property fails this test by construction. LA5's documented /// pluginFailed.error diagnostic is the one free-text value and its /// caller remains responsible for never appending session secrets. /// [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.PluginLoaded("bot", "acdream.good"); writer.PluginFailed("bot", "acdream.bad", "enable failed"); writer.LoginCommandFailed("bot", 1, "/version", "unsupported"); writer.Disconnected("bot", "stopped"); writer.Exited("bot", 0, "disposed"); string[] lines = File.ReadAllLines(file.Path); Assert.Equal(9, 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", "plugin"); AssertExactProperties( lines[5], "v", "e", "t", "sessionId", "plugin", "error"); AssertExactProperties( lines[6], "v", "e", "t", "sessionId", "commandIndex", "command", "error"); AssertExactProperties(lines[7], "v", "e", "t", "sessionId", "reason"); AssertExactProperties(lines[8], "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); } /// /// F1 (Campaign LA LA1 review fix round): a status file whose parent /// directory does not exist yet — the expected first-run shape of /// .../launcher/sessions/<id>/status.jsonl on a fresh cache /// dir — must be created lazily rather than throwing /// out of the transaction the /// writer is merely observing. /// [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); } } /// /// F1: a path whose PARENT SEGMENT already exists as an ordinary file /// (so 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. /// [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); } /// A path that is never actually created — used by the /// no-op test to assert the writer truly never touches disk. internal static TemporaryFile Reserve() => Create(); public void Dispose() { if (File.Exists(Path)) File.Delete(Path); } } }