Item F (slice-1 fix round). IPluginHost.VtankProfileDirectory handed the plugin a raw string path and told it to fall back to its own System.IO-based portable default when null — a plugin reading and resolving filesystem paths itself, which is exactly the seam the rest of IPluginHost.Storage deliberately avoids (Core.Plugins.ScopedPluginHost scopes/validates every key; the plugin never sees a path). - IPluginHost: VtankProfileDirectory (string?) deleted; new VtankProfiles (IPluginStorage, defaults to NoOpPluginStorage) added — a second, UNSCOPED storage instance (unlike Storage, which Core scopes per plugin manifest id) rooted at a host-composed VTank-compatible directory. - ScopedPluginHost.VtankProfiles forwards _inner.VtankProfiles directly (no scoping — it names one shared external location, not per-plugin data). New PluginSessionTests.ScopedHostForwardsVtankProfilesUnscoped proves the forwarded instance is the exact same object (Assert.Same), not a wrapper. - AppPluginHost/Program.cs: new vtankProfiles constructor parameter, composed as FilePluginStorage(runtimeOptions.VtankProfileDirectoryOverride ?? Path.Combine(applicationPaths.DataDirectory, "vtank")). - RuntimeOptions.VtankProfileDirectoryOverride: new init-only property parsed from ACDREAM_VTANK_PROFILE_DIR (row added to docs/launch-options.md, side-effects column states the redirect is the only effect and documents the NullIfEmpty whitespace-not-special-cased quirk it shares with every other path-override flag). New RuntimeOptionsTests.VtankProfileDirectoryOverrideIsNullUnlessSet. - FilePluginStorage.List(prefix): empty prefix now means "the storage root itself" instead of throwing (Resolve() rejects empty/whitespace keys, which is correct for every OTHER caller but wrong for "list everything" — VtankProfileDirectory needs exactly that). - Headless: HeadlessPluginHost gained the same VtankProfiles property/constructor param, threaded through HeadlessPluginSession.Create -> HeadlessSessionHost -> HeadlessProcessHost, composed from the new HeadlessPathSet.VtankProfilesDirectory (<DataDirectory>/vtank, no ACDREAM_VTANK_PROFILE_DIR-equivalent override — Headless path overrides are HeadlessPathOverrides/CLI flags, not env vars). A small AcDream.Headless.Plugins.FilePluginStorage duplicates the App implementation byte-for-byte (Headless does not reference AcDream.App and no shared "platform plugins" library exists yet to host one copy; documented as a reasonable future consolidation, not required here). - VtankProfileDirectory.cs rewritten: Resolve/PortableDefault deleted outright (no more System.IO, no plugin-owned portable-default fallback); ListSettingsProfiles/ListNavigationProfiles/ListMetaProfiles now take IPluginStorage and enumerate through EnumerateFileNames, which calls storage.List(string.Empty) and skips any key containing '/' (VTank's profile directory is flat; a nested key from some other IPluginStorage implementation is not a profile file). VtankProfileDirectoryTests rewritten against an in-memory IPluginStorage fake instead of real temp directories; new NestedPathKeysAreNotTreatedAsProfileFiles pins that skip. The prior Resolve/PortableDefault-specific tests (Linux-path guarantee, host-override-vs-portable-default) are superseded by RuntimeOptionsTests.VtankProfileDirectoryOverrideIsNullUnlessSet plus the RuntimeOptions.FromEnvironment Path.Combine-only composition in Program.cs. - docs/architecture/acdream-architecture.md: one sentence in the Storage/List(prefix) paragraph naming VtankProfiles as the second, unscoped storage. No production caller of VtankProfileDirectory's listing methods exists yet (A2's foundation is not wired into MossTankProfileStore/ MossTankMetaProfileStore/MossTankRouteProfileStore's own selection — per that slice's own ledger note), so this is a contract + plumbing change with no MossTank runtime behavior change. MossTank suite: 562/562. Core.Tests (Plugin filter): 50/50. App.Tests (Plugin|LaunchOptions|RuntimeOptions filter): 135/135. Headless.Tests: 173/174 (the one failure, HeadlessCredentialResolverTests. LinuxRejectsGroupOrOtherCredentialPermissions, is a pre-existing Linux-only lane gate that throws PlatformNotSupportedException on this Windows host — unrelated to this change). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
474 lines
16 KiB
C#
474 lines
16 KiB
C#
using System.Text.Json;
|
|
using AcDream.Core.Plugins;
|
|
using AcDream.Core.Selection;
|
|
using AcDream.Plugin.Abstractions;
|
|
using AcDream.Plugin.Abstractions.Rendering;
|
|
|
|
namespace AcDream.Core.Tests.Plugins;
|
|
|
|
public sealed class PluginSessionTests
|
|
{
|
|
[Fact]
|
|
public void ScopedHostPrefixesStorageWithAuthenticatedManifestId()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
using var alpha = new ScopedPluginHost(
|
|
new StubHost(storage),
|
|
"acdream.alpha",
|
|
"Alpha");
|
|
using var beta = new ScopedPluginHost(
|
|
new StubHost(storage),
|
|
"acdream.beta",
|
|
"Beta");
|
|
|
|
alpha.Storage.WriteText("profile.json", "alpha");
|
|
beta.Storage.WriteText("profile.json", "beta");
|
|
alpha.Storage.WriteText("imports/route.nav", "nav");
|
|
|
|
Assert.Equal("alpha", storage.Text[Path.Combine(
|
|
"acdream.alpha", "profile.json")]);
|
|
Assert.Equal("beta", storage.Text[Path.Combine(
|
|
"acdream.beta", "profile.json")]);
|
|
Assert.Equal(["imports/route.nav"], alpha.Storage.List("imports"));
|
|
Assert.Empty(beta.Storage.List("imports"));
|
|
Assert.Throws<ArgumentException>(() =>
|
|
alpha.Storage.WriteText("../escape.json", "bad"));
|
|
}
|
|
|
|
// Item F (Campaign VT slice-1 fix round): VtankProfiles must be
|
|
// forwarded UNSCOPED, unlike Storage — it names one shared external
|
|
// location (a real VTank profile folder, or a host-composed portable
|
|
// default), not per-plugin data, so prefixing it by manifest id would
|
|
// defeat the point of pointing it at a real installed VTank directory.
|
|
[Fact]
|
|
public void ScopedHostForwardsVtankProfilesUnscoped()
|
|
{
|
|
var vtankProfiles = new MemoryStorage();
|
|
using var scope = new ScopedPluginHost(
|
|
new StubHost(vtankProfiles: vtankProfiles),
|
|
"acdream.alpha",
|
|
"Alpha");
|
|
|
|
scope.VtankProfiles.WriteText("Shared.usd", "content");
|
|
|
|
Assert.Same(vtankProfiles, scope.VtankProfiles);
|
|
Assert.Equal("content", vtankProfiles.Text["Shared.usd"]);
|
|
}
|
|
|
|
[Fact]
|
|
public void ScopedHostNamespacesAndUnregistersLootClassifierOnDispose()
|
|
{
|
|
var global = new PluginLootClassifierRegistry();
|
|
var scope = new ScopedPluginHost(
|
|
new StubHost(lootClassifiers: global),
|
|
"acdream.looter",
|
|
"Looter");
|
|
|
|
scope.LootClassifiers.Register(
|
|
"main",
|
|
"My loot rules",
|
|
new KeepClassifier());
|
|
|
|
PluginLootClassifierInfo registered = Assert.Single(global.Available);
|
|
Assert.Equal("acdream.looter/main", registered.Id);
|
|
Assert.Equal("My loot rules", registered.DisplayName);
|
|
|
|
scope.Dispose();
|
|
|
|
Assert.Empty(global.Available);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
[Fact]
|
|
public void GraphicalKindSet_RegistersRenderPackAndWithdrawsBeforeUnload()
|
|
{
|
|
using var temporary = new TemporaryDirectory();
|
|
InstallFixture(
|
|
temporary.Path,
|
|
"render",
|
|
"acdream.test.render",
|
|
[PluginKind.RenderPack]);
|
|
var statuses = new List<PluginSessionStatus>();
|
|
var registry = new RecordingRenderPackRegistry();
|
|
var plugins = new PluginSession(
|
|
new StubHost(),
|
|
statuses.Add,
|
|
registry,
|
|
[PluginKind.Gameplay, PluginKind.RenderPack]);
|
|
|
|
plugins.Start([temporary.Path], allowList: null);
|
|
|
|
Assert.Equal(1, plugins.LoadedCount);
|
|
Assert.Equal(1, registry.ActiveCount);
|
|
Assert.Equal("acdream.test.noop-pack", registry.Descriptor?.Id);
|
|
IReadOnlyList<WeakReference> contexts =
|
|
plugins.CaptureLoadContextWeakReferences();
|
|
plugins.Dispose();
|
|
Assert.Equal(0, registry.ActiveCount);
|
|
Collect(contexts);
|
|
}
|
|
|
|
[Fact]
|
|
public void GameplayOnlyHost_SkipsUnrequestedRenderPackBeforeDllProbe()
|
|
{
|
|
using var temporary = new TemporaryDirectory();
|
|
InstallBroken(
|
|
temporary.Path,
|
|
"render",
|
|
"acdream.test.render",
|
|
[PluginKind.RenderPack]);
|
|
var statuses = new List<PluginSessionStatus>();
|
|
using var plugins = new PluginSession(new StubHost(), statuses.Add);
|
|
|
|
plugins.Start([temporary.Path], allowList: null);
|
|
|
|
Assert.Equal(0, plugins.LoadedCount);
|
|
Assert.Empty(statuses);
|
|
Assert.Empty(plugins.CaptureLoadContextWeakReferences());
|
|
}
|
|
|
|
[Fact]
|
|
public void RenderPackRegisterFailure_WithdrawsPartialRegistrationBeforeUnload()
|
|
{
|
|
using var temporary = new TemporaryDirectory();
|
|
InstallFixture(
|
|
temporary.Path,
|
|
"render",
|
|
"acdream.test.render",
|
|
[PluginKind.RenderPack]);
|
|
File.WriteAllText(
|
|
Path.Combine(temporary.Path, "render", "throw-after-render-register"),
|
|
string.Empty);
|
|
var registry = new RecordingRenderPackRegistry();
|
|
var statuses = new List<PluginSessionStatus>();
|
|
var plugins = new PluginSession(
|
|
new StubHost(),
|
|
statuses.Add,
|
|
registry,
|
|
[PluginKind.Gameplay, PluginKind.RenderPack]);
|
|
|
|
plugins.Start([temporary.Path], allowList: null);
|
|
|
|
Assert.Equal(0, plugins.LoadedCount);
|
|
Assert.Equal(0, registry.ActiveCount);
|
|
PluginSessionStatus status = Assert.Single(statuses);
|
|
Assert.Equal(PluginSessionStatusKind.Failed, status.Kind);
|
|
Assert.Contains("failed after publishing", status.Error);
|
|
IReadOnlyList<WeakReference> contexts =
|
|
plugins.CaptureLoadContextWeakReferences();
|
|
plugins.Dispose();
|
|
Collect(contexts);
|
|
}
|
|
|
|
[Fact]
|
|
public void GameplayOnlyHost_ExplicitRenderPackReportsKindWithoutDllProbe()
|
|
{
|
|
using var temporary = new TemporaryDirectory();
|
|
InstallBroken(
|
|
temporary.Path,
|
|
"render",
|
|
"acdream.test.render",
|
|
[PluginKind.RenderPack]);
|
|
var statuses = new List<PluginSessionStatus>();
|
|
using var plugins = new PluginSession(new StubHost(), statuses.Add);
|
|
|
|
plugins.Start([temporary.Path], ["acdream.test.render"]);
|
|
|
|
PluginSessionStatus status = Assert.Single(statuses);
|
|
Assert.Equal(PluginSessionStatusKind.Failed, status.Kind);
|
|
Assert.Contains("does not support", status.Error);
|
|
Assert.DoesNotContain("entry dll", status.Error);
|
|
Assert.Empty(plugins.CaptureLoadContextWeakReferences());
|
|
}
|
|
|
|
private static void ReleaseAndCollect(PluginSession plugins)
|
|
{
|
|
IReadOnlyList<WeakReference> contexts =
|
|
plugins.CaptureLoadContextWeakReferences();
|
|
plugins.Dispose();
|
|
Collect(contexts);
|
|
}
|
|
|
|
private static void Collect(IReadOnlyList<WeakReference> contexts)
|
|
{
|
|
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)
|
|
=> InstallFixture(root, folder, id, kinds: null);
|
|
|
|
private static void InstallFixture(
|
|
string root,
|
|
string folder,
|
|
string id,
|
|
IReadOnlyList<PluginKind>? kinds)
|
|
{
|
|
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, kinds);
|
|
}
|
|
|
|
private static void InstallBroken(
|
|
string root,
|
|
string folder,
|
|
string id,
|
|
IReadOnlyList<PluginKind>? kinds = null)
|
|
{
|
|
string pluginDirectory = Path.Combine(root, folder);
|
|
Directory.CreateDirectory(pluginDirectory);
|
|
WriteManifest(pluginDirectory, id, "missing.dll", kinds);
|
|
}
|
|
|
|
private static void WriteManifest(
|
|
string directory,
|
|
string id,
|
|
string entryDll,
|
|
IReadOnlyList<PluginKind>? kinds = null) =>
|
|
File.WriteAllText(
|
|
Path.Combine(directory, "plugin.json"),
|
|
JsonSerializer.Serialize(new
|
|
{
|
|
id,
|
|
displayName = id,
|
|
version = "1.0.0",
|
|
entryDll,
|
|
apiVersion = 1,
|
|
kinds = kinds?.Select(static kind => kind.ToString()),
|
|
}));
|
|
|
|
private sealed class RecordingRenderPackRegistry : IRenderPackRegistry
|
|
{
|
|
private readonly List<Registration> _registrations = [];
|
|
|
|
internal int ActiveCount => _registrations.Count;
|
|
internal RenderPackDescriptor? Descriptor { get; private set; }
|
|
|
|
public IDisposable Register(
|
|
RenderPackDescriptor descriptor,
|
|
IRenderPackAssets assets)
|
|
{
|
|
Descriptor = descriptor;
|
|
var registration = new Registration(this, assets);
|
|
_registrations.Add(registration);
|
|
return registration;
|
|
}
|
|
|
|
private void Remove(Registration registration) =>
|
|
_registrations.Remove(registration);
|
|
|
|
private sealed class Registration(
|
|
RecordingRenderPackRegistry owner,
|
|
IRenderPackAssets assets) : IDisposable
|
|
{
|
|
private RecordingRenderPackRegistry? _owner = owner;
|
|
private IRenderPackAssets? _assets = assets;
|
|
|
|
public void Dispose()
|
|
{
|
|
RecordingRenderPackRegistry? current =
|
|
Interlocked.Exchange(ref _owner, null);
|
|
_assets = null;
|
|
current?.Remove(this);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static string FixturePluginPath()
|
|
{
|
|
string fileName = "AcDream.Core.Tests.Fixtures.HelloPlugin.dll";
|
|
string colocated = Path.Combine(AppContext.BaseDirectory, fileName);
|
|
if (File.Exists(colocated))
|
|
return colocated;
|
|
|
|
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",
|
|
fileName);
|
|
}
|
|
|
|
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(
|
|
IPluginStorage? storage = null,
|
|
IPluginLootClassifierRegistry? lootClassifiers = null,
|
|
IPluginStorage? vtankProfiles = null) : 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;
|
|
public IAutomationSurface Automation => NoOpAutomationSurface.Instance;
|
|
public IPluginStorage Storage { get; } =
|
|
storage ?? NoOpPluginStorage.Instance;
|
|
public IPluginLootClassifierRegistry LootClassifiers { get; } =
|
|
lootClassifiers ?? NoOpPluginLootClassifierRegistry.Instance;
|
|
public IPluginStorage VtankProfiles { get; } =
|
|
vtankProfiles ?? NoOpPluginStorage.Instance;
|
|
}
|
|
|
|
private sealed class KeepClassifier : IPluginLootClassifier
|
|
{
|
|
public PluginLootClassification Classify(
|
|
in PluginLootClassificationContext context) => new(
|
|
true,
|
|
PluginLootAction.Keep);
|
|
}
|
|
|
|
private sealed class MemoryStorage : IPluginStorage
|
|
{
|
|
public Dictionary<string, string> Text { get; } =
|
|
new(StringComparer.Ordinal);
|
|
public bool IsAvailable => true;
|
|
public string? ReadText(string key) =>
|
|
Text.TryGetValue(key, out string? value) ? value : null;
|
|
public IReadOnlyList<string> List(string prefix) => Text.Keys
|
|
.Where(key => key.StartsWith(prefix + Path.DirectorySeparatorChar,
|
|
StringComparison.Ordinal))
|
|
.OrderBy(static key => key, StringComparer.Ordinal)
|
|
.ToArray();
|
|
public void WriteText(string key, string content) => Text[key] = content;
|
|
public bool Delete(string key) => Text.Remove(key);
|
|
}
|
|
|
|
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 { }
|
|
}
|
|
|
|
public event Action<double> Tick
|
|
{
|
|
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);
|
|
}
|
|
}
|
|
}
|