mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
## 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 个测试全绿。
193 lines
7.6 KiB
Go
193 lines
7.6 KiB
Go
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")
|
|
}
|
|
}
|