diff --git a/cmd/smoke-platform/main.go b/cmd/smoke-platform/main.go index 71ed8d9..e02f94b 100644 --- a/cmd/smoke-platform/main.go +++ b/cmd/smoke-platform/main.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/verstak/verstak-desktop/internal/core/capability" "github.com/verstak/verstak-desktop/internal/core/contribution" @@ -558,6 +559,71 @@ func runContributionsTest(root string) { } } + // ── Step 2b: Frontend bundle verification ── + fmt.Printf("\n[frontend bundle verification]\n") + frontendOk := true + + // 2b.1 — Check manifest declares frontend.entry + if target.Manifest.Frontend == nil { + fmt.Printf(" ❌ manifest.frontend is nil — plugin has no frontend config\n") + frontendOk = false + } else { + expectedEntry := "frontend/dist/index.js" + if target.Manifest.Frontend.Entry != expectedEntry { + fmt.Printf(" ❌ frontend.entry: expected %q, got %q\n", expectedEntry, target.Manifest.Frontend.Entry) + frontendOk = false + } else { + fmt.Printf(" ✅ frontend.entry: %s\n", target.Manifest.Frontend.Entry) + } + + // 2b.2 — Resolve entry path and check file exists on disk + resolvedEntry := filepath.Join(target.RootPath, target.Manifest.Frontend.Entry) + if _, err := os.Stat(resolvedEntry); os.IsNotExist(err) { + fmt.Printf(" ❌ frontend bundle NOT FOUND at resolved path: %s\n", resolvedEntry) + frontendOk = false + } else if err != nil { + fmt.Printf(" ❌ frontend bundle stat error: %v\n", err) + frontendOk = false + } else { + fmt.Printf(" ✅ frontend bundle exists: %s\n", resolvedEntry) + + // 2b.3 — Read file and check for "VerstakPluginRegister" + data, err := os.ReadFile(resolvedEntry) + if err != nil { + fmt.Printf(" ❌ failed to read frontend bundle: %v\n", err) + frontendOk = false + } else { + content := string(data) + + if strings.Contains(content, "VerstakPluginRegister") { + fmt.Printf(" ✅ bundle contains VerstakPluginRegister contract\n") + } else { + fmt.Printf(" ❌ bundle MISSING VerstakPluginRegister contract\n") + frontendOk = false + } + + // 2b.4 — Check for expected component names + expectedComponents := []string{"DiagnosticsPanel", "PlatformTestSettings"} + for _, comp := range expectedComponents { + if strings.Contains(content, comp) { + fmt.Printf(" ✅ component registered: %s\n", comp) + } else { + fmt.Printf(" ❌ component MISSING from bundle: %s\n", comp) + frontendOk = false + } + } + } + } + } + + if frontendOk { + fmt.Printf(" ✅ frontend bundle checks passed\n") + } else { + fmt.Printf(" ❌ frontend bundle checks FAILED\n") + exitCode = 1 + return + } + // ── Step 3: Register capabilities (simulates main.go + ReloadPlugins) ── reg := capability.NewRegistry() coreCaps := []string{ diff --git a/docs/PLUGIN_RUNTIME.md b/docs/PLUGIN_RUNTIME.md index 23872f8..e272a2c 100644 --- a/docs/PLUGIN_RUNTIME.md +++ b/docs/PLUGIN_RUNTIME.md @@ -168,7 +168,7 @@ foreach plugin: | Тип | Поле manifest | Описание | Frontend host | |---|---|---|---| | Боковая панель | `sidebarItems` | Элементы в sidebar слева | ✅ Sidebar.svelte (из ContributionRegistry) | -| Основные панели | `views` | Полноценные страницы/панели | ✅ ViewContainer.svelte (placeholder — frontend bundle host not implemented) | +| Основные панели | `views` | Полноценные страницы/панели | ✅ ViewContainer.svelte (PluginBundleHost — real frontend bundle) | | Панели настроек | `settingsPanels` | Панели в Plugin Manager | ✅ PluginManager.svelte (кнопка Settings, открывает modal) | | Команды | `commands` | Команды для command palette | ✅ ContributionRegistry (UI command palette не реализован) | @@ -239,6 +239,73 @@ foreach plugin: - ViewContainer показывает "⚠️ Plugin UI failed" fallback - Error канал: `console.error` + видимый fallback в UI +## Frontend Bundle Contract + +### Регистрация компонентов + +Плагин регистрирует frontend компоненты через глобальную функцию `window.VerstakPluginRegister`: + +```javascript +window.VerstakPluginRegister('plugin.id', { + components: { + 'ComponentName': { + mount: function(containerEl, props, api) { + // containerEl — div, созданный PluginBundleHost + // api — ограниченный VerstakPluginAPI + containerEl.innerHTML = '

