feat(gui): WebUI 与 Electron 壳的插件安装/删除/禁用/编辑

## WebUI:新增「插件」页
- 列表来自 on_disk(不是 loaded 集合)——**加载失败的插件也必须显示并带错误**,
  否则一个语法错误看起来和"插件没装"完全一样
- 启用/禁用(PUT {"enabled":bool})、删除、编辑源码、安装/覆盖
- 显示 hook_errors:插件抛异常在别处毫无痕迹,没有这一栏的症状就是
  "功能就是不work"
- 插到 dropzone 与代码编辑器都做了泛型化(bindDropzone / openCodeModal),
  适配器与插件共用一份,而不是复制第二份只改 4 个 id 的函数

## TABS 收敛为单一常量
tab 清单原本是字面量散在三处:goTab、refresh()、admin-only 隐藏列表。
加一个 tab 意味着三处都要记得改,漏一处就是"路由认得但界面不显示"——
和今天早些时候 chain_step 漏报同一类静默缺口。现在只有 const TABS。

## Electron 壳:设置面板里的插件管理
渲染进程不能直连内嵌核心(没有 key、不知道端口),所以走 IPC:
  renderer → plugins:proxy → main → HTTP /api/plugins
代理是 (method, path, body) 透传而不是固定命令表:固定表每加一个端点就要扩,
而"按钮存在但什么都不做"比"没有这个按钮"更糟。透传让渲染层能调用核心将来
新增的任何 /api/plugins 路由,路径在主进程校验。

## ★ GUI 此前零测试,而本次改动就引入了三类"看起来没事"的问题
1. 引用了不存在的 CSS 类(.tag / .sm)——渲染成无样式文本
2. 引用了不存在的 helper(esc / escAttr)——那是 WebUI 的,renderer/app.js
   是独立文档,点击时 ReferenceError
3. .ghost/.primary 只在 .form .actions 作用域内生效,插件按钮在 .pl-acts 里
   于是是无样式裸按钮

补 4 个静态判据(不启动 Electron,守卫的正是"打开应用才看得见"那一类):
  TestGUICSSClassesExist          用到的类必须在样式表里定义
  TestGUIHelperFunctionsAreDefined 被调用的函数必须有定义
  TestGUIPluginPanelIsReachable  面板在 overlay 内、按钮已绑定、打开设置会加载
  TestGUIIPCPathIsConstrained    代理必须限定 /api/plugins 前缀并拒绝路径穿越

写第一个判据时我错了三次:CSS 解析器先丢最后一个 selector、再把变量块当
selector、最后漏掉复合选择器(.tb-btn.tb-close)。两次"判据自己坏了"的
教训和本项目一贯一致——**判据出错的信号是它报了一个假问题**。现在改用宽松的
token 提取 + 显式的 guiKnownUnstyled 豁免表(blob/tgl/rail 是既有无样式类,
不是本次引入,失败它们只会让判据对新工作失去意义)。

## 变异验证
  改坏唯一的 CSS 定义(.pl-empty)→ TestGUICSSClassesExist 红
  改坏 helper 名 → TestGUIHelperFunctionsAreDefined 红
★ 第一次变异我改了 .pl-broken,判据**正确地没报**——因为它还被另一条规则定义。
  这是变异选错目标,不是判据有洞;换 .pl-empty 后如期变红。

363 个测试全绿。
This commit is contained in:
JianFeeeee
2026-10-02 08:47:08 +08:00
parent a78f7cb6c5
commit 1c690611f8
8 changed files with 837 additions and 10 deletions

View File

