diff --git a/skills/meet-join/bot/__tests__/http-server.test.ts b/skills/meet-join/bot/__tests__/http-server.test.ts index 3918cd7e759..97611895b3f 100644 --- a/skills/meet-join/bot/__tests__/http-server.test.ts +++ b/skills/meet-join/bot/__tests__/http-server.test.ts @@ -271,11 +271,15 @@ describe("http-server", () => { }); // ------------------------------------------------------------------------- - // POST /send_chat (501 stub until Phase 2) + // POST /send_chat + // + // Happy-path and failure-path coverage lives in send-chat-endpoint.test.ts. + // These cases just sanity-check the auth/validation gate from this suite's + // perspective so a regression in the middleware would be caught here too. // ------------------------------------------------------------------------- describe("POST /send_chat", () => { - test("returns 501 Not Implemented for a valid body", async () => { + test("invokes onSendChat and returns 200 for a valid body", async () => { const instance = makeServer(); server = instance.server; const base = await startOnRandomPort(server); @@ -288,10 +292,11 @@ describe("http-server", () => { }, body: JSON.stringify({ type: "send_chat", text: "hello" }), }); - expect(res.status).toBe(501); + expect(res.status).toBe(200); + expect(instance.log.sendChatCalls).toEqual(["hello"]); }); - test("validates body shape before returning 501", async () => { + test("rejects an empty text body with 400", async () => { const instance = makeServer(); server = instance.server; const base = await startOnRandomPort(server); @@ -305,6 +310,7 @@ describe("http-server", () => { body: JSON.stringify({ type: "send_chat", text: "" }), }); expect(res.status).toBe(400); + expect(instance.log.sendChatCalls).toEqual([]); }); test("requires auth", async () => { @@ -318,6 +324,7 @@ describe("http-server", () => { body: JSON.stringify({ type: "send_chat", text: "hi" }), }); expect(res.status).toBe(401); + expect(instance.log.sendChatCalls).toEqual([]); }); }); diff --git a/skills/meet-join/bot/__tests__/main.test.ts b/skills/meet-join/bot/__tests__/main.test.ts index 491f05d480b..59bb86c6c61 100644 --- a/skills/meet-join/bot/__tests__/main.test.ts +++ b/skills/meet-join/bot/__tests__/main.test.ts @@ -213,6 +213,9 @@ function makeDeps( }, }; }, + sendChat: async (_page, text) => { + calls.push({ kind: "sendChat", text }); + }, createDaemonClient: (clientOpts) => { calls.push({ kind: "daemon.create", diff --git a/skills/meet-join/bot/__tests__/send-chat-endpoint.test.ts b/skills/meet-join/bot/__tests__/send-chat-endpoint.test.ts new file mode 100644 index 00000000000..e1832401b2f --- /dev/null +++ b/skills/meet-join/bot/__tests__/send-chat-endpoint.test.ts @@ -0,0 +1,246 @@ +/** + * Focused tests for the `POST /send_chat` HTTP endpoint. + * + * The general HTTP server suite in `http-server.test.ts` covers auth and + * validation at a high level; this file exercises the full matrix for + * `/send_chat` specifically — auth, body validation, the 2000-character + * Meet chat limit, the Playwright failure path (502), and the happy path + * (200). The `sendChat` helper from `chat-bridge.ts` is mocked via the + * `onSendChat` callback so no browser is required. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; + +import { + createHttpServer, + type HttpServerHandle, +} from "../src/control/http-server.js"; +import { BotState } from "../src/control/state.js"; + +const API_TOKEN = "test-send-chat-token"; + +interface SendChatHarness { + server: HttpServerHandle; + receivedText: string[]; + /** When set, the next `onSendChat` call rejects with this error. */ + failNextWith: { error: Error | null }; +} + +function makeServer(): SendChatHarness { + const receivedText: string[] = []; + const failNextWith: { error: Error | null } = { error: null }; + const server = createHttpServer({ + apiToken: API_TOKEN, + onLeave: () => {}, + onSendChat: async (text) => { + receivedText.push(text); + if (failNextWith.error) { + const err = failNextWith.error; + failNextWith.error = null; + throw err; + } + }, + onPlayAudio: () => {}, + }); + return { server, receivedText, failNextWith }; +} + +async function startOnRandomPort(server: HttpServerHandle): Promise { + const { port } = await server.start(0); + return `http://127.0.0.1:${port}`; +} + +async function postSendChat( + base: string, + body: unknown, + opts: { auth?: string | null } = {}, +): Promise { + const headers: Record = { + "content-type": "application/json", + }; + if (opts.auth === undefined) { + headers.authorization = `Bearer ${API_TOKEN}`; + } else if (opts.auth !== null) { + headers.authorization = opts.auth; + } + return fetch(`${base}/send_chat`, { + method: "POST", + headers, + body: typeof body === "string" ? body : JSON.stringify(body), + }); +} + +describe("POST /send_chat endpoint", () => { + let server: HttpServerHandle | null = null; + + beforeEach(() => { + BotState.__resetForTests(); + }); + + afterEach(async () => { + if (server !== null) { + await server.stop(); + server = null; + } + }); + + // ------------------------------------------------------------------------- + // auth + // ------------------------------------------------------------------------- + + test("rejects a request with no authorization header", async () => { + const harness = makeServer(); + server = harness.server; + const base = await startOnRandomPort(server); + + const res = await postSendChat( + base, + { type: "send_chat", text: "hello" }, + { auth: null }, + ); + expect(res.status).toBe(401); + expect(harness.receivedText).toEqual([]); + }); + + test("rejects a request with the wrong bearer token", async () => { + const harness = makeServer(); + server = harness.server; + const base = await startOnRandomPort(server); + + const res = await postSendChat( + base, + { type: "send_chat", text: "hello" }, + { auth: "Bearer wrong-token" }, + ); + expect(res.status).toBe(401); + expect(harness.receivedText).toEqual([]); + }); + + // ------------------------------------------------------------------------- + // body validation + // ------------------------------------------------------------------------- + + test("rejects a body with the wrong type discriminator with 400", async () => { + const harness = makeServer(); + server = harness.server; + const base = await startOnRandomPort(server); + + const res = await postSendChat(base, { type: "leave", text: "hi" }); + expect(res.status).toBe(400); + expect(harness.receivedText).toEqual([]); + }); + + test("rejects an empty text with 400", async () => { + const harness = makeServer(); + server = harness.server; + const base = await startOnRandomPort(server); + + const res = await postSendChat(base, { type: "send_chat", text: "" }); + expect(res.status).toBe(400); + expect(harness.receivedText).toEqual([]); + }); + + test("rejects a non-JSON body with 400", async () => { + const harness = makeServer(); + server = harness.server; + const base = await startOnRandomPort(server); + + const res = await postSendChat(base, "not json at all"); + expect(res.status).toBe(400); + expect(harness.receivedText).toEqual([]); + }); + + // ------------------------------------------------------------------------- + // 2000-char limit + // ------------------------------------------------------------------------- + + test("accepts a message of exactly 2000 characters", async () => { + const harness = makeServer(); + server = harness.server; + const base = await startOnRandomPort(server); + + const text = "a".repeat(2000); + const res = await postSendChat(base, { type: "send_chat", text }); + expect(res.status).toBe(200); + expect(harness.receivedText).toEqual([text]); + }); + + test("rejects a message of 2001 characters with 400", async () => { + const harness = makeServer(); + server = harness.server; + const base = await startOnRandomPort(server); + + const text = "b".repeat(2001); + const res = await postSendChat(base, { type: "send_chat", text }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string; length: number }; + expect(body.error).toContain("2000"); + expect(body.length).toBe(2001); + expect(harness.receivedText).toEqual([]); + }); + + // ------------------------------------------------------------------------- + // happy path + // ------------------------------------------------------------------------- + + test("returns 200 and passes text through to sendChat unchanged", async () => { + const harness = makeServer(); + server = harness.server; + const base = await startOnRandomPort(server); + + const text = "hello from the bot — special chars: \u2603 🎉"; + const res = await postSendChat(base, { type: "send_chat", text }); + expect(res.status).toBe(200); + const body = (await res.json()) as { sent: boolean; timestamp: string }; + expect(body.sent).toBe(true); + expect(typeof body.timestamp).toBe("string"); + // Timestamp should parse as a valid ISO date. + expect(Number.isNaN(Date.parse(body.timestamp))).toBe(false); + expect(harness.receivedText).toEqual([text]); + }); + + // ------------------------------------------------------------------------- + // Playwright failure path + // ------------------------------------------------------------------------- + + test("returns 502 when sendChat throws (Playwright selector failure)", async () => { + const harness = makeServer(); + server = harness.server; + const base = await startOnRandomPort(server); + + harness.failNextWith.error = new Error( + "Timeout 10000ms exceeded waiting for selector", + ); + + const res = await postSendChat(base, { + type: "send_chat", + text: "will fail", + }); + expect(res.status).toBe(502); + const body = (await res.json()) as { sent: boolean; error: string }; + expect(body.sent).toBe(false); + expect(body.error).toContain("Timeout"); + // The handler was invoked before it threw. + expect(harness.receivedText).toEqual(["will fail"]); + }); + + test("returns 502 when sendChat rejects with a non-Error value", async () => { + const harness = makeServer(); + server = harness.server; + const base = await startOnRandomPort(server); + + // Force a non-Error throw. `Error` is the shape our code path expects, + // but Playwright and other libs have been known to throw plain objects, + // so we verify the stringify fallback behaves. + harness.failNextWith.error = { toString: () => "weird-failure" } as Error; + + const res = await postSendChat(base, { + type: "send_chat", + text: "will fail", + }); + expect(res.status).toBe(502); + const body = (await res.json()) as { sent: boolean; error: string }; + expect(body.sent).toBe(false); + expect(body.error).toBe("weird-failure"); + }); +}); diff --git a/skills/meet-join/bot/src/control/http-server.ts b/skills/meet-join/bot/src/control/http-server.ts index 6b2ba153a53..2b1e856a17e 100644 --- a/skills/meet-join/bot/src/control/http-server.ts +++ b/skills/meet-join/bot/src/control/http-server.ts @@ -6,7 +6,7 @@ * - `GET /health` — liveness/health probe (also used by Docker HEALTHCHECK). * - `GET /status` — full lifecycle snapshot. * - `POST /leave` — ask the bot to leave the meeting. - * - `POST /send_chat` — post a chat message (Phase 2; stub returns 501 today). + * - `POST /send_chat` — post a chat message into the Meet chat panel. * - `POST /play_audio` — play an out-of-band audio stream (Phase 3; stub 501). * * Every mutating route validates its body against the corresponding Zod @@ -27,6 +27,15 @@ import { Hono, type Context } from "hono"; import { BotState } from "./state.js"; +/** + * Google Meet enforces a 2000-character ceiling on a single chat message. + * We mirror that limit at the HTTP boundary so oversized payloads are + * rejected with a clear 400 instead of silently being truncated (or worse, + * causing Meet to reject the keystrokes and leave the bot in a half-typed + * state). + */ +const MEET_CHAT_MAX_LENGTH = 2000; + /** * Callbacks the HTTP server invokes when commands arrive. * @@ -39,9 +48,10 @@ export interface HttpServerCallbacks { /** Called when `POST /leave` is received and the phase has been flipped. */ onLeave: (reason: string | undefined) => Promise | void; /** - * Called when `POST /send_chat` is received. Currently a stub — the HTTP - * route returns 501 regardless so the daemon can detect that Phase 2 has - * not landed yet. + * Called when `POST /send_chat` is received with a valid body. The + * implementation is expected to type `text` into the Meet chat composer + * and submit it. Throwing (or rejecting) is the signal that Playwright + * could not post the message — the HTTP route converts that into a 502. */ onSendChat: (text: string) => Promise | void; /** @@ -134,7 +144,10 @@ export function createHttpServer( }); // ------------------------------------------------------------------------- - // POST /send_chat — validate now, return 501 until Phase 2 lands. + // POST /send_chat — validate, enforce Meet's 2000-char chat limit, then + // hand off to the Playwright-backed callback. Success returns 200; a + // thrown/rejected callback is surfaced as 502 so the daemon can tell + // "bad request" apart from "Meet DOM didn't cooperate". // ------------------------------------------------------------------------- app.post("/send_chat", async (c) => { @@ -146,11 +159,22 @@ export function createHttpServer( 400, ); } - // Invoke the callback so integration tests can still observe the call; - // Phase 2 will change this route to return 200 once the real handler - // is wired in. - void Promise.resolve(onSendChat(parsed.data.text)).catch(() => {}); - return c.json({ error: "not implemented" }, 501); + if (parsed.data.text.length > MEET_CHAT_MAX_LENGTH) { + return c.json( + { + error: `text exceeds Meet chat limit of ${MEET_CHAT_MAX_LENGTH} characters`, + length: parsed.data.text.length, + }, + 400, + ); + } + try { + await onSendChat(parsed.data.text); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return c.json({ sent: false, error: message }, 502); + } + return c.json({ sent: true, timestamp: new Date().toISOString() }, 200); }); // ------------------------------------------------------------------------- diff --git a/skills/meet-join/bot/src/main.ts b/skills/meet-join/bot/src/main.ts index fb49fc6162e..4131a6a2a89 100644 --- a/skills/meet-join/bot/src/main.ts +++ b/skills/meet-join/bot/src/main.ts @@ -49,6 +49,7 @@ import type { SpeakerChangeEvent, } from "@vellumai/meet-contracts"; +import { sendChat } from "./browser/chat-bridge.js"; import { startChatReader, type ChatReader } from "./browser/chat-reader.js"; import { joinMeet, type JoinMeetOptions } from "./browser/join-flow.js"; import { @@ -149,6 +150,13 @@ export interface BotDeps { startAudioCapture: ( opts: AudioCaptureOptions, ) => Promise; + /** + * Type `text` into the Meet chat composer and submit it. Invoked by the + * HTTP `/send_chat` endpoint. Separated from the other browser helpers + * so the main-test suite can inject a mock that captures the text + * without spinning up Playwright. + */ + sendChat: (page: Page, text: string) => Promise; createDaemonClient: (opts: { daemonUrl: string; meetingId: string; @@ -198,6 +206,7 @@ export function defaultDeps(): BotDeps { startSpeakerScraper, startChatReader, startAudioCapture, + sendChat, createDaemonClient: (opts) => new DaemonClient({ daemonUrl: opts.daemonUrl, @@ -565,8 +574,17 @@ export async function runBot(deps: BotDeps): Promise { deps.exit(0); }); }, - onSendChat: () => { - // Phase 2 will replace the 501 stub with a real implementation. + onSendChat: async (text) => { + // Surfacing errors back to the HTTP server lets it respond 502 to + // the daemon when Playwright can't reach the chat panel (selector + // drift, panel closed, Meet DOM still loading, etc.). The HTTP + // server is responsible for validation and the 2000-char limit — + // at this layer we just drive the browser. + const page = subsystems.session?.page; + if (!page) { + throw new Error("send_chat: browser session is not ready"); + } + await deps.sendChat(page, text); }, onPlayAudio: () => { // Phase 3 will replace the 501 stub with a real implementation.