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
97
.github/workflows/headless-portability.yml
vendored
Normal file
97
.github/workflows/headless-portability.yml
vendored
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
name: Headless portability
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- ".github/workflows/headless-portability.yml"
|
||||||
|
- "AcDream.slnx"
|
||||||
|
- "src/AcDream.Core/**"
|
||||||
|
- "src/AcDream.Core.Net/**"
|
||||||
|
- "src/AcDream.Content/**"
|
||||||
|
- "src/AcDream.Plugin.Abstractions/**"
|
||||||
|
- "src/AcDream.Runtime/**"
|
||||||
|
- "src/AcDream.Headless/**"
|
||||||
|
- "tests/AcDream.Core.Tests/**"
|
||||||
|
- "tests/AcDream.Core.Net.Tests/**"
|
||||||
|
- "tests/AcDream.Content.Tests/**"
|
||||||
|
- "tests/AcDream.Runtime.Tests/**"
|
||||||
|
- "tests/AcDream.Headless.Tests/**"
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- ".github/workflows/headless-portability.yml"
|
||||||
|
- "AcDream.slnx"
|
||||||
|
- "src/AcDream.Core/**"
|
||||||
|
- "src/AcDream.Core.Net/**"
|
||||||
|
- "src/AcDream.Content/**"
|
||||||
|
- "src/AcDream.Plugin.Abstractions/**"
|
||||||
|
- "src/AcDream.Runtime/**"
|
||||||
|
- "src/AcDream.Headless/**"
|
||||||
|
- "tests/AcDream.Core.Tests/**"
|
||||||
|
- "tests/AcDream.Core.Net.Tests/**"
|
||||||
|
- "tests/AcDream.Content.Tests/**"
|
||||||
|
- "tests/AcDream.Runtime.Tests/**"
|
||||||
|
- "tests/AcDream.Headless.Tests/**"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
portable-headless:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
os: [windows-latest, ubuntu-latest]
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Install .NET 10
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: "10.0.x"
|
||||||
|
|
||||||
|
- name: Build presentation-free closure
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$projects = @(
|
||||||
|
"src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj",
|
||||||
|
"src/AcDream.Core/AcDream.Core.csproj",
|
||||||
|
"src/AcDream.Core.Net/AcDream.Core.Net.csproj",
|
||||||
|
"src/AcDream.Content/AcDream.Content.csproj",
|
||||||
|
"src/AcDream.Runtime/AcDream.Runtime.csproj",
|
||||||
|
"src/AcDream.Headless/AcDream.Headless.csproj"
|
||||||
|
)
|
||||||
|
foreach ($project in $projects) {
|
||||||
|
dotnet build $project -c Release
|
||||||
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
}
|
||||||
|
|
||||||
|
# AcDream.Core.Tests still contains historical App integration fixtures,
|
||||||
|
# so it is deliberately not a member of this no-App restore lane. Core is
|
||||||
|
# built directly above and exercised through every portable downstream
|
||||||
|
# test project below; the complete solution lane retains Core.Tests.
|
||||||
|
- name: Test presentation-free closure
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$projects = @(
|
||||||
|
"tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj",
|
||||||
|
"tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj",
|
||||||
|
"tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj",
|
||||||
|
"tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj"
|
||||||
|
)
|
||||||
|
foreach ($project in $projects) {
|
||||||
|
dotnet test $project -c Release
|
||||||
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Verify CLI without connecting
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
dotnet run --project src/AcDream.Headless/AcDream.Headless.csproj -c Release -- --help
|
||||||
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
Set-Content -LiteralPath headless-k0.json -Value '{"version":1,"sessions":[]}'
|
||||||
|
dotnet run --project src/AcDream.Headless/AcDream.Headless.csproj -c Release -- validate --config headless-k0.json
|
||||||
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
<Project Path="src/AcDream.Content/AcDream.Content.csproj" />
|
<Project Path="src/AcDream.Content/AcDream.Content.csproj" />
|
||||||
<Project Path="src/AcDream.Core/AcDream.Core.csproj" />
|
<Project Path="src/AcDream.Core/AcDream.Core.csproj" />
|
||||||
<Project Path="src/AcDream.Core.Net/AcDream.Core.Net.csproj" />
|
<Project Path="src/AcDream.Core.Net/AcDream.Core.Net.csproj" />
|
||||||
|
<Project Path="src/AcDream.Headless/AcDream.Headless.csproj" />
|
||||||
<Project Path="src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj" />
|
<Project Path="src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj" />
|
||||||
<Project Path="src/AcDream.Plugins.Smoke/AcDream.Plugins.Smoke.csproj" />
|
<Project Path="src/AcDream.Plugins.Smoke/AcDream.Plugins.Smoke.csproj" />
|
||||||
<Project Path="src/AcDream.Runtime/AcDream.Runtime.csproj" />
|
<Project Path="src/AcDream.Runtime/AcDream.Runtime.csproj" />
|
||||||
|
|
@ -23,6 +24,7 @@
|
||||||
<Project Path="tests/AcDream.Core.Tests.Fixtures.HelloPlugin/AcDream.Core.Tests.Fixtures.HelloPlugin.csproj" />
|
<Project Path="tests/AcDream.Core.Tests.Fixtures.HelloPlugin/AcDream.Core.Tests.Fixtures.HelloPlugin.csproj" />
|
||||||
<Project Path="tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj" />
|
<Project Path="tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj" />
|
||||||
<Project Path="tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj" />
|
<Project Path="tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj" />
|
||||||
|
<Project Path="tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj" />
|
||||||
<Project Path="tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj" />
|
<Project Path="tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj" />
|
||||||
<Project Path="tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj" />
|
<Project Path="tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
|
|
|
||||||
22
src/AcDream.Headless/AcDream.Headless.csproj
Normal file
22
src/AcDream.Headless/AcDream.Headless.csproj
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<AssemblyName>acdream-headless</AssemblyName>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="AcDream.Headless.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- The production headless process enters through the same Runtime root
|
||||||
|
as the graphical client. Presentation, UI, native-window, GL, and
|
||||||
|
audio projects are forbidden here and enforced by tests. -->
|
||||||
|
<ProjectReference Include="..\AcDream.Runtime\AcDream.Runtime.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
18
src/AcDream.Headless/Configuration/HeadlessConfiguration.cs
Normal file
18
src/AcDream.Headless/Configuration/HeadlessConfiguration.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace AcDream.Headless.Configuration;
|
||||||
|
|
||||||
|
internal sealed class HeadlessConfiguration
|
||||||
|
{
|
||||||
|
[JsonRequired]
|
||||||
|
public int Version { get; init; }
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public List<HeadlessSessionDescriptor?> Sessions { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class HeadlessSessionDescriptor
|
||||||
|
{
|
||||||
|
[JsonRequired]
|
||||||
|
public string Id { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
namespace AcDream.Headless.Configuration;
|
||||||
|
|
||||||
|
internal sealed class HeadlessConfigurationException : Exception
|
||||||
|
{
|
||||||
|
internal HeadlessConfigurationException(string message)
|
||||||
|
: base(message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace AcDream.Headless.Configuration;
|
||||||
|
|
||||||
|
internal static class HeadlessConfigurationLoader
|
||||||
|
{
|
||||||
|
private const int CurrentVersion = 1;
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions Options = new()
|
||||||
|
{
|
||||||
|
AllowTrailingCommas = false,
|
||||||
|
PropertyNameCaseInsensitive = false,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
ReadCommentHandling = JsonCommentHandling.Disallow,
|
||||||
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
|
||||||
|
};
|
||||||
|
|
||||||
|
internal static HeadlessConfiguration Load(string path)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||||
|
|
||||||
|
string fullPath = Path.GetFullPath(path);
|
||||||
|
using FileStream stream = File.OpenRead(fullPath);
|
||||||
|
HeadlessConfiguration? configuration =
|
||||||
|
JsonSerializer.Deserialize<HeadlessConfiguration>(
|
||||||
|
stream,
|
||||||
|
Options);
|
||||||
|
|
||||||
|
if (configuration is null)
|
||||||
|
{
|
||||||
|
throw new HeadlessConfigurationException(
|
||||||
|
"The configuration document is empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configuration.Version != CurrentVersion)
|
||||||
|
{
|
||||||
|
throw new HeadlessConfigurationException(
|
||||||
|
$"Unsupported configuration version {configuration.Version}; "
|
||||||
|
+ $"expected {CurrentVersion}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configuration.Sessions is null)
|
||||||
|
{
|
||||||
|
throw new HeadlessConfigurationException(
|
||||||
|
"sessions must be an array.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessionIds = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (HeadlessSessionDescriptor? session in configuration.Sessions)
|
||||||
|
{
|
||||||
|
if (session is null
|
||||||
|
|| string.IsNullOrWhiteSpace(session.Id))
|
||||||
|
{
|
||||||
|
throw new HeadlessConfigurationException(
|
||||||
|
"Every session requires a non-empty id.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessionIds.Add(session.Id))
|
||||||
|
{
|
||||||
|
throw new HeadlessConfigurationException(
|
||||||
|
$"Duplicate session id '{session.Id}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return configuration;
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/AcDream.Headless/HeadlessAssemblyMarker.cs
Normal file
6
src/AcDream.Headless/HeadlessAssemblyMarker.cs
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
namespace AcDream.Headless;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Identifies the presentation-free production host assembly.
|
||||||
|
/// </summary>
|
||||||
|
internal static class HeadlessAssemblyMarker;
|
||||||
82
src/AcDream.Headless/HeadlessEntryPoint.cs
Normal file
82
src/AcDream.Headless/HeadlessEntryPoint.cs
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
using AcDream.Headless.Configuration;
|
||||||
|
|
||||||
|
namespace AcDream.Headless;
|
||||||
|
|
||||||
|
internal static class HeadlessEntryPoint
|
||||||
|
{
|
||||||
|
private const string HelpText =
|
||||||
|
"""
|
||||||
|
acdream-headless
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
acdream-headless --help
|
||||||
|
acdream-headless validate --config <path>
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
validate Validate a versioned headless configuration without connecting.
|
||||||
|
|
||||||
|
Secrets are never accepted on the command line.
|
||||||
|
""";
|
||||||
|
|
||||||
|
internal static int Run(
|
||||||
|
IReadOnlyList<string> arguments,
|
||||||
|
TextWriter output,
|
||||||
|
TextWriter error)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(arguments);
|
||||||
|
ArgumentNullException.ThrowIfNull(output);
|
||||||
|
ArgumentNullException.ThrowIfNull(error);
|
||||||
|
|
||||||
|
if (arguments.Count == 0
|
||||||
|
|| IsHelp(arguments[0]))
|
||||||
|
{
|
||||||
|
output.WriteLine(HelpText);
|
||||||
|
return (int)HeadlessExitCode.Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(
|
||||||
|
arguments[0],
|
||||||
|
"validate",
|
||||||
|
StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
error.WriteLine("Unknown command. Run --help for usage.");
|
||||||
|
return (int)HeadlessExitCode.UsageError;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arguments.Count != 3
|
||||||
|
|| !string.Equals(
|
||||||
|
arguments[1],
|
||||||
|
"--config",
|
||||||
|
StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
error.WriteLine(
|
||||||
|
"validate requires exactly: --config <path>");
|
||||||
|
return (int)HeadlessExitCode.UsageError;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
HeadlessConfiguration configuration =
|
||||||
|
HeadlessConfigurationLoader.Load(arguments[2]);
|
||||||
|
output.WriteLine(
|
||||||
|
$"Configuration valid: version {configuration.Version}, "
|
||||||
|
+ $"{configuration.Sessions.Count} session(s).");
|
||||||
|
return (int)HeadlessExitCode.Success;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
when (exception is IOException
|
||||||
|
or UnauthorizedAccessException
|
||||||
|
or ArgumentException
|
||||||
|
or NotSupportedException
|
||||||
|
or System.Text.Json.JsonException
|
||||||
|
or HeadlessConfigurationException)
|
||||||
|
{
|
||||||
|
error.WriteLine($"Configuration invalid: {exception.Message}");
|
||||||
|
return (int)HeadlessExitCode.ConfigurationError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsHelp(string argument) =>
|
||||||
|
string.Equals(argument, "--help", StringComparison.Ordinal)
|
||||||
|
|| string.Equals(argument, "-h", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
8
src/AcDream.Headless/HeadlessExitCode.cs
Normal file
8
src/AcDream.Headless/HeadlessExitCode.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
namespace AcDream.Headless;
|
||||||
|
|
||||||
|
internal enum HeadlessExitCode
|
||||||
|
{
|
||||||
|
Success = 0,
|
||||||
|
UsageError = 2,
|
||||||
|
ConfigurationError = 3,
|
||||||
|
}
|
||||||
3
src/AcDream.Headless/Program.cs
Normal file
3
src/AcDream.Headless/Program.cs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
using AcDream.Headless;
|
||||||
|
|
||||||
|
return HeadlessEntryPoint.Run(args, Console.Out, Console.Error);
|
||||||
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.Text.Json;
|
||||||
using System.Xml.Linq;
|
using System.Xml.Linq;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
namespace AcDream.Runtime.Tests;
|
namespace AcDream.Runtime.Tests;
|
||||||
|
|
||||||
|
|
@ -59,7 +60,11 @@ public sealed class RuntimeDependencyBoundaryTests
|
||||||
.Descendants("ProjectReference")
|
.Descendants("ProjectReference")
|
||||||
.Select(reference => reference.Attribute("Include")?.Value)
|
.Select(reference => reference.Attribute("Include")?.Value)
|
||||||
.Where(static include => !string.IsNullOrWhiteSpace(include))
|
.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)
|
.Order(StringComparer.OrdinalIgnoreCase)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
var expectedReferences = new[]
|
var expectedReferences = new[]
|
||||||
|
|
@ -94,21 +99,38 @@ public sealed class RuntimeDependencyBoundaryTests
|
||||||
Assert.DoesNotContain(loadedAssemblies, IsForbidden);
|
Assert.DoesNotContain(loadedAssemblies, IsForbidden);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string FindRepositoryRoot()
|
private static string FindRepositoryRoot(
|
||||||
|
[CallerFilePath] string sourcePath = "")
|
||||||
{
|
{
|
||||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
string[] starts =
|
||||||
while (directory is not null)
|
|
||||||
{
|
{
|
||||||
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(
|
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)
|
private static string LibraryName(string libraryIdentity)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue