mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-05 07:02:29 +00:00
全部靠截图 + 几何测量发现,DOM 断言当时全绿: - table-layout:fixed 把 954px 均分成 9×106px,写在 <td> 上的每列宽度全部失效 → 相邻表头叠在一起,源 URL 输入框被压成 "https:"。 - 把宽度改到 <th> 并去掉 fixed → 浏览器改按内容算,<th> 宽度又被忽略,币种列 仍塌到 48px(输入框只剩 24px,装不下 "USD")。 - 恢复 fixed + 宽度移到 <colgroup>:fixed 下只有首行宽度生效,而「按模型价格」 格宽度随哪条规则最宽而变(实测 352px vs 206px),整行布局取决于恰好哪条规则 最宽。colgroup 一次性锁死九列,所有行一致。 - 表头双语长文案(峰段星期 UTC 1=周一)超出列宽 → nowrap + ellipsis,完整文案 留在 title。 - 删除列 58px 装不下「删除」按钮(折成两行、按钮高过整行)→ 74px,从峰段倍数 列匀出。 - 价目行 80+96+96+38≈330px 挤在 262px 格子里,× 按钮越界 78px 压在隔壁「删除」 按钮上 → 输入框改 flex:1 1 …/min-width:0 可收缩,× 保持 flex:0 0 auto。 - 顺带修上轮引入的回归:给 modelBlock 的 flex 换行后模型名不再被截断。 判据 TestRuleTableLocksColumnWidthsInAColgroup + 3 个变异(去掉 min-width:0 / 改列宽数组 / 去掉 table-layout:fixed)全部被捕获。第三个变异第一版漏放——判据用 strings.Contains(js, "table-layout:fixed") 匹配到了上方三行的解释性注释,改成匹配 style='…' 字面量后才真正失败。
417 lines
18 KiB
Go
417 lines
18 KiB
Go
package lua
|
||
|
||
import (
|
||
"encoding/json"
|
||
"os"
|
||
"os/exec"
|
||
"regexp"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// The billing page rendered EMPTY in production while its data endpoint returned
|
||
// 200 with real numbers. The cause was one line: render(st) referenced an
|
||
// undefined `s` for two KPI cells, so the ReferenceError aborted the whole
|
||
// render and every table stayed at its initial empty innerHTML.
|
||
//
|
||
// Nothing in the build, the tests or the API surfaced it. This file is the guard
|
||
// for the whole class: a plugin's injected UI that references an undefined name,
|
||
// or that depends on a container the page does not provide, fails silently.
|
||
|
||
// billingUI returns the injected markup for the billing plugin: the full page
|
||
// mount and the status-page element mount.
|
||
func billingUI(t *testing.T) (page string, statusElement string) {
|
||
t.Helper()
|
||
src, err := os.ReadFile("plugins/billing.lua")
|
||
if err != nil {
|
||
t.Fatalf("read billing.lua: %v", err)
|
||
}
|
||
// Located BY CONTENT, not by index. The plugin also uses a long string for
|
||
// its inline SVG icon, so "the first long string" is the icon and "the
|
||
// second" is the page — which is exactly the kind of positional assumption
|
||
// that breaks the next time an icon or a description is added.
|
||
page = longStringContaining(t, string(src), "billing-root")
|
||
statusElement = longStringContaining(t, string(src), "billing-status-tile")
|
||
return page, statusElement
|
||
}
|
||
|
||
// longStringContaining returns the [==[ ... ]==] body that contains marker.
|
||
func longStringContaining(t *testing.T, src, marker string) string {
|
||
t.Helper()
|
||
re := regexp.MustCompile(`(?s)\[==\[(.*?)\]==\]`)
|
||
for _, m := range re.FindAllStringSubmatch(src, -1) {
|
||
if strings.Contains(m[1], marker) {
|
||
return m[1]
|
||
}
|
||
}
|
||
t.Fatalf("no long string contains %q", marker)
|
||
return ""
|
||
}
|
||
|
||
// TestBillingMountScriptExecutes is the guard for the production bug.
|
||
//
|
||
// The Billing page rendered empty while its data endpoint returned 200 with real
|
||
// numbers. Cause: render(st) referenced an undefined `s` for two KPI cells, the
|
||
// ReferenceError aborted the render, and every table kept its initial empty
|
||
// innerHTML. Nothing in the build or the API surfaced it.
|
||
//
|
||
// Two earlier attempts at a static check were both wrong: a "declared names"
|
||
// scan flagged every CSS class inside the inline HTML strings (class, div, td),
|
||
// and a CSS-selector parse of the stylesheet reported the stylesheet itself as
|
||
// broken. Static analysis of JS embedded in HTML strings is the wrong tool.
|
||
//
|
||
// So this actually RUNS the script, in node, against a minimal DOM stub, and
|
||
// fails on any thrown error. Skipped when node is unavailable, with the reason
|
||
// printed — never silently passing as if it had checked.
|
||
func TestBillingMountScriptExecutes(t *testing.T) {
|
||
page, el := billingUI(t)
|
||
scripts := extractScripts(page)
|
||
if len(scripts) == 0 {
|
||
t.Fatal("no <script> found in the billing page mount")
|
||
}
|
||
for i, js := range scripts {
|
||
assertRendersAndDoesNotThrow(t, i, js)
|
||
}
|
||
for i, js := range extractScripts(el) {
|
||
assertRendersAndDoesNotThrow(t, i, js)
|
||
}
|
||
}
|
||
|
||
// assertRendersAndDoesNotThrow executes a mount script under node against a DOM
|
||
// stub and fails on EITHER a thrown/reported error OR an empty render.
|
||
//
|
||
// Checking only for exceptions is not enough, and that is the third wrong
|
||
// attempt at this guard. The plugin's render() opens with `if (!st) return;`,
|
||
// so passing the wrong field (`render(j.stateX)`) is a SILENT no-op: no throw,
|
||
// no console.error, no rejection — just an empty page. Only looking at the
|
||
// produced DOM catches that class.
|
||
func assertRendersAndDoesNotThrow(t *testing.T, idx int, js string) {
|
||
t.Helper()
|
||
node, err := exec.LookPath("node")
|
||
if err != nil {
|
||
t.Skipf("node not available (%v): cannot execute the injected script", err)
|
||
}
|
||
|
||
stub := `
|
||
global.window = global;
|
||
global.document = {
|
||
getElementById: function (id) {
|
||
if (!global.__els) global.__els = {};
|
||
if (!global.__els[id]) global.__els[id] = {
|
||
style: {}, dataset: {}, classList: { add: function(){}, remove: function(){} },
|
||
// Both writes count: the Billing page fills innerHTML, the status-page
|
||
// tile assigns textContent. Watching only one of them flagged the tile as
|
||
// "renders nothing" — a false positive that would have taught everyone to
|
||
// ignore this test.
|
||
set innerHTML(v) { if (v && String(v).trim()) global.__written.push(id); this.__h = v; },
|
||
get innerHTML() { return this.__h || ""; },
|
||
set textContent(v) { if (v !== undefined && String(v).trim()) global.__written.push(id); this.__t = v; },
|
||
get textContent() { return this.__t || ""; },
|
||
set innerText(v) { if (v !== undefined && String(v).trim()) global.__written.push(id); this.__i = v; },
|
||
get innerText() { return this.__i || ""; },
|
||
appendChild: function(){}, querySelector: function(){ return null; },
|
||
querySelectorAll: function(){ return []; }, addEventListener: function(){} };
|
||
return global.__els[id];
|
||
},
|
||
createElement: function () { return { style: {}, dataset: {}, appendChild: function(){}, setAttribute: function(){} }; },
|
||
addEventListener: function () {},
|
||
};
|
||
global.pluginAPI = { onTabShown: function () {} };
|
||
global.fetch = function () {
|
||
// A payload with real numbers, so a working render produces visible output.
|
||
return Promise.resolve({ ok: true, json: function () {
|
||
return Promise.resolve({ plugin: "billing", state: {
|
||
currency: "USD",
|
||
total: { cost: 1.25, requests: 7, prompt_tokens: 100, completion_tokens: 20, failures: 0 },
|
||
by_source: { localzen: { cost: 1.25, requests: 7, prompt_tokens: 100, completion_tokens: 20, failures: 0 } },
|
||
by_model: { m1: { cost: 1.25, requests: 7, prompt_tokens: 100, completion_tokens: 20, failures: 0 } },
|
||
by_key: { k1: { cost: 1.25, requests: 7, prompt_tokens: 100, completion_tokens: 20, failures: 0 } },
|
||
by_day: { "2026-01-01": { cost: 1.25, requests: 7, prompt_tokens: 100, completion_tokens: 20, failures: 0 } },
|
||
degraded_reqs: 1, unpriced_reqs: 2,
|
||
}});
|
||
}});
|
||
};
|
||
global.__written = [];
|
||
global.__errors = [];
|
||
// The plugin's refresh() swallows its own errors so decoration can never break
|
||
// the host page — which is right, and is exactly why the production ReferenceError
|
||
// was invisible. So the trace has to come from console.error, which the plugin now
|
||
// emits. Hooking process events alone made this test pass against the very bug it
|
||
// was written for (verified by re-introducing the typo and watching it stay green).
|
||
var __realErr = console.error;
|
||
console.error = function () {
|
||
global.__errors.push("console.error: " + Array.prototype.map.call(arguments, function (a) {
|
||
return (a && a.message) ? a.message : String(a);
|
||
}).join(" "));
|
||
__realErr.apply(console, arguments);
|
||
};
|
||
process.on("uncaughtException", function (e) { global.__errors.push("uncaught: " + String(e && e.message || e)); });
|
||
process.on("unhandledRejection", function (e) { global.__errors.push("unhandled: " + String(e && e.message || e)); });
|
||
`
|
||
// 50ms is not a guess: the plugin's IIFE kicks off refresh() which awaits a
|
||
// fetch; a rejection lands on the microtask queue almost immediately.
|
||
script := strings.Join([]string{
|
||
stub, js,
|
||
`setTimeout(function(){
|
||
console.log("__ERRS__" + JSON.stringify({errors: global.__errors, written: global.__written}));
|
||
}, 300);`,
|
||
}, "\n")
|
||
cmd := exec.Command(node, "-e", script)
|
||
out, err := cmd.CombinedOutput()
|
||
if err != nil {
|
||
t.Errorf("mount script %d crashed under node: %v\n%s", idx, err, out)
|
||
return
|
||
}
|
||
// The script may print other things; find the JSON we appended.
|
||
line := ""
|
||
for _, l := range strings.Split(string(out), "\n") {
|
||
if i := strings.Index(l, "__ERRS__"); i >= 0 {
|
||
line = strings.TrimSpace(l[i+len("__ERRS__"):])
|
||
}
|
||
}
|
||
if line == "" {
|
||
t.Errorf("mount script %d produced no error report — the harness did not "+
|
||
"run to completion, so it cannot be trusted to have checked anything", idx)
|
||
return
|
||
}
|
||
var report struct {
|
||
Errors []string `json:"errors"`
|
||
Written []string `json:"written"`
|
||
}
|
||
if err := json.Unmarshal([]byte(line), &report); err != nil {
|
||
t.Errorf("could not parse the error report %q: %v", line, err)
|
||
return
|
||
}
|
||
for _, e := range report.Errors {
|
||
t.Errorf("mount script %d reported %q — this is the failure that leaves "+
|
||
"the Billing page blank while the API still returns data", idx, e)
|
||
}
|
||
if len(report.Written) == 0 {
|
||
t.Errorf("mount script %d wrote NOTHING into any container — the page "+
|
||
"renders empty. render() guards with `if (!st) return`, so a wrong "+
|
||
"field name is a silent no-op: no throw, no console output, no error "+
|
||
"anywhere. This is what the production page looked like.", idx)
|
||
}
|
||
}
|
||
|
||
func extractScripts(html string) []string {
|
||
re := regexp.MustCompile(`(?s)<script[^>]*>(.*?)</script>`)
|
||
var out []string
|
||
for _, m := range re.FindAllStringSubmatch(html, -1) {
|
||
out = append(out, m[1])
|
||
}
|
||
return out
|
||
}
|
||
|
||
// TestBillingPageCoversItsData is the other half: the page declares tables for
|
||
// per-source / per-model / per-key / per-day and must actually render into them.
|
||
// A table id that is never written to is exactly how "the page loads and shows
|
||
// nothing" happens without an error.
|
||
func TestBillingPageCoversItsData(t *testing.T) {
|
||
page, _ := billingUI(t)
|
||
for _, id := range []string{
|
||
"billing-kpis", "billing-by-source", "billing-by-model",
|
||
"billing-by-key", "billing-by-day",
|
||
} {
|
||
if !strings.Contains(page, `id="`+id+`"`) {
|
||
t.Errorf("the page has no container #%s", id)
|
||
}
|
||
}
|
||
// Every container must be written to by the script, not just declared.
|
||
scripts := extractScripts(page)
|
||
all := strings.Join(scripts, "\n")
|
||
for _, id := range []string{"billing-kpis", "billing-by-source", "billing-by-model", "billing-by-key", "billing-by-day"} {
|
||
if !strings.Contains(all, `getElementById("`+id+`")`) {
|
||
t.Errorf("#%s is declared but never read by the script — it stays empty forever", id)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestPluginIconIsNotARawEmoji guards the sidebar icon. The billing plugin
|
||
// declared icon = "💰" and the WebUI drops that verbatim into the button, while
|
||
// every native tab uses an inline SVG styled with `stroke: currentColor`. An
|
||
// emoji there renders at the wrong size and ignores the theme, so it does not
|
||
// match its neighbours — which is what the operator reported.
|
||
func TestPluginIconIsNotARawEmoji(t *testing.T) {
|
||
icon := billingIcon(mustBillingSource(t))
|
||
if icon == "" {
|
||
t.Fatal("billing declares no icon; the sidebar entry would be blank")
|
||
}
|
||
if isEmojiIcon(icon) {
|
||
t.Errorf("page icon is the raw emoji %q — the WebUI sidebar renders "+
|
||
"native tabs as inline SVG (stroke: currentColor), so an emoji is the "+
|
||
"wrong size and ignores the theme. Use an inline SVG path instead.", icon)
|
||
}
|
||
}
|
||
|
||
// isEmojiIcon reports whether s is a pictographic emoji rather than markup or a
|
||
// text glyph. Codepoints in the pictographic blocks, plus the regional-indicator
|
||
// pair used by flags.
|
||
func isEmojiIcon(s string) bool {
|
||
r := []rune(s)
|
||
if len(r) == 0 {
|
||
return false
|
||
}
|
||
// Anything containing '<' is markup (an inline <svg>), which is the fix.
|
||
if strings.ContainsRune(s, '<') {
|
||
return false
|
||
}
|
||
for _, c := range r {
|
||
switch {
|
||
case c >= 0x1F300 && c <= 0x1FAFF, // pictographs, symbols, supplemental
|
||
c >= 0x1F000 && c <= 0x1F2FF, // mahjong/domino/cards
|
||
c >= 0x2600 && c <= 0x27BF, // misc symbols + dingbats
|
||
c >= 0x2B00 && c <= 0x2BFF, // arrows/misc symbols
|
||
c == 0xFE0F, // variation selector-16
|
||
c >= 0x1F1E6 && c <= 0x1F1FF: // regional indicators (flags)
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func mustBillingSource(t *testing.T) string {
|
||
t.Helper()
|
||
b, err := os.ReadFile("plugins/billing.lua")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return string(b)
|
||
}
|
||
|
||
// billingIcon extracts the declared page icon. It accepts BOTH a quoted string
|
||
// and a [==[ ... ]==] long string, because an inline SVG cannot be written as a
|
||
// Lua short string without escaping every quote in it.
|
||
func billingIcon(src string) string {
|
||
if m := regexp.MustCompile(`(?m)^\s*icon\s*=\s*"([^"]*)"`).FindStringSubmatch(src); m != nil {
|
||
return m[1]
|
||
}
|
||
if m := regexp.MustCompile(`(?s)\bicon\s*=\s*\[==\[(.*?)\]==\]`).FindStringSubmatch(src); m != nil {
|
||
return m[1]
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// TestBillingTableHeaderMatchesRowColumns catches column drift.
|
||
//
|
||
// row() gained cache columns (fresh / cache / cache%) while the header row did
|
||
// not, in the same edit. The result is a table whose cells are shifted one
|
||
// column left from "fresh" onward — so "cache%" sits under "completion" and the
|
||
// last cell has no label. It renders, it has data, and it is wrong in a way that
|
||
// takes a careful read to notice.
|
||
func TestBillingTableHeaderMatchesRowColumns(t *testing.T) {
|
||
page, _ := billingUI(t)
|
||
js := strings.Join(extractScripts(page), "\n")
|
||
if !strings.Contains(js, "<th>") {
|
||
t.Fatal("no table header found in the billing page script")
|
||
}
|
||
hStart := strings.Index(js, "function tableFor")
|
||
if hStart < 0 {
|
||
t.Fatal("no tableFor in the billing page script")
|
||
}
|
||
// Count the usage table's headers ONLY. The count is taken from tableFor to
|
||
// the end of the script, which used to be fine when the rule editor lived on
|
||
// its own page; now that both halves share one script, the rule table's nine
|
||
// headers were counted too and the check reported 17 vs 8 — a failure about
|
||
// two unrelated tables. Stop at the rule editor's script.
|
||
header := js[hStart:]
|
||
if cut := strings.Index(header, "/api/plugins/billing/rules"); cut > 0 {
|
||
header = header[:cut]
|
||
}
|
||
// Count <th> with OR without attributes: the name column now carries
|
||
// style='width:22%' so the header and its data cell can be given the same
|
||
// width, and a bare "<th>" count silently reported 7 vs 8 — the check
|
||
// failing on the very column it had just been taught to size.
|
||
th := strings.Count(header, "<th>") + strings.Count(header, "<th ")
|
||
|
||
rStart := strings.Index(js, "function row")
|
||
rEnd := strings.Index(js, "function tableFor")
|
||
if rStart < 0 || rEnd < 0 || rEnd <= rStart {
|
||
t.Fatal("could not isolate row()")
|
||
}
|
||
row := js[rStart:rEnd]
|
||
// Each cell closes with </td>; the first cell uses <td><b>..</b></td> so
|
||
// counting </td> is exact.
|
||
td := strings.Count(row, "</td>")
|
||
|
||
if th != td {
|
||
t.Errorf("★ header declares %d columns but row() emits %d cells — the "+
|
||
"table is misaligned from the first differing column on", th, td)
|
||
}
|
||
}
|
||
|
||
// TestRuleTableLocksColumnWidthsInAColgroup guards the layout class of bug that
|
||
// only a screenshot caught: two adjacent headers rendered on top of each other,
|
||
// the URL input squeezed to "https:", and the per-model × button landing 78px
|
||
// past its cell onto the neighbouring Delete button.
|
||
//
|
||
// The measurement that found each one:
|
||
// - table-layout:fixed with widths on <td> → 954px divided into 9 equal
|
||
// 106px columns, every declared width discarded.
|
||
// - widths on <th> WITHOUT fixed → browser sizes from content and ignores
|
||
// them; the 70px currency column still collapsed to 48px (24px of input).
|
||
// - fixed + <th> widths → they take effect, but the model-price cell's width
|
||
// then depended on WHICH RULE was widest (352px vs 206px).
|
||
//
|
||
// The fix is all three together: fixed layout, a colgroup, and flex children
|
||
// that may shrink.
|
||
func TestRuleTableLocksColumnWidthsInAColgroup(t *testing.T) {
|
||
page, _ := billingUI(t)
|
||
js := strings.Join(extractScripts(page), "\n")
|
||
// Match the ATTRIBUTE, not the bare string: a substring check on
|
||
// "table-layout:fixed" is satisfied by the explanatory comment that sits
|
||
// three lines above the tag, so removing the attribute from the table still
|
||
// passed (mutation-verified). Require it inside a style='...' literal.
|
||
if !strings.Contains(js, `font-size:12px;table-layout:fixed'`) {
|
||
t.Error("the rule table must stay table-layout:fixed — with auto layout " +
|
||
"the <colgroup> widths below are ignored and columns size from content")
|
||
}
|
||
colStart := strings.Index(js, "<colgroup>")
|
||
if colStart < 0 {
|
||
t.Fatal("the rule table declares no <colgroup>; per-column widths on " +
|
||
"<th> or <td> are advisory and get recomputed from content")
|
||
}
|
||
// The colgroup is built from a JS array of pixel widths, so assert on the
|
||
// array, not on literal "<col " occurrences — counting the literal found
|
||
// exactly one and reported "1 columns, want 9" against correct code.
|
||
if !strings.Contains(js, "[214, 80, 62, 70, 66, 74, 62, 262, 74]") {
|
||
t.Error("the rule table's colgroup widths changed or vanished; measured " +
|
||
"values were [214 80 62 70 66 74 62 262 74] for a 954px pane — the " +
|
||
"delete column needs 74px or the button wraps to two lines")
|
||
}
|
||
if !strings.Contains(js, `"<col style='width:" + w + "px'>"`) {
|
||
t.Error("colgroup entries are no longer emitted as <col style='width:Npx'>")
|
||
}
|
||
// Every price-row INPUT must be allowed to shrink, or the row's fixed widths
|
||
// sum past the cell and the × button lands on the next column. The × button
|
||
// is the opposite: it must NOT shrink (flex:0 0 auto), because a squashed
|
||
// delete button is unclickable.
|
||
for _, needle := range []string{"m-name", "m-p", "m-c"} {
|
||
i := strings.Index(js, "class='"+needle+"'")
|
||
if i < 0 {
|
||
i = strings.Index(js, "class='ghost small "+needle+"'")
|
||
}
|
||
if i < 0 {
|
||
t.Errorf("price row lost the %s input/button", needle)
|
||
continue
|
||
}
|
||
window := js[i : i+220]
|
||
if !strings.Contains(window, "flex:") {
|
||
t.Errorf("%s has no flex sizing; the row is 80+96+96+38 ≈ 330px wide "+
|
||
"against a 262px cell and overflows onto the next column", needle)
|
||
}
|
||
if !strings.Contains(window, "min-width:0") {
|
||
t.Errorf("%s lacks min-width:0; a flex item will not shrink below its "+
|
||
"content width, which is how the × button ended up 78px past its cell", needle)
|
||
}
|
||
}
|
||
i := strings.Index(js, "class='ghost small m-del'")
|
||
if i < 0 {
|
||
t.Fatal("the per-model delete button is gone")
|
||
}
|
||
if !strings.Contains(js[i:i+120], "flex:0 0 auto") {
|
||
t.Error("the per-model × button must be flex:0 0 auto — it is a fixed-size " +
|
||
"control and must never be squeezed by the flexible inputs")
|
||
}
|
||
}
|