Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions skills/meet-join/bot/__tests__/http-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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 () => {
Expand All @@ -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([]);
});
});

Expand Down
3 changes: 3 additions & 0 deletions skills/meet-join/bot/__tests__/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,9 @@ function makeDeps(
},
};
},
sendChat: async (_page, text) => {
calls.push({ kind: "sendChat", text });
},
createDaemonClient: (clientOpts) => {
calls.push({
kind: "daemon.create",
Expand Down
246 changes: 246 additions & 0 deletions skills/meet-join/bot/__tests__/send-chat-endpoint.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<Response> {
const headers: Record<string, string> = {
"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");
});
});
44 changes: 34 additions & 10 deletions skills/meet-join/bot/src/control/http-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
*
Expand All @@ -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> | 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> | void;
/**
Expand Down Expand Up @@ -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) => {
Expand All @@ -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) {
Comment on lines +171 to +173
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize /send_chat requests before invoking onSendChat

/send_chat handlers run concurrently, but they all drive the same Meet chat input on a single Playwright page. If two authenticated requests overlap, one call can fill() over the other's text before press("Enter"), causing the wrong message to be sent (or sent twice) even though both HTTP requests may return success. Adding a per-server mutex/queue around onSendChat would make message delivery deterministic under parallel sends/retries.

Useful? React with 👍 / 👎.

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);
});

// -------------------------------------------------------------------------
Expand Down
Loading
Loading