feat(headless): establish portable Linux host boundary
Add the presentation-free acdream-headless executable, strict no-connect configuration validation, dependency and assembly guards, and a Windows/Ubuntu CI lane that builds and tests only the portable runtime closure. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
parent
953c469cac
commit
aada8a37c1
14 changed files with 613 additions and 8 deletions
25
tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj
Normal file
25
tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\AcDream.Headless\AcDream.Headless.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
134
tests/AcDream.Headless.Tests/HeadlessDependencyBoundaryTests.cs
Normal file
134
tests/AcDream.Headless.Tests/HeadlessDependencyBoundaryTests.cs
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
using System.Text.Json;
|
||||
using System.Xml.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace AcDream.Headless.Tests;
|
||||
|
||||
public sealed class HeadlessDependencyBoundaryTests
|
||||
{
|
||||
private static readonly string[] ForbiddenDependencyPrefixes =
|
||||
[
|
||||
"AcDream.App",
|
||||
"AcDream.UI.",
|
||||
"Silk.NET",
|
||||
"OpenAL",
|
||||
"Arch",
|
||||
"ImGui",
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public void HeadlessAssemblyReferencesOnlyTheRuntimeProject()
|
||||
{
|
||||
string repositoryRoot = FindRepositoryRoot();
|
||||
string projectPath = Path.Combine(
|
||||
repositoryRoot,
|
||||
"src",
|
||||
"AcDream.Headless",
|
||||
"AcDream.Headless.csproj");
|
||||
var project = XDocument.Load(projectPath);
|
||||
string projectDirectory = Path.GetDirectoryName(projectPath)!;
|
||||
|
||||
string[] actual = project
|
||||
.Descendants("ProjectReference")
|
||||
.Select(reference => reference.Attribute("Include")?.Value)
|
||||
.Where(static include => !string.IsNullOrWhiteSpace(include))
|
||||
.Select(include => Path.GetFullPath(Path.Combine(
|
||||
projectDirectory,
|
||||
include!.Replace(
|
||||
'\\',
|
||||
Path.DirectorySeparatorChar))))
|
||||
.ToArray();
|
||||
string expected = Path.Combine(
|
||||
repositoryRoot,
|
||||
"src",
|
||||
"AcDream.Runtime",
|
||||
"AcDream.Runtime.csproj");
|
||||
|
||||
Assert.Equal([expected], actual);
|
||||
Assert.Empty(project.Descendants("PackageReference"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HeadlessDependencyClosureHasNoPresentationOrBackendLibraries()
|
||||
{
|
||||
string depsPath = Path.ChangeExtension(
|
||||
typeof(HeadlessDependencyBoundaryTests).Assembly.Location,
|
||||
".deps.json");
|
||||
|
||||
using JsonDocument document =
|
||||
JsonDocument.Parse(File.ReadAllText(depsPath));
|
||||
string[] libraries = document.RootElement
|
||||
.GetProperty("libraries")
|
||||
.EnumerateObject()
|
||||
.Select(static library => LibraryName(library.Name))
|
||||
.ToArray();
|
||||
|
||||
Assert.DoesNotContain(libraries, IsForbidden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HelpPathLoadsNoPresentationOrBackendAssembly()
|
||||
{
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
Assert.Equal(
|
||||
0,
|
||||
HeadlessEntryPoint.Run(["--help"], output, error));
|
||||
|
||||
string[] loaded = AppDomain.CurrentDomain
|
||||
.GetAssemblies()
|
||||
.Select(static assembly =>
|
||||
assembly.GetName().Name ?? string.Empty)
|
||||
.ToArray();
|
||||
|
||||
Assert.DoesNotContain(loaded, IsForbidden);
|
||||
}
|
||||
|
||||
private static string FindRepositoryRoot(
|
||||
[CallerFilePath] string sourcePath = "")
|
||||
{
|
||||
string[] starts =
|
||||
{
|
||||
Path.GetDirectoryName(sourcePath) ?? string.Empty,
|
||||
Directory.GetCurrentDirectory(),
|
||||
AppContext.BaseDirectory,
|
||||
};
|
||||
foreach (string start in starts)
|
||||
{
|
||||
if (string.IsNullOrEmpty(start))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
DirectoryInfo? directory = new(start);
|
||||
while (directory is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(
|
||||
directory.FullName,
|
||||
"AcDream.slnx")))
|
||||
{
|
||||
return directory.FullName;
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException(
|
||||
"Could not find AcDream.slnx above the working or output directory.");
|
||||
}
|
||||
|
||||
private static string LibraryName(string libraryIdentity)
|
||||
{
|
||||
int separatorIndex = libraryIdentity.IndexOf('/');
|
||||
return separatorIndex < 0
|
||||
? libraryIdentity
|
||||
: libraryIdentity[..separatorIndex];
|
||||
}
|
||||
|
||||
private static bool IsForbidden(string assemblyName) =>
|
||||
ForbiddenDependencyPrefixes.Any(prefix =>
|
||||
assemblyName.StartsWith(
|
||||
prefix,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
109
tests/AcDream.Headless.Tests/HeadlessEntryPointTests.cs
Normal file
109
tests/AcDream.Headless.Tests/HeadlessEntryPointTests.cs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
namespace AcDream.Headless.Tests;
|
||||
|
||||
public sealed class HeadlessEntryPointTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("--help")]
|
||||
[InlineData("-h")]
|
||||
public void HelpIsPresentationFreeAndSuccessful(string argument)
|
||||
{
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
|
||||
int exitCode = HeadlessEntryPoint.Run(
|
||||
[argument],
|
||||
output,
|
||||
error);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
Assert.Contains("acdream-headless", output.ToString());
|
||||
Assert.Contains("Secrets are never accepted", output.ToString());
|
||||
Assert.Equal(string.Empty, error.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateAcceptsStrictEmptyNoConnectConfiguration()
|
||||
{
|
||||
using var file = TemporaryConfiguration.Create(
|
||||
"""{"version":1,"sessions":[]}""");
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
|
||||
int exitCode = HeadlessEntryPoint.Run(
|
||||
["validate", "--config", file.Path],
|
||||
output,
|
||||
error);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
Assert.Contains("0 session(s)", output.ToString());
|
||||
Assert.Equal(string.Empty, error.ToString());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"version":2,"sessions":[]}""", "Unsupported configuration version")]
|
||||
[InlineData("""{"version":1,"sessions":[],"password":"secret"}""", "could not be mapped")]
|
||||
[InlineData("""{"version":1,"sessions":[{"id":"bot"},{"id":"bot"}]}""", "Duplicate session id")]
|
||||
[InlineData("""{"version":1,"sessions":[null]}""", "non-empty id")]
|
||||
[InlineData("""{"version":1}""", "required properties")]
|
||||
public void ValidateRejectsInvalidOrSecretShapedConfiguration(
|
||||
string json,
|
||||
string expectedError)
|
||||
{
|
||||
using var file = TemporaryConfiguration.Create(json);
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
|
||||
int exitCode = HeadlessEntryPoint.Run(
|
||||
["validate", "--config", file.Path],
|
||||
output,
|
||||
error);
|
||||
|
||||
Assert.Equal(3, exitCode);
|
||||
Assert.Contains(
|
||||
expectedError,
|
||||
error.ToString(),
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("secret", error.ToString());
|
||||
Assert.Equal(string.Empty, output.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownCommandReturnsUsageError()
|
||||
{
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
|
||||
int exitCode = HeadlessEntryPoint.Run(
|
||||
["connect", "--password", "secret"],
|
||||
output,
|
||||
error);
|
||||
|
||||
Assert.Equal(2, exitCode);
|
||||
Assert.DoesNotContain("secret", error.ToString());
|
||||
Assert.Equal(string.Empty, output.ToString());
|
||||
}
|
||||
|
||||
private sealed class TemporaryConfiguration : IDisposable
|
||||
{
|
||||
private TemporaryConfiguration(string path)
|
||||
{
|
||||
Path = path;
|
||||
}
|
||||
|
||||
internal string Path { get; }
|
||||
|
||||
internal static TemporaryConfiguration Create(string json)
|
||||
{
|
||||
string path = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(),
|
||||
$"acdream-headless-{Guid.NewGuid():N}.json");
|
||||
File.WriteAllText(path, json);
|
||||
return new TemporaryConfiguration(path);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
File.Delete(Path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
using System.Text.Json;
|
||||
using System.Xml.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace AcDream.Runtime.Tests;
|
||||
|
||||
|
|
@ -59,7 +60,11 @@ public sealed class RuntimeDependencyBoundaryTests
|
|||
.Descendants("ProjectReference")
|
||||
.Select(reference => reference.Attribute("Include")?.Value)
|
||||
.Where(static include => !string.IsNullOrWhiteSpace(include))
|
||||
.Select(include => Path.GetFullPath(Path.Combine(projectDirectory, include!)))
|
||||
.Select(include => Path.GetFullPath(Path.Combine(
|
||||
projectDirectory,
|
||||
include!.Replace(
|
||||
'\\',
|
||||
Path.DirectorySeparatorChar))))
|
||||
.Order(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
var expectedReferences = new[]
|
||||
|
|
@ -94,21 +99,38 @@ public sealed class RuntimeDependencyBoundaryTests
|
|||
Assert.DoesNotContain(loadedAssemblies, IsForbidden);
|
||||
}
|
||||
|
||||
private static string FindRepositoryRoot()
|
||||
private static string FindRepositoryRoot(
|
||||
[CallerFilePath] string sourcePath = "")
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
string[] starts =
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||
Path.GetDirectoryName(sourcePath) ?? string.Empty,
|
||||
Directory.GetCurrentDirectory(),
|
||||
AppContext.BaseDirectory,
|
||||
};
|
||||
foreach (string start in starts)
|
||||
{
|
||||
if (string.IsNullOrEmpty(start))
|
||||
{
|
||||
return directory.FullName;
|
||||
continue;
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
var directory = new DirectoryInfo(start);
|
||||
while (directory is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(
|
||||
directory.FullName,
|
||||
"AcDream.slnx")))
|
||||
{
|
||||
return directory.FullName;
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException(
|
||||
$"Could not find AcDream.slnx above {AppContext.BaseDirectory}.");
|
||||
"Could not find AcDream.slnx above the source, working, or output directory.");
|
||||
}
|
||||
|
||||
private static string LibraryName(string libraryIdentity)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue