acdream/tests/AcDream.Headless.Tests/LauncherHeadlessCommandLineContractTests.cs
Erik d233538f2c
Some checks failed
CI / linux-portable (push) Failing after 3m0s
CI / windows-gate (push) Failing after 5m0s
CI / release (push) Has been skipped
fix(tests): the launcher/headless command-line contract needs executable stubs on Linux
Run 173's Linux job failed on the contract test added one commit earlier — my
test, not the product.

LauncherExecutableSet refuses a host that exists but has no execute bit on
Linux (HasUnixExecutePermission), which is a real and useful check: an update
whose extraction lost its permissions would otherwise fail deep inside process
start instead of at the launch gate. The stubs were written with
File.WriteAllText, which is 0644, so on Linux every one of the four tests died
at that gate before reaching the command-line contract they exist to pin.

Windows never sees this — the predicate short-circuits to true off Linux — so
the test passed locally and could only fail on the runner.

Stubs are now created through a helper that chmods them executable on non-
Windows. Verified on Windows (4 passed); the Linux half is what run 174 checks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 21:53:19 +02:00

114 lines
4.5 KiB
C#

using AcDream.Headless.Configuration;
using AcDream.Launcher.Core.Launching;
using AcDream.Launcher.Core.Orchestration;
using AcDream.Launcher.Core.Profiles;
namespace AcDream.Headless.Tests;
/// <summary>
/// The launcher spawns the headless host; the headless host decides what a
/// valid command line is. Nothing connected those two facts, and they drifted:
/// the launcher passed <c>--config &lt;path&gt;</c> while
/// <see cref="HeadlessCommandLine.Parse"/> reads <c>arguments[0]</c> as the
/// command and accepts only <c>validate</c> or <c>run</c>. Every
/// launcher-started headless session and every character refresh therefore died
/// instantly with "Invalid command. Run --help for usage." — visible only as an
/// exit code in a status file, which is why it survived a whole campaign.
///
/// <para>These tests are the missing connection: they take the argument vector
/// the launcher will really use and hand it to the parser the host will really
/// use. The two cannot drift again without failing here.</para>
/// </summary>
public sealed class LauncherHeadlessCommandLineContractTests : IDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"acdream-headless-cli-contract",
Guid.NewGuid().ToString("N"));
public LauncherHeadlessCommandLineContractTests()
{
// The spec builders refuse to name an executable that is not there, so
// the contract can only be exercised against files that exist.
Directory.CreateDirectory(AppDirectory);
string suffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty;
CreateStubExecutable(Path.Combine(AppDirectory, "AcDream.App" + suffix));
CreateStubExecutable(Path.Combine(AppDirectory, "acdream-headless" + suffix));
}
/// <summary>
/// On Linux the spec builders also refuse a host that exists but has no
/// execute bit — a real check, since an extracted update that lost its
/// permissions would otherwise fail deep inside process start. A stub
/// written with WriteAllText is 0644, so it has to be made executable for
/// the command-line contract underneath to be reachable at all.
/// </summary>
private static void CreateStubExecutable(string path)
{
File.WriteAllText(path, "stub");
if (!OperatingSystem.IsWindows())
{
File.SetUnixFileMode(
path,
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute
| UnixFileMode.GroupRead | UnixFileMode.GroupExecute
| UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
}
}
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[Fact]
public void ProbeArgumentsParseAsAHeadlessRunCommand()
{
LauncherProcessSpec spec = ExecutableSet().CreateProbeSpec(ConfigPath);
HeadlessCommandLine parsed = HeadlessCommandLine.Parse(spec.Arguments);
Assert.Equal("run", parsed.Command);
Assert.Equal(ConfigPath, parsed.ConfigurationPath);
}
[Fact]
public void HeadlessPlayArgumentsParseAsAHeadlessRunCommand()
{
LauncherProcessSpec spec = ExecutableSet()
.CreatePlaySpec(LaunchMode.Headless, ConfigPath);
HeadlessCommandLine parsed = HeadlessCommandLine.Parse(spec.Arguments);
Assert.Equal("run", parsed.Command);
Assert.Equal(ConfigPath, parsed.ConfigurationPath);
}
/// <summary>
/// The graphical host is a DIFFERENT program with a different grammar — it
/// takes a bare <c>--session-config</c> and has no command word. Copying
/// that shape onto the headless call is exactly the mistake this file
/// exists to prevent, so pin the difference rather than leaving it implied.
/// </summary>
[Theory]
[InlineData(LaunchMode.Gui)]
[InlineData(LaunchMode.GuiSelect)]
public void GraphicalArgumentsAreNotAHeadlessCommandLine(LaunchMode mode)
{
LauncherProcessSpec spec = ExecutableSet().CreatePlaySpec(mode, ConfigPath);
Assert.Equal(["--session-config", ConfigPath], spec.Arguments);
Assert.Throws<HeadlessCommandLineException>(
() => HeadlessCommandLine.Parse(spec.Arguments));
}
private string AppDirectory => Path.Combine(_root, "app");
private string ConfigPath => Path.Combine(_root, "session.json");
private LauncherExecutableSet ExecutableSet() =>
LauncherExecutableSet.FromDirectory(AppDirectory);
}