acdream/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs

429 lines
15 KiB
C#

using System.Text.Json;
using System.Net;
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;
using AcDream.Runtime;
using AcDream.Runtime.Session;
using AcDream.Tests.Fixtures.CampaignLa;
namespace AcDream.Headless.Tests;
public sealed class HeadlessPluginSessionTests
{
private const string FixtureId = "acdream.test.host-fixture";
private const string BrokenId = "acdream.test.broken";
private const string ThrowingId = "acdream.test.throwing-fixture";
[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,
new FixtureSessionOperations(),
pluginRoots: [temporary.Path]);
HeadlessPluginSession plugins = session.Plugins;
_ = session.Start();
_ = 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(
[
"started", "pluginLoaded", "pluginFailed", "connected",
"characterList", "enteredWorld",
],
EventNames(statuses));
Assert.Equal(FixtureId, statuses[1].GetProperty("plugin").GetString());
Assert.Equal(BrokenId, statuses[2].GetProperty("plugin").GetString());
Assert.Contains(
"entry dll not found",
statuses[2].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),
new FixtureSessionOperations(),
pluginRoots: [temporary.Path]);
_ = session.Start();
Assert.Equal(0, session.Plugins.LoadedCount);
Assert.Equal(
["started", "connected", "characterList", "enteredWorld"],
EventNames(ReadStatuses(statusPath)));
Assert.DoesNotContain("fixture-", output.ToString());
}
[Fact]
public void ThrowAfterRegistrationRollsBackEventsAndCollectsContext()
{
using var temporary = new TemporaryDirectory();
string pluginDirectory = InstallFixture(
temporary.Path,
ThrowingId,
"throwing-fixture");
File.WriteAllText(
Path.Combine(pluginDirectory, "throw-after-register"),
string.Empty);
string statusPath = Path.Combine(temporary.Path, "status.jsonl");
var credential = new HeadlessCredentialSecret("fixture", "password");
using var session = new HeadlessSessionHost(
Descriptor([ThrowingId], statusPath),
credential,
new HeadlessDiagnosticWriter(new StringWriter()),
new FixtureSessionOperations(),
pluginRoots: [temporary.Path]);
_ = session.Start();
Assert.Equal(0, session.Plugins.LoadedCount);
_ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000001u, 1f));
Assert.True(((ISelectionService)session.Runtime.ActionOwner.Selection)
.Select(7u));
Assert.False(File.Exists(
Path.Combine(pluginDirectory, "unexpected-callback")));
Assert.Equal(
[
"started", "pluginFailed", "connected", "characterList",
"enteredWorld",
],
EventNames(ReadStatuses(statusPath)));
WeakReference context = Assert.Single(
session.Plugins.CaptureLoadContextWeakReferences());
session.Dispose();
Collect(context);
Assert.False(context.IsAlive);
}
[Fact]
public void LauncherProbeRoundTripKeepsPluginsDisabledInTheRealHost()
{
using var temporary = new TemporaryDirectory();
InstallFixture(temporary.Path, FixtureId);
string configPath = Path.Combine(temporary.Path, "probe.json");
File.WriteAllText(
configPath,
LauncherCoreSessionConfigFixture.ComposeProbe());
HeadlessSessionDescriptor descriptor = Assert.Single(
HeadlessConfigurationLoader.Load(configPath).Sessions)! with
{
StatusFile = Path.Combine(temporary.Path, "probe-status.jsonl"),
};
var credential = new HeadlessCredentialSecret("fixture", "password");
using var session = new HeadlessSessionHost(
descriptor,
credential,
new HeadlessDiagnosticWriter(new StringWriter()),
new FixtureSessionOperations(),
pluginRoots: [temporary.Path]);
RuntimeSessionStartResult result = session.Start();
Assert.Equal(RuntimeSessionStartStatus.ProbeComplete, result.Status);
Assert.NotNull(descriptor.Plugins);
Assert.Empty(descriptor.Plugins);
Assert.Equal(0, session.Plugins.LoadedCount);
Assert.Equal(
["started", "connected", "characterList"],
EventNames(ReadStatuses(descriptor.StatusFile!)));
}
[Fact]
public async Task LateSubscriberReplayQueuesConcurrentRegistrationExactlyOnceInOrder()
{
using var temporary = new TemporaryDirectory();
var credential = new HeadlessCredentialSecret("fixture", "password");
using var session = new HeadlessSessionHost(
Descriptor([], Path.Combine(temporary.Path, "status.jsonl")),
credential,
new HeadlessDiagnosticWriter(new StringWriter()),
new FixtureSessionOperations(),
pluginRoots: [temporary.Path]);
_ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000001u, 1f));
HeadlessPluginHost host = session.Plugins.Host;
using var replayCaptured = new ManualResetEventSlim();
using var releaseReplay = new ManualResetEventSlim();
host.ReplayCapturedForTest = () =>
{
replayCaptured.Set();
Assert.True(releaseReplay.Wait(TimeSpan.FromSeconds(10)));
};
var observed = new List<uint>();
Action<WorldEntitySnapshot> handler = snapshot =>
{
lock (observed)
observed.Add(snapshot.Id);
};
Task subscribe = Task.Run(() => host.Events.EntitySpawned += handler);
Assert.True(replayCaptured.Wait(TimeSpan.FromSeconds(10)));
using var registrationStarted = new ManualResetEventSlim();
Task registration = Task.Run(() =>
{
registrationStarted.Set();
_ = session.Runtime.EntityObjects.RegisterEntity(
Spawn(0x50000002u, 2f));
});
Assert.True(registrationStarted.Wait(TimeSpan.FromSeconds(10)));
try
{
await Task.Delay(TimeSpan.FromMilliseconds(100));
Assert.False(registration.IsCompleted);
}
finally
{
releaseReplay.Set();
}
await Task.WhenAll(subscribe, registration)
.WaitAsync(TimeSpan.FromSeconds(10));
host.Events.EntitySpawned -= handler;
Assert.Equal([1_000_000u, 1_000_001u], observed);
}
private static HeadlessSessionDescriptor Descriptor(
List<string> 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<JsonElement> events) =>
events.Select(static item => item.GetProperty("e").GetString()!).ToArray();
private static string InstallFixture(
string root,
string id,
string directoryName = "host-fixture")
{
string source = FixtureAssemblyPath();
Assert.True(File.Exists(source), $"fixture DLL not found: {source}");
string pluginDirectory = Path.Combine(root, directoryName);
Directory.CreateDirectory(pluginDirectory);
string fileName = Path.GetFileName(source);
File.Copy(source, Path.Combine(pluginDirectory, fileName));
WriteManifest(pluginDirectory, id, fileName);
return pluginDirectory;
}
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 FixtureSessionOperations : ILiveSessionOperations
{
private static readonly CharacterList.Parsed Characters = new(
0u,
[new CharacterList.Character(0x50000001u, "Fixture", 0u)],
[],
1,
"account",
true,
true);
public IPEndPoint ResolveEndpoint(string host, int port) =>
new(IPAddress.Loopback, port);
public WorldSession CreateSession(IPEndPoint endpoint) => new(endpoint);
public void Connect(WorldSession session, string user, string password)
{
}
public CharacterList.Parsed? GetCharacters(WorldSession session) => Characters;
public void EnterWorld(WorldSession session, int activeCharacterIndex)
{
}
public void Tick(WorldSession session)
{
}
public void DisposeSession(WorldSession session) => session.Dispose();
}
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()
{
for (int attempt = 0; Directory.Exists(Path); attempt++)
{
try
{
Directory.Delete(Path, recursive: true);
return;
}
catch (Exception error)
when (error is IOException or UnauthorizedAccessException
&& attempt < 9)
{
GC.Collect();
GC.WaitForPendingFinalizers();
Thread.Sleep(10);
}
}
}
}
}