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); } } } }