Hello from plugin!

'; + }, + unmount: function(containerEl) { + // Очистка при смене view + containerEl.innerHTML = ''; + } + } + } +}); +``` + +### VerstakPluginAPI + +API объект передаётся в `mount()` и содержит только ограниченный набор методов: + +| Свойство | Статус | Описание | +|---|---|---| +| `api.pluginId` | ✅ Работает | ID плагина | +| `api.capabilities.has(id)` | 🔧 Stub | Запрос capability registry (planned) | +| `api.events.publish(type, payload)` | 🔧 Stub | Публикация события (planned) | +| `api.events.subscribe(type, handler)` | 🔧 Stub | Подписка на события (planned) | +| `api.settings.read(key)` | 🔧 Stub | Чтение настроек плагина (planned) | +| `api.settings.write(key, value)` | 🔧 Stub | Запись настроек плагина (planned) | +| `api.commands.execute(id, args)` | 🔧 Stub | Выполнение команды (planned) | + +### Загрузка бандла + +1. `PluginBundleHost` получает pluginId и componentId +2. Вызывает `App.GetPluginFrontendInfo(pluginId)` — получает entry/style/rootPath +3. Вызывает `App.GetPluginAssetContent(pluginId, entry)` — получает JS контент +4. Выполняет контент через `new Function(content)` — bundle вызывает `VerstakPluginRegister` +5. Находит компонент по componentId и вызывает `mount(container, props, api)` +6. При смене view — вызывает `unmount(container)` для старого компонента + +### Безопасность asset path + +| Правило | Проверка | +|---|---| +| Нет абсолютных путей | Пути, начинающиеся с `/` или `\`, отклоняются | +| Нет path traversal | Пути, содержащие `..`, отклоняются | +| Нет выхода за root | После `filepath.Join` проверяется, что путь внутри plugin root | +| Только существующие файлы | `os.ReadFile` возвращает ошибку если файл не существует | + +### manifest frontend config + +```json +{ + "frontend": { + "entry": "frontend/dist/index.js", + "style": "frontend/style.css" + } +} +``` + ## Reload `ReloadPlugins()` в `internal/api/app.go` позволяет перезагрузить plugins без перезапуска приложения: diff --git a/frontend/src/lib/plugin-host/PluginBundleHost.svelte b/frontend/src/lib/plugin-host/PluginBundleHost.svelte new file mode 100644 index 0000000..0f43182 --- /dev/null +++ b/frontend/src/lib/plugin-host/PluginBundleHost.svelte @@ -0,0 +1,292 @@ + + +
+ {#if loadState === 'idle'} +
+

Select a plugin view from the sidebar

+
+ + {:else if loadState === 'loading'} +
+
+

Loading plugin bundle...

+
+ + {:else if loadState === 'error'} +
+
⚠️
+

Plugin View Error

+
+

Plugin: {currentPluginId || 'unknown'}

+

Component: {currentComponent || 'unknown'}

+

{errorText || 'Unknown error'}

+ {#if pluginInfo} +

Frontend entry: {pluginInfo.entry || 'none'}

+ {/if} + {#if getComponentList().length > 0} +

Available components: {getComponentList().join(', ')}

+ {/if} +
+
+ + {:else if loadState === 'loaded'} +
+ {/if} +
+ + diff --git a/frontend/src/lib/plugin-host/VerstakPluginAPI.js b/frontend/src/lib/plugin-host/VerstakPluginAPI.js new file mode 100644 index 0000000..43f8f10 --- /dev/null +++ b/frontend/src/lib/plugin-host/VerstakPluginAPI.js @@ -0,0 +1,68 @@ +// VerstakPluginAPI is the restricted API passed to plugin frontend bundles. +// Plugins do NOT get direct access to Wails bridge — only what's exposed here. +// All methods are stubs or limited implementations. + +(function() { + // Store registered components per plugin + window.__VERSTAK_PLUGIN_REGISTRY__ = window.__VERSTAK_PLUGIN_REGISTRY__ || {}; + + // Original register function + const origRegister = window.VerstakPluginRegister; + if (origRegister) { + // Already defined — don't override + return; + } + + window.VerstakPluginRegister = function(pluginId, bundle) { + if (!pluginId || !bundle || !bundle.components) { + console.error('[VerstakPluginRegister] invalid registration:', pluginId); + return; + } + console.log('[VerstakPluginRegister] registered:', pluginId, Object.keys(bundle.components)); + window.__VERSTAK_PLUGIN_REGISTRY__[pluginId] = bundle.components; + }; + + // Create the restricted API object for a plugin host context + window.VerstakPluginAPI = function(pluginId) { + return { + pluginId: pluginId, + + capabilities: { + has: function(capId) { + // planned: query backend cap registry + console.log('[plugin:' + pluginId + '] capabilities.has(' + capId + ') — stub'); + return false; + } + }, + + events: { + publish: function(type, payload) { + console.log('[plugin:' + pluginId + '] event publish:', type, payload); + // planned: actual event bus bridge + }, + subscribe: function(type, handler) { + console.log('[plugin:' + pluginId + '] event subscribe:', type, '(stub)'); + // planned: actual event bus bridge + } + }, + + settings: { + read: function(key) { + console.log('[plugin:' + pluginId + '] settings.read(' + key + ') — stub'); + return null; + }, + write: function(key, value) { + console.log('[plugin:' + pluginId + '] settings.write(' + key + ',', value, ') — stub'); + // planned: backend storage namespace + } + }, + + commands: { + execute: function(cmdId, args) { + console.log('[plugin:' + pluginId + '] commands.execute(' + cmdId + ') — stub'); + // planned: command execution + } + } + }; + }; +})(); diff --git a/frontend/src/lib/plugin-manager/PluginManager.svelte b/frontend/src/lib/plugin-manager/PluginManager.svelte index a15d9e2..1403763 100644 --- a/frontend/src/lib/plugin-manager/PluginManager.svelte +++ b/frontend/src/lib/plugin-manager/PluginManager.svelte @@ -1,7 +1,8 @@ {#key `${activeViewPluginId}:${activeView}`} @@ -51,17 +59,27 @@
{currentView.icon || '📦'}

{currentView.title}

+ {#if hasFrontend} + frontend bundle + {:else} + no frontend bundle + {/if}
-
+ {#if hasFrontend} + + {:else}

Plugin View Host

Plugin: {currentView.pluginId}

View ID: {currentView.id}

Component: {currentView.component}

-

frontend bundle host not implemented yet

+

frontend bundle not available

-
+ {/if}
@@ -109,14 +127,30 @@ flex: 1; } .view-icon { font-size: 1.3rem; } + .frontend-badge { + font-size: 0.7rem; + padding: 0.15rem 0.5rem; + background: rgba(78, 204, 163, 0.15); + color: #4ecca3; + border-radius: 8px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.02em; + } + .no-frontend-badge { + font-size: 0.7rem; + padding: 0.15rem 0.5rem; + background: rgba(233, 69, 96, 0.1); + color: #e94560; + border-radius: 8px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.02em; + } .view-content { flex: 1; overflow: auto; } - .plugin-view-host { - width: 100%; - min-height: 200px; - } .placeholder { color: #666; font-style: italic; diff --git a/frontend/wailsjs/go/api/App.d.ts b/frontend/wailsjs/go/api/App.d.ts index f11e856..5a40591 100755 --- a/frontend/wailsjs/go/api/App.d.ts +++ b/frontend/wailsjs/go/api/App.d.ts @@ -27,6 +27,10 @@ export function GetCurrentWorkspaceNode():Promise>; export function GetPermissions():Promise>; +export function GetPluginAssetContent(arg1:string,arg2:string):Promise; + +export function GetPluginFrontendInfo(arg1:string):Promise>; + export function GetPlugins():Promise>; export function GetVaultPluginState():Promise>; diff --git a/frontend/wailsjs/go/api/App.js b/frontend/wailsjs/go/api/App.js index 465e2ad..16d74ac 100755 --- a/frontend/wailsjs/go/api/App.js +++ b/frontend/wailsjs/go/api/App.js @@ -46,6 +46,14 @@ export function GetPermissions() { return window['go']['api']['App']['GetPermissions'](); } +export function GetPluginAssetContent(arg1, arg2) { + return window['go']['api']['App']['GetPluginAssetContent'](arg1, arg2); +} + +export function GetPluginFrontendInfo(arg1) { + return window['go']['api']['App']['GetPluginFrontendInfo'](arg1); +} + export function GetPlugins() { return window['go']['api']['App']['GetPlugins'](); } diff --git a/frontend/wailsjs/runtime/package.json b/frontend/wailsjs/runtime/package.json old mode 100644 new mode 100755 diff --git a/frontend/wailsjs/runtime/runtime.d.ts b/frontend/wailsjs/runtime/runtime.d.ts old mode 100644 new mode 100755 diff --git a/frontend/wailsjs/runtime/runtime.js b/frontend/wailsjs/runtime/runtime.js old mode 100644 new mode 100755 diff --git a/internal/api/app.go b/internal/api/app.go index a6ddccb..e719193 100644 --- a/internal/api/app.go +++ b/internal/api/app.go @@ -711,3 +711,69 @@ func (a *App) SelectVaultForOpen() string { } return selected } + +// ─── Plugin Frontend Asset API ─────────────────────────── + +// GetPluginFrontendInfo returns frontend metadata for a plugin. +// Returns empty map if plugin has no frontend bundle or is not found. +func (a *App) GetPluginFrontendInfo(pluginID string) map[string]interface{} { + for _, p := range a.plugins { + if p.Manifest.ID != pluginID { + continue + } + if p.Manifest.Frontend == nil { + return map[string]interface{}{"status": "no-frontend"} + } + return map[string]interface{}{ + "pluginId": p.Manifest.ID, + "name": p.Manifest.Name, + "icon": p.Manifest.Icon, + "version": p.Manifest.Version, + "entry": p.Manifest.Frontend.Entry, + "style": p.Manifest.Frontend.Style, + "rootPath": p.RootPath, + } + } + return map[string]interface{}{"status": "not-found"} +} + +// GetPluginAssetContent reads a frontend asset file from a plugin directory. +// Security: validates that the assetPath is relative and does not escape the plugin root. +func (a *App) GetPluginAssetContent(pluginID, assetPath string) (string, string) { + // Validate asset path — reject absolute paths and path traversal + if strings.HasPrefix(assetPath, "/") || strings.HasPrefix(assetPath, "\\") { + return "", "absolute paths not allowed" + } + if strings.Contains(assetPath, "..") { + return "", "path traversal not allowed" + } + + // Find the plugin + var pluginRoot string + found := false + for _, p := range a.plugins { + if p.Manifest.ID == pluginID && p.Manifest.Frontend != nil { + pluginRoot = p.RootPath + found = true + break + } + } + if !found { + return "", "plugin not found or has no frontend" + } + + // Resolve path relative to plugin root + fullPath := filepath.Join(pluginRoot, assetPath) + // Verify we haven't escaped plugin root + absRoot, _ := filepath.Abs(pluginRoot) + absPath, _ := filepath.Abs(fullPath) + if !strings.HasPrefix(absPath, absRoot+string(filepath.Separator)) && absPath != absRoot { + return "", "path escapes plugin root" + } + + data, err := os.ReadFile(absPath) + if err != nil { + return "", fmt.Sprintf("failed to read asset: %v", err) + } + return string(data), "" +} diff --git a/internal/api/app_test.go b/internal/api/app_test.go new file mode 100644 index 0000000..1e38588 --- /dev/null +++ b/internal/api/app_test.go @@ -0,0 +1,257 @@ +package api + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/verstak/verstak-desktop/internal/core/plugin" +) + +// newTestApp creates an App with a mocked plugin list for testing. +func newTestApp(tmpRoot string) *App { + return &App{ + plugins: []plugin.Plugin{ + { + Manifest: plugin.Manifest{ + ID: "test.plugin", + Name: "Test Plugin", + Version: "1.0.0", + Icon: "🧪", + Frontend: &plugin.FrontendConfig{ + Entry: "frontend/dist/index.js", + Style: "frontend/style.css", + }, + Provides: []string{"test/cap/v1"}, + Permissions: []string{"test.perm"}, + }, + RootPath: tmpRoot, + Status: plugin.StatusLoaded, + Enabled: true, + }, + { + Manifest: plugin.Manifest{ + ID: "no-fe.plugin", + Name: "No Frontend Plugin", + Provides: []string{"test/nofe/v1"}, + Permissions: []string{"test.perm"}, + }, + RootPath: "/tmp/no-fe-plugin", + Status: plugin.StatusLoaded, + Enabled: true, + }, + }, + } +} + +// TestGetPluginFrontendInfo_KnownPluginWithFrontend verifies that +// GetPluginFrontendInfo returns correct metadata for a plugin with a frontend. +func TestGetPluginFrontendInfo_KnownPluginWithFrontend(t *testing.T) { + app := newTestApp("/tmp/test-plugin") + info := app.GetPluginFrontendInfo("test.plugin") + + if info["status"] != nil { + t.Errorf("unexpected status key: expected no status, got %v", info["status"]) + } + if info["pluginId"] != "test.plugin" { + t.Errorf("pluginId: expected %q, got %v", "test.plugin", info["pluginId"]) + } + if info["name"] != "Test Plugin" { + t.Errorf("name: expected %q, got %v", "Test Plugin", info["name"]) + } + if info["icon"] != "🧪" { + t.Errorf("icon: expected %q, got %v", "🧪", info["icon"]) + } + if info["version"] != "1.0.0" { + t.Errorf("version: expected %q, got %v", "1.0.0", info["version"]) + } + if info["entry"] != "frontend/dist/index.js" { + t.Errorf("entry: expected %q, got %v", "frontend/dist/index.js", info["entry"]) + } + if info["style"] != "frontend/style.css" { + t.Errorf("style: expected %q, got %v", "frontend/style.css", info["style"]) + } + if info["rootPath"] != "/tmp/test-plugin" { + t.Errorf("rootPath: expected %q, got %v", "/tmp/test-plugin", info["rootPath"]) + } +} + +// TestGetPluginFrontendInfo_PluginWithoutFrontend verifies that +// GetPluginFrontendInfo returns {"status": "no-frontend"} for a plugin +// that has no FrontendConfig. +func TestGetPluginFrontendInfo_PluginWithoutFrontend(t *testing.T) { + app := newTestApp("/tmp/test-plugin") + info := app.GetPluginFrontendInfo("no-fe.plugin") + + if info["status"] != "no-frontend" { + t.Errorf("expected status %q, got %v", "no-frontend", info["status"]) + } +} + +// TestGetPluginFrontendInfo_UnknownPlugin verifies that +// GetPluginFrontendInfo returns {"status": "not-found"} for a plugin ID +// that does not exist. +func TestGetPluginFrontendInfo_UnknownPlugin(t *testing.T) { + app := newTestApp("/tmp/test-plugin") + info := app.GetPluginFrontendInfo("nonexistent.plugin") + + if info["status"] != "not-found" { + t.Errorf("expected status %q, got %v", "not-found", info["status"]) + } +} + +// TestGetPluginAssetContent_ExistingFile verifies that GetPluginAssetContent +// can read an existing frontend file from a plugin directory. +func TestGetPluginAssetContent_ExistingFile(t *testing.T) { + tmpDir := t.TempDir() + + // Create a test frontend file + frontendDir := filepath.Join(tmpDir, "frontend", "dist") + if err := os.MkdirAll(frontendDir, 0755); err != nil { + t.Fatal(err) + } + content := "console.log('test');\n" + if err := os.WriteFile(filepath.Join(frontendDir, "index.js"), []byte(content), 0644); err != nil { + t.Fatal(err) + } + + app := newTestApp(tmpDir) + got, errStr := app.GetPluginAssetContent("test.plugin", "frontend/dist/index.js") + + if errStr != "" { + t.Errorf("unexpected error: %s", errStr) + } + if got != content { + t.Errorf("content mismatch:\n expected: %q\n got: %q", content, got) + } +} + +// TestGetPluginAssetContent_ExistingStyleFile verifies reading a style file. +func TestGetPluginAssetContent_ExistingStyleFile(t *testing.T) { + tmpDir := t.TempDir() + + frontendDir := filepath.Join(tmpDir, "frontend") + if err := os.MkdirAll(frontendDir, 0755); err != nil { + t.Fatal(err) + } + content := "body { color: red; }\n" + if err := os.WriteFile(filepath.Join(frontendDir, "style.css"), []byte(content), 0644); err != nil { + t.Fatal(err) + } + + app := newTestApp(tmpDir) + got, errStr := app.GetPluginAssetContent("test.plugin", "frontend/style.css") + + if errStr != "" { + t.Errorf("unexpected error: %s", errStr) + } + if got != content { + t.Errorf("content mismatch:\n expected: %q\n got: %q", content, got) + } +} + +// TestGetPluginAssetContent_AbsolutePathRejected verifies that asset paths +// starting with "/" are rejected. +func TestGetPluginAssetContent_AbsolutePathRejected(t *testing.T) { + app := newTestApp("/tmp/test-plugin") + + _, errStr := app.GetPluginAssetContent("test.plugin", "/etc/passwd") + if errStr == "" { + t.Error("expected error for absolute path, got empty") + } + if !strings.Contains(strings.ToLower(errStr), "absolute") { + t.Errorf("error should mention 'absolute', got: %s", errStr) + } + + // Also test Windows-style absolute path + _, errStr = app.GetPluginAssetContent("test.plugin", "\\etc\\passwd") + if errStr == "" { + t.Error("expected error for windows-style absolute path, got empty") + } +} + +// TestGetPluginAssetContent_PathTraversalRejected verifies that asset paths +// containing ".." are rejected. +func TestGetPluginAssetContent_PathTraversalRejected(t *testing.T) { + app := newTestApp("/tmp/test-plugin") + + _, errStr := app.GetPluginAssetContent("test.plugin", "../../etc/passwd") + if errStr == "" { + t.Error("expected error for path traversal, got empty") + } + if !strings.Contains(strings.ToLower(errStr), "traversal") { + t.Errorf("error should mention 'traversal', got: %s", errStr) + } +} + +// TestGetPluginAssetContent_PathEscapeRejected verifies that paths that +// resolve outside the plugin root directory are rejected after Join. +// This tests the absRoot-prefix check in GetPluginAssetContent. +func TestGetPluginAssetContent_PathEscapeRejected(t *testing.T) { + tmpDir := t.TempDir() + + // Do NOT create the traversed-to file — the security check happens + // before os.ReadFile, so the file should not matter. + app := newTestApp(tmpDir) + + // Use a relative path with ".." that would escape the plugin root + // The code checks strings.Contains(assetPath, "..") first, so this + // would be caught at the traversal check. But let's also test a case + // where ".." is NOT in the path but Join resolves outside root. + // For instance: symlink-based escape (not testable easily) or + // Join "/tmp/root" + "foo/../../../etc" — but ".." is in there. + // + // Instead, test that the absRoot prefix check works: create a path + // that after cleaning technically starts differently but doesn't use "..". + // This is hard to reproduce without symlinks. The ".." check catches + // common cases. Let's just ensure the ".." check is solid: + _, errStr := app.GetPluginAssetContent("test.plugin", "frontend/../../etc/passwd") + if errStr == "" { + t.Error("expected error for path traversal via nested '..', got empty") + } +} + +// TestGetPluginAssetContent_PluginNotFound verifies that GetPluginAssetContent +// returns an error for a nonexistent plugin ID. +func TestGetPluginAssetContent_PluginNotFound(t *testing.T) { + app := newTestApp("/tmp/test-plugin") + + _, errStr := app.GetPluginAssetContent("nonexistent.plugin", "frontend/dist/index.js") + if errStr == "" { + t.Error("expected error for nonexistent plugin, got empty") + } + if !strings.Contains(strings.ToLower(errStr), "not found") && + !strings.Contains(strings.ToLower(errStr), "no frontend") { + t.Errorf("error should mention 'not found' or 'no frontend', got: %s", errStr) + } +} + +// TestGetPluginAssetContent_NoFrontend verifies that GetPluginAssetContent +// returns an error for a plugin that exists but has no frontend config. +func TestGetPluginAssetContent_NoFrontend(t *testing.T) { + app := newTestApp("/tmp/test-plugin") + + _, errStr := app.GetPluginAssetContent("no-fe.plugin", "frontend/dist/index.js") + if errStr == "" { + t.Error("expected error for plugin without frontend, got empty") + } + if !strings.Contains(strings.ToLower(errStr), "no frontend") { + t.Errorf("error should mention 'no frontend', got: %s", errStr) + } +} + +// TestGetPluginAssetContent_NonexistentFile verifies that GetPluginAssetContent +// returns an error when the asset file does not exist on disk. +func TestGetPluginAssetContent_NonexistentFile(t *testing.T) { + tmpDir := t.TempDir() + app := newTestApp(tmpDir) + + _, errStr := app.GetPluginAssetContent("test.plugin", "frontend/dist/missing.js") + if errStr == "" { + t.Error("expected error for nonexistent file, got empty") + } + if !strings.Contains(strings.ToLower(errStr), "failed to read") { + t.Errorf("error should mention 'failed to read', got: %s", errStr) + } +}