merge: Campaign LA LA5 - plugin hosting review-closed
This commit is contained in:
commit
5535d0adac
44 changed files with 3009 additions and 195 deletions
|
|
@ -25,6 +25,15 @@
|
|||
<ProjectReference Include="..\..\src\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Build ordering only. Tests copy this DLL into a temporary plugin root
|
||||
and the production loader loads it through a collectible ALC. -->
|
||||
<ProjectReference Include="..\AcDream.Plugin.Tests.Fixtures.HostPlugin\AcDream.Plugin.Tests.Fixtures.HostPlugin.csproj">
|
||||
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
||||
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\Fixtures\campaign-la\LauncherCoreSessionConfigFixture.cs"
|
||||
Link="Fixtures\LauncherCoreSessionConfigFixture.cs" />
|
||||
|
|
|
|||
|
|
@ -56,6 +56,18 @@ public sealed class SessionConfigurationSharedFixtureTests
|
|||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppReaderPreservesLauncherExplicitEmptyPluginAllowList()
|
||||
{
|
||||
using TemporaryFile file = TemporaryFile.Create(
|
||||
LauncherCoreSessionConfigFixture.ComposeEmptyPlugins());
|
||||
|
||||
(_, SessionDescriptor session) = SessionConfigurationLoader.Load(file.Path);
|
||||
|
||||
Assert.NotNull(session.Plugins);
|
||||
Assert.Empty(session.Plugins);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppReaderAcceptsTheProductionShapedSharedFixture()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using AcDream.App.Plugins;
|
||||
using AcDream.App.UI;
|
||||
|
||||
namespace AcDream.App.Tests.Plugins;
|
||||
|
||||
|
|
@ -18,4 +19,26 @@ public class BufferedUiRegistryTests
|
|||
|
||||
Assert.Empty(reg.Drain()); // consumed
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScopedRegistrationTokenRemovesAnAlreadyMountedElement()
|
||||
{
|
||||
var registry = new BufferedUiRegistry();
|
||||
IDisposable registration = registry.RegisterMarkupPanel(
|
||||
"plugin.xml",
|
||||
new object());
|
||||
BufferedUiRegistry.Pending pending = Assert.Single(registry.Drain());
|
||||
var root = new UiRoot();
|
||||
var element = new UiPanel();
|
||||
root.AddChild(element);
|
||||
registry.CompleteMount(pending, root, element);
|
||||
|
||||
Assert.Contains(element, root.Children);
|
||||
Assert.Equal(1, registry.RegistrationCount);
|
||||
|
||||
registration.Dispose();
|
||||
|
||||
Assert.DoesNotContain(element, root.Children);
|
||||
Assert.Equal(0, registry.RegistrationCount);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
347
tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs
Normal file
347
tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
using System.Text.Json;
|
||||
using System.Runtime.CompilerServices;
|
||||
using AcDream.App.Configuration;
|
||||
using AcDream.App.Plugins;
|
||||
using AcDream.Core.Plugins;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Platform;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
using AcDream.Runtime.Session;
|
||||
using AcDream.Tests.Fixtures.CampaignLa;
|
||||
|
||||
namespace AcDream.App.Tests.Plugins;
|
||||
|
||||
public sealed class GraphicalPluginSessionTests
|
||||
{
|
||||
private const string FixtureId = "acdream.test.host-fixture";
|
||||
private const string ThrowingId = "acdream.test.throwing-fixture";
|
||||
private const string InitializeThrowingId =
|
||||
"acdream.test.initialize-throwing-fixture";
|
||||
|
||||
[Fact]
|
||||
public void ConfiguredSetLoadsOnlyAllowedPluginAndReportsBothOutcomes()
|
||||
{
|
||||
using var temporary = new TemporaryDirectory();
|
||||
ApplicationPathSet paths = Paths(temporary.Path);
|
||||
InstallFixture(paths.PluginsDirectory, FixtureId);
|
||||
string statusPath = Path.Combine(temporary.Path, "status.jsonl");
|
||||
var logger = new CapturingLogger();
|
||||
var state = new WorldGameState();
|
||||
var events = new WorldEvents();
|
||||
var selection = new SelectionState();
|
||||
var ui = new BufferedUiRegistry();
|
||||
var host = new AppPluginHost(logger, state, events, selection, ui);
|
||||
|
||||
using GraphicalPluginSession plugins = GraphicalPluginSession.Create(
|
||||
paths,
|
||||
[FixtureId.ToUpperInvariant(), "acdream.test.missing"],
|
||||
"gui-session",
|
||||
host,
|
||||
new SessionStatusWriter(statusPath));
|
||||
plugins.Start();
|
||||
|
||||
Assert.Equal(1, plugins.LoadedCount);
|
||||
Assert.True(host.HasUi);
|
||||
AssertPanelWasRegisteredAndReleaseBinding(ui);
|
||||
Assert.Contains(
|
||||
logger.Messages,
|
||||
message => message.Contains("fixture-enabled:hasUi=True", StringComparison.Ordinal));
|
||||
|
||||
JsonElement[] statuses = ReadStatuses(statusPath);
|
||||
Assert.Equal(["started", "pluginLoaded", "pluginFailed"], EventNames(statuses));
|
||||
Assert.Equal(FixtureId, statuses[1].GetProperty("plugin").GetString());
|
||||
Assert.Equal(
|
||||
"acdream.test.missing",
|
||||
statuses[2].GetProperty("plugin").GetString());
|
||||
Assert.Contains(
|
||||
"not found",
|
||||
statuses[2].GetProperty("error").GetString(),
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
WeakReference context = Assert.Single(
|
||||
plugins.CaptureLoadContextWeakReferences());
|
||||
plugins.Dispose();
|
||||
Assert.Equal(0, ui.RegistrationCount);
|
||||
Collect(context);
|
||||
Assert.False(context.IsAlive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExplicitEmptyConfiguredSetLoadsNone()
|
||||
{
|
||||
using var temporary = new TemporaryDirectory();
|
||||
ApplicationPathSet paths = Paths(temporary.Path);
|
||||
InstallFixture(paths.PluginsDirectory, FixtureId);
|
||||
string statusPath = Path.Combine(temporary.Path, "status.jsonl");
|
||||
string configPath = Path.Combine(temporary.Path, "session.json");
|
||||
File.WriteAllText(
|
||||
configPath,
|
||||
LauncherCoreSessionConfigFixture.ComposeEmptyPlugins());
|
||||
(_, SessionDescriptor descriptor) =
|
||||
SessionConfigurationLoader.Load(configPath);
|
||||
var ui = new BufferedUiRegistry();
|
||||
var host = new AppPluginHost(
|
||||
new CapturingLogger(),
|
||||
new WorldGameState(),
|
||||
new WorldEvents(),
|
||||
new SelectionState(),
|
||||
ui);
|
||||
|
||||
using GraphicalPluginSession plugins = GraphicalPluginSession.Create(
|
||||
paths,
|
||||
descriptor.Plugins,
|
||||
"gui-session",
|
||||
host,
|
||||
new SessionStatusWriter(statusPath));
|
||||
plugins.Start();
|
||||
|
||||
Assert.Equal(0, plugins.LoadedCount);
|
||||
Assert.NotNull(descriptor.Plugins);
|
||||
Assert.Empty(descriptor.Plugins);
|
||||
Assert.Empty(ui.Drain());
|
||||
Assert.Equal(["started"], EventNames(ReadStatuses(statusPath)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThrowAfterRegistrationRollsBackUiAndEventsAndCollectsContext()
|
||||
{
|
||||
using var temporary = new TemporaryDirectory();
|
||||
ApplicationPathSet paths = Paths(temporary.Path);
|
||||
string pluginDirectory = InstallFixture(
|
||||
paths.PluginsDirectory,
|
||||
ThrowingId,
|
||||
"throwing-fixture");
|
||||
File.WriteAllText(
|
||||
Path.Combine(pluginDirectory, "throw-after-register"),
|
||||
string.Empty);
|
||||
string statusPath = Path.Combine(temporary.Path, "status.jsonl");
|
||||
var events = new WorldEvents();
|
||||
var selection = new SelectionState();
|
||||
var ui = new BufferedUiRegistry();
|
||||
var host = new AppPluginHost(
|
||||
new CapturingLogger(),
|
||||
new WorldGameState(),
|
||||
events,
|
||||
selection,
|
||||
ui);
|
||||
|
||||
using GraphicalPluginSession plugins = GraphicalPluginSession.Create(
|
||||
paths,
|
||||
[ThrowingId],
|
||||
"gui-session",
|
||||
host,
|
||||
new SessionStatusWriter(statusPath));
|
||||
plugins.Start();
|
||||
|
||||
Assert.Equal(0, plugins.LoadedCount);
|
||||
Assert.Empty(ui.Drain());
|
||||
Assert.Equal(0, ui.RegistrationCount);
|
||||
events.FireEntitySpawned(new WorldEntitySnapshot(
|
||||
1u,
|
||||
2u,
|
||||
default,
|
||||
System.Numerics.Quaternion.Identity));
|
||||
Assert.True(((ISelectionService)selection).Select(7u));
|
||||
Assert.False(File.Exists(
|
||||
Path.Combine(pluginDirectory, "unexpected-callback")));
|
||||
Assert.Equal(
|
||||
["started", "pluginFailed"],
|
||||
EventNames(ReadStatuses(statusPath)));
|
||||
|
||||
WeakReference context = Assert.Single(
|
||||
plugins.CaptureLoadContextWeakReferences());
|
||||
plugins.Dispose();
|
||||
Collect(context);
|
||||
Assert.False(context.IsAlive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InitializeFailureRollsBackEveryRegistrationBeforeUnload()
|
||||
{
|
||||
using var temporary = new TemporaryDirectory();
|
||||
ApplicationPathSet paths = Paths(temporary.Path);
|
||||
string pluginDirectory = InstallFixture(
|
||||
paths.PluginsDirectory,
|
||||
InitializeThrowingId,
|
||||
"initialize-throwing-fixture");
|
||||
File.WriteAllText(
|
||||
Path.Combine(pluginDirectory, "throw-during-initialize"),
|
||||
string.Empty);
|
||||
string statusPath = Path.Combine(temporary.Path, "status.jsonl");
|
||||
var events = new WorldEvents();
|
||||
var selection = new SelectionState();
|
||||
var ui = new BufferedUiRegistry();
|
||||
var host = new AppPluginHost(
|
||||
new CapturingLogger(),
|
||||
new WorldGameState(),
|
||||
events,
|
||||
selection,
|
||||
ui);
|
||||
|
||||
using GraphicalPluginSession plugins = GraphicalPluginSession.Create(
|
||||
paths,
|
||||
[InitializeThrowingId],
|
||||
"gui-session",
|
||||
host,
|
||||
new SessionStatusWriter(statusPath));
|
||||
plugins.Start();
|
||||
|
||||
Assert.Equal(0, plugins.LoadedCount);
|
||||
Assert.Empty(ui.Drain());
|
||||
Assert.Equal(0, ui.RegistrationCount);
|
||||
Assert.Equal(
|
||||
"ui=True;events=True;selection=True",
|
||||
File.ReadAllText(Path.Combine(
|
||||
pluginDirectory,
|
||||
"unload-observation")));
|
||||
events.FireEntitySpawned(new WorldEntitySnapshot(
|
||||
1u,
|
||||
2u,
|
||||
default,
|
||||
System.Numerics.Quaternion.Identity));
|
||||
Assert.True(((ISelectionService)selection).Select(9u));
|
||||
Assert.False(File.Exists(
|
||||
Path.Combine(pluginDirectory, "unexpected-callback")));
|
||||
Assert.Equal(
|
||||
["started", "pluginFailed"],
|
||||
EventNames(ReadStatuses(statusPath)));
|
||||
|
||||
WeakReference context = Assert.Single(
|
||||
plugins.CaptureLoadContextWeakReferences());
|
||||
plugins.Dispose();
|
||||
Collect(context);
|
||||
Assert.False(context.IsAlive);
|
||||
}
|
||||
|
||||
private static ApplicationPathSet Paths(string root) => new(
|
||||
Path.Combine(root, "config"),
|
||||
Path.Combine(root, "data"),
|
||||
Path.Combine(root, "cache"),
|
||||
LegacyConfigDirectory: null);
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private static void AssertPanelWasRegisteredAndReleaseBinding(
|
||||
BufferedUiRegistry ui)
|
||||
{
|
||||
BufferedUiRegistry.Pending panel = Assert.Single(ui.Drain());
|
||||
Assert.EndsWith(
|
||||
"fixture-panel.xml",
|
||||
panel.MarkupPath,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Equal(
|
||||
"AcDream.Plugin.Tests.Fixtures.HostPlugin",
|
||||
panel.Binding.GetType().Assembly.GetName().Name);
|
||||
}
|
||||
|
||||
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));
|
||||
File.WriteAllText(
|
||||
Path.Combine(pluginDirectory, "plugin.json"),
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
id,
|
||||
displayName = "Host fixture",
|
||||
version = "1.0.0",
|
||||
entryDll = fileName,
|
||||
apiVersion = 1,
|
||||
}));
|
||||
return pluginDirectory;
|
||||
}
|
||||
|
||||
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 CapturingLogger : IPluginLogger
|
||||
{
|
||||
internal List<string> Messages { get; } = [];
|
||||
|
||||
public void Info(string message) => Messages.Add(message);
|
||||
public void Warn(string message) => Messages.Add(message);
|
||||
public void Error(string message, Exception? exception = null) =>
|
||||
Messages.Add(exception is null ? message : $"{message}: {exception.Message}");
|
||||
}
|
||||
|
||||
private sealed class TemporaryDirectory : IDisposable
|
||||
{
|
||||
internal TemporaryDirectory()
|
||||
{
|
||||
Path = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(),
|
||||
$"acdream-graphical-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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -447,6 +447,11 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
public void Shutdown_PreservesDependencyStagesAndNativeWindowLast()
|
||||
{
|
||||
string source = GameWindowSource();
|
||||
string program = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Program.cs"));
|
||||
string lifetime = GameWindowLifetimeSource();
|
||||
string manifest = Slice(
|
||||
lifetime,
|
||||
|
|
@ -464,6 +469,7 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
[
|
||||
"new ResourceShutdownStage(\"host and session barriers\"",
|
||||
"new ResourceShutdownStage(\"physical ingress cleanup\"",
|
||||
"new ResourceShutdownStage(\"plugin host\"",
|
||||
"new ResourceShutdownStage(\"frame borrowers\"",
|
||||
"new ResourceShutdownStage(\"session dependents\"",
|
||||
"new ResourceShutdownStage(\"live entities\"",
|
||||
|
|
@ -499,6 +505,8 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
"Soft(\"gameplay actions\", () => DisposeGameplayActions(ingress.GameplayActions))",
|
||||
"Soft(\"camera pointer\", () => DisposeCameraPointer(ingress.CameraPointer))",
|
||||
"Soft(\"native window callbacks\", () => DisposeWindowCallbacks(ingress.WindowCallbacks))",
|
||||
"new ResourceShutdownStage(\"plugin host\"",
|
||||
"Hard(\"plugins\", () => ingress.Plugins?.Dispose())",
|
||||
"new ResourceShutdownStage(\"session dependents\"",
|
||||
"Hard(\"mouse capture\", () => live.CameraPointer?.ReleaseMouseLookAfterSessionRetirement())");
|
||||
Assert.Contains(
|
||||
|
|
@ -509,7 +517,27 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
"UiHost? RetainedUiHost,",
|
||||
lifetime,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"IDisposable? Plugins,",
|
||||
lifetime,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("_uiHost,", source, StringComparison.Ordinal);
|
||||
Assert.Contains("_pluginSession,", source, StringComparison.Ordinal);
|
||||
AssertAppearsInOrder(
|
||||
program,
|
||||
"GraphicalPluginSession pluginSession = GraphicalPluginSession.Create(",
|
||||
"window.StartPluginHosting(pluginSession);",
|
||||
"window.Run();");
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"_pluginSession = pluginSession;",
|
||||
"pluginSession.Start();",
|
||||
"public void Run()");
|
||||
AssertAppearsInOrder(
|
||||
manifest,
|
||||
"Hard(\"plugins\", () => ingress.Plugins?.Dispose())",
|
||||
"Hard(\"retail UI\", () => DisposeRetailUi(live.RetailUi))",
|
||||
"Hard(\"game runtime\", () => DisposeGameRuntime(live.Runtime))");
|
||||
AssertAppearsInOrder(
|
||||
nativeRelease,
|
||||
"TryComplete();",
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ public class PluginLoaderTests
|
|||
|
||||
private sealed class StubHost : IPluginHost
|
||||
{
|
||||
public bool HasUi => true;
|
||||
public IPluginLogger Log { get; } = new StubLogger();
|
||||
public IGameState State { get; } = new StubState();
|
||||
public IEvents Events { get; } = new StubEvents();
|
||||
|
|
@ -84,6 +85,8 @@ public class PluginLoaderTests
|
|||
Assert.True(loaded.Success);
|
||||
Assert.NotNull(loaded.Plugin);
|
||||
Assert.Equal("HelloPlugin", loaded.Plugin!.GetType().Name);
|
||||
loaded.Plugin.Disable();
|
||||
loaded.LoadContext!.Unload();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -122,5 +125,7 @@ public class PluginLoaderTests
|
|||
|
||||
Assert.False(loaded.Success);
|
||||
Assert.Contains("IAcDreamPlugin", loaded.Error!.Message);
|
||||
Assert.NotNull(loaded.LoadContext);
|
||||
loaded.LoadContext!.Unload();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
201
tests/AcDream.Core.Tests/Plugins/PluginSessionTests.cs
Normal file
201
tests/AcDream.Core.Tests/Plugins/PluginSessionTests.cs
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
using System.Text.Json;
|
||||
using AcDream.Core.Plugins;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace AcDream.Core.Tests.Plugins;
|
||||
|
||||
public sealed class PluginSessionTests
|
||||
{
|
||||
[Fact]
|
||||
public void AbsentAllowListLoadsEveryDiscoveredPlugin()
|
||||
{
|
||||
using var temporary = new TemporaryDirectory();
|
||||
InstallFixture(temporary.Path, "alpha", "acdream.test.alpha");
|
||||
InstallFixture(temporary.Path, "beta", "acdream.test.beta");
|
||||
var statuses = new List<PluginSessionStatus>();
|
||||
var plugins = new PluginSession(new StubHost(), statuses.Add);
|
||||
|
||||
plugins.Start([temporary.Path], allowList: null);
|
||||
|
||||
Assert.Equal(2, plugins.LoadedCount);
|
||||
Assert.Equal(
|
||||
["acdream.test.alpha", "acdream.test.beta"],
|
||||
plugins.LoadedPluginIds);
|
||||
Assert.All(
|
||||
statuses,
|
||||
status => Assert.Equal(PluginSessionStatusKind.Loaded, status.Kind));
|
||||
ReleaseAndCollect(plugins);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllowListIsCaseInsensitiveAndOneFailureDoesNotBlockAnotherPlugin()
|
||||
{
|
||||
using var temporary = new TemporaryDirectory();
|
||||
InstallFixture(temporary.Path, "good", "acdream.test.good");
|
||||
InstallBroken(temporary.Path, "broken", "acdream.test.broken");
|
||||
var statuses = new List<PluginSessionStatus>();
|
||||
var plugins = new PluginSession(new StubHost(), statuses.Add);
|
||||
|
||||
plugins.Start(
|
||||
[temporary.Path],
|
||||
[
|
||||
"ACDREAM.TEST.BROKEN",
|
||||
"ACDREAM.TEST.GOOD",
|
||||
"acdream.test.missing",
|
||||
]);
|
||||
|
||||
Assert.Equal(["acdream.test.good"], plugins.LoadedPluginIds);
|
||||
Assert.Equal(
|
||||
[
|
||||
("ACDREAM.TEST.BROKEN", PluginSessionStatusKind.Failed),
|
||||
("acdream.test.good", PluginSessionStatusKind.Loaded),
|
||||
("acdream.test.missing", PluginSessionStatusKind.Failed),
|
||||
],
|
||||
statuses.Select(static status => (status.Plugin, status.Kind)));
|
||||
Assert.All(
|
||||
statuses.Where(static status => status.Kind == PluginSessionStatusKind.Failed),
|
||||
status => Assert.False(string.IsNullOrWhiteSpace(status.Error)));
|
||||
ReleaseAndCollect(plugins);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExplicitEmptyAllowListLoadsNothing()
|
||||
{
|
||||
using var temporary = new TemporaryDirectory();
|
||||
InstallFixture(temporary.Path, "fixture", "acdream.test.fixture");
|
||||
var statuses = new List<PluginSessionStatus>();
|
||||
using var plugins = new PluginSession(new StubHost(), statuses.Add);
|
||||
|
||||
plugins.Start([temporary.Path], []);
|
||||
|
||||
Assert.Equal(0, plugins.LoadedCount);
|
||||
Assert.Empty(statuses);
|
||||
}
|
||||
|
||||
private static void ReleaseAndCollect(PluginSession plugins)
|
||||
{
|
||||
IReadOnlyList<WeakReference> contexts =
|
||||
plugins.CaptureLoadContextWeakReferences();
|
||||
plugins.Dispose();
|
||||
for (int attempt = 0;
|
||||
attempt < 10 && contexts.Any(static context => context.IsAlive);
|
||||
attempt++)
|
||||
{
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect();
|
||||
}
|
||||
Assert.All(contexts, static context => Assert.False(context.IsAlive));
|
||||
}
|
||||
|
||||
private static void InstallFixture(string root, string folder, string id)
|
||||
{
|
||||
string source = FixturePluginPath();
|
||||
Assert.True(File.Exists(source), $"fixture DLL not found: {source}");
|
||||
string pluginDirectory = Path.Combine(root, folder);
|
||||
Directory.CreateDirectory(pluginDirectory);
|
||||
string fileName = Path.GetFileName(source);
|
||||
File.Copy(source, Path.Combine(pluginDirectory, fileName));
|
||||
WriteManifest(pluginDirectory, id, fileName);
|
||||
}
|
||||
|
||||
private static void InstallBroken(string root, string folder, string id)
|
||||
{
|
||||
string pluginDirectory = Path.Combine(root, folder);
|
||||
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 = id,
|
||||
version = "1.0.0",
|
||||
entryDll,
|
||||
apiVersion = 1,
|
||||
}));
|
||||
|
||||
private static string FixturePluginPath()
|
||||
{
|
||||
string configuration = new DirectoryInfo(AppContext.BaseDirectory)
|
||||
.Parent!.Name;
|
||||
string root = FindRepoRoot(AppContext.BaseDirectory);
|
||||
return Path.Combine(
|
||||
root,
|
||||
"tests",
|
||||
"AcDream.Core.Tests.Fixtures.HelloPlugin",
|
||||
"bin",
|
||||
configuration,
|
||||
"net10.0",
|
||||
"AcDream.Core.Tests.Fixtures.HelloPlugin.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 sealed class StubHost : IPluginHost
|
||||
{
|
||||
public bool HasUi => false;
|
||||
public IPluginLogger Log { get; } = new StubLogger();
|
||||
public IGameState State { get; } = new StubState();
|
||||
public IEvents Events { get; } = new StubEvents();
|
||||
public ISelectionService Selection { get; } = new SelectionState();
|
||||
public IUiRegistry Ui => NoOpUiRegistry.Instance;
|
||||
}
|
||||
|
||||
private sealed class StubLogger : IPluginLogger
|
||||
{
|
||||
public void Info(string message) { }
|
||||
public void Warn(string message) { }
|
||||
public void Error(string message, Exception? exception = null) { }
|
||||
}
|
||||
|
||||
private sealed class StubState : IGameState
|
||||
{
|
||||
public IReadOnlyList<WorldEntitySnapshot> Entities => [];
|
||||
}
|
||||
|
||||
private sealed class StubEvents : IEvents
|
||||
{
|
||||
public event Action<WorldEntitySnapshot> EntitySpawned
|
||||
{
|
||||
add { }
|
||||
remove { }
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TemporaryDirectory : IDisposable
|
||||
{
|
||||
internal TemporaryDirectory()
|
||||
{
|
||||
Path = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(),
|
||||
$"acdream-plugin-session-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(Path);
|
||||
}
|
||||
|
||||
internal string Path { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(Path))
|
||||
Directory.Delete(Path, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,15 @@
|
|||
<ProjectReference Include="..\..\src\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Build ordering only. Tests copy this DLL into a temporary plugin root
|
||||
and the production loader loads it through a collectible ALC. -->
|
||||
<ProjectReference Include="..\AcDream.Plugin.Tests.Fixtures.HostPlugin\AcDream.Plugin.Tests.Fixtures.HostPlugin.csproj">
|
||||
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
||||
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\Fixtures\campaign-la\LauncherCoreSessionConfigFixture.cs"
|
||||
Link="Fixtures\LauncherCoreSessionConfigFixture.cs" />
|
||||
|
|
|
|||
429
tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs
Normal file
429
tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -60,6 +60,33 @@ public sealed class SessionConfigurationSharedFixtureTests
|
|||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HeadlessReaderPreservesLauncherExplicitEmptyPluginAllowList()
|
||||
{
|
||||
using TemporaryFile file = TemporaryFile.Create(
|
||||
LauncherCoreSessionConfigFixture.ComposeEmptyPlugins());
|
||||
|
||||
HeadlessSessionDescriptor session = Assert.Single(
|
||||
HeadlessConfigurationLoader.Load(file.Path).Sessions)!;
|
||||
|
||||
Assert.NotNull(session.Plugins);
|
||||
Assert.Empty(session.Plugins);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HeadlessReaderPreservesProbeLoadNoneAllowList()
|
||||
{
|
||||
using TemporaryFile file = TemporaryFile.Create(
|
||||
LauncherCoreSessionConfigFixture.ComposeProbe());
|
||||
|
||||
HeadlessSessionDescriptor session = Assert.Single(
|
||||
HeadlessConfigurationLoader.Load(file.Path).Sessions)!;
|
||||
|
||||
Assert.Equal(HeadlessSessionMode.Probe, session.Mode);
|
||||
Assert.NotNull(session.Plugins);
|
||||
Assert.Empty(session.Plugins);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HeadlessReaderAcceptsTheProductionShapedSharedFixture()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -175,7 +175,7 @@ public sealed class SessionConfigComposerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void PluginsAndLoginCommandsAreOmittedWhenEmptyRatherThanEmptyArrays()
|
||||
public void EmptyPluginsRemainAnExplicitLoadNoneAllowListWhileLoginCommandsAreOmitted()
|
||||
{
|
||||
CharacterProfile character = Character(LaunchMode.Gui);
|
||||
character.Plugins = [];
|
||||
|
|
@ -190,7 +190,8 @@ public sealed class SessionConfigComposerTests
|
|||
sessionId: "session-empty-lists");
|
||||
|
||||
JsonObject session = SingleSession(composed);
|
||||
Assert.False(session.ContainsKey("plugins"));
|
||||
Assert.True(session.ContainsKey("plugins"));
|
||||
Assert.Empty(session["plugins"]!.AsArray());
|
||||
Assert.False(session.ContainsKey("loginCommands"));
|
||||
}
|
||||
|
||||
|
|
@ -243,7 +244,7 @@ public sealed class SessionConfigComposerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void ProbeModeSetsModeAndOmitsCharacterPolicyPluginsAndLoginCommands()
|
||||
public void ProbeModeSetsModeAndCarriesExplicitLoadNonePluginAllowList()
|
||||
{
|
||||
ComposedSessionConfig composed = SessionConfigComposer.ComposeProbe(
|
||||
Server(),
|
||||
|
|
@ -256,7 +257,7 @@ public sealed class SessionConfigComposerTests
|
|||
|
||||
AssertKeys(
|
||||
session,
|
||||
"id", "mode", "endpoint", "account", "credential", "statusFile");
|
||||
"id", "mode", "endpoint", "account", "credential", "plugins", "statusFile");
|
||||
|
||||
Assert.Equal("session-probe", (string?)session["id"]);
|
||||
Assert.Equal("probe", (string?)session["mode"]);
|
||||
|
|
@ -266,7 +267,7 @@ public sealed class SessionConfigComposerTests
|
|||
Assert.Equal("standardInput", (string?)session["credential"]!["provider"]);
|
||||
Assert.False(session.ContainsKey("character"));
|
||||
Assert.False(session.ContainsKey("policy"));
|
||||
Assert.False(session.ContainsKey("plugins"));
|
||||
Assert.Empty(session["plugins"]!.AsArray());
|
||||
Assert.False(session.ContainsKey("loginCommands"));
|
||||
Assert.False(session.ContainsKey("loginCommandDelayMs"));
|
||||
Assert.Equal(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<!-- The host owns this contract assembly. Keeping it out of the fixture's
|
||||
output is required for IAcDreamPlugin type identity in the collectible
|
||||
load context. -->
|
||||
<ProjectReference Include="..\..\src\AcDream.Plugin.Abstractions\AcDream.Plugin.Abstractions.csproj">
|
||||
<Private>false</Private>
|
||||
<ExcludeAssets>runtime</ExcludeAssets>
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
130
tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/HostPlugin.cs
Normal file
130
tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/HostPlugin.cs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
using AcDream.Plugin.Abstractions;
|
||||
using System.Runtime.Loader;
|
||||
|
||||
namespace AcDream.Plugin.Tests.Fixtures.HostPlugin;
|
||||
|
||||
/// <summary>
|
||||
/// Cross-host LA5 fixture. It deliberately takes the same path on graphical
|
||||
/// and no-window hosts: observe the capability, register UI, and subscribe to
|
||||
/// gameplay events. A headless registry must make the UI call harmless without
|
||||
/// retaining this instance in the default load context.
|
||||
/// </summary>
|
||||
public sealed class HostPlugin : IAcDreamPlugin
|
||||
{
|
||||
private IPluginHost? _host;
|
||||
private string? _assemblyDirectory;
|
||||
private bool _throwAfterRegistration;
|
||||
private bool _throwDuringInitialize;
|
||||
private int _entitiesSeen;
|
||||
|
||||
public void Initialize(IPluginHost host)
|
||||
{
|
||||
_host = host ?? throw new ArgumentNullException(nameof(host));
|
||||
_assemblyDirectory = Path.GetDirectoryName(
|
||||
typeof(HostPlugin).Assembly.Location);
|
||||
_throwAfterRegistration = File.Exists(
|
||||
Path.Combine(_assemblyDirectory!, "throw-after-register"));
|
||||
_throwDuringInitialize = File.Exists(
|
||||
Path.Combine(_assemblyDirectory!, "throw-during-initialize"));
|
||||
host.Log.Info($"fixture-initialized:hasUi={host.HasUi}");
|
||||
if (_throwDuringInitialize)
|
||||
{
|
||||
RegisterHostCallbacks(host);
|
||||
AssemblyLoadContext.GetLoadContext(typeof(HostPlugin).Assembly)!
|
||||
.Unloading += OnUnloading;
|
||||
throw new InvalidOperationException(
|
||||
"fixture initialize failed after registering UI, entity, and selection callbacks");
|
||||
}
|
||||
}
|
||||
|
||||
public void Enable()
|
||||
{
|
||||
IPluginHost host = _host
|
||||
?? throw new InvalidOperationException("The fixture was not initialized.");
|
||||
RegisterHostCallbacks(host);
|
||||
if (_throwAfterRegistration)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"fixture enable failed after registering UI and events");
|
||||
}
|
||||
host.Log.Info(
|
||||
$"fixture-enabled:hasUi={host.HasUi}:entities={host.State.Entities.Count}");
|
||||
}
|
||||
|
||||
public void Disable()
|
||||
{
|
||||
IPluginHost? host = _host;
|
||||
if (host is null)
|
||||
return;
|
||||
if (_throwAfterRegistration || _throwDuringInitialize)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"fixture disable intentionally refuses cleanup");
|
||||
}
|
||||
host.Events.EntitySpawned -= OnEntitySpawned;
|
||||
host.Selection.Changed -= OnSelectionChanged;
|
||||
host.Log.Info($"fixture-disabled:entitiesSeen={_entitiesSeen}");
|
||||
_host = null;
|
||||
}
|
||||
|
||||
private void RegisterHostCallbacks(IPluginHost host)
|
||||
{
|
||||
host.Ui.AddMarkupPanel(
|
||||
Path.Combine(AppContext.BaseDirectory, "fixture-panel.xml"),
|
||||
this);
|
||||
host.Events.EntitySpawned += OnEntitySpawned;
|
||||
host.Selection.Changed += OnSelectionChanged;
|
||||
}
|
||||
|
||||
private void OnEntitySpawned(WorldEntitySnapshot snapshot)
|
||||
{
|
||||
_entitiesSeen++;
|
||||
RecordUnexpectedCallback(snapshot.Id);
|
||||
}
|
||||
|
||||
private void OnSelectionChanged(SelectionChangedEvent change) =>
|
||||
RecordUnexpectedCallback(change.SelectedObjectId ?? 0u);
|
||||
|
||||
private void RecordUnexpectedCallback(uint objectId)
|
||||
{
|
||||
if ((_throwAfterRegistration || _throwDuringInitialize)
|
||||
&& _assemblyDirectory is not null)
|
||||
{
|
||||
File.AppendAllText(
|
||||
Path.Combine(_assemblyDirectory, "unexpected-callback"),
|
||||
$"{objectId}{Environment.NewLine}");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnUnloading(AssemblyLoadContext context)
|
||||
{
|
||||
IPluginHost host = _host!;
|
||||
bool uiClosed = Rejects(() => host.Ui.AddMarkupPanel(
|
||||
Path.Combine(AppContext.BaseDirectory, "unloading-panel.xml"),
|
||||
this));
|
||||
bool eventsClosed = Rejects(() =>
|
||||
{
|
||||
host.Events.EntitySpawned += OnEntitySpawned;
|
||||
});
|
||||
bool selectionClosed = Rejects(() =>
|
||||
{
|
||||
host.Selection.Changed += OnSelectionChanged;
|
||||
});
|
||||
File.WriteAllText(
|
||||
Path.Combine(_assemblyDirectory!, "unload-observation"),
|
||||
$"ui={uiClosed};events={eventsClosed};selection={selectionClosed}");
|
||||
}
|
||||
|
||||
private static bool Rejects(Action action)
|
||||
{
|
||||
try
|
||||
{
|
||||
action();
|
||||
return false;
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -30,11 +30,13 @@ public sealed class SessionStatusWriterTests
|
|||
new LiveSessionRosterEntry(0x50000002u, "Grey", 10u),
|
||||
]));
|
||||
writer.EnteredWorld("s1", 0x50000001u, "Ready");
|
||||
writer.PluginLoaded("s1", "acdream.good");
|
||||
writer.PluginFailed("s1", "acdream.bad", "enable failed");
|
||||
writer.Disconnected("s1", "stopped");
|
||||
writer.Exited("s1", 0, "disposed");
|
||||
|
||||
string[] lines = File.ReadAllLines(file.Path);
|
||||
Assert.Equal(6, lines.Length);
|
||||
Assert.Equal(8, lines.Length);
|
||||
|
||||
JsonElement started = Parse(lines[0]);
|
||||
Assert.Equal(1, started.GetProperty("v").GetInt32());
|
||||
|
|
@ -62,11 +64,20 @@ public sealed class SessionStatusWriterTests
|
|||
Assert.Equal(0x50000001u, enteredWorld.GetProperty("characterId").GetUInt32());
|
||||
Assert.Equal("Ready", enteredWorld.GetProperty("characterName").GetString());
|
||||
|
||||
JsonElement disconnected = Parse(lines[4]);
|
||||
JsonElement pluginLoaded = Parse(lines[4]);
|
||||
Assert.Equal("pluginLoaded", pluginLoaded.GetProperty("e").GetString());
|
||||
Assert.Equal("acdream.good", pluginLoaded.GetProperty("plugin").GetString());
|
||||
|
||||
JsonElement pluginFailed = Parse(lines[5]);
|
||||
Assert.Equal("pluginFailed", pluginFailed.GetProperty("e").GetString());
|
||||
Assert.Equal("acdream.bad", pluginFailed.GetProperty("plugin").GetString());
|
||||
Assert.Equal("enable failed", pluginFailed.GetProperty("error").GetString());
|
||||
|
||||
JsonElement disconnected = Parse(lines[6]);
|
||||
Assert.Equal("disconnected", disconnected.GetProperty("e").GetString());
|
||||
Assert.Equal("stopped", disconnected.GetProperty("reason").GetString());
|
||||
|
||||
JsonElement exited = Parse(lines[5]);
|
||||
JsonElement exited = Parse(lines[7]);
|
||||
Assert.Equal("exited", exited.GetProperty("e").GetString());
|
||||
Assert.Equal(0, exited.GetProperty("code").GetInt32());
|
||||
Assert.Equal("disposed", exited.GetProperty("reason").GetString());
|
||||
|
|
@ -80,6 +91,8 @@ public sealed class SessionStatusWriterTests
|
|||
|
||||
writer.Started("s1");
|
||||
writer.Connected("s1");
|
||||
writer.PluginLoaded("s1", "acdream.good");
|
||||
writer.PluginFailed("s1", "acdream.bad", "failed");
|
||||
writer.Disconnected("s1", "stopped");
|
||||
writer.Exited("s1", 0, "disposed");
|
||||
|
||||
|
|
@ -164,9 +177,10 @@ public sealed class SessionStatusWriterTests
|
|||
/// credential material into this stream" contract: each event kind
|
||||
/// serializes EXACTLY its pinned property set — the shared envelope
|
||||
/// (<c>v</c>/<c>e</c>/<c>t</c>/<c>sessionId</c>) plus that event's own
|
||||
/// named fields, nothing else. An extra property (a smuggled password,
|
||||
/// or any other accidental field) fails this test by construction,
|
||||
/// regardless of what value it carries.
|
||||
/// named fields, nothing else. An extra credential-shaped or otherwise
|
||||
/// accidental property fails this test by construction. LA5's documented
|
||||
/// <c>pluginFailed.error</c> diagnostic is the one free-text value and its
|
||||
/// caller remains responsible for never appending session secrets.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EachEventSerializesExactlyItsPinnedPropertySetAndNothingElse()
|
||||
|
|
@ -183,11 +197,13 @@ public sealed class SessionStatusWriterTests
|
|||
11,
|
||||
[new LiveSessionRosterEntry(0x50000001u, "Ready", 0u)]));
|
||||
writer.EnteredWorld("bot", 0x50000001u, "Ready");
|
||||
writer.PluginLoaded("bot", "acdream.good");
|
||||
writer.PluginFailed("bot", "acdream.bad", "enable failed");
|
||||
writer.Disconnected("bot", "stopped");
|
||||
writer.Exited("bot", 0, "disposed");
|
||||
|
||||
string[] lines = File.ReadAllLines(file.Path);
|
||||
Assert.Equal(6, lines.Length);
|
||||
Assert.Equal(8, lines.Length);
|
||||
|
||||
AssertExactProperties(lines[0], "v", "e", "t", "sessionId");
|
||||
AssertExactProperties(lines[1], "v", "e", "t", "sessionId");
|
||||
|
|
@ -196,8 +212,11 @@ public sealed class SessionStatusWriterTests
|
|||
"v", "e", "t", "sessionId", "accountName", "slotCount", "characters");
|
||||
AssertExactProperties(
|
||||
lines[3], "v", "e", "t", "sessionId", "characterId", "characterName");
|
||||
AssertExactProperties(lines[4], "v", "e", "t", "sessionId", "reason");
|
||||
AssertExactProperties(lines[5], "v", "e", "t", "sessionId", "code", "reason");
|
||||
AssertExactProperties(lines[4], "v", "e", "t", "sessionId", "plugin");
|
||||
AssertExactProperties(
|
||||
lines[5], "v", "e", "t", "sessionId", "plugin", "error");
|
||||
AssertExactProperties(lines[6], "v", "e", "t", "sessionId", "reason");
|
||||
AssertExactProperties(lines[7], "v", "e", "t", "sessionId", "code", "reason");
|
||||
|
||||
// The nested characters[] entries are exact too — the exact shape a
|
||||
// password could otherwise be smuggled through.
|
||||
|
|
|
|||
|
|
@ -55,4 +55,66 @@ internal static class LauncherCoreSessionConfigFixture
|
|||
|
||||
return SessionConfigComposer.Serialize(composed.Document);
|
||||
}
|
||||
|
||||
internal static string ComposeEmptyPlugins()
|
||||
{
|
||||
(ServerProfile server, AccountProfile account,
|
||||
LauncherInstallRecord install, ApplicationPathSet paths) = Inputs();
|
||||
var character = new CharacterProfile
|
||||
{
|
||||
Name = "Composer Character",
|
||||
Id = "0x50000001",
|
||||
LaunchMode = LaunchMode.Headless,
|
||||
Plugins = [],
|
||||
LoginCommands = [],
|
||||
};
|
||||
|
||||
ComposedSessionConfig composed = SessionConfigComposer.Compose(
|
||||
server,
|
||||
account,
|
||||
character,
|
||||
install,
|
||||
paths,
|
||||
"composer-empty-plugins");
|
||||
return SessionConfigComposer.Serialize(composed.Document);
|
||||
}
|
||||
|
||||
internal static string ComposeProbe()
|
||||
{
|
||||
(ServerProfile server, AccountProfile account,
|
||||
LauncherInstallRecord install, ApplicationPathSet paths) = Inputs();
|
||||
ComposedSessionConfig composed = SessionConfigComposer.ComposeProbe(
|
||||
server,
|
||||
account,
|
||||
install,
|
||||
paths,
|
||||
"composer-probe");
|
||||
return SessionConfigComposer.Serialize(composed.Document);
|
||||
}
|
||||
|
||||
private static (
|
||||
ServerProfile Server,
|
||||
AccountProfile Account,
|
||||
LauncherInstallRecord Install,
|
||||
ApplicationPathSet Paths) Inputs() =>
|
||||
(
|
||||
new ServerProfile
|
||||
{
|
||||
Name = "Composer Server",
|
||||
Host = "composer.example",
|
||||
Port = 9010,
|
||||
},
|
||||
new AccountProfile
|
||||
{
|
||||
Account = "composer-account",
|
||||
Password = Password,
|
||||
},
|
||||
new LauncherInstallRecord(
|
||||
"composer-dats",
|
||||
"composer-dats/acdream.pak"),
|
||||
new ApplicationPathSet(
|
||||
Path.Combine(Path.GetTempPath(), "composer-config"),
|
||||
Path.Combine(Path.GetTempPath(), "composer-data"),
|
||||
Path.Combine(Path.GetTempPath(), "composer-cache"),
|
||||
LegacyConfigDirectory: null));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue