test(headless): S3 end-to-end HeadlessProcessHost console proof

Existing coverage exercises HeadlessSessionHost.SubmitConsoleLine
directly (bypassing the background reader thread and the scheduler) or
HeadlessConsoleController in isolation (a fake submit callback, no real
session). Add one test that drives the actual production wiring: a
plain StringReader feeds "hello" and "/quit" through the real
HeadlessProcessHost constructor (background reader thread -> per-tick
ConsolePump -> ChatCommandRouter.Submit -> the wire), asserting the
outbound Talk action reaches the fixture AND that /quit ends
RunAsync with HeadlessExitCode.Success -- the same graceful path an
external cancellation takes.

Mutation: removed the session.ConsolePump assignment in
HeadlessProcessHost's constructor (never wiring the drain+pump
delegate). The test failed with a TimeoutException -- the queued
console lines were never drained, so /quit's cancellation never fired
and RunAsync ran until the test's own 10s WaitAsync bound.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-07 08:10:52 +02:00
parent 314cb5f4a5
commit 70e86c180e

View file

@ -524,6 +524,51 @@ public sealed class HeadlessConsoleTests
// ── HeadlessProcessHost: end-to-end console wiring ───────────────────
/// <summary>
/// S3: an end-to-end proof that a console line, read from a plain
/// <see cref="StringReader"/>, reaches the real session's
/// <c>SubmitConsoleLine</c> pipeline through the actual
/// <see cref="HeadlessProcessHost"/> wiring (background reader thread →
/// per-tick <c>ConsolePump</c> → <c>ChatCommandRouter.Submit</c> → the
/// wire), and that <c>/quit</c> ends <see cref="HeadlessProcessHost.RunAsync"/>
/// through the SAME graceful path an external cancellation takes —
/// <see cref="HeadlessExitCode.Success"/>, not an error code.
/// </summary>
[Fact]
public async Task ConsoleLineReachesTheSessionAndQuitEndsTheProcessGracefully()
{
var captured = new List<byte[]>();
var operations = new FixtureSessionOperations
{
GameActionCapture = body => captured.Add(body),
};
var configuration = new HeadlessConfiguration
{
Version = 1,
Sessions = [Descriptor()],
};
using var diagnostics = new StringWriter();
using var input = new System.IO.StringReader(
"hello" + Environment.NewLine + "/quit" + Environment.NewLine);
using var host = new HeadlessProcessHost(
configuration,
HeadlessPathSet.Resolve(new HeadlessPathOverrides()),
input,
diagnostics,
operations,
new FakeTimeProvider(),
directCredentials: new HeadlessDirectCredentials("account", "password"),
consoleEnabled: true);
HeadlessExitCode exitCode = await host.RunAsync(CancellationToken.None)
.WaitAsync(TimeSpan.FromSeconds(10));
Assert.Equal(HeadlessExitCode.Success, exitCode);
byte[] body = Assert.Single(captured);
Assert.Equal(ChatRequests.TalkOpcode, ActionOpcode(body));
Assert.Equal("hello", TalkText(body));
}
/// <summary>
/// S6: <c>standardOutputIsTerminal</c> is threaded in as a constructor
/// parameter, not read from the real <c>System.Console</c> inside