Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ jobs:
working-directory: PluginManager
run: bash tools/Build/smoke.sh

# 作者测试包:内存上下文、手动调度与生命周期宿主。
- name: Testing 包冒烟
working-directory: PluginManager
run: dotnet run --project testing/Hosihikari.Testing.Smoke/Hosihikari.Testing.Smoke.csproj -c Release

# 核心包本身也过一遍打包,避免发布时才暴露形状问题。
- name: 打包检查
working-directory: PluginManager
Expand Down
15 changes: 14 additions & 1 deletion src/Hosihikari.PluginManager.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,21 @@
<EnableDynamicLoading>true</EnableDynamicLoading>
<!-- 日志出口要用函数指针调回父级。 -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<PackageId>Hosihikari.PluginManager</PackageId>
<Version>1.1.0</Version>
<Authors>HosihikariFramework</Authors>
<Description>Managed plugin manager for the Hosihikari Framework.</Description>
<PackageLicenseExpression>LGPL-3.0-or-later</PackageLicenseExpression>
<RepositoryUrl>https://github.com/HosihikariFramework/PluginManager</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageTags>hosihikari;minecraft;bedrock;plugin;plugin-manager</PackageTags>
<PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>

<ItemGroup>
<None Include="../README.md" Pack="true" PackagePath="/" />
</ItemGroup>

<ItemGroup>
<!-- 日志是唯一的线索(ADR-0009);异步与迭代器的栈是编译器生成的,不去谜读不懂。 -->
<PackageReference Include="Ben.Demystifier" Version="0.4.1" />
Expand Down Expand Up @@ -44,7 +57,7 @@
</ItemGroup>

<ItemGroup Condition="!Exists('../../Plugin.Abstractions/src/Hosihikari.Plugin.Abstractions.csproj')">
<PackageReference Include="Hosihikari.Plugin.Abstractions" Version="[1.0.0]" />
<PackageReference Include="Hosihikari.Plugin.Abstractions" Version="[1.1.0]" />
</ItemGroup>

</Project>
16 changes: 16 additions & 0 deletions testing/Hosihikari.Testing.Smoke/Hosihikari.Testing.Smoke.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>Hosihikari.Testing.Smoke</AssemblyName>
<RootNamespace>Hosihikari.Testing.Smoke</RootNamespace>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="../Hosihikari.Testing/Hosihikari.Testing.csproj" />
</ItemGroup>

</Project>
268 changes: 268 additions & 0 deletions testing/Hosihikari.Testing.Smoke/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
using Hosihikari;
using Hosihikari.Testing;

var checks = new List<(string Name, bool Passed, string Detail)>();

var invalidHostRejected = false;
try
{
_ = new PluginTestHost(new ProbePlugin(), "../escape");
}
catch (ArgumentException)
{
invalidHostRejected = true;
}

var probe = new ProbePlugin();
using (var host = new PluginTestHost(probe, "Probe"))
{
host.RunLifecycle();
checks.Add(("测试宿主拒绝越界的插件名", invalidHostRejected, invalidHostRejected ? "已拒绝" : "(没有拒绝)"));
checks.Add((
"生命周期按 Load → Enable → Disable → Unload 驱动",
host.HookLog.SequenceEqual(["load", "enable", "disable", "unload"]),
string.Join(" → ", host.HookLog)));
checks.Add((
"上下文给出包目录与数据目录",
Directory.Exists(host.Context.PackageDirectory) && Directory.Exists(host.Context.DataDirectory),
host.Context.PackageDirectory));
checks.Add(("插件拿到上下文", probe.Context is not null, probe.Context?.Name ?? "(没有)"));

var seen = 0;
var subscription = host.Context.TestEvents.Subscribe<string>(_ => seen++);
host.Context.TestEvents.Publish("first");
host.Context.TestEvents.Publish("second");
subscription.Dispose();
host.Context.TestEvents.Publish("after-dispose");
checks.Add(("事件总线订阅与退订", seen == 2, $"seen={seen}"));

var once = 0;
host.Context.TestScheduler.ScheduleOnce(TimeSpan.Zero, () => once++);
host.Context.TestScheduler.RunOnce();
checks.Add(("一次性调度手动执行", once == 1 && host.Context.TestScheduler.PendingOnce == 0, $"once={once}"));

var periodic = 0;
var cancel = host.Context.TestScheduler.SchedulePeriodic(TimeSpan.Zero, () => periodic++);
host.Context.TestScheduler.RunPeriodic();
host.Context.TestScheduler.RunPeriodic();
cancel.Dispose();
host.Context.TestScheduler.RunPeriodic();
checks.Add(("周期调度可取消", periodic == 2, $"periodic={periodic}"));

var services = host.Context.TestServices;
var first = services.Register<IProbeService>(new ProbeService("first"));
var duplicate = services.Register<IProbeService>(new ProbeService("second"));
checks.Add((
"服务注册先到先得",
first && !duplicate && services.Get<IProbeService>()?.Name == "first",
services.Get<IProbeService>()?.Name ?? "(没有)"));

host.Context.ReportFault("probe fault");
checks.Add(("故障报告可检查", host.Context.Faults.Contains("probe fault"), string.Join(";", host.Context.Faults)));
}

