using System.Text.Json; using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Headless.Configuration; using AcDream.Headless.Credentials; using AcDream.Headless.Diagnostics; using AcDream.Headless.Hosting; using AcDream.Headless.Plugins; using AcDream.Plugin.Abstractions; namespace AcDream.Headless.Tests; public sealed class HeadlessPluginSessionTests { private const string FixtureId = "acdream.test.host-fixture"; private const string BrokenId = "acdream.test.broken"; [Fact] public void ConfiguredSetBorrowsRuntimeUsesNoOpUiIsolatesFailureAndUnloads() { using var temporary = new TemporaryDirectory(); InstallFixture(temporary.Path, FixtureId); InstallBrokenPlugin(temporary.Path, BrokenId); var output = new StringWriter(); var diagnostics = new HeadlessDiagnosticWriter(output); string statusPath = Path.Combine(temporary.Path, "status.jsonl"); var credential = new HeadlessCredentialSecret("fixture", "password"); using var session = new HeadlessSessionHost( Descriptor([FixtureId.ToUpperInvariant(), BrokenId], statusPath), credential, diagnostics, pluginRoots: [temporary.Path]); HeadlessPluginSession plugins = session.Plugins; _ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000001u, 1f)); Assert.Equal(1, plugins.LoadedCount); Assert.False(plugins.Host.HasUi); Assert.Same(NoOpUiRegistry.Instance, plugins.Host.Ui); Assert.Same( session.Runtime.ActionOwner.Selection, plugins.Host.Selection); WorldEntitySnapshot first = Assert.Single(plugins.Host.State.Entities); Assert.Equal(1_000_000u, first.Id); Assert.Equal(0x02000001u, first.SourceId); _ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000002u, 2f)); Assert.Equal(2, plugins.Host.State.Entities.Count); JsonElement[] statuses = ReadStatuses(statusPath); Assert.Equal(["pluginLoaded", "pluginFailed"], EventNames(statuses)); Assert.Equal(FixtureId, statuses[0].GetProperty("plugin").GetString()); Assert.Equal(BrokenId, statuses[1].GetProperty("plugin").GetString()); Assert.Contains( "entry dll not found", statuses[1].GetProperty("error").GetString()!, StringComparison.OrdinalIgnoreCase); Assert.Contains("fixture-enabled:hasUi=False:entities=0", output.ToString()); WeakReference context = Assert.Single( plugins.CaptureLoadContextWeakReferences()); session.Dispose(); Assert.Contains("fixture-disabled:entitiesSeen=2", output.ToString()); Assert.True(session.Runtime.CaptureOwnership().IsConverged); Assert.True(credential.IsDisposed); Collect(context); Assert.False(context.IsAlive); } [Fact] public void ExplicitEmptyConfiguredSetLoadsNone() { using var temporary = new TemporaryDirectory(); InstallFixture(temporary.Path, FixtureId); var output = new StringWriter(); string statusPath = Path.Combine(temporary.Path, "status.jsonl"); var credential = new HeadlessCredentialSecret("fixture", "password"); using var session = new HeadlessSessionHost( Descriptor([], statusPath), credential, new HeadlessDiagnosticWriter(output), pluginRoots: [temporary.Path]); Assert.Equal(0, session.Plugins.LoadedCount); Assert.False(File.Exists(statusPath)); Assert.DoesNotContain("fixture-", output.ToString()); } private static HeadlessSessionDescriptor Descriptor( List plugins, string statusPath) => new() { Id = "headless-session", Endpoint = new HeadlessEndpointDescriptor { Host = "127.0.0.1", Port = 9000, }, Account = "account", Character = new HeadlessCharacterSelector { Name = "Fixture", }, Policy = new HeadlessBotPolicyDescriptor { Id = "idle", }, Credential = new HeadlessCredentialReference { Provider = HeadlessCredentialProviderKind.Environment, Reference = "FIXTURE_PASSWORD", }, Plugins = plugins, StatusFile = statusPath, }; private static WorldSession.EntitySpawn Spawn(uint guid, float x) => new( guid, new CreateObject.ServerPosition( 0x01010001u, x, 10f, 5f, 1f, 0f, 0f, 0f), 0x02000001u, [], [], [], null, null, "Fixture", null, null, null); private static JsonElement[] ReadStatuses(string path) => File.ReadAllLines(path) .Select(static line => JsonDocument.Parse(line).RootElement.Clone()) .ToArray(); private static string[] EventNames(IEnumerable events) => events.Select(static item => item.GetProperty("e").GetString()!).ToArray(); private static void InstallFixture(string root, string id) { string source = FixtureAssemblyPath(); Assert.True(File.Exists(source), $"fixture DLL not found: {source}"); string pluginDirectory = Path.Combine(root, "host-fixture"); Directory.CreateDirectory(pluginDirectory); string fileName = Path.GetFileName(source); File.Copy(source, Path.Combine(pluginDirectory, fileName)); WriteManifest(pluginDirectory, id, fileName); } private static void InstallBrokenPlugin(string root, string id) { string pluginDirectory = Path.Combine(root, "broken"); Directory.CreateDirectory(pluginDirectory); WriteManifest(pluginDirectory, id, "missing.dll"); } private static void WriteManifest( string directory, string id, string entryDll) => File.WriteAllText( Path.Combine(directory, "plugin.json"), JsonSerializer.Serialize(new { id, displayName = "Host fixture", version = "1.0.0", entryDll, apiVersion = 1, })); private static string FixtureAssemblyPath() { string configuration = new DirectoryInfo(AppContext.BaseDirectory) .Parent!.Name; string root = FindRepoRoot(AppContext.BaseDirectory); return Path.Combine( root, "tests", "AcDream.Plugin.Tests.Fixtures.HostPlugin", "bin", configuration, "net10.0", "AcDream.Plugin.Tests.Fixtures.HostPlugin.dll"); } private static string FindRepoRoot(string start) { DirectoryInfo? directory = new(start); while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) { directory = directory.Parent; } return directory?.FullName ?? throw new InvalidOperationException("Repository root not found."); } private static void Collect(WeakReference reference) { for (int attempt = 0; attempt < 10 && reference.IsAlive; attempt++) { GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); } } private sealed class TemporaryDirectory : IDisposable { internal TemporaryDirectory() { Path = System.IO.Path.Combine( System.IO.Path.GetTempPath(), $"acdream-headless-plugins-{Guid.NewGuid():N}"); Directory.CreateDirectory(Path); } internal string Path { get; } public void Dispose() { if (Directory.Exists(Path)) Directory.Delete(Path, recursive: true); } } }