mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
## ★ 用户报告「Billing 页还是空白」—— 上一轮的验证有漏洞
上一轮我用 goTab('billing') 直接调用验证,显示"有数据、无错误"就下了结论。
但用户是**点侧栏按钮**。真实点击路径走 goTab,而 goTab 只遍历硬编码的 TABS
常量来切换 `hidden` 类 —— 插件页不在 TABS 里,所以 #tab-billing 的 hidden
**永远不会被移除**。内容一直躺在 DOM 里(KPI/表格都填好了),只是不可见。
这个 bug 没有任何报错:注入正确、数据正确、API 200,唯一的问题是宿主页的
路由逻辑没把插件页纳入。而它是上一轮「TABS 收敛为单一常量」时留下的:
收敛让三处共用一个常量,却没让插件页进入它。
修法:goTab 同时遍历 PLUGIN_PAGES。PLUGIN_PAGES 从 const 改为 var 并**提前到
goTab 之前声明** —— const 在文件后部声明的话,goTab 的读取落在 TDZ 里,
第一次点击插件页就会抛 ReferenceError(同类问题这个文件里已是第二次)。
判据 TestPluginPagesAreReachableByGoTab 锁两件事:goTab 遍历插件页集合 +
声明在使用之前。变异验证:删掉遍历 → 红;var 改回 const → 红。
## 插件 UI 不跟随多语言
宿主的 applyI18n/data-i 只覆盖**宿主渲染的标记**;插件注入的 HTML 对它不可见,
所以整个 UI 切中文时 Billing 页还是英文。
pluginAPI 增加 lang(getter,实时值)与 onLangChange(切换回调)。
billing 页所有文案改走双语字典:KPI、表头(fresh/cache/cache%)、区块标题
(占位后由脚本填)、空态、状态页 tile 标签。切换时立即重渲染标题,
不用等下一次 fetch。
## 部分页面超出 UI 区域
#main 只有 overflow-y,插件页内容(8 列表格 min-width、长字符串)会横向撑破。
两层修:插件 pane 统一 min-width:0/max-width:100%/overflow-x:auto(第三方
任意 HTML 的兜底,与原生 pane 一致);billing 的宽表格在自身容器内滚动。
## 状态页 tile 的 TypeError(每次重绘都报)
tile 的 tick() 在 await 之后直接 getElementById(...).textContent = ...,
但状态页每次刷新都整体重建 pane,元素可能已不存在 → null 属性赋值。
await 之后重新取元素并判空。
## 顺手补的缺口
上一轮加了缓存表格列,但 KPI 卡片漏了(那次替换 assert 失败后重试只重做了
表格)—— 缓存命中率在表格里有、KPI 里没有。本次补上。
## 验证
真实浏览器(禁缓存、真实点击侧栏按钮):pane 可见、KPI 9 项、表头双语、
语言双向切换正确(Per source ⇄ 按源)、无水平溢出、无 billing 控制台错误。
生产数据:Total USD 0.566798 / 732 请求 / 降级 231 / 2.09 亿 prompt tokens。
387+ 测试全绿。
## DSL(进行中,未完)
config.BillingDSL(active + profiles + rules,rule 按 url 匹配 mode=free/
token/subscription/unpriced)与 internal/billing.Compile(url 规则 → 插件
prices 表,含峰谷窗口的形状编译 —— 之前手写 JSON 两次弄错的正是这个形状)
已落地并通过校验/编译;core 启动接线已写。profile 切换 API 与 WebUI 选择器
未做,生产 config.yaml 也尚未写 billing 段 —— 下一轮继续。
369 lines
15 KiB
Go
369 lines
15 KiB
Go
package gateway
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// The plugin UI injection is JavaScript inside the embedded index.html, and it
|
|
// is the ONLY thing that turns a plugin's `ui` block into a visible page or
|
|
// element. These tests pin the wiring on the JS side; the server side (what the
|
|
// payload contains) is covered by TestUIInjectServesPluginUI and the lua
|
|
// package's TestBillingPluginDeclaresUI.
|
|
//
|
|
// What makes this worth pinning: a missing hook here fails SILENTLY. The page
|
|
// simply never appears, there is no error anywhere, and it looks like "the
|
|
// plugin didn't declare a page" rather than "the UI forgot to inject it".
|
|
|
|
// uiSource returns the embedded WebUI document.
|
|
func uiSourceX(t *testing.T) string {
|
|
t.Helper()
|
|
return uiSource(t)
|
|
}
|
|
|
|
// TestUIFetchesPluginInjection: the boot sequence must ask the kernel what to
|
|
// inject. Without this fetch the whole feature is inert.
|
|
func TestUIFetchesPluginInjection(t *testing.T) {
|
|
src := uiSourceX(t)
|
|
if !strings.Contains(src, "/api/ui-inject") {
|
|
t.Error("the WebUI never calls /api/ui-inject; plugin pages and elements can never appear")
|
|
}
|
|
}
|
|
|
|
// TestUIInjectsBeforeFirstRender: injection must be awaited before the first
|
|
// refresh, otherwise the sidebar is built without the plugin entry and the
|
|
// first paint races the fetch. This is an ordering contract, so it is asserted
|
|
// on the source order rather than trusted.
|
|
func TestUIInjectsBeforeFirstRender(t *testing.T) {
|
|
src := uiSourceX(t)
|
|
iInject := strings.Index(src, "injectPluginUI()")
|
|
iRefresh := strings.LastIndex(src, `refresh("status")`)
|
|
if iInject < 0 {
|
|
t.Fatal("injectPluginUI() is never called")
|
|
}
|
|
if iRefresh < 0 {
|
|
t.Fatal("the boot sequence no longer calls refresh(\"status\")")
|
|
}
|
|
if iInject > iRefresh {
|
|
t.Error("injectPluginUI() is called after the first refresh; the sidebar " +
|
|
"and #main would be built before the plugin page exists")
|
|
}
|
|
// And it must be awaited, not fire-and-forget.
|
|
window := src[iInject:]
|
|
if !strings.Contains(window[:200], ".finally") && !strings.Contains(window[:200], "await") {
|
|
t.Error("injectPluginUI() is not awaited before refresh; a slow response " +
|
|
"would race the first paint")
|
|
}
|
|
}
|
|
|
|
// TestUIPluginScriptsRunAfterMarkup is the subtle one. Setting innerHTML with a
|
|
// <script> tag does NOT execute it; appending via a template neither does. The
|
|
// mount therefore has to be inserted first and its scripts re-created
|
|
// afterwards, or a plugin's script runs before its own DOM exists — which is
|
|
// exactly the "document.getElementById returns null" failure mode.
|
|
func TestUIPluginScriptsRunAfterMarkup(t *testing.T) {
|
|
src := uiSourceX(t)
|
|
// A <template> is used to parse the mount without executing scripts...
|
|
if !strings.Contains(src, "createElement(\"template\")") {
|
|
t.Error("the mount is not parsed via <template>; scripts could execute before their DOM")
|
|
}
|
|
// ...and scripts are then re-created as fresh elements so they DO run.
|
|
if !strings.Contains(src, "document.createElement(\"script\")") {
|
|
t.Error("plugin <script> blocks are never re-created, so they never execute")
|
|
}
|
|
if !strings.Contains(src, "replaceWith(s)") {
|
|
t.Error("the original inert <script> is not replaced by an executable one")
|
|
}
|
|
}
|
|
|
|
// TestUIPluginAPISurface: the documented browser API must exist with the exact
|
|
// names docs/plugins.md promises, since plugin authors code against it.
|
|
func TestUIPluginAPISurface(t *testing.T) {
|
|
src := uiSourceX(t)
|
|
for _, member := range []string{"fetchState", "postState", "onTabShown"} {
|
|
if !strings.Contains(src, member+":") && !strings.Contains(src, member+"(") {
|
|
t.Errorf("window.pluginAPI.%s is missing; docs/plugins.md documents it", member)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestUIPluginPageBecomesRealTab: a plugin page must get a pane in #main AND a
|
|
// sidebar button wired to goTab, otherwise the page is unreachable.
|
|
func TestUIPluginPageBecomesRealTab(t *testing.T) {
|
|
src := uiSourceX(t)
|
|
// pane in #main
|
|
if !strings.Contains(src, `pane.id = "tab-" + id`) {
|
|
t.Error("no pane is created for a plugin page")
|
|
}
|
|
if !strings.Contains(src, "main.appendChild(pane)") {
|
|
t.Error("the plugin pane is not appended to #main")
|
|
}
|
|
// sidebar button wired to the tab router
|
|
if !strings.Contains(src, "btn.dataset.tab = id") {
|
|
t.Error("the sidebar button is not given a data-tab, so goTab() will not route to it")
|
|
}
|
|
if !strings.Contains(src, "btn.onclick = () => goTab(id)") {
|
|
t.Error("the sidebar button is not wired to goTab()")
|
|
}
|
|
// and the router must know about it
|
|
if !strings.Contains(src, "PLUGIN_PAGES.has(tab)") {
|
|
t.Error("refresh() does not route plugin pages, so opening one renders nothing")
|
|
}
|
|
}
|
|
|
|
// TestUIPluginElementsHonorAnchor: elements declare top / bottom / before:sel /
|
|
// after:sel. Silently ignoring the anchor would put a "top" tile at the bottom
|
|
// of the status page, which looks like a layout bug rather than a plugin bug.
|
|
func TestUIPluginElementsHonorAnchor(t *testing.T) {
|
|
src := uiSourceX(t)
|
|
for _, anchor := range []string{`anchor === "top"`, `anchor.startsWith("before:")`, `"after:"`} {
|
|
if !strings.Contains(src, anchor) {
|
|
t.Errorf("the anchor form %s is not handled; elements would all land at the bottom", anchor)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestUIPluginInjectionFailureIsNonFatal: plugins are optional, so a failed
|
|
// /api/ui-inject must still leave a working UI (the dashboard has to render).
|
|
// Two places have to cooperate: the function swallows the fetch error, and the
|
|
// caller catches anything that still escapes so refresh() always runs.
|
|
func TestUIPluginInjectionFailureIsNonFatal(t *testing.T) {
|
|
src := uiSourceX(t)
|
|
// inside the function: the fetch is wrapped in try/catch
|
|
fnStart := strings.Index(src, "async function injectPluginUI()")
|
|
if fnStart < 0 {
|
|
t.Fatal("injectPluginUI() is not defined")
|
|
}
|
|
fn := src[fnStart:]
|
|
if !strings.Contains(fn, "plugins are optional; the UI must work without them") {
|
|
t.Error("injectPluginUI does not guard its own fetch failure")
|
|
}
|
|
// at the call site: the rejection cannot escape before the first render
|
|
// LastIndex, not Index: the DEFINITION of injectPluginUI also matches, and
|
|
// the definition has no .catch on it.
|
|
iCall := strings.LastIndex(src, "injectPluginUI()")
|
|
if iCall < 0 {
|
|
t.Fatal("injectPluginUI() is never called")
|
|
}
|
|
// Bound the window at len(src): the call site sits near EOF and a fixed
|
|
// slice overruns it (a panic in a test is worse than a skipped assertion).
|
|
end := iCall + 220
|
|
if end > len(src) {
|
|
end = len(src)
|
|
}
|
|
if !strings.Contains(src[iCall:end], ".catch") {
|
|
t.Error("a failed /api/ui-inject would reject before refresh(\"status\"), " +
|
|
"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")
|
|
}
|
|
}
|
|
|
|
// TestPluginElementsSurviveHostRebuild guards the defect that made plugin
|
|
// elements look absent no matter how the injection was configured.
|
|
//
|
|
// renderStatus (and six other pages) assign pane.innerHTML wholesale. Anything a
|
|
// plugin had mounted into that pane is destroyed by the assignment. The symptom
|
|
// is silent and misleading: the plugin really did declare an element, the
|
|
// payload really did arrive, and the element is still gone on the next repaint —
|
|
// so the natural conclusion is "my plugin declared nothing", which sends you
|
|
// looking in the wrong file.
|
|
//
|
|
// The fix is to re-mount after the rebuild. This test asserts the re-mount is
|
|
// wired at the SINGLE place every renderer passes through, rather than leaving
|
|
// it to be re-added per page.
|
|
func TestPluginElementsSurviveHostRebuild(t *testing.T) {
|
|
html := uiSource(t)
|
|
|
|
if !strings.Contains(html, "function remountPluginElements") {
|
|
t.Fatal("remountPluginElements is not defined; nothing can re-attach a plugin element after a host rebuild")
|
|
}
|
|
if !strings.Contains(html, "const PLUGIN_MOUNT_HOOKS = []") {
|
|
t.Fatal("PLUGIN_MOUNT_HOOKS is not declared")
|
|
}
|
|
// The hook array must be declared BEFORE injectPluginUI pushes to it, and
|
|
// before refresh() calls into it. A use-before-declaration in a const
|
|
// block is a hard TDZ ReferenceError at first paint.
|
|
hookDecl := strings.Index(html, "const PLUGIN_MOUNT_HOOKS = []")
|
|
push := strings.Index(html, "PLUGIN_MOUNT_HOOKS.push")
|
|
use := strings.Index(html, "PLUGIN_MOUNT_HOOKS.forEach")
|
|
if hookDecl < 0 || push < 0 || use < 0 {
|
|
t.Fatal("the hook array is declared but never both filled and drained")
|
|
}
|
|
if hookDecl > push || hookDecl > use {
|
|
t.Error("PLUGIN_MOUNT_HOOKS is used before its declaration (const TDZ: first paint would throw)")
|
|
}
|
|
|
|
// refresh() is the chokepoint every renderer passes through.
|
|
rf := strings.Index(html, "function refresh(tab)")
|
|
if rf < 0 {
|
|
t.Fatal("refresh(tab) is gone")
|
|
}
|
|
body := html[rf:]
|
|
if i := strings.Index(body, "\n }"); i > 0 {
|
|
body = body[:i]
|
|
}
|
|
if !strings.Contains(body, "remountPluginElements") {
|
|
t.Error("refresh() does not re-mount plugin elements: a page that rebuilds its DOM wipes them")
|
|
}
|
|
|
|
// A mount must be idempotent, or the widget's <script> runs again on every
|
|
// host repaint and its counters silently double.
|
|
if !strings.Contains(html, `data-idx="`) || !strings.Contains(html, "plugin-el[data-idx=") {
|
|
t.Error("element mounting is not guarded by a per-pane marker; re-mounting would re-run plugin scripts")
|
|
}
|
|
}
|
|
|
|
// TestPluginPagesAreReachableByGoTab guards a bug that only shows up in a
|
|
// browser: goTab() iterated the hardcoded TABS list to toggle `hidden`, and a
|
|
// plugin-contributed page is not in that list. The pane existed, the plugin had
|
|
// filled it with real data, and clicking the sidebar entry changed nothing —
|
|
// the `hidden` class was never removed.
|
|
//
|
|
// It reads exactly like "the page is blank" while the content sits in the DOM,
|
|
// and no static check catches it: the injection is correct, the data is correct,
|
|
// and the API returns 200.
|
|
//
|
|
// So this asserts the two structural facts that make it reachable: goTab
|
|
// consults the plugin page set, and that set is declared before goTab runs (a
|
|
// `const` further down the file would be a temporal-dead-zone ReferenceError on
|
|
// the first click).
|
|
func TestPluginPagesAreReachableByGoTab(t *testing.T) {
|
|
ui := uiSource(t)
|
|
|
|
goTab := strings.Index(ui, "function goTab(")
|
|
if goTab < 0 {
|
|
t.Fatal("goTab() is gone")
|
|
}
|
|
end := strings.Index(ui[goTab:], "\n }")
|
|
if end < 0 {
|
|
t.Fatal("could not isolate goTab()")
|
|
}
|
|
body := ui[goTab : goTab+end]
|
|
|
|
if !strings.Contains(body, "PLUGIN_PAGES.forEach") {
|
|
t.Error("★ goTab() does not toggle plugin pages — a plugin page keeps its " +
|
|
"`hidden` class forever, so the sidebar entry does nothing")
|
|
}
|
|
|
|
// Declaration must precede the use, or the first click throws.
|
|
decl := strings.Index(ui, "var PLUGIN_PAGES = new Set()")
|
|
if decl < 0 {
|
|
// A const later in the file would also "work" only if nothing reads it
|
|
// first — make that explicit rather than silent.
|
|
if strings.Contains(ui, "const PLUGIN_PAGES = new Set()") {
|
|
t.Error("PLUGIN_PAGES is a `const` declared after goTab() reads it — " +
|
|
"temporal dead zone: the first click on a plugin page throws")
|
|
} else {
|
|
t.Error("PLUGIN_PAGES is not declared anywhere")
|
|
}
|
|
return
|
|
}
|
|
if decl > goTab {
|
|
t.Errorf("PLUGIN_PAGES is declared at %d but goTab() at %d reads it — "+
|
|
"declaration must come first", decl, goTab)
|
|
}
|
|
}
|