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 `) 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 ), 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, "") { 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 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 "" count silently reported 7 vs 8 — the check // failing on the very column it had just been taught to size. th := strings.Count(header, "") + strings.Count(header, "; the first cell uses .. so // counting is exact. td := strings.Count(row, "") 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 → 954px divided into 9 equal // 106px columns, every declared width discarded. // - widths on WITHOUT fixed → browser sizes from content and ignores // them; the 70px currency column still collapsed to 48px (24px of input). // - fixed + 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 widths below are ignored and columns size from content") } colStart := strings.Index(js, "") if colStart < 0 { t.Fatal("the rule table declares no ; per-column widths on " + " or 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 ""`) { t.Error("colgroup entries are no longer emitted as ") } // 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") } }