Files
ModelRouter/internal/gateway/ui_contract_test.go
JianFeeeee c309448414 feat(webui): Mono theme — pure white in light mode, pure black in dark mode
Adds a fourth accent alongside sakura/ocean/violet. Unlike those it is not just a
different hue: the coloured themes are glass surfaces (translucent cards with
backdrop-filter) floating over an animated gradient-mesh background, so setting
--card:#ffffff there still renders as a tinted grey. Mono therefore also switches
off the translucency and hides the blobs, so #ffffff is actually #ffffff and
#000000 is actually #000000, with greys carrying the hierarchy that hue carries
elsewhere. A side effect worth having: no backdrop-filter and no animated blobs
makes it the cheapest theme to render, which helps on weak GPUs and over remote
desktops.

Both light and dark variable blocks are defined, so the existing light/dark
toggle drives it with no extra wiring: light -> white, dark -> black.

Also fixes a latent theme bug found while checking contrast on black: the active
chart's grid baseline assigned the literal string "var(--line)" to
ctx.strokeStyle. Canvas 2D does not resolve CSS custom properties, so that was an
invalid colour the browser ignored, leaving the previous fillStyle (black) — an
invisible baseline on every dark theme. Colours used on a canvas now go through a
cssVar() helper.

Tests: TestUIThemeMatrix asserts every accent defines BOTH a light and a dark
block plus a picker button (a half-defined theme shows up as unreadable text, not
as an error); TestUIMonoThemeIsFlat pins the opaque surfaces and disabled blobs;
TestUICanvasColorsResolveVars fails if any ctx.strokeStyle/fillStyle is handed a
raw var().

Unrelated packaging fix in the same commit: dist:linux only built deb+AppImage
while build.linux.target listed rpm too, so `make gui-dist` silently skipped the
rpm that release builds are expected to produce. Makefile/README wording updated
to match.
2026-08-30 10:04:53 +08:00

350 lines
11 KiB
Go

