using Microsoft.VisualStudio.TestTools.UnitTesting;
using Microsoft.Data.Sqlite;
using YMhut.Box.Core.App;
using YMhut.Box.Core.Plugins;
using YMhut.Box.Core.Settings;
using YMhut.Box.Core.Tools;
namespace YMhut.Box.Tests;
[TestClass]
public sealed class PluginTests
{
[TestMethod]
public async Task PluginRegistryLoadsValidManifestAndToolModule()
{
using var workspace = TempWorkspace();
var pluginRoot = CreatePlugin(workspace.Path, "hello-tools", """
{
"id": "hello-tools",
"name": "Hello Tools",
"version": "1.0.0",
"author": "tester",
"description": "Test plugin",
"entry": "index.html",
"permissions": ["Input", "Output", "Log", "Storage"],
"surfaces": [
{ "kind": "ToolboxTool", "id": "hello", "name": "Hello", "description": "Hello tool", "entry": "index.html", "category": "dev" },
{ "kind": "NavPage", "id": "home", "name": "Home", "description": "Home page", "entry": "index.html" }
],
"resources": ["index.html"]
}
""");
File.WriteAllText(Path.Combine(pluginRoot, "index.html"), "
Hello
");
var paths = AppPaths.ForCurrentUser(workspace.Path);
var stateStore = new PluginStateStore(paths);
await stateStore.SetEnabledAsync("hello-tools", true);
var registry = new PluginRegistryService(paths, stateStore);
var plugins = await registry.LoadPluginsAsync();
var tools = await registry.LoadEnabledToolModulesAsync();
Assert.HasCount(1, plugins);
Assert.IsTrue(plugins[0].IsValid, string.Join(", ", plugins[0].Errors));
Assert.HasCount(1, tools);
Assert.AreEqual("plugin:hello-tools:hello", tools[0].Id);
}
[TestMethod]
public async Task PluginRegistryRejectsInvalidAndCoreAssetOverride()
{
using var workspace = TempWorkspace();
var pluginRoot = CreatePlugin(workspace.Path, "bad", """
{
"id": "plugin:bad",
"name": "Bad",
"version": "1.0.0",
"author": "tester",
"description": "Bad plugin",
"entry": "index.html",
"permissions": [],
"surfaces": [
{ "kind": "ToolboxTool", "id": "json_formatter", "name": "Override", "description": "conflict", "entry": "index.html" }
],
"resources": ["Assets/icons/app_icon.ico"]
}
""");
File.WriteAllText(Path.Combine(pluginRoot, "index.html"), "Bad
");
var paths = AppPaths.ForCurrentUser(workspace.Path);
var registry = new PluginRegistryService(paths, new PluginStateStore(paths));
var plugin = (await registry.LoadPluginsAsync()).Single();
Assert.IsFalse(plugin.IsValid);
Assert.IsTrue(plugin.Errors.Any(error => error.Contains("plugin:", StringComparison.OrdinalIgnoreCase)));
Assert.IsTrue(plugin.Errors.Any(error => error.Contains("built-in", StringComparison.OrdinalIgnoreCase)));
Assert.IsTrue(plugin.Errors.Any(error => error.Contains("Core asset", StringComparison.OrdinalIgnoreCase)));
}
[TestMethod]
public async Task PluginStateStorePersistsStatePermissionsSurfacesAndKv()
{
using var workspace = TempWorkspace();
var paths = AppPaths.ForCurrentUser(workspace.Path);
var store = new PluginStateStore(paths);
await store.SetEnabledAsync("demo", true);
await store.SetPermissionAsync("demo", PluginPermission.Log, true);
await store.SetSurfaceMountedAsync("demo", "tool", true);
await store.SetValueAsync("demo", "name", "value");
var secondStore = new PluginStateStore(paths);
var state = await secondStore.GetStateAsync("demo");
var values = await secondStore.ListValuesAsync("demo");
Assert.IsTrue(state.Enabled);
CollectionAssert.Contains(state.GrantedPermissions.ToList(), PluginPermission.Log);
CollectionAssert.Contains(state.MountedSurfaceIds.ToList(), "tool");
Assert.AreEqual("value", values["name"]);
}
[TestMethod]
public async Task PluginToolsMergeIntoToolCatalogWhenEnabled()
{
using var workspace = TempWorkspace();
var pluginRoot = CreatePlugin(workspace.Path, "merge-demo", """
{
"id": "merge-demo",
"name": "Merge Demo",
"version": "1.0.0",
"author": "tester",
"description": "Merge plugin",
"entry": "index.html",
"permissions": [],
"surfaces": [
{ "kind": "ToolboxTool", "id": "tool", "name": "Merged Tool", "description": "Merged", "entry": "index.html" }
],
"resources": ["index.html"]
}
""");
File.WriteAllText(Path.Combine(pluginRoot, "index.html"), "Merged
");
var paths = AppPaths.ForCurrentUser(workspace.Path);
var store = new PluginStateStore(paths);
await store.SetEnabledAsync("merge-demo", true);
var registry = new PluginRegistryService(paths, store);
var catalog = new ToolCatalog(ToolCatalog.DefaultModules().Concat(await registry.LoadEnabledToolModulesAsync()));
Assert.IsNotNull(catalog.GetById("plugin:merge-demo:tool"));
}
[TestMethod]
public async Task PluginRegistryHonorsSettingsEnabledAndCustomRoot()
{
using var workspace = TempWorkspace();
var paths = AppPaths.ForCurrentUser(workspace.Path);
var settings = new AppSettingsService(new AppSettingsStore(paths.Root));
await settings.LoadAsync();
var store = new PluginStateStore(paths);
var defaultPluginRoot = CreatePlugin(workspace.Path, "default-demo", BasicManifest("default-demo"));
File.WriteAllText(Path.Combine(defaultPluginRoot, "index.html"), "Default
");
await store.SetEnabledAsync("default-demo", true);
var registry = new PluginRegistryService(paths, store, settingsService: settings);
Assert.IsFalse(settings.Current.PluginsEnabled);
Assert.HasCount(0, await registry.LoadEnabledToolModulesAsync());
var customPluginsRoot = Path.Combine(workspace.Path, "ExternalPlugins");
var customPluginRoot = CreatePluginAtRoot(customPluginsRoot, "custom-demo", BasicManifest("custom-demo"));
File.WriteAllText(Path.Combine(customPluginRoot, "index.html"), "Custom
");
await store.SetEnabledAsync("custom-demo", true);
await settings.UpdateAsync(value =>
{
value.PluginsEnabled = true;
value.PluginRootPath = customPluginsRoot;
});
Assert.AreEqual(Path.GetFullPath(customPluginsRoot), registry.PluginsRoot);
var tools = await registry.LoadEnabledToolModulesAsync();
Assert.HasCount(1, tools);
Assert.AreEqual("plugin:custom-demo:tool", tools[0].Id);
}
[TestMethod]
public async Task PluginRegistryRequiresReadmeFile()
{
using var workspace = TempWorkspace();
var pluginRoot = CreatePlugin(workspace.Path, "no-readme", BasicManifest("no-readme"));
File.WriteAllText(Path.Combine(pluginRoot, "index.html"), "No readme
");
File.Delete(Path.Combine(pluginRoot, "README.md"));
var paths = AppPaths.ForCurrentUser(workspace.Path);
var registry = new PluginRegistryService(paths, new PluginStateStore(paths));
var plugin = (await registry.LoadPluginsAsync()).Single();
Assert.IsFalse(plugin.IsValid);
Assert.IsTrue(plugin.Errors.Any(error => error.Contains("README", StringComparison.OrdinalIgnoreCase)));
}
[TestMethod]
public async Task PluginSnapshotRoundTripsEnabledToolModules()
{
using var workspace = TempWorkspace();
var pluginRoot = CreatePlugin(workspace.Path, "snapshot-demo", BasicManifest("snapshot-demo"));
File.WriteAllText(Path.Combine(pluginRoot, "index.html"), "Snapshot
");
var paths = AppPaths.ForCurrentUser(workspace.Path);
var store = new PluginStateStore(paths);
await store.SetEnabledAsync("snapshot-demo", true);
var registry = new PluginRegistryService(paths, store);
var plugin = (await registry.LoadPluginsAsync()).Single();
var tool = (await registry.LoadEnabledToolModulesAsync()).Single();
var snapshot = new PluginSnapshot(
true,
registry.PluginsRoot,
[LoadedPluginDto.FromLoadedPlugin(plugin)],
[PluginToolDto.FromPluginToolModule(tool)],
DateTimeOffset.Now);
var message = new PluginHostMessage(PluginHostProtocol.SnapshotChanged, Snapshot: snapshot);
var roundTrip = PluginHostProtocol.Deserialize(PluginHostProtocol.Serialize(message));
Assert.IsNotNull(roundTrip?.Snapshot);
Assert.HasCount(1, roundTrip.Snapshot.EnabledTools);
Assert.AreEqual("plugin:snapshot-demo:tool", roundTrip.Snapshot.EnabledTools[0].ToToolModule().Id);
}
[TestMethod]
public async Task BuiltInPluginInstallerCopiesMissingPluginOnly()
{
using var workspace = TempWorkspace();
var paths = AppPaths.ForCurrentUser(Path.Combine(workspace.Path, "App"));
var installer = new BuiltInPluginInstallerService(paths);
await installer.EnsureInstalledAsync();
var installed = Path.Combine(paths.Root, "Plugins", "ipcheck-demo", "index.html");
var installedReadme = Path.Combine(paths.Root, "Plugins", "ipcheck-demo", "README.md");
var installedManifest = Path.Combine(paths.Root, "Plugins", "ipcheck-demo", PluginManifest.FileName);
Assert.IsTrue(File.Exists(installed));
Assert.IsTrue(File.Exists(installedReadme));
Assert.IsTrue(File.Exists(installedManifest));
Assert.IsTrue(File.ReadAllText(installed).Contains("IPCheck 网络工具箱", StringComparison.OrdinalIgnoreCase));
var manifestText = File.ReadAllText(installedManifest);
Assert.IsTrue(manifestText.Contains("\"Http\"", StringComparison.OrdinalIgnoreCase));
Assert.IsTrue(manifestText.Contains("\"OpenExternal\"", StringComparison.OrdinalIgnoreCase));
Assert.IsTrue(manifestText.Contains("\"README.md\"", StringComparison.OrdinalIgnoreCase));
File.WriteAllText(installed, "User version
");
await installer.EnsureInstalledAsync();
Assert.AreEqual("User version
", File.ReadAllText(installed));
}
[TestMethod]
public async Task BuiltInSamplePluginIsValidAndDocumentsBridgeCapabilities()
{
using var workspace = TempWorkspace();
var paths = AppPaths.ForCurrentUser(Path.Combine(workspace.Path, "App"));
var installer = new BuiltInPluginInstallerService(paths);
await installer.EnsureInstalledAsync();
var stateStore = new PluginStateStore(paths);
var registry = new PluginRegistryService(paths, stateStore);
var plugin = (await registry.LoadPluginsAsync()).Single(item => item.Manifest.Id == "ipcheck-demo");
Assert.IsTrue(plugin.IsValid, string.Join("; ", plugin.Errors));
CollectionAssert.Contains(plugin.Manifest.Permissions.ToList(), PluginPermission.Storage);
CollectionAssert.Contains(plugin.Manifest.Permissions.ToList(), PluginPermission.Output);
CollectionAssert.Contains(plugin.Manifest.Permissions.ToList(), PluginPermission.OpenExternal);
CollectionAssert.Contains(plugin.Manifest.Resources.ToList(), "README.md");
var readme = File.ReadAllText(Path.Combine(plugin.RootPath, "README.md"));
StringAssert.Contains(readme, "Bridge 能力示例");
StringAssert.Contains(readme, "安全浏览器");
StringAssert.Contains(readme, "AI 实现提示");
}
[TestMethod]
public void PluginDocsAndToolResultAssetsExposeRequiredSections()
{
var root = FindRepositoryRoot();
var pluginDocs = File.ReadAllText(Path.Combine(root, "docs", "plugins", "README.md"));
var aiDocs = File.ReadAllText(Path.Combine(root, "docs", "plugins", "AI-INTEGRATION.md"));
var helpPage = File.ReadAllText(Path.Combine(root, "src", "box-winUI", "Views", "PluginDocsPage.cs"));
var resultJs = File.ReadAllText(Path.Combine(root, "src", "box-winUI", "Assets", "tool-results", "result.js"));
var resultCss = File.ReadAllText(Path.Combine(root, "src", "box-winUI", "Assets", "tool-results", "result.css"));
var pageJs = File.ReadAllText(Path.Combine(root, "src", "box-winUI", "Assets", "tool-pages", "tool-page.js"));
var pageCss = File.ReadAllText(Path.Combine(root, "src", "box-winUI", "Assets", "tool-pages", "tool-page.css"));
StringAssert.Contains(pluginDocs, "权限");
StringAssert.Contains(pluginDocs, "安全边界");
StringAssert.Contains(pluginDocs, "独立窗口");
StringAssert.Contains(aiDocs, "Acceptance Checklist");
StringAssert.Contains(helpPage, "输出区");
StringAssert.Contains(helpPage, "常见问题");
StringAssert.Contains(resultJs, "openSystemBrowser");
StringAssert.Contains(pageJs, "openSystemBrowser");
StringAssert.Contains(resultCss, "rank-1");
StringAssert.Contains(pageCss, "rank-1");
StringAssert.Contains(resultCss, "prefers-reduced-motion");
StringAssert.Contains(pageCss, "prefers-reduced-motion");
}
private static string CreatePlugin(string root, string folder, string manifest)
{
var pluginRoot = Path.Combine(root, "Plugins", folder);
Directory.CreateDirectory(pluginRoot);
File.WriteAllText(Path.Combine(pluginRoot, PluginManifest.FileName), manifest);
File.WriteAllText(Path.Combine(pluginRoot, "README.md"), "# Test plugin");
return pluginRoot;
}
private static string CreatePluginAtRoot(string pluginsRoot, string folder, string manifest)
{
var pluginRoot = Path.Combine(pluginsRoot, folder);
Directory.CreateDirectory(pluginRoot);
File.WriteAllText(Path.Combine(pluginRoot, PluginManifest.FileName), manifest);
File.WriteAllText(Path.Combine(pluginRoot, "README.md"), "# Test plugin");
return pluginRoot;
}
private static string BasicManifest(string id) => $$"""
{
"id": "{{id}}",
"name": "{{id}}",
"version": "1.0.0",
"author": "tester",
"description": "Test plugin",
"entry": "index.html",
"permissions": [],
"surfaces": [
{ "kind": "ToolboxTool", "id": "tool", "name": "Tool", "description": "Tool", "entry": "index.html" }
],
"resources": ["index.html"]
}
""";
private static TempDirectory TempWorkspace()
{
return new TempDirectory(Path.Combine(Path.GetTempPath(), "ymhut-plugin-tests", Guid.NewGuid().ToString("N")));
}
private static string FindRepositoryRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
if (Directory.Exists(Path.Combine(directory.FullName, "src")) &&
Directory.Exists(Path.Combine(directory.FullName, "docs")) &&
File.Exists(Path.Combine(directory.FullName, "YMhut.Box.Native.sln")))
{
return directory.FullName;
}
directory = directory.Parent;
}
throw new DirectoryNotFoundException("Could not locate repository root from test output path.");
}
private sealed class TempDirectory : IDisposable
{
public TempDirectory(string path)
{
Path = path;
Directory.CreateDirectory(path);
}
public string Path { get; }
public void Dispose()
{
SqliteConnection.ClearAllPools();
if (Directory.Exists(Path))
{
Directory.Delete(Path, recursive: true);
}
}
}
}