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