diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index 0ac206e21c5..3d054839787 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -1,13 +1,8 @@ import { EventEmitter } from "node:events"; -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; import { PassThrough, Writable } from "node:stream"; -import { Command } from "commander"; import type { RealtimeVoiceProviderPlugin } from "openclaw/plugin-sdk/realtime-voice"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import plugin from "./index.js"; -import { registerGoogleMeetCli } from "./src/cli.js"; import { resolveGoogleMeetConfig, resolveGoogleMeetConfigWithEnv } from "./src/config.js"; import { buildGoogleMeetPreflightReport, @@ -18,20 +13,10 @@ import { fetchGoogleMeetSpace, normalizeGoogleMeetSpaceName, } from "./src/meet.js"; -import { - buildGoogleMeetAuthUrl, - refreshGoogleMeetAccessToken, - resolveGoogleMeetAccessToken, -} from "./src/oauth.js"; import { startNodeRealtimeAudioBridge } from "./src/realtime-node.js"; import { startCommandRealtimeAudioBridge } from "./src/realtime.js"; import { normalizeMeetUrl } from "./src/runtime.js"; -import type { GoogleMeetRuntime } from "./src/runtime.js"; -import { - captureStdout, - noopLogger, - setupGoogleMeetPlugin, -} from "./src/test-support/plugin-harness.js"; +import { noopLogger, setupGoogleMeetPlugin } from "./src/test-support/plugin-harness.js"; import { buildMeetDtmfSequence, normalizeDialInNumber } from "./src/transports/twilio.js"; const voiceCallMocks = vi.hoisted(() => ({ @@ -595,63 +580,6 @@ describe("google-meet plugin", () => { }); }); - it("builds Meet OAuth URLs and prefers fresh cached access tokens", async () => { - const url = new URL( - buildGoogleMeetAuthUrl({ - clientId: "client-id", - challenge: "challenge", - state: "state", - }), - ); - expect(url.hostname).toBe("accounts.google.com"); - expect(url.searchParams.get("client_id")).toBe("client-id"); - expect(url.searchParams.get("code_challenge")).toBe("challenge"); - expect(url.searchParams.get("access_type")).toBe("offline"); - expect(url.searchParams.get("scope")).toContain("meetings.space.created"); - expect(url.searchParams.get("scope")).toContain("meetings.conference.media.readonly"); - - await expect( - resolveGoogleMeetAccessToken({ - accessToken: "cached-token", - expiresAt: Date.now() + 120_000, - }), - ).resolves.toEqual({ - accessToken: "cached-token", - expiresAt: expect.any(Number), - refreshed: false, - }); - }); - - it("refreshes Google Meet access tokens with a refresh-token grant", async () => { - const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => { - return new Response( - JSON.stringify({ - access_token: "new-access-token", - expires_in: 3600, - token_type: "Bearer", - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - }); - vi.stubGlobal("fetch", fetchMock); - - await expect( - refreshGoogleMeetAccessToken({ - clientId: "client-id", - clientSecret: "client-secret", - refreshToken: "refresh-token", - }), - ).resolves.toMatchObject({ - accessToken: "new-access-token", - tokenType: "Bearer", - }); - const body = fetchMock.mock.calls[0]?.[1]?.body; - expect(body).toBeInstanceOf(URLSearchParams); - const params = body as URLSearchParams; - expect(params.get("grant_type")).toBe("refresh_token"); - expect(params.get("refresh_token")).toBe("refresh-token"); - }); - it("builds Twilio dial plans from a PIN", () => { expect(normalizeDialInNumber("+1 (555) 123-4567")).toBe("+15551234567"); expect(buildMeetDtmfSequence({ pin: "123 456" })).toBe("123456#"); @@ -882,459 +810,6 @@ describe("google-meet plugin", () => { ); }); - it("CLI setup prints human-readable checks by default", async () => { - const program = new Command(); - const stdout = captureStdout(); - registerGoogleMeetCli({ - program, - config: resolveGoogleMeetConfig({}), - ensureRuntime: async () => - ({ - setupStatus: async () => ({ - ok: true, - checks: [ - { - id: "audio-bridge", - ok: true, - message: "Chrome command-pair realtime audio bridge configured", - }, - ], - }), - }) as unknown as GoogleMeetRuntime, - }); - - try { - await program.parseAsync(["googlemeet", "setup"], { from: "user" }); - expect(stdout.output()).toContain("Google Meet setup: OK"); - expect(stdout.output()).toContain( - "[ok] audio-bridge: Chrome command-pair realtime audio bridge configured", - ); - expect(stdout.output()).not.toContain('"checks"'); - } finally { - stdout.restore(); - } - }); - - it("CLI setup preserves JSON output with --json", async () => { - const program = new Command(); - const stdout = captureStdout(); - registerGoogleMeetCli({ - program, - config: resolveGoogleMeetConfig({}), - ensureRuntime: async () => - ({ - setupStatus: async () => ({ - ok: false, - checks: [{ id: "twilio-voice-call-plugin", ok: false, message: "missing" }], - }), - }) as unknown as GoogleMeetRuntime, - }); - - try { - await program.parseAsync(["googlemeet", "setup", "--json"], { from: "user" }); - expect(JSON.parse(stdout.output())).toMatchObject({ - ok: false, - checks: [{ id: "twilio-voice-call-plugin", ok: false }], - }); - } finally { - stdout.restore(); - } - }); - - it("CLI artifacts prints JSON output", async () => { - stubMeetArtifactsApi(); - const program = new Command(); - const stdout = captureStdout(); - registerGoogleMeetCli({ - program, - config: resolveGoogleMeetConfig({}), - ensureRuntime: async () => ({}) as unknown as GoogleMeetRuntime, - }); - - try { - await program.parseAsync( - [ - "googlemeet", - "artifacts", - "--access-token", - "token", - "--expires-at", - String(Date.now() + 120_000), - "--conference-record", - "rec-1", - "--json", - ], - { from: "user" }, - ); - expect(JSON.parse(stdout.output())).toMatchObject({ - conferenceRecords: [{ name: "conferenceRecords/rec-1" }], - artifacts: [ - { - recordings: [{ name: "conferenceRecords/rec-1/recordings/r1" }], - transcripts: [{ name: "conferenceRecords/rec-1/transcripts/t1" }], - transcriptEntries: [ - { - transcript: "conferenceRecords/rec-1/transcripts/t1", - entries: [{ text: "Hello from the transcript." }], - }, - ], - smartNotes: [{ name: "conferenceRecords/rec-1/smartNotes/sn1" }], - }, - ], - tokenSource: "cached-access-token", - }); - } finally { - stdout.restore(); - } - }); - - it("CLI latest prints the latest conference record", async () => { - stubMeetArtifactsApi(); - const program = new Command(); - const stdout = captureStdout(); - registerGoogleMeetCli({ - program, - config: resolveGoogleMeetConfig({}), - ensureRuntime: async () => ({}) as unknown as GoogleMeetRuntime, - }); - - try { - await program.parseAsync( - [ - "googlemeet", - "latest", - "--access-token", - "token", - "--expires-at", - String(Date.now() + 120_000), - "--meeting", - "abc-defg-hij", - ], - { from: "user" }, - ); - expect(stdout.output()).toContain("space: spaces/abc-defg-hij"); - expect(stdout.output()).toContain("conference record: conferenceRecords/rec-1"); - } finally { - stdout.restore(); - } - }); - - it("CLI artifacts writes markdown output", async () => { - stubMeetArtifactsApi(); - const program = new Command(); - const stdout = captureStdout(); - const tempDir = mkdtempSync(path.join(tmpdir(), "openclaw-google-meet-artifacts-")); - const outputPath = path.join(tempDir, "artifacts.md"); - registerGoogleMeetCli({ - program, - config: resolveGoogleMeetConfig({}), - ensureRuntime: async () => ({}) as unknown as GoogleMeetRuntime, - }); - - try { - await program.parseAsync( - [ - "googlemeet", - "artifacts", - "--access-token", - "token", - "--expires-at", - String(Date.now() + 120_000), - "--conference-record", - "rec-1", - "--format", - "markdown", - "--output", - outputPath, - ], - { from: "user" }, - ); - const markdown = readFileSync(outputPath, "utf8"); - expect(stdout.output()).toContain(`wrote: ${outputPath}`); - expect(markdown).toContain("# Google Meet Artifacts"); - expect(markdown).toContain("## conferenceRecords/rec-1"); - expect(markdown).toContain("### Transcript Entries: conferenceRecords/rec-1/transcripts/t1"); - expect(markdown).toContain("Hello from the transcript."); - } finally { - stdout.restore(); - rmSync(tempDir, { recursive: true, force: true }); - } - }); - - it("CLI attendance prints participant sessions by default", async () => { - stubMeetArtifactsApi(); - const program = new Command(); - const stdout = captureStdout(); - registerGoogleMeetCli({ - program, - config: resolveGoogleMeetConfig({}), - ensureRuntime: async () => ({}) as unknown as GoogleMeetRuntime, - }); - - try { - await program.parseAsync( - [ - "googlemeet", - "attendance", - "--access-token", - "token", - "--expires-at", - String(Date.now() + 120_000), - "--conference-record", - "rec-1", - ], - { from: "user" }, - ); - expect(stdout.output()).toContain("attendance rows: 1"); - expect(stdout.output()).toContain("participant: Alice"); - expect(stdout.output()).toContain( - "conferenceRecords/rec-1/participants/p1/participantSessions/s1", - ); - } finally { - stdout.restore(); - } - }); - - it("CLI attendance prints markdown output", async () => { - stubMeetArtifactsApi(); - const program = new Command(); - const stdout = captureStdout(); - registerGoogleMeetCli({ - program, - config: resolveGoogleMeetConfig({}), - ensureRuntime: async () => ({}) as unknown as GoogleMeetRuntime, - }); - - try { - await program.parseAsync( - [ - "googlemeet", - "attendance", - "--access-token", - "token", - "--expires-at", - String(Date.now() + 120_000), - "--conference-record", - "rec-1", - "--format", - "markdown", - ], - { from: "user" }, - ); - expect(stdout.output()).toContain("# Google Meet Attendance"); - expect(stdout.output()).toContain("## Alice"); - expect(stdout.output()).toContain( - "conferenceRecords/rec-1/participants/p1/participantSessions/s1", - ); - } finally { - stdout.restore(); - } - }); - - it("CLI doctor prints human-readable session health", async () => { - const program = new Command(); - const stdout = captureStdout(); - registerGoogleMeetCli({ - program, - config: resolveGoogleMeetConfig({}), - ensureRuntime: async () => - ({ - status: () => ({ - found: true, - session: { - id: "meet_1", - url: "https://meet.google.com/abc-defg-hij", - state: "active", - transport: "chrome-node", - mode: "realtime", - participantIdentity: "signed-in Google Chrome profile on a paired node", - createdAt: "2026-04-25T00:00:00.000Z", - updatedAt: "2026-04-25T00:00:01.000Z", - realtime: { enabled: true, provider: "openai", toolPolicy: "safe-read-only" }, - chrome: { - audioBackend: "blackhole-2ch", - launched: true, - nodeId: "node-1", - audioBridge: { type: "node-command-pair", provider: "openai" }, - health: { - inCall: true, - providerConnected: true, - realtimeReady: true, - audioInputActive: true, - audioOutputActive: false, - lastInputAt: "2026-04-25T00:00:02.000Z", - lastInputBytes: 160, - lastOutputBytes: 0, - }, - }, - notes: [], - }, - }), - }) as unknown as GoogleMeetRuntime, - }); - - try { - await program.parseAsync(["googlemeet", "doctor", "meet_1"], { from: "user" }); - expect(stdout.output()).toContain("session: meet_1"); - expect(stdout.output()).toContain("node: node-1"); - expect(stdout.output()).toContain("provider connected: yes"); - expect(stdout.output()).toContain("audio input active: yes"); - expect(stdout.output()).toContain("audio output active: no"); - } finally { - stdout.restore(); - } - }); - - it("CLI doctor verifies Google Meet OAuth refresh without printing secrets", async () => { - const program = new Command(); - const stdout = captureStdout(); - const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => { - return new Response( - JSON.stringify({ - access_token: "new-access-token", - expires_in: 3600, - token_type: "Bearer", - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - }); - vi.stubGlobal("fetch", fetchMock); - - const ensureRuntime = vi.fn(async () => { - throw new Error("runtime should not be loaded for OAuth doctor"); - }); - registerGoogleMeetCli({ - program, - config: resolveGoogleMeetConfig({ - oauth: { - clientId: "client-id", - clientSecret: "client-secret", - refreshToken: "rt-secret", - }, - }), - ensureRuntime: ensureRuntime as unknown as () => Promise, - }); - - try { - await program.parseAsync(["googlemeet", "doctor", "--oauth", "--json"], { from: "user" }); - const output = stdout.output(); - expect(output).not.toContain("new-access-token"); - expect(output).not.toContain("rt-secret"); - expect(output).not.toContain("client-secret"); - expect(JSON.parse(output)).toMatchObject({ - ok: true, - configured: true, - tokenSource: "refresh-token", - checks: [ - { id: "oauth-config", ok: true }, - { id: "oauth-token", ok: true }, - ], - }); - expect(ensureRuntime).not.toHaveBeenCalled(); - const body = fetchMock.mock.calls[0]?.[1]?.body as URLSearchParams; - expect(body.get("grant_type")).toBe("refresh_token"); - } finally { - stdout.restore(); - } - }); - - it("CLI doctor can prove Google Meet API create access", async () => { - const program = new Command(); - const stdout = captureStdout(); - vi.stubGlobal( - "fetch", - vi.fn(async (input: RequestInfo | URL) => { - const url = - typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - if (url === "https://oauth2.googleapis.com/token") { - return new Response( - JSON.stringify({ - access_token: "new-access-token", - expires_in: 3600, - token_type: "Bearer", - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - } - if (url === "https://meet.googleapis.com/v2/spaces") { - return new Response( - JSON.stringify({ - name: "spaces/new-space", - meetingUri: "https://meet.google.com/new-abcd-xyz", - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - } - return new Response("not found", { status: 404 }); - }), - ); - - registerGoogleMeetCli({ - program, - config: resolveGoogleMeetConfig({ - oauth: { - clientId: "client-id", - refreshToken: "refresh-token", - }, - }), - ensureRuntime: async () => ({}) as GoogleMeetRuntime, - }); - - try { - await program.parseAsync(["googlemeet", "doctor", "--oauth", "--create-space", "--json"], { - from: "user", - }); - expect(JSON.parse(stdout.output())).toMatchObject({ - ok: true, - tokenSource: "refresh-token", - createdSpace: "spaces/new-space", - meetingUri: "https://meet.google.com/new-abcd-xyz", - checks: [ - { id: "oauth-config", ok: true }, - { id: "oauth-token", ok: true }, - { id: "meet-spaces-create", ok: true }, - ], - }); - } finally { - stdout.restore(); - } - }); - - it("CLI recover-tab focuses and summarizes an existing Meet tab", async () => { - const program = new Command(); - const stdout = captureStdout(); - registerGoogleMeetCli({ - program, - config: resolveGoogleMeetConfig({ defaultTransport: "chrome-node" }), - ensureRuntime: async () => - ({ - recoverCurrentTab: async () => ({ - nodeId: "node-1", - found: true, - targetId: "tab-1", - tab: { targetId: "tab-1", url: "https://meet.google.com/abc-defg-hij" }, - browser: { - inCall: false, - manualActionRequired: true, - manualActionReason: "meet-admission-required", - manualActionMessage: "Admit the OpenClaw browser participant in Google Meet.", - browserUrl: "https://meet.google.com/abc-defg-hij", - }, - message: "Admit the OpenClaw browser participant in Google Meet.", - }), - }) as unknown as GoogleMeetRuntime, - }); - - try { - await program.parseAsync(["googlemeet", "recover-tab"], { from: "user" }); - expect(stdout.output()).toContain("Google Meet current tab: found"); - expect(stdout.output()).toContain("target: tab-1"); - expect(stdout.output()).toContain("manual reason: meet-admission-required"); - } finally { - stdout.restore(); - } - }); - it("launches Chrome after the BlackHole check", async () => { const originalPlatform = process.platform; Object.defineProperty(process, "platform", { value: "darwin" }); diff --git a/extensions/google-meet/index.ts b/extensions/google-meet/index.ts index 01b2b57961c..c5e3db94e57 100644 --- a/extensions/google-meet/index.ts +++ b/extensions/google-meet/index.ts @@ -3,18 +3,12 @@ import type { GatewayRequestHandlerOptions } from "openclaw/plugin-sdk/gateway-r import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import { Type } from "typebox"; -import { registerGoogleMeetCli } from "./src/cli.js"; import { resolveGoogleMeetConfig, type GoogleMeetConfig, type GoogleMeetMode, type GoogleMeetTransport, } from "./src/config.js"; -import { - createAndJoinMeetFromParams, - createMeetFromParams, - shouldJoinCreatedMeet, -} from "./src/create.js"; import { buildGoogleMeetPreflightReport, fetchGoogleMeetArtifacts, @@ -23,7 +17,6 @@ import { fetchGoogleMeetSpace, } from "./src/meet.js"; import { handleGoogleMeetNodeHostCommand } from "./src/node-host.js"; -import { resolveGoogleMeetAccessToken } from "./src/oauth.js"; import { GoogleMeetRuntime } from "./src/runtime.js"; import { isGoogleMeetBrowserManualActionError } from "./src/transports/chrome-create.js"; @@ -244,10 +237,34 @@ function resolveOptionalPositiveInteger(value: unknown): number | undefined { return parsed; } +function shouldJoinCreatedMeet(raw: Record): boolean { + return raw.join !== false && raw.join !== "false"; +} + +async function createMeetFromParams(params: { + config: GoogleMeetConfig; + runtime: OpenClawPluginApi["runtime"]; + raw: Record; +}) { + const create = await import("./src/create.js"); + return create.createMeetFromParams(params); +} + +async function createAndJoinMeetFromParams(params: { + config: GoogleMeetConfig; + runtime: OpenClawPluginApi["runtime"]; + raw: Record; + ensureRuntime: () => Promise; +}) { + const create = await import("./src/create.js"); + return create.createAndJoinMeetFromParams(params); +} + async function resolveGoogleMeetTokenFromParams( config: GoogleMeetConfig, raw: Record, ) { + const { resolveGoogleMeetAccessToken } = await import("./src/oauth.js"); return resolveGoogleMeetAccessToken({ clientId: normalizeOptionalString(raw.clientId) ?? config.oauth.clientId, clientSecret: normalizeOptionalString(raw.clientSecret) ?? config.oauth.clientSecret, @@ -661,12 +678,14 @@ export default definePluginEntry({ }); api.registerCli( - ({ program }) => + async ({ program }) => { + const { registerGoogleMeetCli } = await import("./src/cli.js"); registerGoogleMeetCli({ program, config, ensureRuntime, - }), + }); + }, { commands: ["googlemeet"], descriptors: [ diff --git a/extensions/google-meet/src/cli.test.ts b/extensions/google-meet/src/cli.test.ts new file mode 100644 index 00000000000..bf4b2390570 --- /dev/null +++ b/extensions/google-meet/src/cli.test.ts @@ -0,0 +1,555 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { Command } from "commander"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { registerGoogleMeetCli } from "./cli.js"; +import { resolveGoogleMeetConfig } from "./config.js"; +import type { GoogleMeetRuntime } from "./runtime.js"; + +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", () => ({ + fetchWithSsrFGuard: fetchGuardMocks.fetchWithSsrFGuard, +})); + +function captureStdout() { + let output = ""; + const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => { + output += String(chunk); + return true; + }) as typeof process.stdout.write); + return { + output: () => output, + restore: () => writeSpy.mockRestore(), + }; +} + +function jsonResponse(value: unknown): Response { + return new Response(JSON.stringify(value), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +function requestUrl(input: RequestInfo | URL): URL { + if (typeof input === "string") { + return new URL(input); + } + if (input instanceof URL) { + return input; + } + return new URL(input.url); +} + +function stubMeetArtifactsApi() { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = requestUrl(input); + if (url.pathname === "/v2/spaces/abc-defg-hij") { + return jsonResponse({ + name: "spaces/abc-defg-hij", + meetingCode: "abc-defg-hij", + meetingUri: "https://meet.google.com/abc-defg-hij", + }); + } + if (url.pathname === "/v2/conferenceRecords") { + return jsonResponse({ + conferenceRecords: [ + { + name: "conferenceRecords/rec-1", + space: "spaces/abc-defg-hij", + startTime: "2026-04-25T10:00:00Z", + endTime: "2026-04-25T10:30:00Z", + }, + ], + }); + } + if (url.pathname === "/v2/conferenceRecords/rec-1") { + return jsonResponse({ + name: "conferenceRecords/rec-1", + space: "spaces/abc-defg-hij", + startTime: "2026-04-25T10:00:00Z", + endTime: "2026-04-25T10:30:00Z", + }); + } + if (url.pathname === "/v2/conferenceRecords/rec-1/participants") { + return jsonResponse({ + participants: [ + { + name: "conferenceRecords/rec-1/participants/p1", + signedinUser: { displayName: "Alice" }, + }, + ], + }); + } + if (url.pathname === "/v2/conferenceRecords/rec-1/participants/p1/participantSessions") { + return jsonResponse({ + participantSessions: [ + { + name: "conferenceRecords/rec-1/participants/p1/participantSessions/s1", + startTime: "2026-04-25T10:00:00Z", + endTime: "2026-04-25T10:10:00Z", + }, + ], + }); + } + if (url.pathname === "/v2/conferenceRecords/rec-1/recordings") { + return jsonResponse({ + recordings: [ + { + name: "conferenceRecords/rec-1/recordings/r1", + state: "FILE_GENERATED", + driveDestination: { file: "drive-file-1" }, + }, + ], + }); + } + if (url.pathname === "/v2/conferenceRecords/rec-1/transcripts") { + return jsonResponse({ + transcripts: [ + { + name: "conferenceRecords/rec-1/transcripts/t1", + state: "FILE_GENERATED", + docsDestination: { document: "doc-1" }, + }, + ], + }); + } + if (url.pathname === "/v2/conferenceRecords/rec-1/transcripts/t1/entries") { + return jsonResponse({ + transcriptEntries: [ + { + name: "conferenceRecords/rec-1/transcripts/t1/entries/e1", + text: "Hello from the transcript.", + startTime: "2026-04-25T10:01:00Z", + participant: "conferenceRecords/rec-1/participants/p1", + }, + ], + }); + } + if (url.pathname === "/v2/conferenceRecords/rec-1/smartNotes") { + return jsonResponse({ + smartNotes: [ + { + name: "conferenceRecords/rec-1/smartNotes/sn1", + state: "FILE_GENERATED", + docsDestination: { document: "notes-1" }, + }, + ], + }); + } + return new Response("not found", { status: 404 }); + }), + ); +} + +function setupCli(params: { + config?: Parameters[0]; + runtime?: Partial; + ensureRuntime?: () => Promise; +}) { + const program = new Command(); + registerGoogleMeetCli({ + program, + config: resolveGoogleMeetConfig(params.config ?? {}), + ensureRuntime: + params.ensureRuntime ?? (async () => (params.runtime ?? {}) as unknown as GoogleMeetRuntime), + }); + return program; +} + +describe("google-meet CLI", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("prints setup checks as text and JSON", async () => { + { + const stdout = captureStdout(); + try { + await setupCli({ + runtime: { + setupStatus: async () => ({ + ok: true, + checks: [ + { + id: "audio-bridge", + ok: true, + message: "Chrome command-pair realtime audio bridge configured", + }, + ], + }), + }, + }).parseAsync(["googlemeet", "setup"], { from: "user" }); + expect(stdout.output()).toContain("Google Meet setup: OK"); + expect(stdout.output()).toContain( + "[ok] audio-bridge: Chrome command-pair realtime audio bridge configured", + ); + expect(stdout.output()).not.toContain('"checks"'); + } finally { + stdout.restore(); + } + } + + { + const stdout = captureStdout(); + try { + await setupCli({ + runtime: { + setupStatus: async () => ({ + ok: false, + checks: [{ id: "twilio-voice-call-plugin", ok: false, message: "missing" }], + }), + }, + }).parseAsync(["googlemeet", "setup", "--json"], { from: "user" }); + expect(JSON.parse(stdout.output())).toMatchObject({ + ok: false, + checks: [{ id: "twilio-voice-call-plugin", ok: false }], + }); + } finally { + stdout.restore(); + } + } + }); + + it("prints artifacts and attendance output", async () => { + stubMeetArtifactsApi(); + + const artifactsStdout = captureStdout(); + try { + await setupCli({}).parseAsync( + [ + "googlemeet", + "artifacts", + "--access-token", + "token", + "--expires-at", + String(Date.now() + 120_000), + "--conference-record", + "rec-1", + "--json", + ], + { from: "user" }, + ); + expect(JSON.parse(artifactsStdout.output())).toMatchObject({ + conferenceRecords: [{ name: "conferenceRecords/rec-1" }], + artifacts: [ + { + recordings: [{ name: "conferenceRecords/rec-1/recordings/r1" }], + transcripts: [{ name: "conferenceRecords/rec-1/transcripts/t1" }], + transcriptEntries: [ + { + transcript: "conferenceRecords/rec-1/transcripts/t1", + entries: [{ text: "Hello from the transcript." }], + }, + ], + smartNotes: [{ name: "conferenceRecords/rec-1/smartNotes/sn1" }], + }, + ], + tokenSource: "cached-access-token", + }); + } finally { + artifactsStdout.restore(); + } + + const attendanceStdout = captureStdout(); + try { + await setupCli({}).parseAsync( + [ + "googlemeet", + "attendance", + "--access-token", + "token", + "--expires-at", + String(Date.now() + 120_000), + "--conference-record", + "rec-1", + ], + { from: "user" }, + ); + expect(attendanceStdout.output()).toContain("attendance rows: 1"); + expect(attendanceStdout.output()).toContain("participant: Alice"); + expect(attendanceStdout.output()).toContain( + "conferenceRecords/rec-1/participants/p1/participantSessions/s1", + ); + } finally { + attendanceStdout.restore(); + } + }); + + it("prints the latest conference record", async () => { + stubMeetArtifactsApi(); + const stdout = captureStdout(); + + try { + await setupCli({}).parseAsync( + [ + "googlemeet", + "latest", + "--access-token", + "token", + "--expires-at", + String(Date.now() + 120_000), + "--meeting", + "abc-defg-hij", + ], + { from: "user" }, + ); + expect(stdout.output()).toContain("space: spaces/abc-defg-hij"); + expect(stdout.output()).toContain("conference record: conferenceRecords/rec-1"); + } finally { + stdout.restore(); + } + }); + + it("prints markdown artifact and attendance output", async () => { + stubMeetArtifactsApi(); + const tempDir = mkdtempSync(path.join(tmpdir(), "openclaw-google-meet-artifacts-")); + const outputPath = path.join(tempDir, "artifacts.md"); + const artifactsStdout = captureStdout(); + + try { + await setupCli({}).parseAsync( + [ + "googlemeet", + "artifacts", + "--access-token", + "token", + "--expires-at", + String(Date.now() + 120_000), + "--conference-record", + "rec-1", + "--format", + "markdown", + "--output", + outputPath, + ], + { from: "user" }, + ); + const markdown = readFileSync(outputPath, "utf8"); + expect(artifactsStdout.output()).toContain(`wrote: ${outputPath}`); + expect(markdown).toContain("# Google Meet Artifacts"); + expect(markdown).toContain("## conferenceRecords/rec-1"); + expect(markdown).toContain("### Transcript Entries: conferenceRecords/rec-1/transcripts/t1"); + expect(markdown).toContain("Hello from the transcript."); + } finally { + artifactsStdout.restore(); + rmSync(tempDir, { recursive: true, force: true }); + } + + const attendanceStdout = captureStdout(); + try { + await setupCli({}).parseAsync( + [ + "googlemeet", + "attendance", + "--access-token", + "token", + "--expires-at", + String(Date.now() + 120_000), + "--conference-record", + "rec-1", + "--format", + "markdown", + ], + { from: "user" }, + ); + expect(attendanceStdout.output()).toContain("# Google Meet Attendance"); + expect(attendanceStdout.output()).toContain("## Alice"); + expect(attendanceStdout.output()).toContain( + "conferenceRecords/rec-1/participants/p1/participantSessions/s1", + ); + } finally { + attendanceStdout.restore(); + } + }); + + it("prints human-readable session doctor output", async () => { + const stdout = captureStdout(); + try { + await setupCli({ + runtime: { + status: () => ({ + found: true, + session: { + id: "meet_1", + url: "https://meet.google.com/abc-defg-hij", + state: "active", + transport: "chrome-node", + mode: "realtime", + participantIdentity: "signed-in Google Chrome profile on a paired node", + createdAt: "2026-04-25T00:00:00.000Z", + updatedAt: "2026-04-25T00:00:01.000Z", + realtime: { enabled: true, provider: "openai", toolPolicy: "safe-read-only" }, + chrome: { + audioBackend: "blackhole-2ch", + launched: true, + nodeId: "node-1", + audioBridge: { type: "node-command-pair", provider: "openai" }, + health: { + inCall: true, + providerConnected: true, + realtimeReady: true, + audioInputActive: true, + audioOutputActive: false, + lastInputAt: "2026-04-25T00:00:02.000Z", + lastInputBytes: 160, + lastOutputBytes: 0, + }, + }, + notes: [], + }, + }), + }, + }).parseAsync(["googlemeet", "doctor", "meet_1"], { from: "user" }); + expect(stdout.output()).toContain("session: meet_1"); + expect(stdout.output()).toContain("node: node-1"); + expect(stdout.output()).toContain("provider connected: yes"); + expect(stdout.output()).toContain("audio input active: yes"); + expect(stdout.output()).toContain("audio output active: no"); + } finally { + stdout.restore(); + } + }); + + it("verifies OAuth refresh without printing secrets", async () => { + const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => + jsonResponse({ + access_token: "new-access-token", + expires_in: 3600, + token_type: "Bearer", + }), + ); + vi.stubGlobal("fetch", fetchMock); + const ensureRuntime = vi.fn(async () => { + throw new Error("runtime should not be loaded for OAuth doctor"); + }); + const stdout = captureStdout(); + + try { + await setupCli({ + config: { + oauth: { + clientId: "client-id", + clientSecret: "client-secret", + refreshToken: "rt-secret", + }, + }, + ensureRuntime: ensureRuntime as unknown as () => Promise, + }).parseAsync(["googlemeet", "doctor", "--oauth", "--json"], { from: "user" }); + const output = stdout.output(); + expect(output).not.toContain("new-access-token"); + expect(output).not.toContain("rt-secret"); + expect(output).not.toContain("client-secret"); + expect(JSON.parse(output)).toMatchObject({ + ok: true, + configured: true, + tokenSource: "refresh-token", + checks: [ + { id: "oauth-config", ok: true }, + { id: "oauth-token", ok: true }, + ], + }); + expect(ensureRuntime).not.toHaveBeenCalled(); + const body = fetchMock.mock.calls[0]?.[1]?.body as URLSearchParams; + expect(body.get("grant_type")).toBe("refresh_token"); + } finally { + stdout.restore(); + } + }); + + it("can prove Google Meet API create access", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = requestUrl(input).href; + if (url === "https://oauth2.googleapis.com/token") { + return jsonResponse({ + access_token: "new-access-token", + expires_in: 3600, + token_type: "Bearer", + }); + } + if (url === "https://meet.googleapis.com/v2/spaces") { + return jsonResponse({ + name: "spaces/new-space", + meetingUri: "https://meet.google.com/new-abcd-xyz", + }); + } + return new Response("not found", { status: 404 }); + }), + ); + const stdout = captureStdout(); + + try { + await setupCli({ + config: { + oauth: { + clientId: "client-id", + refreshToken: "refresh-token", + }, + }, + }).parseAsync(["googlemeet", "doctor", "--oauth", "--create-space", "--json"], { + from: "user", + }); + expect(JSON.parse(stdout.output())).toMatchObject({ + ok: true, + tokenSource: "refresh-token", + createdSpace: "spaces/new-space", + meetingUri: "https://meet.google.com/new-abcd-xyz", + checks: [ + { id: "oauth-config", ok: true }, + { id: "oauth-token", ok: true }, + { id: "meet-spaces-create", ok: true }, + ], + }); + } finally { + stdout.restore(); + } + }); + + it("recovers and summarizes an existing Meet tab", async () => { + const stdout = captureStdout(); + try { + await setupCli({ + config: { defaultTransport: "chrome-node" }, + runtime: { + recoverCurrentTab: async () => ({ + nodeId: "node-1", + found: true, + targetId: "tab-1", + tab: { targetId: "tab-1", url: "https://meet.google.com/abc-defg-hij" }, + browser: { + inCall: false, + manualActionRequired: true, + manualActionReason: "meet-admission-required", + manualActionMessage: "Admit the OpenClaw browser participant in Google Meet.", + browserUrl: "https://meet.google.com/abc-defg-hij", + }, + message: "Admit the OpenClaw browser participant in Google Meet.", + }), + }, + }).parseAsync(["googlemeet", "recover-tab"], { from: "user" }); + expect(stdout.output()).toContain("Google Meet current tab: found"); + expect(stdout.output()).toContain("target: tab-1"); + expect(stdout.output()).toContain("manual reason: meet-admission-required"); + } finally { + stdout.restore(); + } + }); +}); diff --git a/extensions/google-meet/src/oauth.test.ts b/extensions/google-meet/src/oauth.test.ts new file mode 100644 index 00000000000..debb989ef4c --- /dev/null +++ b/extensions/google-meet/src/oauth.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + buildGoogleMeetAuthUrl, + refreshGoogleMeetAccessToken, + resolveGoogleMeetAccessToken, +} from "./oauth.js"; + +describe("Google Meet OAuth", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("builds auth URLs and prefers fresh cached access tokens", async () => { + const url = new URL( + buildGoogleMeetAuthUrl({ + clientId: "client-id", + challenge: "challenge", + state: "state", + }), + ); + expect(url.hostname).toBe("accounts.google.com"); + expect(url.searchParams.get("client_id")).toBe("client-id"); + expect(url.searchParams.get("code_challenge")).toBe("challenge"); + expect(url.searchParams.get("access_type")).toBe("offline"); + expect(url.searchParams.get("scope")).toContain("meetings.space.created"); + expect(url.searchParams.get("scope")).toContain("meetings.conference.media.readonly"); + + await expect( + resolveGoogleMeetAccessToken({ + accessToken: "cached-token", + expiresAt: Date.now() + 120_000, + }), + ).resolves.toEqual({ + accessToken: "cached-token", + expiresAt: expect.any(Number), + refreshed: false, + }); + }); + + it("refreshes access tokens with a refresh-token grant", async () => { + const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => { + return new Response( + JSON.stringify({ + access_token: "new-access-token", + expires_in: 3600, + token_type: "Bearer", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }); + vi.stubGlobal("fetch", fetchMock); + + await expect( + refreshGoogleMeetAccessToken({ + clientId: "client-id", + clientSecret: "client-secret", + refreshToken: "refresh-token", + }), + ).resolves.toMatchObject({ + accessToken: "new-access-token", + tokenType: "Bearer", + }); + const body = fetchMock.mock.calls[0]?.[1]?.body; + expect(body).toBeInstanceOf(URLSearchParams); + const params = body as URLSearchParams; + expect(params.get("grant_type")).toBe("refresh_token"); + expect(params.get("refresh_token")).toBe("refresh-token"); + }); +}); diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index c309e2b1e6e..7ac4a15e94e 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -249,6 +249,10 @@ const TOOLING_TEST_TARGETS = new Map([ ]); const SOURCE_TEST_TARGETS = new Map([ ...PRECISE_SOURCE_TEST_TARGETS, + ["extensions/google-meet/index.ts", ["extensions/google-meet/index.test.ts"]], + ["extensions/google-meet/src/cli.ts", ["extensions/google-meet/src/cli.test.ts"]], + ["extensions/google-meet/src/create.ts", ["extensions/google-meet/index.test.ts"]], + ["extensions/google-meet/src/oauth.ts", ["extensions/google-meet/src/oauth.test.ts"]], ["src/agents/live-model-turn-probes.ts", ["src/agents/live-model-turn-probes.test.ts"]], [ "src/auto-reply/reply/dispatch-from-config.ts", diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index 7f00db5ff7a..4d8cdd8dea3 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -301,6 +301,28 @@ describe("scripts/test-projects changed-target routing", () => { ], }); }); + + it("routes Google Meet CLI edits to the lightweight CLI tests", () => { + expect(resolveChangedTestTargetPlan(["extensions/google-meet/src/cli.ts"])).toEqual({ + mode: "targets", + targets: ["extensions/google-meet/src/cli.test.ts"], + }); + }); + + it("routes Google Meet OAuth edits to the lightweight OAuth tests", () => { + expect(resolveChangedTestTargetPlan(["extensions/google-meet/src/oauth.ts"])).toEqual({ + mode: "targets", + targets: ["extensions/google-meet/src/oauth.test.ts"], + }); + }); + + it("routes Google Meet entry edits to the plugin entry tests", () => { + expect(resolveChangedTestTargetPlan(["extensions/google-meet/index.ts"])).toEqual({ + mode: "targets", + targets: ["extensions/google-meet/index.test.ts"], + }); + }); + it("routes changed utils and shared files to their light scoped lanes", () => { const plans = buildVitestRunPlans(["--changed", "origin/main"], process.cwd(), () => [ "src/shared/string-normalization.ts",