var refused = new RefusePlugin();
using (var host = new PluginTestHost(refused))
{
host.Load();
var result = host.Enable();
checks.Add((
"拒绝启用保留原因",
!result.Allowed && result.Reason == "not now",
result.Reason ?? "(没有原因)"));
}

var gate = new GatePlugin();
using (var host = new PluginTestHost(gate))
{
host.Load();
host.Context.Events.Publish("before-enable");
host.Enable();
host.Context.Events.Publish("enabled");
host.Context.TestScheduler.RunOnce();
host.Disable();
host.Context.Events.Publish("disabled");
host.Unload();
host.Context.Events.Publish("unloaded");

checks.Add((
"启用闸门拦住启用前、停用后与卸载后的回调",
gate.Seen == 1 && gate.Scheduled == 1,
$"seen={gate.Seen} scheduled={gate.Scheduled}"));
checks.Add((
"卸载释放插件登记的服务",
host.Context.TestServices.Get<IProbeService>() is null,
host.Context.TestServices.Get<IProbeService>()?.Name ?? "(已释放)"));
}

var thrower = new ThrowUnloadPlugin();
using (var host = new PluginTestHost(thrower))
{
host.Load();
host.Enable();
var threw = false;
try
{
host.Unload();
}
catch (InvalidOperationException)
{
threw = true;
}

checks.Add((
"OnUnload 抛出仍释放作用域",
threw && host.Context.TestServices.Get<IProbeService>() is null,
$"threw={threw} service={host.Context.TestServices.Get<IProbeService>()?.Name ?? "已释放"}"));
}

var unloadPublish = new UnloadPublishPlugin();
using (var host = new PluginTestHost(unloadPublish))
{
host.Load();
host.Enable();
host.Unload();
checks.Add((
"直接卸载时先关闸门再调 OnUnload",
unloadPublish.Seen == 0,
$"seen={unloadPublish.Seen}"));
}

using (var harness = new PluginManagerHarness())
{
var escaped = false;
try
{
harness.CreatePackage("../escape", "{}");
}
catch (ArgumentException)
{
escaped = true;
}

checks.Add(("管理者辅助拒绝越界的包名", escaped, escaped ? "已拒绝" : "(没有拒绝)"));

harness.Manager.RegisterLoader(new ProbeLoader());
harness.CreatePackage(
"HarnessProbe",
"""
{
"name": "HarnessProbe",
"entry": "HarnessProbe.dll",
"type": "hosihikari",
"version": "1.0.0",
"extraInfo": { "api_version": "2", "manifest_version": "1", "loader": "probe" }
}
""");
var loaded = harness.Load("HarnessProbe");
var enabled = harness.Manager.Enable("HarnessProbe");
var unloaded = harness.Manager.Unload("HarnessProbe");
checks.Add((
"真实管理者辅助加载、启用并卸载",
loaded && enabled && unloaded.Released,
$"loaded={loaded} enabled={enabled} unloaded={unloaded.Released} {unloaded.Error}"));
}

Console.WriteLine("=== Hosihikari.Testing 冒烟 ===");
foreach (var (name, passed, detail) in checks)
{
Console.WriteLine($"{(passed ? "PASS" : "FAIL")} {name} ({detail})");
}

return checks.All(check => check.Passed) ? 0 : 1;

interface IProbeService
{
string Name { get; }
}

sealed class ProbeService(string name) : IProbeService
{
public string Name { get; } = name;
}

