import { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import plugin, { __testing as googleMeetPluginTesting } from "./index.js"; import { registerGoogleMeetCli } from "./src/cli.js"; import { resolveGoogleMeetConfig } from "./src/config.js"; import type { GoogleMeetRuntime } from "./src/runtime.js"; import { captureStdout, invokeGoogleMeetGatewayMethodForTest, setupGoogleMeetPlugin, } from "./src/test-support/plugin-harness.js"; import { CREATE_MEET_FROM_BROWSER_SCRIPT } from "./src/transports/chrome-create.js"; const voiceCallMocks = vi.hoisted(() => ({ joinMeetViaVoiceCallGateway: vi.fn(async () => ({ callId: "call-1", dtmfSent: true, introSent: true, })), endMeetVoiceCallGatewayCall: vi.fn(async () => {}), speakMeetViaVoiceCallGateway: vi.fn(async () => {}), })); const fetchGuardMocks = vi.hoisted(() => ({ fetchWithSsrFGuard: vi.fn( async (params: { url: string; init?: RequestInit; }): Promise<{ response: Response; release: () => Promise; }> => ({ response: await fetch(params.url, params.init), release: vi.fn(async () => {}), }), ), })); vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, fetchWithSsrFGuard: fetchGuardMocks.fetchWithSsrFGuard, }; }); vi.mock("./src/voice-call-gateway.js", () => ({ joinMeetViaVoiceCallGateway: voiceCallMocks.joinMeetViaVoiceCallGateway, endMeetVoiceCallGatewayCall: voiceCallMocks.endMeetVoiceCallGatewayCall, speakMeetViaVoiceCallGateway: voiceCallMocks.speakMeetViaVoiceCallGateway, })); function setup( config?: Parameters[1], options?: Parameters[2], ) { const harness = setupGoogleMeetPlugin(plugin, config, options); googleMeetPluginTesting.setCallGatewayFromCliForTests( async (method, _opts, params) => (await invokeGoogleMeetGatewayMethodForTest(harness.methods, method, params)) as Record< string, unknown >, ); googleMeetPluginTesting.setPlatformForTests(() => options?.registerPlatform ?? "darwin"); return harness; } async function runCreateMeetBrowserScript(params: { buttonText: string }) { const location = { href: "https://meet.google.com/new", hostname: "meet.google.com", }; const button = { disabled: false, innerText: params.buttonText, textContent: params.buttonText, getAttribute: (name: string) => (name === "aria-label" ? params.buttonText : null), click: vi.fn(() => { location.href = "https://meet.google.com/abc-defg-hij"; }), }; const document = { title: "Meet", body: { innerText: "Do you want people to hear you in the meeting?", textContent: "Do you want people to hear you in the meeting?", }, querySelectorAll: (selector: string) => (selector === "button" ? [button] : []), }; vi.stubGlobal("document", document); vi.stubGlobal("location", location); const fn = (0, eval)(`(${CREATE_MEET_FROM_BROWSER_SCRIPT})`) as () => Promise<{ meetingUri?: string; manualActionReason?: string; notes?: string[]; retryAfterMs?: number; }>; return { button, result: await fn() }; } describe("google-meet create flow", () => { beforeEach(() => { vi.clearAllMocks(); }); afterEach(() => { vi.unstubAllGlobals(); googleMeetPluginTesting.setCallGatewayFromCliForTests(); googleMeetPluginTesting.setPlatformForTests(); }); it("CLI create can configure API-created space access", async () => { const fetchMock = vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) => { const url = input instanceof Request ? input.url : input.toString(); if (url.includes("oauth2.googleapis.com")) { return new Response( JSON.stringify({ access_token: "new-access-token", expires_in: 3600, token_type: "Bearer", }), { status: 200, headers: { "Content-Type": "application/json" } }, ); } return new Response( JSON.stringify({ name: "spaces/new-space", meetingCode: "new-abcd-xyz", meetingUri: "https://meet.google.com/new-abcd-xyz", }), { status: 200, headers: { "Content-Type": "application/json" } }, ); }); vi.stubGlobal("fetch", fetchMock); const program = new Command(); const stdout = captureStdout(); registerGoogleMeetCli({ program, config: resolveGoogleMeetConfig({ oauth: { clientId: "client-id", refreshToken: "refresh-token" }, }), ensureRuntime: async () => ({}) as GoogleMeetRuntime, }); try { await program.parseAsync( [ "googlemeet", "create", "--no-join", "--access-type", "OPEN", "--entry-point-access", "ALL", ], { from: "user" }, ); expect(stdout.output()).toContain("meeting uri: https://meet.google.com/new-abcd-xyz"); expect(stdout.output()).toContain("space: spaces/new-space"); expect(fetchMock).toHaveBeenCalledWith( "https://meet.googleapis.com/v2/spaces", expect.objectContaining({ method: "POST", body: JSON.stringify({ config: { accessType: "OPEN", entryPointAccess: "ALL" } }), }), ); } finally { stdout.restore(); } }); it("can create a Meet through browser fallback without joining when requested", async () => { const { methods, nodesInvoke } = setup( { defaultTransport: "chrome-node", chromeNode: { node: "parallels-macos" }, }, { nodesInvokeHandler: async (params) => { const proxy = params.params as { path?: string; body?: { url?: string } }; if (proxy.path === "/tabs") { return { payload: { result: { tabs: [] } } }; } if (proxy.path === "/tabs/open") { return { payload: { result: { targetId: "tab-1", title: "Meet", url: proxy.body?.url, }, }, }; } if (proxy.path === "/act") { return { payload: { result: { ok: true, targetId: "tab-1", result: { meetingUri: "https://meet.google.com/browser-made-url", browserUrl: "https://meet.google.com/browser-made-url", browserTitle: "Meet", }, }, }, }; } return { payload: { result: { ok: true } } }; }, }, ); const handler = methods.get("googlemeet.create") as | ((ctx: { params: Record; respond: ReturnType; }) => Promise) | undefined; const respond = vi.fn(); await handler?.({ params: { join: false }, respond }); expect(respond.mock.calls[0]?.[0]).toBe(true); expect(respond.mock.calls[0]?.[1]).toMatchObject({ source: "browser", meetingUri: "https://meet.google.com/browser-made-url", joined: false, browser: { nodeId: "node-1", targetId: "tab-1" }, }); expect(nodesInvoke).toHaveBeenCalledWith( expect.objectContaining({ command: "browser.proxy", params: expect.objectContaining({ path: "/tabs/open", body: { url: "https://meet.google.com/new" }, }), }), ); }); it("rejects access policy flags when tool create would use browser fallback", async () => { const { methods } = setup( { defaultTransport: "chrome-node", chromeNode: { node: "parallels-macos" }, }, { nodesInvokeHandler: async () => { throw new Error("browser fallback should not run"); }, }, ); await expect( invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.create", { join: false, accessType: "OPEN", }), ).rejects.toThrow("access policy options require OAuth/API room creation"); }); it("reports structured manual action when browser creation needs Google login", async () => { const { methods } = setup( { defaultTransport: "chrome-node", chromeNode: { node: "parallels-macos" }, }, { nodesInvokeHandler: async (params) => { const proxy = params.params as { path?: string; body?: { url?: string } }; if (proxy.path === "/tabs") { return { payload: { result: { tabs: [] } } }; } if (proxy.path === "/tabs/open") { return { payload: { result: { targetId: "login-tab", title: "New Tab", url: proxy.body?.url, }, }, }; } if (proxy.path === "/act") { return { payload: { result: { ok: true, targetId: "login-tab", result: { manualActionReason: "google-login-required", manualAction: "Sign in to Google in the OpenClaw browser profile, then retry meeting creation.", browserUrl: "https://accounts.google.com/signin", browserTitle: "Sign in - Google Accounts", notes: ["Sign-in page detected."], }, }, }, }; } throw new Error(`unexpected browser proxy path ${proxy.path}`); }, }, ); const handler = methods.get("googlemeet.create") as | ((ctx: { params: Record; respond: ReturnType; }) => Promise) | undefined; const respond = vi.fn(); await handler?.({ params: {}, respond }); expect(respond.mock.calls[0]?.[0]).toBe(false); expect(respond.mock.calls[0]?.[1]).toMatchObject({ source: "browser", error: "google-login-required: Sign in to Google in the OpenClaw browser profile, then retry meeting creation.", manualActionRequired: true, manualActionReason: "google-login-required", manualActionMessage: "Sign in to Google in the OpenClaw browser profile, then retry meeting creation.", browser: { nodeId: "node-1", targetId: "login-tab", browserUrl: "https://accounts.google.com/signin", browserTitle: "Sign in - Google Accounts", notes: ["Sign-in page detected."], }, }); }); it("creates and joins a Meet through the create tool action by default", async () => { const { tools, nodesInvoke } = setup( { defaultTransport: "chrome-node", defaultMode: "transcribe", chromeNode: { node: "parallels-macos" }, }, { nodesInvokeHandler: async (params) => { if (params.command === "googlemeet.chrome") { return { payload: { launched: true } }; } const proxy = params.params as { path?: string; body?: { url?: string; targetId?: string; fn?: string }; }; if (proxy.path === "/tabs") { return { payload: { result: { tabs: [] } } }; } if (proxy.path === "/tabs/open") { return { payload: { result: { targetId: proxy.body?.url === "https://meet.google.com/new" ? "create-tab" : "join-tab", title: "Meet", url: proxy.body?.url, }, }, }; } if (proxy.path === "/act" && proxy.body?.fn?.includes("meetUrlPattern")) { return { payload: { result: { ok: true, targetId: "create-tab", result: { meetingUri: "https://meet.google.com/new-abcd-xyz", browserUrl: "https://meet.google.com/new-abcd-xyz", browserTitle: "Meet", }, }, }, }; } if (proxy.path === "/act") { return { payload: { result: { ok: true, targetId: "join-tab", result: JSON.stringify({ inCall: true, micMuted: false, title: "Meet call", url: "https://meet.google.com/new-abcd-xyz", }), }, }, }; } return { payload: { result: { ok: true } } }; }, }, ); const tool = tools[0] as { execute: ( id: string, params: unknown, ) => Promise<{ details: { joined?: boolean; meetingUri?: string; join?: { session: { url: string } } }; }>; }; const result = await tool.execute("id", { action: "create" }); expect(result.details).toMatchObject({ source: "browser", joined: true, meetingUri: "https://meet.google.com/new-abcd-xyz", join: { session: { url: "https://meet.google.com/new-abcd-xyz" } }, }); expect(nodesInvoke).toHaveBeenCalledWith( expect.objectContaining({ command: "googlemeet.chrome", params: expect.objectContaining({ action: "start", url: "https://meet.google.com/new-abcd-xyz", launch: false, }), }), ); }); it("returns structured manual action from the create tool action", async () => { const { tools } = setup( { defaultTransport: "chrome-node", chromeNode: { node: "parallels-macos" }, }, { nodesInvokeHandler: async (params) => { const proxy = params.params as { path?: string; body?: { url?: string } }; if (proxy.path === "/tabs") { return { payload: { result: { tabs: [] } } }; } if (proxy.path === "/tabs/open") { return { payload: { result: { targetId: "permission-tab", title: "Meet", url: proxy.body?.url, }, }, }; } if (proxy.path === "/act") { return { payload: { result: { ok: true, targetId: "permission-tab", result: { manualActionReason: "meet-permission-required", manualAction: "Allow microphone/camera permissions for Meet in the OpenClaw browser profile, then retry meeting creation.", browserUrl: "https://meet.google.com/new", browserTitle: "Meet", }, }, }, }; } throw new Error(`unexpected browser proxy path ${proxy.path}`); }, }, ); const tool = tools[0] as { execute: (id: string, params: unknown) => Promise<{ details: Record }>; }; const result = await tool.execute("id", { action: "create" }); expect(result.details).toMatchObject({ source: "browser", manualActionRequired: true, manualActionReason: "meet-permission-required", manualActionMessage: "Allow microphone/camera permissions for Meet in the OpenClaw browser profile, then retry meeting creation.", browser: { nodeId: "node-1", targetId: "permission-tab", browserUrl: "https://meet.google.com/new", browserTitle: "Meet", }, }); }); it("reuses an existing browser create tab instead of opening duplicates", async () => { const { methods, nodesInvoke } = setup( { defaultTransport: "chrome-node", chromeNode: { node: "parallels-macos" }, }, { nodesInvokeHandler: async (params) => { const proxy = params.params as { path?: string; body?: { targetId?: string } }; if (proxy.path === "/tabs") { return { payload: { result: { tabs: [ { targetId: "existing-create-tab", title: "Meet", url: "https://meet.google.com/new", }, ], }, }, }; } if (proxy.path === "/tabs/focus") { return { payload: { result: { ok: true } } }; } if (proxy.path === "/act") { return { payload: { result: { ok: true, targetId: proxy.body?.targetId ?? "existing-create-tab", result: { meetingUri: "https://meet.google.com/reu-sedx-tab", browserUrl: "https://meet.google.com/reu-sedx-tab", browserTitle: "Meet", }, }, }, }; } throw new Error(`unexpected browser proxy path ${proxy.path}`); }, }, ); const handler = methods.get("googlemeet.create") as | ((ctx: { params: Record; respond: ReturnType; }) => Promise) | undefined; const respond = vi.fn(); await handler?.({ params: { join: false }, respond }); expect(respond.mock.calls[0]?.[0]).toBe(true); expect(respond.mock.calls[0]?.[1]).toMatchObject({ source: "browser", meetingUri: "https://meet.google.com/reu-sedx-tab", browser: { nodeId: "node-1", targetId: "existing-create-tab" }, }); expect(nodesInvoke).toHaveBeenCalledWith( expect.objectContaining({ params: expect.objectContaining({ path: "/tabs/focus", body: { targetId: "existing-create-tab" }, }), }), ); expect(nodesInvoke).not.toHaveBeenCalledWith( expect.objectContaining({ params: expect.objectContaining({ path: "/tabs/open" }), }), ); }); it.each([ ["Use microphone", "Accepted Meet microphone prompt with browser automation."], [ "Continue without microphone", "Continued through Meet microphone prompt with browser automation.", ], ])( "uses browser automation for Meet's %s choice during browser creation", async (buttonText, note) => { const { button, result } = await runCreateMeetBrowserScript({ buttonText }); expect(result).toMatchObject({ retryAfterMs: 1000, notes: [note], }); expect(button.click).toHaveBeenCalledTimes(1); expect(result.meetingUri).toBeUndefined(); expect(result.manualActionReason).toBeUndefined(); }, ); });