From 4533dd6e5d984eaa37fbedb24a85a9bb7fcd8560 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 10 Jan 2026 20:25:38 +0100 Subject: [PATCH] test: add image attachment regression coverage --- src/gateway/chat-attachments.test.ts | 93 ++++++++++++++++++++++++++ src/gateway/server.chat.test.ts | 17 ++++- src/gateway/server.node-bridge.test.ts | 67 +++++++++++++++++++ 3 files changed, 175 insertions(+), 2 deletions(-) diff --git a/src/gateway/chat-attachments.test.ts b/src/gateway/chat-attachments.test.ts index 2cc47fb48..edd44e758 100644 --- a/src/gateway/chat-attachments.test.ts +++ b/src/gateway/chat-attachments.test.ts @@ -59,6 +59,59 @@ describe("buildMessageWithAttachments", () => { }); describe("parseMessageWithAttachments", () => { + it("strips data URL prefix", async () => { + const parsed = await parseMessageWithAttachments( + "see this", + [ + { + type: "image", + mimeType: "image/png", + fileName: "dot.png", + content: `data:image/png;base64,${PNG_1x1}`, + }, + ], + { log: { warn: () => {} } }, + ); + expect(parsed.images).toHaveLength(1); + expect(parsed.images[0]?.mimeType).toBe("image/png"); + expect(parsed.images[0]?.data).toBe(PNG_1x1); + }); + + it("rejects invalid base64 content", async () => { + await expect( + parseMessageWithAttachments( + "x", + [ + { + type: "image", + mimeType: "image/png", + fileName: "dot.png", + content: "%not-base64%", + }, + ], + { log: { warn: () => {} } }, + ), + ).rejects.toThrow(/base64/i); + }); + + it("rejects images over limit", async () => { + const big = Buffer.alloc(6_000_000, 0).toString("base64"); + await expect( + parseMessageWithAttachments( + "x", + [ + { + type: "image", + mimeType: "image/png", + fileName: "big.png", + content: big, + }, + ], + { maxBytes: 5_000_000, log: { warn: () => {} } }, + ), + ).rejects.toThrow(/exceeds size limit/i); + }); + it("sniffs mime when missing", async () => { const logs: string[] = []; const parsed = await parseMessageWithAttachments( @@ -118,4 +171,44 @@ describe("parseMessageWithAttachments", () => { expect(logs).toHaveLength(1); expect(logs[0]).toMatch(/mime mismatch/i); }); + + it("drops unknown mime when sniff fails and logs", async () => { + const logs: string[] = []; + const unknown = Buffer.from("not an image").toString("base64"); + const parsed = await parseMessageWithAttachments( + "x", + [{ type: "file", fileName: "unknown.bin", content: unknown }], + { log: { warn: (message) => logs.push(message) } }, + ); + expect(parsed.images).toHaveLength(0); + expect(logs).toHaveLength(1); + expect(logs[0]).toMatch(/unable to detect image mime type/i); + }); + + it("keeps valid images and drops invalid ones", async () => { + const logs: string[] = []; + const pdf = Buffer.from("%PDF-1.4\n").toString("base64"); + const parsed = await parseMessageWithAttachments( + "x", + [ + { + type: "image", + mimeType: "image/png", + fileName: "dot.png", + content: PNG_1x1, + }, + { + type: "file", + mimeType: "image/png", + fileName: "not-image.pdf", + content: pdf, + }, + ], + { log: { warn: (message) => logs.push(message) } }, + ); + expect(parsed.images).toHaveLength(1); + expect(parsed.images[0]?.mimeType).toBe("image/png"); + expect(parsed.images[0]?.data).toBe(PNG_1x1); + expect(logs.some((l) => /non-image/i.test(l))).toBe(true); + }); }); diff --git a/src/gateway/server.chat.test.ts b/src/gateway/server.chat.test.ts index 9ab565b84..a299efb1f 100644 --- a/src/gateway/server.chat.test.ts +++ b/src/gateway/server.chat.test.ts @@ -188,6 +188,12 @@ describe("gateway server chat", () => { const { server, ws } = await startServerWithClient(); await connectOk(ws); + const spy = vi.mocked(agentCommand); + const callsBefore = spy.mock.calls.length; + + const pngB64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII="; + const reqId = "chat-img"; ws.send( JSON.stringify({ @@ -203,8 +209,7 @@ describe("gateway server chat", () => { type: "image", mimeType: "image/png", fileName: "dot.png", - content: - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=", + content: `data:image/png;base64,${pngB64}`, }, ], }, @@ -219,6 +224,14 @@ describe("gateway server chat", () => { expect(res.ok).toBe(true); expect(res.payload?.runId).toBeDefined(); + await waitFor(() => spy.mock.calls.length > callsBefore, 8000); + const call = spy.mock.calls.at(-1)?.[0] as + | { images?: Array<{ type: string; data: string; mimeType: string }> } + | undefined; + expect(call?.images).toEqual([ + { type: "image", data: pngB64, mimeType: "image/png" }, + ]); + ws.close(); await server.close(); }); diff --git a/src/gateway/server.node-bridge.test.ts b/src/gateway/server.node-bridge.test.ts index 56b0b3336..de24ad840 100644 --- a/src/gateway/server.node-bridge.test.ts +++ b/src/gateway/server.node-bridge.test.ts @@ -33,6 +33,15 @@ const decodeWsData = (data: unknown): string => { return ""; }; +async function waitFor(condition: () => boolean, timeoutMs = 1500) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (condition()) return; + await new Promise((r) => setTimeout(r, 5)); + } + throw new Error("timeout waiting for condition"); +} + installGatewayTestHooks(); describe("gateway server node/bridge", () => { @@ -733,6 +742,64 @@ describe("gateway server node/bridge", () => { await server.close(); }); + test("bridge chat.send forwards image attachments to agentCommand", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); + testState.sessionStorePath = path.join(dir, "sessions.json"); + await fs.writeFile( + testState.sessionStorePath, + JSON.stringify( + { + main: { + sessionId: "sess-main", + updatedAt: Date.now(), + }, + }, + null, + 2, + ), + "utf-8", + ); + + const port = await getFreePort(); + const server = await startGatewayServer(port); + const bridgeCall = bridgeStartCalls.at(-1); + expect(bridgeCall?.onRequest).toBeDefined(); + + const spy = vi.mocked(agentCommand); + const callsBefore = spy.mock.calls.length; + + const pngB64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII="; + + const reqRes = await bridgeCall?.onRequest?.("ios-node", { + id: "img-1", + method: "chat.send", + paramsJSON: JSON.stringify({ + sessionKey: "main", + message: "see image", + idempotencyKey: "idem-bridge-img", + attachments: [ + { + type: "image", + fileName: "dot.png", + content: `data:image/png;base64,${pngB64}`, + }, + ], + }), + }); + expect(reqRes?.ok).toBe(true); + + await waitFor(() => spy.mock.calls.length > callsBefore, 8000); + const call = spy.mock.calls.at(-1)?.[0] as + | { images?: Array<{ type: string; data: string; mimeType: string }> } + | undefined; + expect(call?.images).toEqual([ + { type: "image", data: pngB64, mimeType: "image/png" }, + ]); + + await server.close(); + }); + test("bridge voice transcript defaults to main session", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); testState.sessionStorePath = path.join(dir, "sessions.json");