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 }