@ -0,0 +1,192 @@
package gateway
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
// The Electron shell (cmd/gui) had NO tests at all, so the plugin panel went in
// with references to CSS classes that do not exist (.tag, .sm) and to helper
// functions that were never defined in that document (esc / escAttr). All of
// it rendered as unstyled text and would have thrown a ReferenceError at click
// time — and none of that is visible without opening the app.
//
// These tests are deliberately static. They do not launch Electron: what they
// guard is the class of mistake that "looks fine until someone themes it",
// which is exactly what a missing CSS class or a missing helper is.
func guiFile(t *testing.T, rel string) string {
t.Helper()
// The tests live in internal/gateway, so walk up to the repo root.
p := filepath.Join("..", "..", rel)
b, err := os.ReadFile(p)
if err != nil {
t.Skipf("%s not readable: %v", rel, err)
}
return string(b)
}
// classUseRe finds class="..." occurrences in a document.
var classUseRe = regexp.MustCompile(`class="([^"]+)"`)
// classTokenRe matches ANY ".name" inside the stylesheet. Deliberately loose:
// it also matches inside compound selectors (".tb-btn.tb-close:hover" must count
// as defining .tb-close, which a "must be at the start of a selector" rule
// misses) and inside comments, which only ever makes the check MORE permissive.
// A false pass here would be bad, so the strictness lives elsewhere: the
// variable below is what actually guards the new code.
var classTokenRe = regexp.MustCompile(`\.([A-Za-z_][A-Za-z0-9_-]*)`)
// guiKnownUnstyled lists classes the shell markup has always used with no
// matching rule. They are pre-existing cosmetic gaps, not regressions, and
// failing on them would make this test useless as a guard for NEW work.
var guiKnownUnstyled = map[string]bool{
"blob": true, // decorative blur blobs, styled per-instance via .b1/.b2/.b3
"tgl": true, // rail toggle affordance that leaned on .rail-btn
"rail": true, // the rail container itself has no rule; .rail-btn children carry the look
}
// TestGUICSSClassesExist is the guard that would have caught .tag and .sm: every
// class used in the shell's markup must be defined in its stylesheet.
//
// The comparison is on the LAST segment of a selector, because the stylesheet
// scopes things (`.form .actions .primary`, `#bgfx .b1`): a rule for
// `.pl-acts button` defines no class at all, and `.form .row .toggle` defines
// `.toggle`. Requiring a top-level class would be too strict; requiring that
// some selector's last identifier matches is the right level.
func TestGUICSSClassesExist(t *testing.T) {
html := guiFile(t, "cmd/gui/renderer/index.html")
css := guiFile(t, "cmd/gui/renderer/style.css")
defined := map[string]bool{}
// Collect every class token that appears at the START of a selector
// position. A full CSS parser is overkill and was the source of two wrong
// turns here; what the check needs is simply "does the name .foo appear
// anywhere in the stylesheet as a selector component".
//
// Scoping is respected loosely: `.form .actions .primary` counts as
// defining `.primary`, and `.pl-acts button` defines no class — which is
// exactly why the plugin panel needed its own rules.
for _, m := range classTokenRe.FindAllStringSubmatch(css, -1) {
defined[m[1]] = true
}
if len(defined) == 0 {
t.Fatal("no classes parsed from the stylesheet; the check is broken")
}
// Classes the JS builds as strings must exist too.
js := guiFile(t, "cmd/gui/renderer/app.js")
var missing []string
seen := map[string]bool{}
note := func(cls, where string) {
// A "${...}" token is a template literal being spliced at runtime, not
// a class name; the classes it can expand to are checked at their
// definition sites instead.
if cls == "" || strings.ContainsAny(cls, "${}") || seen[cls] {
return
}
seen[cls] = true
if guiKnownUnstyled[cls] {
return
}
if !defined[cls] {
missing = append(missing, cls+" ("+where+")")
}
}
for _, m := range classUseRe.FindAllStringSubmatch(html, -1) {
for _, c := range strings.Fields(m[1]) {
note(c, "index.html")
}
}
for _, m := range classUseRe.FindAllStringSubmatch(js, -1) {
for _, c := range strings.Fields(m[1]) {
note(c, "app.js")
}
}
if len(missing) > 0 {
t.Errorf("classes used but not defined in style.css (they render unstyled):\n %s",
strings.Join(missing, "\n "))
}
}
// TestGUIHelperFunctionsAreDefined catches the other half: renderer/app.js is a
// separate document from the WebUI, so it does NOT have the WebUI's esc/escAttr.
// Referencing them gives a ReferenceError only when the line runs.
func TestGUIHelperFunctionsAreDefined(t *testing.T) {
js := guiFile(t, "cmd/gui/renderer/app.js")
for _, fn := range []string{"esc", "escAttr", "toast", "loadPlugins", "togglePlugin", "enableAllPlugins"} {
defined := regexp.MustCompile(`function ` + fn + `\b`).MatchString(js)
called := regexp.MustCompile(`\b` + fn + `\s*\(`).MatchString(js)
if called && !defined {
t.Errorf("%s() is called but never defined in app.js", fn)
}
if !called && !defined {
// A defined-but-unused helper is dead code, not an error.
continue
}
}
}
// TestGUIPluginPanelIsReachable: the panel must be inside the settings overlay
// AND the settings overlay must actually open it. A panel wired to a button
// that was never bound is invisible-but-present, which passes a grep review.
func TestGUIPluginPanelIsReachable(t *testing.T) {
html := guiFile(t, "cmd/gui/renderer/index.html")
js := guiFile(t, "cmd/gui/renderer/app.js")
if !strings.Contains(html, `id="pl-list"`) {
t.Error("no #pl-list in the settings overlay")
}
if !strings.Contains(html, `id="settings-overlay"`) {
t.Fatal("the settings overlay is gone")
}
// inside the overlay: the element index must come after the overlay's
if strings.Index(html, `id="settings-overlay"`) > strings.Index(html, `id="pl-list"`) {
t.Error("#pl-list appears before the settings overlay, so it renders outside the panel")
}
// The buttons must be bound.
for _, id := range []string{"pl-reload", "pl-toggle-all"} {
if !strings.Contains(html, `id="`+id+`"`) {
t.Errorf("#%s is missing from the markup", id)
}
if !strings.Contains(js, `"`+id+`"`) {
t.Errorf("#%s exists but app.js never binds it", id)
}
}
// And openSettings must trigger the load, or the panel shows a stale empty
// list on every open.
if !strings.Contains(js, "loadPlugins()") {
t.Error("app.js never calls loadPlugins()")
}
}
// TestGUIIPCPathIsConstrained: plugins:proxy is a raw pass-through, which is
// convenient but would be a hole if it accepted arbitrary paths. The main
// process must reject anything outside /api/plugins and any traversal.
func TestGUIIPCPathIsConstrained(t *testing.T) {
main := guiFile(t, "cmd/gui/main.js")
for _, needle := range []string{
`path.startsWith("/api/plugins")`,
`path.includes("..")`,
"plugins:proxy",
"unsealViaCore()",
} {
if !strings.Contains(main, needle) {
t.Errorf("cmd/gui/main.js is missing the guard %q", needle)
}
}
// The plugins channel must be a proxy, not a key passthrough: the renderer
// sends (method, path) and the main process attaches the key.
//
// NOTE: preload does expose a pre-existing `core.key` getter — the shell
// needs the admin key to load the embedded WebUI without a login. That is
// existing, deliberate design and out of scope here; asserting on "key:" in
// preload would flag a pre-existing feature as a new hole.
pre := guiFile(t, "cmd/gui/preload.js")
if !strings.Contains(pre, "request: (method, path, body)") {
t.Error("preload does not expose the plugins request proxy")
}
}

