diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 5aa74aa8..6ceef12f 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -24,14 +24,14 @@ jobs:
dotnet test src/Serein.Tests --logger "GitHubActions;summary.includePassedTests=true;summary.includeSkippedTests=true"
- name: Generate coverage
- continue-on-error: true
- if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
+ id: coverage
+ if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && (failure() || success())
run: |
dotnet tool install --global JetBrains.dotCover.CommandLineTools
dotnet dotcover cover-dotnet --Output=AppCoverageReport.xml --ReportType=DetailedXML -- test --no-build
- name: Run codacy-coverage-reporter
- if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
+ if: steps.coverage.conclusion != 'skipped'
uses: codacy/codacy-coverage-reporter-action@v1.3.0
with:
api-token: ${{ secrets.CODACY_API_TOKEN }}
diff --git a/README.md b/README.md
index eb205da2..7d83731d 100644
--- a/README.md
+++ b/README.md
@@ -59,4 +59,4 @@ dotnet build
## 📄 License
-[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2FSereinDev%2FSerein.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2FSereinDev%2FSerein?ref=badge_large)
\ No newline at end of file
+[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2FSereinDev%2FSerein.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2FSereinDev%2FSerein?ref=badge_large)
diff --git a/src/Serein.Core/AppType.cs b/src/Serein.Core/AppType.cs
index 3e131e15..e0f88395 100644
--- a/src/Serein.Core/AppType.cs
+++ b/src/Serein.Core/AppType.cs
@@ -8,5 +8,6 @@ public enum AppType
Unknown,
Cli,
Lite,
- Plus
+ Plus,
+ Tests,
}
diff --git a/src/Serein.Core/Models/Plugins/Js/JsPlugin.cs b/src/Serein.Core/Models/Plugins/Js/JsPlugin.cs
index 126f4415..0a00f105 100644
--- a/src/Serein.Core/Models/Plugins/Js/JsPlugin.cs
+++ b/src/Serein.Core/Models/Plugins/Js/JsPlugin.cs
@@ -72,7 +72,22 @@ public void Dispose()
GC.SuppressFinalize(this);
}
- public bool Invoke(Event @event, CancellationToken cancellationToken, params object[] args)
+ internal void SetListener(Event @event, Function? func)
+ {
+ lock (_eventHandlers)
+ {
+ if (func is null)
+ {
+ _eventHandlers.TryRemove(@event, out _);
+ }
+ else
+ {
+ _eventHandlers[@event] = func;
+ }
+ }
+ }
+
+ internal bool Invoke(Event @event, CancellationToken cancellationToken, params object[] args)
{
var entered = false;
try
diff --git a/src/Serein.Core/Models/Plugins/Js/JsPluginConfig.cs b/src/Serein.Core/Models/Plugins/Js/JsPluginConfig.cs
index 17e1cb1b..ecc959ca 100644
--- a/src/Serein.Core/Models/Plugins/Js/JsPluginConfig.cs
+++ b/src/Serein.Core/Models/Plugins/Js/JsPluginConfig.cs
@@ -1,6 +1,6 @@
namespace Serein.Core.Models.Plugins.Js;
-public class JsPluginConfig
+public sealed class JsPluginConfig
{
public static readonly JsPluginConfig Default = new();
diff --git a/src/Serein.Core/SereinApp.cs b/src/Serein.Core/SereinApp.cs
index 887d04e6..a4a70dbd 100644
--- a/src/Serein.Core/SereinApp.cs
+++ b/src/Serein.Core/SereinApp.cs
@@ -21,6 +21,7 @@ static SereinApp()
"Serein.Cli" => AppType.Cli,
"Serein.Lite" => AppType.Lite,
"Serein.Plus" => AppType.Plus,
+ "Serein.Tests" => AppType.Tests,
_ => AppType.Unknown,
};
diff --git a/src/Serein.Core/Services/CoreService.cs b/src/Serein.Core/Services/CoreService.cs
index cbc19f8d..9b962c01 100644
--- a/src/Serein.Core/Services/CoreService.cs
+++ b/src/Serein.Core/Services/CoreService.cs
@@ -78,6 +78,8 @@ public Task StartAsync(CancellationToken cancellationToken)
public Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("正在停止");
+ _updateChecker.Dispose();
+
return Task.CompletedTask;
}
diff --git a/src/Serein.Core/Services/Network/UpdateChecker.cs b/src/Serein.Core/Services/Network/UpdateChecker.cs
index 3059bdb1..7c31d276 100644
--- a/src/Serein.Core/Services/Network/UpdateChecker.cs
+++ b/src/Serein.Core/Services/Network/UpdateChecker.cs
@@ -9,7 +9,7 @@
namespace Serein.Core.Services.Network;
-public sealed class UpdateChecker
+public sealed class UpdateChecker : IDisposable
{
private readonly ILogger _logger;
private readonly System.Timers.Timer _timer;
@@ -84,4 +84,10 @@ public async Task StartAsync()
await CheckAsync();
}
}
+
+ public void Dispose()
+ {
+ _timer.Stop();
+ _timer.Dispose();
+ }
}
diff --git a/src/Serein.Core/Services/Plugins/EventDispatcher.cs b/src/Serein.Core/Services/Plugins/EventDispatcher.cs
index 1e8cb364..9a3dd95a 100644
--- a/src/Serein.Core/Services/Plugins/EventDispatcher.cs
+++ b/src/Serein.Core/Services/Plugins/EventDispatcher.cs
@@ -29,6 +29,10 @@ JsPluginLoader jsPluginLoader
private readonly NetPluginLoader _netPluginLoader = netPluginLoader;
private readonly JsPluginLoader _jsPluginLoader = jsPluginLoader;
+ ///
+ /// 分发事件
+ ///
+ /// 如果此事件被拦截则返回false
internal bool Dispatch(Event @event, params object[] args)
{
_logger.LogDebug("分发事件:{}", @event);
@@ -61,7 +65,7 @@ internal bool Dispatch(Event @event, params object[] args)
}
cancellationTokenSource.Cancel();
- return tasks.Select((t) => !t.IsCompleted || t.Result).Any((b) => !b);
+ return !tasks.Where((task) => task.IsCompleted).Any((task) => !task.Result);
}
private void DispatchToJsPlugins(
diff --git a/src/Serein.Core/Services/Plugins/Js/Properties/CommandProperty.cs b/src/Serein.Core/Services/Plugins/Js/Properties/CommandProperty.cs
index 0844ec24..9ca26128 100644
--- a/src/Serein.Core/Services/Plugins/Js/Properties/CommandProperty.cs
+++ b/src/Serein.Core/Services/Plugins/Js/Properties/CommandProperty.cs
@@ -27,7 +27,7 @@ public Command Parse(string? command)
}
#pragma warning restore CA1822
- public void SetVariable(string key, object? value)
+ public void SetVariable(string key, string? value)
{
_pluginManager.SetCommandVariable(key, value);
}
diff --git a/src/Serein.Core/Services/Plugins/Js/ScriptInstance.cs b/src/Serein.Core/Services/Plugins/Js/ScriptInstance.cs
index 8058f1b2..9aec8e19 100644
--- a/src/Serein.Core/Services/Plugins/Js/ScriptInstance.cs
+++ b/src/Serein.Core/Services/Plugins/Js/ScriptInstance.cs
@@ -18,6 +18,8 @@
using Serein.Core.Services.Plugins.Js.Properties;
using Console = Serein.Core.Services.Plugins.Js.Properties.Console;
+using Jint.Native.Function;
+using Serein.Core.Models.Plugins;
namespace Serein.Core.Services.Plugins.Js;
@@ -100,7 +102,19 @@ public object GetService(string type)
var t = Type.GetType(type);
return t?.IsPublic == true
? _serviceProvider.GetRequiredService(t)
- : throw new InvalidOperationException("无法获取指定的类型");
+ : throw new ArgumentException("无法获取指定的类型", nameof(type));
+ }
+
+ public void SetListener(string eventName, JsValue jsValue)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(eventName);
+
+ if (!Enum.TryParse(eventName, true, out var @event))
+ {
+ throw new ArgumentException("无效的事件名称", nameof(eventName));
+ }
+
+ _jsPlugin.SetListener(@event, jsValue as Function);
}
public string Resolve(params string[] paths) => PluginManager.Resolve(_jsPlugin, paths);
diff --git a/src/Serein.Core/Services/Plugins/PluginManager.cs b/src/Serein.Core/Services/Plugins/PluginManager.cs
index 406cf5a9..0e8fc109 100644
--- a/src/Serein.Core/Services/Plugins/PluginManager.cs
+++ b/src/Serein.Core/Services/Plugins/PluginManager.cs
@@ -3,6 +3,7 @@
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
+using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
@@ -20,7 +21,7 @@
namespace Serein.Core.Services.Plugins;
-public sealed class PluginManager(
+public sealed partial class PluginManager(
ILogger logger,
IPluginLogger pluginLogger,
JsPluginLoader jsPluginLoader,
@@ -30,6 +31,9 @@ public sealed class PluginManager(
PermissionManager permissionManager
)
{
+ [GeneratedRegex(@"^[a-zA-Z][a-zA-Z0-9\-]{2,24}$")]
+ private static partial Regex GetIdRegex();
+ private static readonly Regex IdRegex = GetIdRegex();
private static readonly JsonSerializerOptions Options =
new(JsonSerializerOptionsFactory.Common)
{
@@ -50,35 +54,41 @@ PermissionManager permissionManager
public event EventHandler? PluginsReloading;
public event EventHandler? PluginsLoaded;
- public bool Loading { get; private set; }
- public bool Reloading { get; private set; }
+ public bool IsLoading { get; private set; }
+ public bool IsReloading { get; private set; }
public void ExportVariables(string key, object? value)
{
ExportedVariables.AddOrUpdate(key, value, (_, _) => value);
}
- public void SetCommandVariable(string key, object? value)
+ public void SetCommandVariable(string key, string? value)
{
- var str = value?.ToString() ?? string.Empty;
- CommandVariables.AddOrUpdate(key, str, (_, _) => str);
+ if (value == null)
+ {
+ CommandVariables.TryRemove(key, out _);
+ }
+ else
+ {
+ CommandVariables.AddOrUpdate(key, value, (_, _) => value);
+ }
}
public void Load()
{
try
{
- if (Loading)
+ if (IsLoading)
{
throw new InvalidOperationException("正在加载插件");
}
- Loading = true;
+ IsLoading = true;
if (!Directory.Exists(PathConstants.PluginsDirectory))
{
Directory.CreateDirectory(PathConstants.PluginsDirectory);
- Loading = false;
+ IsLoading = false;
return;
}
@@ -101,10 +111,15 @@ public void Load()
Options
) ?? throw new InvalidDataException("插件信息为空");
- if (string.IsNullOrWhiteSpace(pluginInfo.Id))
+ if (string.IsNullOrEmpty(pluginInfo.Id))
{
throw new InvalidOperationException("Id不可为空");
}
+
+ if(!IdRegex.IsMatch(pluginInfo.Id))
+ {
+ throw new InvalidOperationException("Id不符合规范");
+ }
}
catch (Exception e)
{
@@ -166,12 +181,12 @@ public void Load()
}
catch (Exception e)
{
- _logger.LogDebug(e, "[{}] 插件加载时出现异常", nameof(PluginManager));
+ _logger.LogDebug(e, "插件加载时出现异常");
throw;
}
finally
{
- Loading = false;
+ IsLoading = false;
_logger.LogDebug("插件加载结束");
}
}
@@ -190,12 +205,12 @@ public void Unload()
public void Reload()
{
- if (Reloading || Loading)
+ if (IsReloading || IsLoading)
{
throw new InvalidOperationException("正在加载插件");
}
- Reloading = true;
+ IsReloading = true;
try
{
@@ -206,7 +221,7 @@ public void Reload()
}
finally
{
- Reloading = false;
+ IsReloading = false;
}
}
diff --git a/src/Serein.Core/Services/SentryReporter.cs b/src/Serein.Core/Services/SentryReporter.cs
index 88333404..53279d05 100644
--- a/src/Serein.Core/Services/SentryReporter.cs
+++ b/src/Serein.Core/Services/SentryReporter.cs
@@ -29,7 +29,7 @@ public void Initialize()
options.AutoSessionTracking = true;
options.TracesSampleRate = 1;
options.ProfilesSampleRate = 0.5;
- options.AddIntegration(new ProfilingIntegration(TimeSpan.FromMilliseconds(1000)));
+ options.AddIntegration(new ProfilingIntegration(TimeSpan.FromMilliseconds(500)));
#if DEBUG
// options.Debug = true;
diff --git a/src/Serein.Core/Services/Servers/ServerPluginManager.cs b/src/Serein.Core/Services/Servers/ServerPluginManager.cs
index 0c6d9f07..ebc32c31 100644
--- a/src/Serein.Core/Services/Servers/ServerPluginManager.cs
+++ b/src/Serein.Core/Services/Servers/ServerPluginManager.cs
@@ -38,7 +38,7 @@ public sealed class ServerPluginManager
///
/// 禁用插件的扩展名
///
- public const string DisabledPluginExtension = ".disabled";
+ public static readonly string DisabledPluginExtension = ".disabled";
public event EventHandler? Updated;
public IReadOnlyList Plugins => _plugins;
diff --git a/src/Serein.Core/Utils/CrashHelper.cs b/src/Serein.Core/Utils/CrashHelper.cs
index cc488534..981a2b44 100644
--- a/src/Serein.Core/Utils/CrashHelper.cs
+++ b/src/Serein.Core/Utils/CrashHelper.cs
@@ -11,7 +11,11 @@ internal static class CrashHelper
{
public static string CreateLog(Exception e)
{
- SentrySdk.CaptureException(e);
+ if (SentrySdk.IsEnabled)
+ {
+ SentrySdk.CaptureException(e);
+ }
+
try
{
Directory.CreateDirectory(PathConstants.LogDirectory + "/crash");
diff --git a/src/Serein.Core/Utils/UrlConstants.cs b/src/Serein.Core/Utils/UrlConstants.cs
index d99196e8..cbc7aabc 100644
--- a/src/Serein.Core/Utils/UrlConstants.cs
+++ b/src/Serein.Core/Utils/UrlConstants.cs
@@ -22,15 +22,15 @@ public static class UrlConstants
public static readonly string Docs = "https://sereindev.github.io/";
- public static readonly string DocsPlugins = "https://sereindev.github.io/docs/guidance/plugins";
+ public static readonly string DocsArgument = "https://sereindev.github.io/docs/more/agreement";
- public static readonly string DocsVariables =
- "https://sereindev.github.io/docs/guidance/variables";
+ public static readonly string DocsPlugins = "https://sereindev.github.io/docs/guidance/plugins";
public static readonly string DocsMatch = "https://sereindev.github.io/docs/guidance/match";
public static readonly string DocsSchedule =
"https://sereindev.github.io/docs/guidance/schedule";
- public static readonly string DocsArgument = "https://sereindev.github.io/docs/more/agreement";
+ public static readonly string DocsVariables =
+ "https://sereindev.github.io/docs/guidance/variables";
}
diff --git a/src/Serein.Lite/Ui/Function/PluginPage.cs b/src/Serein.Lite/Ui/Function/PluginPage.cs
index 39ca59c2..1096fae8 100644
--- a/src/Serein.Lite/Ui/Function/PluginPage.cs
+++ b/src/Serein.Lite/Ui/Function/PluginPage.cs
@@ -103,7 +103,7 @@ private void DisableToolStripMenuItem_Click(object sender, EventArgs e)
private void ReloadToolStripMenuItem_Click(object sender, EventArgs e)
{
- if (!_pluginManager.Loading && !_pluginManager.Reloading)
+ if (!_pluginManager.IsLoading && !_pluginManager.IsReloading)
{
Task.Run(_pluginManager.Reload);
}
@@ -125,7 +125,7 @@ private void LookUpDocsToolStripMenuItem_Click(object sender, EventArgs e)
private void ReloadButton_Click(object sender, EventArgs e)
{
- if (!_pluginManager.Loading && !_pluginManager.Reloading)
+ if (!_pluginManager.IsLoading && !_pluginManager.IsReloading)
{
Task.Run(_pluginManager.Reload);
}
diff --git a/src/Serein.Plugins.Demo/Demo.cs b/src/Serein.Plugins.Demo/Demo.cs
index b7828235..5b694cea 100644
--- a/src/Serein.Plugins.Demo/Demo.cs
+++ b/src/Serein.Plugins.Demo/Demo.cs
@@ -2,8 +2,6 @@
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
-using Newtonsoft.Json;
-
using Serein.Core.Models.Plugins.Net;
namespace Serein.Plugins.Demo;
@@ -13,7 +11,6 @@ public class Demo : PluginBase
public Demo(IServiceProvider serviceProvider)
{
Call();
- Console.WriteLine(JsonConvert.DeserializeObject("{}"));
}
public override void Dispose()
diff --git a/src/Serein.Plugins.Demo/Serein.Plugins.Demo.csproj b/src/Serein.Plugins.Demo/Serein.Plugins.Demo.csproj
index 3b5609b0..49c9445d 100644
--- a/src/Serein.Plugins.Demo/Serein.Plugins.Demo.csproj
+++ b/src/Serein.Plugins.Demo/Serein.Plugins.Demo.csproj
@@ -6,7 +6,6 @@
-
false
all
diff --git a/src/Serein.Tests/Serein.Tests.csproj b/src/Serein.Tests/Serein.Tests.csproj
index 1d6f18a8..aab45d91 100644
--- a/src/Serein.Tests/Serein.Tests.csproj
+++ b/src/Serein.Tests/Serein.Tests.csproj
@@ -12,6 +12,7 @@
runtime; build; native; contentfiles; analyzers; buildtransitive
all
+
diff --git a/src/Serein.Tests/Services/NetPlugin/EventTests.cs b/src/Serein.Tests/Services/NetPlugin/EventTests.cs
new file mode 100644
index 00000000..283b419d
--- /dev/null
+++ b/src/Serein.Tests/Services/NetPlugin/EventTests.cs
@@ -0,0 +1,197 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Threading.Tasks;
+
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+using Serein.Core.Models.Plugins.Info;
+using Serein.Core.Services.Plugins.Net;
+using Serein.Core.Services.Servers;
+using Serein.Core.Utils;
+using Serein.Core.Utils.Json;
+using Serein.Tests.Utils;
+
+using Xunit;
+
+namespace Serein.Tests.Services.NetPlugin;
+
+[Collection(nameof(Serein))]
+public sealed class EventTests : IDisposable
+{
+ private readonly IHost _host;
+ private readonly NetPluginLoader _netPluginLoader;
+
+ public EventTests()
+ {
+ _host = HostFactory.BuildNew();
+ _netPluginLoader = _host.Services.GetRequiredService();
+
+ Directory.CreateDirectory(PathConstants.PluginsDirectory);
+ }
+
+ public void Dispose()
+ {
+ _host.StopAsync();
+ _host.Dispose();
+ }
+
+ [Fact]
+ public async Task ShouldInvokePluginLoadedEvent()
+ {
+ CSharpCompilationOptionsHelper.Compile(
+ """
+ using System;
+ using System.Threading.Tasks;
+ using Serein.Core.Models.Plugins.Net;
+
+ namespace MyPlugin;
+
+ public class Plugin: PluginBase
+ {
+ public bool IsInvoked { get; private set; }
+ public override void Dispose() { }
+
+ protected override Task OnPluginsLoaded()
+ {
+ IsInvoked = true;
+ return Task.CompletedTask;
+ }
+ }
+ """,
+ nameof(ShouldInvokePluginLoadedEvent),
+ Path.Join(
+ PathConstants.PluginsDirectory,
+ "test",
+ nameof(ShouldInvokePluginLoadedEvent) + ".dll"
+ )
+ );
+
+ File.WriteAllText(
+ Path.Join(PathConstants.PluginsDirectory, "test", PathConstants.PluginInfoFileName),
+ JsonSerializer.Serialize(
+ new PluginInfo
+ {
+ Id = "test1",
+ Name = "test",
+ Type = PluginType.Net,
+ EntryFile = nameof(ShouldInvokePluginLoadedEvent) + ".dll",
+ },
+ JsonSerializerOptionsFactory.Common
+ )
+ );
+ await _host.StartAsync();
+
+ dynamic d = _netPluginLoader.NetPlugins.First().Value;
+ Assert.True(d.IsInvoked);
+ }
+
+ [Fact]
+ public async Task ShouldInvokeServerEvent()
+ {
+ CSharpCompilationOptionsHelper.Compile(
+ """
+ using System;
+ using System.Collections.Generic;
+ using System.Threading.Tasks;
+ using Serein.Core.Models.Plugins.Net;
+ using Serein.Core.Services.Servers;
+
+ namespace MyPlugin;
+
+ public class Plugin: PluginBase
+ {
+ public List Events { get; } = [];
+ public override void Dispose() { }
+
+ protected override Task OnServerStarting(Server server)
+ {
+ Events.Add(nameof(OnServerStarting));
+ return Task.FromResult(true);
+ }
+
+ protected override Task OnServerStarted(Server server)
+ {
+ Events.Add(nameof(OnServerStarted));
+ return Task.CompletedTask;
+ }
+
+ protected override Task OnServerStopping(Server server)
+ {
+ Events.Add(nameof(OnServerStopping));
+ return Task.FromResult(true);
+ }
+
+ protected override Task OnServerExited(Server server, int exitcode, DateTime exitTime)
+ {
+ Events.Add(nameof(OnServerExited));
+ return Task.CompletedTask;
+ }
+
+ protected override Task OnServerOutput(Server server, string line)
+ {
+ Events.Add(nameof(OnServerOutput));
+ return Task.FromResult(true);
+ }
+
+ protected override Task OnServerRawOutput(Server server, string line)
+ {
+ Events.Add(nameof(OnServerRawOutput));
+ return Task.FromResult(true);
+ }
+
+ protected override Task OnServerInput(Server server, string line)
+ {
+ Events.Add(nameof(OnServerInput));
+ return Task.CompletedTask;
+ }
+ }
+ """,
+ nameof(ShouldInvokeServerEvent),
+ Path.Join(
+ PathConstants.PluginsDirectory,
+ "test",
+ nameof(ShouldInvokeServerEvent) + ".dll"
+ )
+ );
+
+ File.WriteAllText(
+ Path.Join(PathConstants.PluginsDirectory, "test", PathConstants.PluginInfoFileName),
+ JsonSerializer.Serialize(
+ new PluginInfo
+ {
+ Id = "test1",
+ Name = "test",
+ Type = PluginType.Net,
+ EntryFile = nameof(ShouldInvokeServerEvent) + ".dll",
+ },
+ JsonSerializerOptionsFactory.Common
+ )
+ );
+ await _host.StartAsync();
+
+ var serverManager = _host.Services.GetRequiredService();
+ var server = serverManager.Add(
+ "abcdefg",
+ new() { FileName = "cmd", OutputEncoding = EncodingMap.EncodingType.GBK }
+ );
+ server.Start();
+ server.Input("HELP");
+ server.Stop();
+ server.Terminate();
+ await Task.Delay(1000);
+
+ dynamic d = _netPluginLoader.NetPlugins.First().Value;
+ var list = (List)d.Events;
+ Assert.Contains("OnServerStarting", list);
+ Assert.Contains("OnServerStarted", list);
+ Assert.Contains("OnServerStopping", list);
+ Assert.Contains("OnServerExited", list);
+ Assert.Contains("OnServerOutput", list);
+ Assert.Contains("OnServerRawOutput", list);
+ Assert.Contains("OnServerInput", list);
+ }
+}
diff --git a/src/Serein.Tests/Services/NetPlugin/LoadTests.cs b/src/Serein.Tests/Services/NetPlugin/LoadTests.cs
new file mode 100644
index 00000000..2afd2c73
--- /dev/null
+++ b/src/Serein.Tests/Services/NetPlugin/LoadTests.cs
@@ -0,0 +1,195 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Threading.Tasks;
+
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+using Serein.Core.Models.Plugins.Info;
+using Serein.Core.Services.Plugins.Net;
+using Serein.Core.Utils;
+using Serein.Core.Utils.Json;
+using Serein.Tests.Utils;
+
+using Xunit;
+
+namespace Serein.Tests.Services.NetPlugin;
+
+[Collection(nameof(Serein))]
+public sealed class LoadTests : IDisposable
+{
+ private readonly IHost _host;
+ private readonly NetPluginLoader _netPluginLoader;
+
+ public LoadTests()
+ {
+ _host = HostFactory.BuildNew();
+ _netPluginLoader = _host.Services.GetRequiredService();
+
+ Directory.CreateDirectory(PathConstants.PluginsDirectory);
+ Directory.CreateDirectory(Path.Join(PathConstants.PluginsDirectory, "test"));
+ }
+
+ public void Dispose()
+ {
+ _host.StopAsync();
+ _host.Dispose();
+ }
+
+ [Fact]
+ public async Task ShouldNotLoadNetPluginWithoutValidPluginClass()
+ {
+ CSharpCompilationOptionsHelper.Compile(
+ """
+ namespace EmptyPlugin;
+ public class EmptyClass{}
+ """,
+ nameof(ShouldNotLoadNetPluginWithoutValidPluginClass),
+ Path.Join(PathConstants.PluginsDirectory, "test", "output.dll")
+ );
+
+ File.WriteAllText(
+ Path.Join(PathConstants.PluginsDirectory, "test", PathConstants.PluginInfoFileName),
+ JsonSerializer.Serialize(
+ new PluginInfo
+ {
+ Id = "test1",
+ Name = "test",
+ EntryFile = "output.dll",
+ Type = PluginType.Net,
+ },
+ JsonSerializerOptionsFactory.Common
+ )
+ );
+
+ await _host.StartAsync();
+ await Task.Delay(100);
+
+ Assert.Empty(_netPluginLoader.Plugins);
+ }
+
+ [Fact]
+ public async Task ShouldNotLoadNetPluginWithTwoPluginClasses()
+ {
+ CSharpCompilationOptionsHelper.Compile(
+ """
+ using Serein.Core.Models.Plugins.Net;
+
+ namespace MyPlugin;
+
+ public class Plugin1: PluginBase
+ {
+ public override void Dispose() { }
+ }
+
+ public class Plugin2: PluginBase
+ {
+ public override void Dispose() { }
+ }
+ """,
+ nameof(ShouldNotLoadNetPluginWithTwoPluginClasses),
+ Path.Join(
+ PathConstants.PluginsDirectory,
+ "test",
+ nameof(ShouldNotLoadNetPluginWithTwoPluginClasses) + ".dll"
+ )
+ );
+
+ File.WriteAllText(
+ Path.Join(PathConstants.PluginsDirectory, "test", PathConstants.PluginInfoFileName),
+ JsonSerializer.Serialize(
+ new PluginInfo
+ {
+ Id = "test1",
+ Name = "test",
+ EntryFile = nameof(ShouldNotLoadNetPluginWithTwoPluginClasses) + ".dll",
+ Type = PluginType.Net,
+ },
+ JsonSerializerOptionsFactory.Common
+ )
+ );
+
+ await _host.StartAsync();
+ await Task.Delay(100);
+
+ Assert.Empty(_netPluginLoader.Plugins);
+ }
+
+ [Fact]
+ public async Task ShouldLoadNetPluginWithValidAssembly()
+ {
+ CSharpCompilationOptionsHelper.Compile(
+ """
+ using Serein.Core.Models.Plugins.Net;
+
+ namespace MyPlugin;
+ public class Plugin: PluginBase
+ {
+ public override void Dispose() { }
+ }
+ """,
+ nameof(ShouldLoadNetPluginWithValidAssembly),
+ Path.Join(PathConstants.PluginsDirectory, "test", "test111.dll")
+ );
+
+ File.WriteAllText(
+ Path.Join(PathConstants.PluginsDirectory, "test", PathConstants.PluginInfoFileName),
+ JsonSerializer.Serialize(
+ new PluginInfo
+ {
+ Id = "test1",
+ Name = "test",
+ EntryFile = "test111.dll",
+ Type = PluginType.Net,
+ },
+ JsonSerializerOptionsFactory.Common
+ )
+ );
+
+ await _host.StartAsync();
+ await Task.Delay(100);
+
+ Assert.Single(_netPluginLoader.Plugins);
+ Assert.Equal("test1", _netPluginLoader.Plugins.First().Key);
+ }
+
+ [Fact]
+ public async Task ShouldLoadNetPluginWithoutSpecifyingEntryFile()
+ {
+ CSharpCompilationOptionsHelper.Compile(
+ """
+ using System;
+ using Serein.Core.Models.Plugins.Net;
+
+ namespace MyPlugin;
+
+ public class Plugin: PluginBase
+ {
+ public override void Dispose() { }
+ }
+ """,
+ nameof(ShouldLoadNetPluginWithoutSpecifyingEntryFile),
+ Path.Join(PathConstants.PluginsDirectory, "test", "test1.dll")
+ );
+
+ File.WriteAllText(
+ Path.Join(PathConstants.PluginsDirectory, "test", PathConstants.PluginInfoFileName),
+ JsonSerializer.Serialize(
+ new PluginInfo
+ {
+ Id = "test1",
+ Name = "test",
+ Type = PluginType.Net,
+ },
+ JsonSerializerOptionsFactory.Common
+ )
+ );
+
+ await _host.StartAsync();
+ await Task.Delay(100);
+
+ Assert.Single(_netPluginLoader.Plugins);
+ }
+}
diff --git a/src/Serein.Tests/Utils/CSharpCompilationOptionsHelper.cs b/src/Serein.Tests/Utils/CSharpCompilationOptionsHelper.cs
new file mode 100644
index 00000000..c79c748f
--- /dev/null
+++ b/src/Serein.Tests/Utils/CSharpCompilationOptionsHelper.cs
@@ -0,0 +1,55 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Text;
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+
+namespace Serein.Tests.Utils;
+
+#pragma warning disable IL3000
+
+public static class CSharpCompilationOptionsHelper
+{
+ private static readonly CSharpCompilationOptions Default = new CSharpCompilationOptions(
+ OutputKind.DynamicallyLinkedLibrary
+ )
+ .WithOptimizationLevel(OptimizationLevel.Release)
+ .WithPlatform(Platform.AnyCpu);
+
+ public static void Compile(
+ string code,
+ string assemblyName,
+ string path,
+ params MetadataReference[] metadataReferences
+ )
+ {
+ var syntaxTree = CSharpSyntaxTree.ParseText(
+ code,
+ CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Latest)
+ );
+ var references = metadataReferences
+ .Concat(
+ AppDomain
+ .CurrentDomain.GetAssemblies()
+ .Where((assembly) => File.Exists(assembly.Location))
+ .Select((assembly) => MetadataReference.CreateFromFile(assembly.Location))
+ )
+ .Distinct();
+
+ var compilation = CSharpCompilation.Create(assemblyName, [syntaxTree], references, Default);
+ var emitResult = compilation.Emit(path);
+
+ if (!emitResult.Success)
+ {
+ var stringBuilder = new StringBuilder();
+ stringBuilder.AppendLine("Compilation failed:");
+ foreach (var diagnostic in emitResult.Diagnostics)
+ {
+ stringBuilder.AppendLine(" " + diagnostic.ToString());
+ }
+ throw new InvalidOperationException(stringBuilder.ToString());
+ }
+ }
+}