sealed class ProbePlugin : IPlugin
{
public IPluginContext? Context { get; private set; }

public void OnLoad(IPluginContext context) => Context = context;
public PluginHookResult OnEnable() => PluginHookResult.Allow();
public PluginHookResult OnDisable() => PluginHookResult.Allow();
public void OnUnload() { }
}

sealed class RefusePlugin : IPlugin
{
public void OnLoad(IPluginContext context) { }
public PluginHookResult OnEnable() => PluginHookResult.Refuse("not now");
public PluginHookResult OnDisable() => PluginHookResult.Allow();
public void OnUnload() { }
}

sealed class GatePlugin : IPlugin
{
public int Seen { get; private set; }
public int Scheduled { get; private set; }

public void OnLoad(IPluginContext context)
{
context.Events.Subscribe<string>(_ => Seen++);
context.Scheduler.ScheduleOnce(TimeSpan.Zero, () => Scheduled++);
context.PluginServices.Register<IProbeService>(new ProbeService("from-plugin"));
}

public PluginHookResult OnEnable() => PluginHookResult.Allow();
public PluginHookResult OnDisable() => PluginHookResult.Allow();
public void OnUnload() { }
}

sealed class UnloadPublishPlugin : IPlugin
{
private IPluginContext? _context;
public int Seen { get; private set; }

public void OnLoad(IPluginContext context)
{
_context = context;
context.Events.Subscribe<string>(_ => Seen++);
}

public PluginHookResult OnEnable() => PluginHookResult.Allow();
public PluginHookResult OnDisable() => PluginHookResult.Allow();
public void OnUnload() => _context?.Events.Publish("during-unload");
}

sealed class ThrowUnloadPlugin : IPlugin
{
public void OnLoad(IPluginContext context) =>
context.PluginServices.Register<IProbeService>(new ProbeService("throw-unload"));

public PluginHookResult OnEnable() => PluginHookResult.Allow();
public PluginHookResult OnDisable() => PluginHookResult.Allow();
public void OnUnload() => throw new InvalidOperationException("unload failed");
}

sealed class ProbeLoader : IPluginLoader
{
public string Id => "probe";
public bool CanLoad(PluginManifest manifest) => manifest.Loader == Id;

public PluginHandle? Load(PluginManifest manifest, string packageDirectory, IPluginContext context) =>
new ProbeHandle(new ProbePlugin());
}

sealed class ProbeHandle(ProbePlugin plugin) : PluginHandle(plugin)
{
private bool _unloaded;

public override bool IsUnloaded => _unloaded;

public override void Unload()
{
_unloaded = true;
Plugin = null;
}

public override Task<bool> WaitForUnloadAsync(CancellationToken cancellationToken = default) =>
Task.FromResult(true);
}
41 changes: 41 additions & 0 deletions testing/Hosihikari.Testing/Hosihikari.Testing.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>Hosihikari.Testing</RootNamespace>
<AssemblyName>Hosihikari.Testing</AssemblyName>
<PackageId>Hosihikari.Testing</PackageId>
<Version>1.0.0</Version>
<Authors>HosihikariFramework</Authors>
<Description>Test host and in-memory host services for Hosihikari plugins.</Description>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<RepositoryUrl>https://github.com/HosihikariFramework/PluginManager</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageTags>hosihikari;minecraft;bedrock;plugin;testing</PackageTags>
<PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.13" />
<None Include="README.md" Pack="true" PackagePath="/" />
</ItemGroup>

<ItemGroup Condition="Exists('../../../Plugin.Abstractions/src/Hosihikari.Plugin.Abstractions.csproj')">
<ProjectReference Include="../../../Plugin.Abstractions/src/Hosihikari.Plugin.Abstractions.csproj" />
</ItemGroup>

<ItemGroup Condition="!Exists('../../../Plugin.Abstractions/src/Hosihikari.Plugin.Abstractions.csproj')">
<PackageReference Include="Hosihikari.Plugin.Abstractions" Version="[1.1.0]" />
</ItemGroup>

<ItemGroup Condition="Exists('../../src/Hosihikari.PluginManager.csproj')">
<ProjectReference Include="../../src/Hosihikari.PluginManager.csproj" />
</ItemGroup>

<ItemGroup Condition="!Exists('../../src/Hosihikari.PluginManager.csproj')">
<PackageReference Include="Hosihikari.PluginManager" Version="[1.1.0]" />
</ItemGroup>

</Project>
Loading
Loading