diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index 6775462..7c238ef 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -3000,9 +3000,25 @@ async function renderChat() { btn.textContent = t("imgWorking").replace("%d", String(Math.round((Date.now() - t0) / 1000))); }, 1000); try { + // api() is a thin wrapper over fetch(p, o) — o is the NATIVE + // fetch options object, so body must be a STRING. + // + // Passing the object directly made fetch coerce it to the literal + // text "[object Object]", the gateway's json.Unmarshal rejected it, + // and the page showed a bare "400 Bad Request" with no image and no + // usable message. It cost a round of debugging because the DOM, the + // model list and the button state were all correct — only the wire + // format was wrong, which is invisible until you capture the request. + // + // JSON.stringify + an explicit Content-Type is what every other + // POST in this file does; the missing header plus the object body + // meant the server saw Content-Type: text/plain and a string body. + const payload = { model: model, prompt: prompt, n: n }; + if (size) payload.size = size; const r = await api("/v1/images/generations", { method: "POST", - body: size ? { model: model, prompt: prompt, n: n, size: size } : { model: model, prompt: prompt, n: n }, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), }); const empty = $("#it-empty"); if (empty) empty.remove(); @@ -3483,7 +3499,14 @@ async function renderChat() { try { const r = await api("/api/sources/discover", { method: "POST", - body: { base_url: baseUrl, api_key: apiKey }, + // api() hands its second argument straight to fetch, so `body` + // must be a STRING. Passing the object made fetch send the literal + // text "[object Object]" and the gateway answered + // 400 invalid character 'o' — the Auto-discover button looked + // dead while curl against the same endpoint worked, because the + // curl test exercised the ENDPOINT and not this BUTTON. + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ base_url: baseUrl, api_key: apiKey }), }); // The server normalized the URL form. Showing the operator exactly // what will be saved is the whole point of probing server-side diff --git a/internal/gateway/ui_image_wire_test.go b/internal/gateway/ui_image_wire_test.go new file mode 100644 index 0000000..56efb32 --- /dev/null +++ b/internal/gateway/ui_image_wire_test.go @@ -0,0 +1,109 @@ +package gateway + +import ( + "strings" + "testing" +) + +// TestUI_ImageRequestSendsJSONBody pins the bug that made the image page +// unusable in production while every DOM assertion still passed. +// +// The page called api(path, {method, body: {...}}). api() is +// `(p, o) => fetch(p, o)` — o is the NATIVE fetch options object, so an object +// in `body` is coerced by fetch into the literal text "[object Object]". The +// gateway's json.Unmarshal rejected it, the response was a bare 400 with +// lat_ms 0, and the page showed an error toast with no image and no usable +// message. +// +// What made this expensive: the pane rendered, the model list had all six +// models, the button enabled itself, the console was clean. Only the WIRE +// format was wrong, and nothing in the DOM shows a wire format. +func TestUI_ImageRequestSendsJSONBody(t *testing.T) { + body := sendImageSource(t) + + if !strings.Contains(body, "JSON.stringify") { + t.Error("sendImage() does not JSON.stringify its body — fetch coerces " + + "the object to the text \"[object Object]\" and the gateway answers " + + "400 with lat_ms 0") + } + if !strings.Contains(body, `"Content-Type": "application/json"`) { + t.Error("sendImage() does not set Content-Type: application/json; the " + + "server sees text/plain and may refuse to parse the body") + } + // The literal defect, asserted so a later refactor reintroducing it fails + // here rather than in a browser. + if strings.Contains(body, "body: size ? {") || strings.Contains(body, "body: { model") { + t.Error("sendImage() still passes an object literal as the fetch body") + } +} + +// TestUI_PostCallsSendJSONString guards every POST in the file, not just the +// image one. The image page happened to be the only place that used api() with +// an object body, so a check scoped to it would pass while another page had the +// same defect. +func TestUI_PostCallsSendJSONString(t *testing.T) { + ui := uiSource(t) + lines := strings.Split(ui, "\n") + + var bad []string + for i, l := range lines { + if !strings.Contains(l, `method: "POST"`) { + continue + } + end := i + 8 + if end > len(lines) { + end = len(lines) + } + window := strings.Join(lines[i:end], "\n") + if !strings.Contains(window, "body:") { + continue // no body at all: legitimate (a bare trigger) + } + if strings.Contains(window, "JSON.stringify") || strings.Contains(window, "FormData") { + continue + } + bad = append(bad, strings.TrimSpace(l)) + } + if len(bad) > 0 { + t.Errorf("%d POST call(s) send a non-string body:", len(bad)) + for _, b := range bad { + if len(b) > 100 { + b = b[:100] + } + t.Errorf(" %s", b) + } + } +} + +// TestUI_ImagePageReportsServerErrors pins that a failed generation surfaces +// something actionable. The production bug produced a bare "400 Bad Request" +// toast; the requirement is that the gateway's own message reaches the page. +func TestUI_ImagePageReportsServerErrors(t *testing.T) { + body := sendImageSource(t) + + // api() throws Error(j.error.message) on !r.ok, so appending e.message is + // what puts the explanation on screen. + if !strings.Contains(body, "e && e.message") { + t.Error("sendImage() does not surface the server's error message; a " + + "failure would show a bare status with no cause") + } + // And a success carrying no image must not look like a success. + if !strings.Contains(body, "imgNoData") { + t.Error("sendImage() does not warn when the upstream returns no image") + } +} + +// sendImageSource returns the text of sendImage() only, so an assertion cannot +// be satisfied by a string that happens to appear elsewhere in the file. +func sendImageSource(t *testing.T) string { + t.Helper() + ui := uiSource(t) + i := strings.Index(ui, "async function sendImage(") + if i < 0 { + t.Fatal("sendImage() not found") + } + body := ui[i:] + if end := strings.Index(body[1:], "\n function "); end > 0 { + body = body[:end+1] + } + return body +} \ No newline at end of file