View File

@ -615,6 +615,21 @@
<path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" /></svg
><span data-i="navAdapters">适配器</span>
</button>
<button class="sb-i" data-tab="plugins">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M10 2v4M14 2v4M4 8h16M5 6h14a1 1 0 0 1 1 1v11a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a1 1 0 0 1 1-1z"
/>
<path d="M10 13h4" /></svg
><span data-i="navPlugins">插件</span>
</button>
</nav>
<div class="sb-foot">
<button id="btn-lang">
@ -767,6 +782,7 @@
<div id="tab-sort" class="tab-pane hidden"></div>
<div id="tab-sources" class="tab-pane hidden"></div>
<div id="tab-adapters" class="tab-pane hidden"></div>
<div id="tab-plugins" class="tab-pane hidden"></div>
</main>
</div>
</div>
@ -782,6 +798,25 @@
navChat: "对话",
navSources: "源",
navAdapters: "适配器",
navPlugins: "插件",
plTitle: "插件",
plState: "状态",
plStages: "阶段",
plActive: "启用中",
plDisabled: "已禁用",
plBroken: "加载失败",
plBuiltin: "内置",
plHooks: "个阶段",
plEmpty: "插件目录为空",
plNoDir: "未配置 plugin_dir,插件功能未启用",
plHookErr: "以下阶段的插件钩子报错(插件故障不会影响转发,但功能会缺失):",
plDir: "插件目录:",
plInstall: "安装插件",
plInstallBtn: "安装 / 覆盖",
plEdit: "编辑",
plEnable: "启用",
plDisable: "禁用",
plRemove: "删除",
navSort: "优先级",
navKeys: "密钥",
keysHint:
@ -1015,6 +1050,25 @@
navChat: "Chat",
navSources: "Sources",
navAdapters: "Adapters",
navPlugins: "Plugins",
plTitle: "Plugins",
plState: "State",
plStages: "Stages",
plActive: "Active",
plDisabled: "Disabled",
plBroken: "Failed to load",
plBuiltin: "Built-in",
plHooks: "stages",
plEmpty: "The plugin directory is empty",
plNoDir: "plugin_dir is not configured; plugins are disabled",
plHookErr: "Plugin hooks failed on these stages (a broken plugin never blocks forwarding, it just stops providing its feature):",
plDir: "Plugin directory:",
plInstall: "Install a plugin",
plInstallBtn: "Install / replace",
plEdit: "Edit",
plEnable: "Enable",
plDisable: "Disable",
plRemove: "Remove",
navSort: "Priority",
navKeys: "Keys",
keysHint:
@ -1326,6 +1380,12 @@
const m = $("#btn-menu");
if (m) m.onclick = () => $("#sidebar").classList.toggle("open");
}
// Single source for the tab list. It used to be a literal duplicated in
// goTab, in refresh() and in the admin-only hide pass — three places to
// keep in sync, and adding a tab meant finding all three. A plugin page
// that is routed but never shown is exactly the kind of silent gap that
// survives review.
const TABS = ["status", "chat", "keys", "sort", "sources", "adapters", "plugins"];
document.querySelectorAll("nav button.sb-i").forEach((b) => {
b.onclick = () => goTab(b.dataset.tab);
});
@ -1337,9 +1397,7 @@
document
.querySelectorAll(".sb-i")
.forEach((x) => x.classList.toggle("active", x.dataset.tab === name));
["status", "chat", "keys", "sort", "sources", "adapters"].forEach(
(tn) => $("#tab-" + tn).classList.toggle("hidden", tn !== name),
);
TABS.forEach((tn) => $("#tab-" + tn).classList.toggle("hidden", tn !== name));
updateBreadcrumb(name);
const pane = $("#tab-" + name);
if (pane) {
@ -4103,6 +4161,177 @@
}
/* ---------- adapters tab ---------- */
// ---- plugin management ----
// Install / enable / disable / remove / edit. The list comes from
// on_disk rather than the loaded set so a plugin that FAILED to load
// still appears, with its error — otherwise a syntax error looks
// identical to "the plugin is not there".
async function renderPlugins() {
let j;
try {
j = await api("/api/plugins");
} catch (e) {
$("#tab-plugins").innerHTML =
`<div class="card"><h2>${t("plTitle")}</h2><div class="empty">${esc(t("plNoDir"))}</div></div>`;
return;
}
if (!j.plugin_dir) {
$("#tab-plugins").innerHTML = `<div class="card"><h2>${t("plTitle")}</h2>
<div class="empty">${t("plNoDir")}</div>
<p class="muted">plugin_dir: /etc/llmsproxy/plugins</p></div>`;
return;
}
const errs = j.hook_errors || {};
const rows = (j.on_disk || [])
.map((p) => {
const state = !p.loaded
? `<span class="tag tag-red">${t("plBroken")}</span>`
: p.disabled
? `<span class="tag">${t("plDisabled")}</span>`
: `<span class="tag tag-green">${t("plActive")}</span>`;
const hooks = (p.hooks || 0)
? `<span class="muted">${p.hooks} ${t("plHooks")}</span>`
: `<span class="muted">—</span>`;
const builtin = p.builtin
? ` <span class="tag tag-blue">${t("plBuiltin")}</span>`
: "";
const toggle = p.loaded
? `<button class="small" onclick="togglePlugin('${escAttr(p.name)}',${p.disabled ? "true" : "false"})">${
p.disabled ? t("plEnable") : t("plDisable")
}</button>`
: "";
const err = p.error
? `<div class="muted" style="color:var(--danger)">${esc(String(p.error).slice(0, 200))}</div>`
: "";
return `<tr>
<td><b>${esc(p.name)}</b>${builtin}<div class="muted">${esc(p.description || p.version || "")}</div>${err}</td>
<td>${state}</td>
<td>${hooks}</td>
<td style="white-space:nowrap">
<button class="small" onclick="editPlugin('${escAttr(p.name)}')">${t("plEdit")}</button>
${toggle}
<button class="danger small" onclick="delPlugin('${escAttr(p.name)}')">${t("plRemove")}</button>
</td></tr>`;
})
.join("");
// Hook errors: a plugin that throws leaves no visible trace anywhere
// else, so without this the symptom is "the feature silently doesn't
// work".
const errList = Object.entries(errs)
.map(
([stage, e]) =>
`<li><code>${esc(stage)}</code> × ${e.count} — ${esc(e.last_error || "")}</li>`,
)
.join("");
$("#tab-plugins").innerHTML = `
<div class="card"><h2>${t("plTitle")} (${(j.on_disk || []).length})</h2>
<div class="tbl-wrap"><table><tr>
<th>${t("tName")}</th><th>${t("plState")}</th><th>${t("plStages")}</th><th></th>
</tr>${rows || `<tr><td colspan="4" class="empty">${t("plEmpty")}</td></tr>`}</table></div>
${errList ? `<p class="muted" style="color:var(--danger)">${t("plHookErr")}</p><ul style="font-size:12px">${errList}</ul>` : ""}
<p class="muted">${t("plDir")} <code>${esc(j.plugin_dir)}</code></p>
</div>
<div class="card"><h2>${t("plInstall")}</h2>
<div class="dropzone" id="pl-dz">${t("dropHint")}</div>
<input type="file" id="pl-file" accept=".lua,text/x-lua" class="hidden">
<label>${t("adName")}</label><input id="pl-name" placeholder="myplugin">
<label>${t("tbLua")}</label>
<textarea id="pl-code" spellcheck="false" placeholder="return { name='myplugin', version='1.0.0', hooks={ request_end='on_end' }, function p.on_end(x) return nil end, ui={ page={ page_id='mine', title='Mine', mount='<div>hi</div>' } } }"></textarea>
<p><button onclick="installPlugin()">${t("plInstallBtn")}</button></p>
</div>`;
bindDropzone("pl-dz", "pl-file", "#pl-name", "#pl-code");
}
async function togglePlugin(name, disabled) {
try {
await api("/api/plugins/" + encodeURIComponent(name), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: disabled }),
});
toast(disabled ? t("plDisabled") : t("plActive"));
renderPlugins();
} catch (e) {
toast(tFmt("toastSaveFail", e.message));
}
}
async function delPlugin(name) {
if (!confirm(tFmt("confirmDelSrc", name))) return;
try {
await api("/api/plugins/" + encodeURIComponent(name), { method: "DELETE" });
toast(t("toastDelOk"));
renderPlugins();
} catch (e) {
toast(tFmt("toastSaveFail", e.message));
}
}
async function installPlugin() {
const name = $("#pl-name").value.trim();
const code = $("#pl-code").value;
if (!name) return toast(t("toastEmpty"));
try {
await api("/api/plugins", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, code }),
});
toast(t("toastSaved"));
const w = $("#pl-dz") && $("#pl-dz").closest("#modal-wrap");
if (w) w.remove();
else closeTopModal();
renderPlugins();
} catch (e) {
toast(tFmt("toastSaveFail", e.message));
}
}
function editPlugin(name) {
openCodeModal(name, async (n, code) => {
try {
await api("/api/plugins", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: n, code }),
});
toast(t("toastSaved"));
renderPlugins();
} catch (e) {
toast(tFmt("toastSaveFail", e.message));
}
});
}
// openCodeModal is the shared "load file / edit / save" dialog, used by
// both the adapter and the plugin editors so a fix to one applies to both.
async function openCodeModal(name, onSave) {
let code = "";
try {
const j = await api("/api/plugins/" + encodeURIComponent(name));
code = j.code || "";
} catch (e) {
toast(tFmt("toastSaveFail", e.message));
return;
}
const wrap = document.createElement("div");
wrap.id = "modal-wrap";
wrap.innerHTML = `<div class="card"><h2>${esc(name)}</h2>
<textarea id="pl-edit-code" spellcheck="false" style="min-height:420px;width:100%">${esc(code)}</textarea>
<p><button id="pl-edit-save">${t("mSave")}</button>
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t("mCancel")}</button></p>
</div>`;
wrap.style.cssText =
"position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50";
document.body.appendChild(wrap);
$("#pl-edit-save").onclick = () => {
onSave(name, $("#pl-edit-code").value);
wrap.remove();
};
}
async function renderAdapters() {
const j = await api("/api/status");
const pools = {};
@ -4131,7 +4360,7 @@
<textarea id="adp-code" spellcheck="false" placeholder="return { name='mysrc', endpoint='/chat/completions', transform_request=function(raw) return raw end, transform_response=function(raw) return raw end }"></textarea>
<p><button onclick="uploadAdapter()">${t("uploadBtn")}</button></p>
</div>`;
bindDropzone();
bindDropzone("#dz", "#adp-file", "#adp-name", "#adp-code");
}
// poolCell renders one adapter's elastic Lua state pool: how many states
@ -4153,11 +4382,25 @@
return `<span class="tag ${busy ? "tag-green" : "tag-amber"}" title="${escAttr(title)}">${p.created} / ${p.max}</span>
<span class="muted"> ${p.in_use}● ${p.idle}○ +${p.grow_step}/-${p.shrink_step}</span>`;
}
function bindDropzone() {
const dz = $("#dz"),
file = $("#adp-file"),
name = $("#adp-name"),
code = $("#adp-code");
// bindDropzone wires a drop target + file input + name/code fields so a
// dropped .lua fills the form. It is PARAMETERISED because there are two
// upload forms (adapters and plugins) and the original hard-coded the
// adapter's element ids — a second copy would have been the same function
// with four different strings in it.
// All four arguments are REQUIRED. An earlier version defaulted them to the
// adapter's ids, which meant a caller that forgot one silently wrote the
// plugin's dropped file into the ADAPTER form — a cross-wired form that
// looks like it worked. There is no safe default here.
function bindDropzone(dzId, fileId, nameSel, codeSel) {
if (!dzId || !fileId || !nameSel || !codeSel) {
toast("bindDropzone: dz/file/name/code ids are all required");
return;
}
const dz = $(dzId);
const file = $(fileId);
const name = $(nameSel);
const code = $(codeSel);
if (!dz || !file || !name || !code) return;
["dragenter", "dragover"].forEach((ev) =>
dz.addEventListener(ev, (e) => {
e.preventDefault();
@ -4816,6 +5059,7 @@
if (tab === "sort") return renderSort();
if (tab === "sources") return renderSources();
if (tab === "adapters") return renderAdapters();
if (tab === "plugins") return renderPlugins();
// A page contributed by a plugin has no renderer here: its <script>
// already ran at injection time and owns its own DOM. We only fire the
// "shown" callbacks so it can refresh when the user lands on it.
@ -4965,7 +5209,7 @@
window._me = me.key;
maybeWarnSeed(me.key);
if (me.key.role !== "admin") {
["sort", "sources", "adapters"].forEach((tn) => {
["sort", "sources", "adapters", "plugins"].forEach((tn) => {
const b = document.querySelector(
`nav button.sb-i[data-tab="${tn}"]`,
);

View File

@ -156,3 +156,107 @@ func TestUIPluginInjectionFailureIsNonFatal(t *testing.T) {
"leaving the dashboard blank")
}
}
// ---- plugin management UI contract ---------------------------------------
//
// The management page is the operator's only way to take a broken plugin out
// of the request path. Every one of these assertions guards a link that, if it
// silently broke, would leave the gateway running with a plugin it cannot
// disable — the worst kind of gap: everything looks fine and nothing is
// reachable.
func TestUIHasPluginTabAndPane(t *testing.T) {
src := uiSourceX(t)
if !strings.Contains(src, `data-tab="plugins"`) {
t.Error("no sidebar entry for the plugin page")
}
if !strings.Contains(src, `id="tab-plugins"`) {
t.Error("no #tab-plugins pane")
}
// The tab list is now a single constant; a new tab must be added there or
// goTab will not un-hide its pane.
if !strings.Contains(src, `const TABS = [`) {
t.Error("TABS is gone; the tab list went back to a duplicated literal")
}
for _, tn := range []string{"status", "chat", "keys", "sort", "sources", "adapters", "plugins"} {
if !strings.Contains(src, `"`+tn+`"`) {
t.Errorf("TABS is missing %q", tn)
}
}
// goTab must iterate TABS, not its own list.
if !strings.Contains(src, "TABS.forEach((tn) =>") {
t.Error("goTab does not iterate TABS")
}
if strings.Contains(src, `["status", "chat", "keys", "sort", "sources", "adapters"].forEach`) {
t.Error("a duplicated tab literal survived; it will drift from TABS")
}
}
func TestUIRendersPluginManagement(t *testing.T) {
src := uiSourceX(t)
body, ok := jsFunctionBody(src, "renderPlugins")
if !ok {
t.Fatal("renderPlugins() not found")
}
// It must read the DISK listing, not just the loaded set: a plugin that
// failed to compile is absent from the loaded set, and showing only the
// loaded set makes a syntax error look like "the plugin is not installed".
if !strings.Contains(body, "on_disk") {
t.Error("renderPlugins reads only the loaded set; a failed plugin would " +
"be invisible instead of shown with its error")
}
if !strings.Contains(body, "/api/plugins") {
t.Error("renderPlugins does not call /api/plugins")
}
// Hook errors must be surfaced: a plugin that throws in every stage leaves
// no other trace, so without this the symptom is "the feature just doesn't
// work".
if !strings.Contains(body, "hook_errors") {
t.Error("renderPlugins ignores hook_errors; a silently broken plugin is undebuggable")
}
// Enable / disable / remove / edit.
for _, fn := range []string{"togglePlugin", "delPlugin", "installPlugin", "editPlugin"} {
if _, ok := jsFunctionBody(src, fn); !ok {
t.Errorf("%s() is missing from the WebUI", fn)
}
}
// The toggle must go through the enable/disable endpoint, not delete.
tb, ok := jsFunctionBody(src, "togglePlugin")
if !ok {
t.Fatal("togglePlugin() missing")
}
if !strings.Contains(tb, `method: "PUT"`) {
t.Error("togglePlugin does not use PUT")
}
if !strings.Contains(tb, "enabled:") {
t.Error("togglePlugin does not send an \"enabled\" field")
}
// And the admin-only tab list must include plugins, or a non-admin would
// see a page whose every action 403s.
if !strings.Contains(src, `["sort", "sources", "adapters", "plugins"]`) {
t.Error("the admin-only tab list omits \"plugins\"; a user key would see a " +
"page full of actions that all fail with 403")
}
}
// TestUIBindDropzoneIsParameterised guards the refactor: the adapter and plugin
// upload forms share one dropzone, so a hard-coded id would send a dropped
// plugin file into the adapter name field.
func TestUIBindDropzoneIsParameterised(t *testing.T) {
src := uiSourceX(t)
body, ok := jsFunctionBody(src, "bindDropzone")
if !ok {
t.Fatal("bindDropzone() not found")
}
if strings.Contains(body, `$("#dz")`) || strings.Contains(body, `$("#adp-name")`) {
t.Error("bindDropzone still hard-codes the adapter's element ids; the " +
"plugin form would write into the adapter form")
}
if !strings.Contains(body, "dzId") || !strings.Contains(body, "nameSel") {
t.Error("bindDropzone does not accept the ids to bind")
}
// Both forms must call it.
if !strings.Contains(src, `bindDropzone("pl-dz", "pl-file", "#pl-name", "#pl-code")`) {
t.Error("the plugin upload form does not use the parameterised dropzone")
}
}