package gateway
import (
"fmt"
"regexp"
"strings"
"testing"
)
// The WebUI talks to this package through a tiny `api(path, options)` helper
// that wraps fetch(). fetch() defaults to GET when no method is given, so an
// options object without `method` silently turns a DELETE/PUT into a GET —
// the request hits the wrong branch of the handler and the user sees a
// confusing error like "use GET /api/keys" while nothing is deleted.
//
// This has now bitten twice: ab20f1b fixed delSource/delTemplate, and the very
// same pattern was still live in delKey (deleting a gateway key was impossible)
// and delAdapter. Reviewing 4000+ lines of inline JS by eye clearly does not
// catch it, so it is pinned here instead.
// apiCallRe finds the start of every api(...) call in the UI source.
var apiCallRe = regexp.MustCompile(`\bapi\(`)
// uiSource returns the embedded WebUI document.
func uiSource(t *testing.T) string {
t.Helper()
b, err := uiFS.ReadFile("ui/index.html")
if err != nil {
t.Fatalf("read embedded ui: %v", err)
}
return string(b)
}
// extractCall returns the source of the balanced api(...) call starting at the
// opening parenthesis index, plus the line number it starts on.
func extractCall(src string, openIdx int) (string, bool) {
depth := 0
for i := openIdx; i < len(src); i++ {
switch src[i] {
case '(':
depth++
case ')':
depth--
if depth == 0 {
return src[openIdx : i+1], true
}
case '\n':
// A call spanning more than ~25 lines is not one of ours; bail out
// rather than scanning the rest of the document.
if i-openIdx > 4000 {
return "", false
}
}
}
return "", false
}
func lineOf(src string, idx int) int {
return strings.Count(src[:idx], "\n") + 1
}
// TestUIAPICallsDeclareMethod asserts that every api() call passing an options
// object also declares an HTTP method (or is a GET that only passes an
// AbortSignal). Without this, fetch defaults to GET and mutating endpoints are
// never reached.
func TestUIAPICallsDeclareMethod(t *testing.T) {
src := uiSource(t)
var offenders []string
for _, m := range apiCallRe.FindAllStringIndex(src, -1) {
open := m[1] - 1 // index of '('
call, ok := extractCall(src, open)
if !ok {
continue
}
// No options object at all => a plain GET, which is fine.
if !strings.Contains(call, "{") {
continue
}
if strings.Contains(call, "method:") {
continue
}
// A read that only carries an AbortSignal is a deliberate GET.
if strings.Contains(call, "signal:") && !strings.Contains(call, "body:") {
continue
}
offenders = append(offenders, fmt.Sprintf("line %d: %s", lineOf(src, open), oneLine(call)))
}
if len(offenders) > 0 {
t.Fatalf("api() called with an options object but no method (fetch will send GET):\n %s",
strings.Join(offenders, "\n "))
}
}
// TestUIDeleteHelpersUseDelete pins the specific helpers that remove things.
// Each must issue a DELETE; a GET here is the exact bug users reported as
// "cannot delete key: use GET /api/keys".
func TestUIDeleteHelpersUseDelete(t *testing.T) {
src := uiSource(t)
for _, fn := range []string{"delKey", "delAdapter", "delSource", "delTemplate"} {
body, ok := jsFunctionBody(src, fn)
if !ok {
t.Errorf("helper %s() not found in the WebUI", fn)
continue
}
if !strings.Contains(body, "api(") {
t.Errorf("%s() does not call api()", fn)
continue
}
if !strings.Contains(body, `method: "DELETE"`) {
t.Errorf("%s() must issue a DELETE, got:\n%s", fn, body)
}
}
}
// TestUIMutatingHelpersUseWriteMethods covers the write (non-delete) helpers:
// each must declare an explicit POST/PUT/PATCH.
func TestUIMutatingHelpersUseWriteMethods(t *testing.T) {
src := uiSource(t)
want := map[string][]string{
"createKey": {`method: "POST"`},
"putScope": {`method: "PUT"`},
"uploadAdapter": {`method: "POST"`},
"saveAuto": {`method: "PUT"`, `method: "POST"`},
}
for fn, methods := range want {
body, ok := jsFunctionBody(src, fn)
if !ok {
continue // helper renamed or absent: the method test above still guards it
}
found := false
for _, m := range methods {
if strings.Contains(body, m) {
found = true
break
}
}
if !found {
t.Errorf("%s() must declare one of %v", fn, methods)
}
}
}
// jsFunctionBody returns the source of `function name(` / `async function name(`
// up to its closing brace, using brace balancing.
func jsFunctionBody(src, name string) (string, bool) {
for _, prefix := range []string{"async function " + name + "(", "function " + name + "("} {
idx := strings.Index(src, prefix)
if idx < 0 {
continue
}
open := strings.Index(src[idx:], "{")
if open < 0 {
continue
}
open += idx
depth := 0
for i := open; i < len(src); i++ {
switch src[i] {
case '{':
depth++
case '}':
depth--
if depth == 0 {
return src[idx : i+1], true
}
}
}
}
return "", false
}
func oneLine(s string) string {
s = strings.Join(strings.Fields(s), " ")
if len(s) > 140 {
return s[:140] + "…"
}
return s
}
// TestUIRecordsPagingWiring guards the record table's paging plumbing. Two
// separate bugs made "scroll to the bottom" silently stop loading:
//
// 1. paintRecords rebuilt the whole table on every 5s poll whenever the row
// count did not exceed the first screen, wiping loaded pages and resetting
// scroll position.
// 2. IntersectionObserver only fires on TRANSITIONS. With a short list — or a
// page whose rows all duplicated the first screen — the sentinel stayed
// visible and never fired again.
//
// The fixes are a build-once flag, a scroll-position fallback, an initial
// viewport fill, and chaining when a page yields no new rows. None of that is
// reachable from Go, so the wiring is asserted structurally.
func TestUIRecordsPagingWiring(t *testing.T) {
src := uiSource(t)
paint, ok := jsFunctionBody(src, "paintRecords")
if !ok {
t.Fatal("paintRecords() not found")
}
if !strings.Contains(paint, "recsState.built") {
t.Error("paintRecords must build the table once (recsState.built) instead of rebuilding on every poll")
}
if !strings.Contains(paint, "afterbegin") {
t.Error("paintRecords must PREPEND newer rows on later polls, not rebuild the table")
}
if !strings.Contains(paint, "fillRecordsViewport") {
t.Error("paintRecords must fill the viewport so the sentinel can transition")
}
attach, ok := jsFunctionBody(src, "attachRecsObserver")
if !ok {
t.Fatal("attachRecsObserver() not found")
}
if !strings.Contains(attach, "IntersectionObserver") {
t.Error("attachRecsObserver must still use IntersectionObserver")
}
if !strings.Contains(attach, `addEventListener("scroll"`) {
t.Error("attachRecsObserver needs a scroll fallback: an already-visible sentinel never fires again")
}
load, ok := jsFunctionBody(src, "loadMoreRecords")
if !ok {
t.Fatal("loadMoreRecords() not found")
}
if !strings.Contains(load, "maxChain") {
t.Error("loadMoreRecords must chain when a page adds no new rows (the first page overlaps the first screen)")
}
if !strings.Contains(load, "signal: ctrl.signal") {
t.Error("loadMoreRecords must remain abortable")
}
fill, ok := jsFunctionBody(src, "fillRecordsViewport")
if !ok {
t.Fatal("fillRecordsViewport() not found")
}
for _, want := range []string{"scrollHeight", "clientHeight"} {
if !strings.Contains(fill, want) {
t.Errorf("fillRecordsViewport must compare %s to decide whether the list overflows", want)
}
}
rel, ok := jsFunctionBody(src, "releaseRecords")
if !ok {
t.Fatal("releaseRecords() not found")
}
for _, want := range []string{"observer.disconnect()", "removeEventListener", "abort()", "recsState.built = false"} {
if !strings.Contains(rel, want) {
t.Errorf("releaseRecords must clean up %s", want)
}
}
}
// TestUIThemeMatrix pins the theme system: every accent must define both a light
// and a dark variable block, so switching light/dark can never leave a theme
// half-defined (which shows up as unreadable text rather than as an error).
func TestUIThemeMatrix(t *testing.T) {
src := uiSource(t)
accents := []string{"sakura", "ocean", "violet", "mono"}
for _, a := range accents {
light := fmt.Sprintf(`html[data-accent=%q]{`, a)
dark := fmt.Sprintf(`html[data-theme="dark"][data-accent=%q]{`, a)
if !strings.Contains(src, light) {
t.Errorf("accent %q has no light-mode variable block (%s)", a, light)
}
// sakura is the default palette: its light values live in :root, and the
// generic dark block covers it.
if a != "sakura" && !strings.Contains(src, dark) {
t.Errorf("accent %q has no dark-mode variable block (%s)", a, dark)
}
if !strings.Contains(src, fmt.Sprintf(`data-accent="%s" title=`, a)) {
t.Errorf("accent %q has no picker button", a)
}
}
}
// TestUIMonoThemeIsFlat asserts the mono theme really is pure white / pure black:
// the surfaces must be opaque and the glass/blob decoration must be switched off,
// otherwise "#ffffff" renders as a grey translucent card over a gradient mesh.
func TestUIMonoThemeIsFlat(t *testing.T) {
src := uiSource(t)
lightBlock, ok := cssBlock(src, `html[data-accent="mono"]{`)
if !ok {
t.Fatal("mono light block not found")
}
for _, want := range []string{"--bg-s1:#ffffff", "--card:#ffffff", "--glass:0px"} {
if !strings.Contains(lightBlock, want) {
t.Errorf("mono light theme must set %s, got:\n%s", want, lightBlock)
}
}
if !strings.Contains(lightBlock, "--blob1:transparent") {
t.Error("mono light theme must disable the gradient blobs")
}
darkBlock, ok := cssBlock(src, `html[data-theme="dark"][data-accent="mono"]{`)
if !ok {
t.Fatal("mono dark block not found")
}
for _, want := range []string{"--bg-s1:#000000", "--glass:0px"} {
if !strings.Contains(darkBlock, want) {
t.Errorf("mono dark theme must set %s, got:\n%s", want, darkBlock)
}
}
if !strings.Contains(darkBlock, "--blob1:transparent") {
t.Error("mono dark theme must disable the gradient blobs")
}
// the flat-surface overrides must exist, or backdrop-filter turns #fff grey
for _, want := range []string{
`html[data-accent="mono"] #bgfx .blob{display:none}`,
`backdrop-filter:none`,
} {
if !strings.Contains(src, want) {
t.Errorf("mono theme needs the flat-surface override %q", want)
}
}
}
// TestUICanvasColorsResolveVars guards a class of silent theme bug: Canvas 2D
// does not understand CSS var(), so assigning "var(--x)" to strokeStyle is a
// no-op that leaves the previous colour (often black) in place — invisible on a
// dark background. Theme-driven canvas colours must go through cssVar().
func TestUICanvasColorsResolveVars(t *testing.T) {
src := uiSource(t)
if !strings.Contains(src, "function cssVar(") {
t.Fatal("cssVar() helper missing: canvas colours cannot resolve CSS variables without it")
}
for _, prop := range []string{"strokeStyle", "fillStyle"} {
bad := fmt.Sprintf(`ctx.%s = "var(--`, prop)
if strings.Contains(src, bad) {
t.Errorf("ctx.%s assigned a raw CSS var(): Canvas ignores it silently, use cssVar()", prop)
}
}
}
// cssBlock returns the text of the CSS rule starting at selector, up to its
// closing brace.
func cssBlock(src, selector string) (string, bool) {
i := strings.Index(src, selector)
if i < 0 {
return "", false
}
j := strings.Index(src[i:], "}")
if j < 0 {
return "", false
}
return src[i : i+j+1], true
}