Files
ModelRouter/internal/lua/billing_ui_test.go
JianFeeeee ba6de89d57 fix(billing): 未定价按天统计,让「已修好」和「还在漏」不再长得一样
计费页只有一个终身计数器 unpriced_reqs,实测 6378 —— 占 18981 请求的 34%。
但这个数字回答不了运维唯一会问的问题:「还在漏吗?」

原因是它把两件事混在一起:加规则之前的历史欠账,和加规则之后仍在发生的漏计。
修好一个源之后终身计数纹丝不动,于是仪表盘上「未定价 6378」和一个仍在
什么都没记的系统完全无法区分。

改动:
- 钩子按 UTC 天写入 unpriced_by_day{day:{reqs, models}}。终身计数保留
  (报表不该改写历史),天桶是新增的观察维度。
- 页面加「未定价(近 7 天)」一行。为 0 时额外显示「近 7 天无」——这时候
  零本身就是结论:加的规则确实生效了。
- 没有天桶数据时返回 null 而不是 0。旧的 state 文件说「未知」,
  不该假装有一份干净记录。

生产实测:全部视图 终身 6378 / 近 7 天 2;日视图隐藏终身计数(null)但保留
近 7 天 —— 周期视图原本把两者都置 null,因为那时根本没有天桶。

失败的请求同样计入(上游 502 的 gpt-5.5 也进了天桶):一次失败的请求照样
消耗上游配额,只是没拿到 token,单看 token 维度会漏掉。

判据:两个不同日期的请求必须落到两个天桶且终身计数不重复累加;变异成
「总记今天」即变红(只判首日会漏掉这一点)。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-04 12:34:51 +08:00

441 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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")
}
}
// TestBillingUIShowsRecentUnpriced: the page must be able to distinguish a
// settled pricing debt from a live leak. The lifetime unpriced counter answers
// neither — it keeps every request from before a rule was added, so it reads the
// same number in both situations. The day buckets exist to make the difference
// visible, and a bucket nothing renders is a bucket nobody benefits from.
func TestBillingUIShowsRecentUnpriced(t *testing.T) {
raw, err := os.ReadFile("plugins/billing.lua")
if err != nil {
t.Fatal(err)
}
src := string(raw)
for _, needle := range []string{
"unpriced_by_day", // the tally is written
"recentUnpriced", // it is reduced to a window
"unpriced_recent", // it reaches the view
"unpriced7d", // the card labels it
"unpricedClean", // and says so when the window is clear
} {
if !strings.Contains(src, needle) {
t.Errorf("billing UI lacks %q", needle)
}
}
}