mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 10:30:44 +00:00
2591 lines
80 KiB
TypeScript
2591 lines
80 KiB
TypeScript
import { EventEmitter } from "node:events";
|
|
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { PassThrough, Writable } from "node:stream";
|
|
import type { RealtimeVoiceProviderPlugin } from "openclaw/plugin-sdk/realtime-voice";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import plugin, { __testing as googleMeetPluginTesting } from "./index.js";
|
|
import {
|
|
extractGoogleMeetUriFromCalendarEvent,
|
|
findGoogleMeetCalendarEvent,
|
|
listGoogleMeetCalendarEvents,
|
|
} from "./src/calendar.js";
|
|
import { resolveGoogleMeetConfig, resolveGoogleMeetConfigWithEnv } from "./src/config.js";
|
|
import {
|
|
buildGoogleMeetPreflightReport,
|
|
createGoogleMeetSpace,
|
|
fetchGoogleMeetArtifacts,
|
|
fetchGoogleMeetAttendance,
|
|
fetchLatestGoogleMeetConferenceRecord,
|
|
fetchGoogleMeetSpace,
|
|
normalizeGoogleMeetSpaceName,
|
|
} from "./src/meet.js";
|
|
import { handleGoogleMeetNodeHostCommand } from "./src/node-host.js";
|
|
import { startNodeRealtimeAudioBridge } from "./src/realtime-node.js";
|
|
import { startCommandRealtimeAudioBridge } from "./src/realtime.js";
|
|
import { GoogleMeetRuntime, normalizeMeetUrl } from "./src/runtime.js";
|
|
import {
|
|
invokeGoogleMeetGatewayMethodForTest,
|
|
noopLogger,
|
|
setupGoogleMeetPlugin,
|
|
} from "./src/test-support/plugin-harness.js";
|
|
import { __testing as chromeTransportTesting } from "./src/transports/chrome.js";
|
|
import { buildMeetDtmfSequence, normalizeDialInNumber } from "./src/transports/twilio.js";
|
|
import type { GoogleMeetSession } from "./src/transports/types.js";
|
|
|
|
const voiceCallMocks = vi.hoisted(() => ({
|
|
joinMeetViaVoiceCallGateway: vi.fn(async () => ({ callId: "call-1", dtmfSent: true })),
|
|
endMeetVoiceCallGatewayCall: vi.fn(async () => {}),
|
|
}));
|
|
|
|
const fetchGuardMocks = vi.hoisted(() => ({
|
|
fetchWithSsrFGuard: vi.fn(
|
|
async (params: {
|
|
url: string;
|
|
init?: RequestInit;
|
|
}): Promise<{
|
|
response: Response;
|
|
release: () => Promise<void>;
|
|
}> => ({
|
|
response: await fetch(params.url, params.init),
|
|
release: vi.fn(async () => {}),
|
|
}),
|
|
),
|
|
}));
|
|
|
|
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
|
|
fetchWithSsrFGuard: fetchGuardMocks.fetchWithSsrFGuard,
|
|
}));
|
|
|
|
vi.mock("./src/voice-call-gateway.js", () => ({
|
|
joinMeetViaVoiceCallGateway: voiceCallMocks.joinMeetViaVoiceCallGateway,
|
|
endMeetVoiceCallGatewayCall: voiceCallMocks.endMeetVoiceCallGatewayCall,
|
|
}));
|
|
|
|
function setup(
|
|
config?: Parameters<typeof setupGoogleMeetPlugin>[1],
|
|
options?: Parameters<typeof setupGoogleMeetPlugin>[2],
|
|
) {
|
|
const harness = setupGoogleMeetPlugin(plugin, config, options);
|
|
googleMeetPluginTesting.setCallGatewayFromCliForTests(
|
|
async (method, _opts, params) =>
|
|
(await invokeGoogleMeetGatewayMethodForTest(harness.methods, method, params)) as Record<
|
|
string,
|
|
unknown
|
|
>,
|
|
);
|
|
return harness;
|
|
}
|
|
|
|
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 mockLocalMeetBrowserRequest(
|
|
browserActResult: Record<string, unknown> = {
|
|
inCall: true,
|
|
micMuted: false,
|
|
title: "Meet call",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
},
|
|
) {
|
|
const callGatewayFromCli = vi.fn(
|
|
async (
|
|
_method: string,
|
|
_opts: unknown,
|
|
params?: unknown,
|
|
_extra?: unknown,
|
|
): Promise<Record<string, unknown>> => {
|
|
const request = params as { path?: string; body?: { targetId?: string; url?: string } };
|
|
if (request.path === "/tabs") {
|
|
return { tabs: [] };
|
|
}
|
|
if (request.path === "/tabs/open") {
|
|
return {
|
|
targetId: "local-meet-tab",
|
|
title: "Meet",
|
|
url: request.body?.url ?? "https://meet.google.com/abc-defg-hij",
|
|
};
|
|
}
|
|
if (request.path === "/tabs/focus") {
|
|
return { ok: true };
|
|
}
|
|
if (request.path === "/permissions/grant") {
|
|
return {
|
|
ok: true,
|
|
origin: "https://meet.google.com",
|
|
grantedPermissions: ["audioCapture", "videoCapture", "speakerSelection"],
|
|
unsupportedPermissions: [],
|
|
};
|
|
}
|
|
if (request.path === "/act") {
|
|
return { result: JSON.stringify(browserActResult) };
|
|
}
|
|
throw new Error(`unexpected browser request path ${request.path}`);
|
|
},
|
|
);
|
|
chromeTransportTesting.setDepsForTest({ callGatewayFromCli });
|
|
return callGatewayFromCli;
|
|
}
|
|
|
|
function stubMeetArtifactsApi() {
|
|
const fetchMock = 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 === "/calendar/v3/calendars/primary/events") {
|
|
return jsonResponse({
|
|
items: [
|
|
{
|
|
id: "event-1",
|
|
summary: "Project sync",
|
|
hangoutLink: "https://meet.google.com/abc-defg-hij",
|
|
start: { dateTime: "2026-04-25T10:00:00Z" },
|
|
end: { dateTime: "2026-04-25T10:30:00Z" },
|
|
},
|
|
],
|
|
});
|
|
}
|
|
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",
|
|
earliestStartTime: "2026-04-25T10:00:00Z",
|
|
latestEndTime: "2026-04-25T10:30:00Z",
|
|
signedinUser: { user: "users/alice", 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:30:00Z",
|
|
},
|
|
],
|
|
});
|
|
}
|
|
if (url.pathname === "/v2/conferenceRecords/rec-1/recordings") {
|
|
return jsonResponse({
|
|
recordings: [
|
|
{
|
|
name: "conferenceRecords/rec-1/recordings/r1",
|
|
driveDestination: { file: "drive/file-1" },
|
|
},
|
|
],
|
|
});
|
|
}
|
|
if (url.pathname === "/v2/conferenceRecords/rec-1/transcripts") {
|
|
return jsonResponse({
|
|
transcripts: [
|
|
{
|
|
name: "conferenceRecords/rec-1/transcripts/t1",
|
|
docsDestination: { document: "docs/doc-1" },
|
|
},
|
|
],
|
|
});
|
|
}
|
|
if (url.pathname === "/v2/conferenceRecords/rec-1/transcripts/t1/entries") {
|
|
return jsonResponse({
|
|
transcriptEntries: [
|
|
{
|
|
name: "conferenceRecords/rec-1/transcripts/t1/entries/e1",
|
|
participant: "conferenceRecords/rec-1/participants/p1",
|
|
text: "Hello from the transcript.",
|
|
languageCode: "en-US",
|
|
startTime: "2026-04-25T10:01:00Z",
|
|
endTime: "2026-04-25T10:01:05Z",
|
|
},
|
|
],
|
|
});
|
|
}
|
|
if (url.pathname === "/v2/conferenceRecords/rec-1/smartNotes") {
|
|
return jsonResponse({
|
|
smartNotes: [
|
|
{
|
|
name: "conferenceRecords/rec-1/smartNotes/sn1",
|
|
docsDestination: { document: "docs/doc-2" },
|
|
},
|
|
],
|
|
});
|
|
}
|
|
if (url.pathname === "/drive/v3/files/doc-1/export") {
|
|
return new Response("Transcript document body.", {
|
|
status: 200,
|
|
headers: { "Content-Type": "text/plain" },
|
|
});
|
|
}
|
|
if (url.pathname === "/drive/v3/files/doc-2/export") {
|
|
return new Response("Smart note document body.", {
|
|
status: 200,
|
|
headers: { "Content-Type": "text/plain" },
|
|
});
|
|
}
|
|
return new Response(`unexpected ${url.pathname}`, { status: 404 });
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
return fetchMock;
|
|
}
|
|
|
|
type TestBridgeProcess = {
|
|
stdin?: { write(chunk: unknown): unknown } | null;
|
|
stdout?: { on(event: "data", listener: (chunk: unknown) => void): unknown } | null;
|
|
stderr: PassThrough;
|
|
killed: boolean;
|
|
kill: ReturnType<typeof vi.fn>;
|
|
on: EventEmitter["on"];
|
|
emit: EventEmitter["emit"];
|
|
};
|
|
|
|
describe("google-meet plugin", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
chromeTransportTesting.setDepsForTest(null);
|
|
googleMeetPluginTesting.setCallGatewayFromCliForTests();
|
|
});
|
|
|
|
it("defaults to chrome realtime with safe read-only tools", () => {
|
|
expect(resolveGoogleMeetConfig({})).toMatchObject({
|
|
enabled: true,
|
|
defaults: {},
|
|
preview: { enrollmentAcknowledged: false },
|
|
defaultTransport: "chrome",
|
|
defaultMode: "realtime",
|
|
chrome: {
|
|
audioBackend: "blackhole-2ch",
|
|
launch: true,
|
|
guestName: "OpenClaw Agent",
|
|
reuseExistingTab: true,
|
|
autoJoin: true,
|
|
waitForInCallMs: 20000,
|
|
audioFormat: "pcm16-24khz",
|
|
audioInputCommand: [
|
|
"sox",
|
|
"-q",
|
|
"-t",
|
|
"coreaudio",
|
|
"BlackHole 2ch",
|
|
"-t",
|
|
"raw",
|
|
"-r",
|
|
"24000",
|
|
"-c",
|
|
"1",
|
|
"-e",
|
|
"signed-integer",
|
|
"-b",
|
|
"16",
|
|
"-L",
|
|
"-",
|
|
],
|
|
audioOutputCommand: [
|
|
"sox",
|
|
"-q",
|
|
"-t",
|
|
"raw",
|
|
"-r",
|
|
"24000",
|
|
"-c",
|
|
"1",
|
|
"-e",
|
|
"signed-integer",
|
|
"-b",
|
|
"16",
|
|
"-L",
|
|
"-",
|
|
"-t",
|
|
"coreaudio",
|
|
"BlackHole 2ch",
|
|
],
|
|
},
|
|
voiceCall: { enabled: true, requestTimeoutMs: 30000, dtmfDelayMs: 2500 },
|
|
realtime: {
|
|
provider: "openai",
|
|
introMessage: "Say exactly: I'm here and listening.",
|
|
toolPolicy: "safe-read-only",
|
|
},
|
|
oauth: {},
|
|
auth: { provider: "google-oauth" },
|
|
});
|
|
expect(resolveGoogleMeetConfig({}).realtime.instructions).toContain("openclaw_agent_consult");
|
|
});
|
|
|
|
it("resolves the realtime consult agent id", () => {
|
|
expect(
|
|
resolveGoogleMeetConfig({
|
|
realtime: {
|
|
agentId: " jay ",
|
|
},
|
|
}).realtime.agentId,
|
|
).toBe("jay");
|
|
});
|
|
|
|
it("keeps legacy command-pair audio format when custom commands omit a format", () => {
|
|
expect(
|
|
resolveGoogleMeetConfig({
|
|
chrome: {
|
|
audioInputCommand: ["capture-legacy"],
|
|
audioOutputCommand: ["play-legacy"],
|
|
},
|
|
}).chrome,
|
|
).toMatchObject({
|
|
audioFormat: "g711-ulaw-8khz",
|
|
audioInputCommand: ["capture-legacy"],
|
|
audioOutputCommand: ["play-legacy"],
|
|
});
|
|
});
|
|
|
|
it("uses env fallbacks for OAuth, preview, and default meeting values", () => {
|
|
expect(
|
|
resolveGoogleMeetConfigWithEnv(
|
|
{},
|
|
{
|
|
OPENCLAW_GOOGLE_MEET_CLIENT_ID: "client-id",
|
|
GOOGLE_MEET_CLIENT_SECRET: "client-secret",
|
|
OPENCLAW_GOOGLE_MEET_REFRESH_TOKEN: "refresh-token",
|
|
GOOGLE_MEET_ACCESS_TOKEN: "access-token",
|
|
OPENCLAW_GOOGLE_MEET_ACCESS_TOKEN_EXPIRES_AT: "123456",
|
|
GOOGLE_MEET_DEFAULT_MEETING: "https://meet.google.com/abc-defg-hij",
|
|
OPENCLAW_GOOGLE_MEET_PREVIEW_ACK: "true",
|
|
},
|
|
),
|
|
).toMatchObject({
|
|
defaults: { meeting: "https://meet.google.com/abc-defg-hij" },
|
|
preview: { enrollmentAcknowledged: true },
|
|
oauth: {
|
|
clientId: "client-id",
|
|
clientSecret: "client-secret",
|
|
refreshToken: "refresh-token",
|
|
accessToken: "access-token",
|
|
expiresAt: 123456,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("requires explicit Meet URLs", () => {
|
|
expect(normalizeMeetUrl("https://meet.google.com/abc-defg-hij")).toBe(
|
|
"https://meet.google.com/abc-defg-hij",
|
|
);
|
|
expect(() => normalizeMeetUrl("https://example.com/abc-defg-hij")).toThrow("meet.google.com");
|
|
});
|
|
|
|
it("advertises only the googlemeet CLI descriptor", () => {
|
|
const { cliRegistrations } = setup();
|
|
|
|
expect(cliRegistrations).toContainEqual({
|
|
commands: ["googlemeet"],
|
|
descriptors: [
|
|
{
|
|
name: "googlemeet",
|
|
description: "Join and manage Google Meet calls",
|
|
hasSubcommands: true,
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("registers the node-host command used by chrome-node transport", () => {
|
|
const { nodeHostCommands } = setup();
|
|
|
|
expect(nodeHostCommands).toContainEqual(
|
|
expect.objectContaining({
|
|
command: "googlemeet.chrome",
|
|
cap: "google-meet",
|
|
handle: expect.any(Function),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("returns structured gateway errors for missing session ids", async () => {
|
|
const { methods } = setup();
|
|
for (const method of ["googlemeet.leave", "googlemeet.speak"]) {
|
|
const handler = methods.get(method) as
|
|
| ((ctx: {
|
|
params: Record<string, unknown>;
|
|
respond: ReturnType<typeof vi.fn>;
|
|
}) => Promise<void>)
|
|
| undefined;
|
|
const respond = vi.fn();
|
|
|
|
await handler?.({ params: {}, respond });
|
|
|
|
expect(respond).toHaveBeenCalledWith(
|
|
false,
|
|
{ error: "sessionId required" },
|
|
{
|
|
code: "INVALID_REQUEST",
|
|
message: "sessionId required",
|
|
details: { error: "sessionId required" },
|
|
},
|
|
);
|
|
}
|
|
});
|
|
|
|
it("uses a provider-safe flat tool parameter schema", () => {
|
|
const { tools } = setup();
|
|
const tool = tools[0] as { description?: string; parameters: unknown };
|
|
|
|
expect(tool.description).toContain("recover_current_tab");
|
|
expect(JSON.stringify(tool.parameters)).not.toContain("anyOf");
|
|
expect(tool.parameters).toMatchObject({
|
|
type: "object",
|
|
properties: {
|
|
action: {
|
|
type: "string",
|
|
enum: [
|
|
"join",
|
|
"create",
|
|
"status",
|
|
"setup_status",
|
|
"resolve_space",
|
|
"preflight",
|
|
"latest",
|
|
"calendar_events",
|
|
"artifacts",
|
|
"attendance",
|
|
"export",
|
|
"recover_current_tab",
|
|
"leave",
|
|
"speak",
|
|
"test_speech",
|
|
],
|
|
description: expect.stringContaining("recover_current_tab"),
|
|
},
|
|
transport: { type: "string", enum: ["chrome", "chrome-node", "twilio"] },
|
|
mode: { type: "string", enum: ["realtime", "transcribe"] },
|
|
},
|
|
});
|
|
});
|
|
|
|
it("normalizes Meet URLs, codes, and space names for the Meet API", () => {
|
|
expect(normalizeGoogleMeetSpaceName("spaces/abc-defg-hij")).toBe("spaces/abc-defg-hij");
|
|
expect(normalizeGoogleMeetSpaceName("abc-defg-hij")).toBe("spaces/abc-defg-hij");
|
|
expect(normalizeGoogleMeetSpaceName("https://meet.google.com/abc-defg-hij")).toBe(
|
|
"spaces/abc-defg-hij",
|
|
);
|
|
expect(() => normalizeGoogleMeetSpaceName("https://example.com/abc-defg-hij")).toThrow(
|
|
"meet.google.com",
|
|
);
|
|
});
|
|
|
|
it("finds Google Meet links from Calendar events", async () => {
|
|
const fetchMock = stubMeetArtifactsApi();
|
|
|
|
expect(
|
|
extractGoogleMeetUriFromCalendarEvent({
|
|
conferenceData: {
|
|
entryPoints: [
|
|
{
|
|
entryPointType: "video",
|
|
uri: "https://meet.google.com/abc-defg-hij",
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
).toBe("https://meet.google.com/abc-defg-hij");
|
|
await expect(
|
|
findGoogleMeetCalendarEvent({
|
|
accessToken: "token",
|
|
now: new Date("2026-04-25T09:50:00Z"),
|
|
timeMin: "2026-04-25T00:00:00Z",
|
|
timeMax: "2026-04-26T00:00:00Z",
|
|
}),
|
|
).resolves.toMatchObject({
|
|
calendarId: "primary",
|
|
meetingUri: "https://meet.google.com/abc-defg-hij",
|
|
event: { summary: "Project sync" },
|
|
});
|
|
await expect(
|
|
listGoogleMeetCalendarEvents({
|
|
accessToken: "token",
|
|
now: new Date("2026-04-25T09:50:00Z"),
|
|
timeMin: "2026-04-25T00:00:00Z",
|
|
timeMax: "2026-04-26T00:00:00Z",
|
|
}),
|
|
).resolves.toMatchObject({
|
|
events: [
|
|
{
|
|
meetingUri: "https://meet.google.com/abc-defg-hij",
|
|
selected: true,
|
|
},
|
|
],
|
|
});
|
|
const calendarCall = fetchMock.mock.calls.find(([input]) => {
|
|
const url = requestUrl(input);
|
|
return url.pathname === "/calendar/v3/calendars/primary/events";
|
|
});
|
|
if (!calendarCall) {
|
|
throw new Error("Expected Calendar events.list fetch call");
|
|
}
|
|
const url = requestUrl(calendarCall[0]);
|
|
expect(url.searchParams.get("singleEvents")).toBe("true");
|
|
expect(url.searchParams.get("orderBy")).toBe("startTime");
|
|
expect(fetchGuardMocks.fetchWithSsrFGuard).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
policy: { allowedHostnames: ["www.googleapis.com"] },
|
|
auditContext: "google-meet.calendar.events.list",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("adds a reauth hint for missing Calendar scopes", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async () => new Response("insufficientPermissions", { status: 403 })),
|
|
);
|
|
|
|
await expect(
|
|
findGoogleMeetCalendarEvent({
|
|
accessToken: "token",
|
|
timeMin: "2026-04-25T00:00:00Z",
|
|
timeMax: "2026-04-26T00:00:00Z",
|
|
}),
|
|
).rejects.toThrow("calendar.events.readonly");
|
|
await expect(
|
|
findGoogleMeetCalendarEvent({
|
|
accessToken: "token",
|
|
timeMin: "2026-04-25T00:00:00Z",
|
|
timeMax: "2026-04-26T00:00:00Z",
|
|
}),
|
|
).rejects.toThrow("googlemeet auth login");
|
|
});
|
|
|
|
it("fetches Meet spaces without percent-encoding the spaces path separator", async () => {
|
|
const fetchMock = vi.fn(async () => {
|
|
return new Response(
|
|
JSON.stringify({
|
|
name: "spaces/abc-defg-hij",
|
|
meetingCode: "abc-defg-hij",
|
|
meetingUri: "https://meet.google.com/abc-defg-hij",
|
|
}),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
);
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
await expect(
|
|
fetchGoogleMeetSpace({
|
|
accessToken: "token",
|
|
meeting: "spaces/abc-defg-hij",
|
|
}),
|
|
).resolves.toMatchObject({ name: "spaces/abc-defg-hij" });
|
|
expect(fetchGuardMocks.fetchWithSsrFGuard).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
url: "https://meet.googleapis.com/v2/spaces/abc-defg-hij",
|
|
init: expect.objectContaining({
|
|
headers: expect.objectContaining({ Authorization: "Bearer token" }),
|
|
}),
|
|
policy: { allowedHostnames: ["meet.googleapis.com"] },
|
|
auditContext: "google-meet.spaces.get",
|
|
}),
|
|
);
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
"https://meet.googleapis.com/v2/spaces/abc-defg-hij",
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({ Authorization: "Bearer token" }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("creates Meet spaces and returns the meeting URL", async () => {
|
|
const fetchMock = vi.fn(async () => {
|
|
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);
|
|
|
|
await expect(createGoogleMeetSpace({ accessToken: "token" })).resolves.toMatchObject({
|
|
meetingUri: "https://meet.google.com/new-abcd-xyz",
|
|
space: { name: "spaces/new-space" },
|
|
});
|
|
expect(fetchGuardMocks.fetchWithSsrFGuard).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
url: "https://meet.googleapis.com/v2/spaces",
|
|
init: expect.objectContaining({
|
|
method: "POST",
|
|
headers: expect.objectContaining({ Authorization: "Bearer token" }),
|
|
body: "{}",
|
|
}),
|
|
policy: { allowedHostnames: ["meet.googleapis.com"] },
|
|
auditContext: "google-meet.spaces.create",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("lists Meet artifact metadata for the latest conference record by default", async () => {
|
|
const fetchMock = stubMeetArtifactsApi();
|
|
|
|
await expect(
|
|
fetchGoogleMeetArtifacts({
|
|
accessToken: "token",
|
|
meeting: "abc-defg-hij",
|
|
pageSize: 2,
|
|
}),
|
|
).resolves.toMatchObject({
|
|
input: "abc-defg-hij",
|
|
space: { name: "spaces/abc-defg-hij" },
|
|
conferenceRecords: [{ name: "conferenceRecords/rec-1" }],
|
|
artifacts: [
|
|
{
|
|
conferenceRecord: { name: "conferenceRecords/rec-1" },
|
|
participants: [{ name: "conferenceRecords/rec-1/participants/p1" }],
|
|
recordings: [{ name: "conferenceRecords/rec-1/recordings/r1" }],
|
|
transcripts: [{ name: "conferenceRecords/rec-1/transcripts/t1" }],
|
|
transcriptEntries: [
|
|
{
|
|
transcript: "conferenceRecords/rec-1/transcripts/t1",
|
|
entries: [
|
|
{
|
|
name: "conferenceRecords/rec-1/transcripts/t1/entries/e1",
|
|
text: "Hello from the transcript.",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
smartNotes: [{ name: "conferenceRecords/rec-1/smartNotes/sn1" }],
|
|
},
|
|
],
|
|
});
|
|
|
|
const listCall = fetchMock.mock.calls.find(([input]) => {
|
|
const url = requestUrl(input);
|
|
return url.pathname === "/v2/conferenceRecords";
|
|
});
|
|
if (!listCall) {
|
|
throw new Error("Expected conferenceRecords.list fetch call");
|
|
}
|
|
const listUrl = requestUrl(listCall[0]);
|
|
expect(listUrl.searchParams.get("filter")).toBe('space.name = "spaces/abc-defg-hij"');
|
|
expect(listUrl.searchParams.get("pageSize")).toBe("1");
|
|
expect(fetchGuardMocks.fetchWithSsrFGuard).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
url: "https://meet.googleapis.com/v2/conferenceRecords/rec-1/smartNotes?pageSize=2",
|
|
auditContext: "google-meet.conferenceRecords.smartNotes.list",
|
|
}),
|
|
);
|
|
expect(fetchGuardMocks.fetchWithSsrFGuard).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
url: "https://meet.googleapis.com/v2/conferenceRecords/rec-1/transcripts/t1/entries?pageSize=2",
|
|
auditContext: "google-meet.conferenceRecords.transcripts.entries.list",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("keeps all conference records available when requested", async () => {
|
|
const fetchMock = stubMeetArtifactsApi();
|
|
|
|
await fetchGoogleMeetArtifacts({
|
|
accessToken: "token",
|
|
meeting: "abc-defg-hij",
|
|
pageSize: 2,
|
|
allConferenceRecords: true,
|
|
});
|
|
|
|
const listCall = fetchMock.mock.calls.find(([input]) => {
|
|
const url = requestUrl(input);
|
|
return url.pathname === "/v2/conferenceRecords";
|
|
});
|
|
if (!listCall) {
|
|
throw new Error("Expected conferenceRecords.list fetch call");
|
|
}
|
|
const listUrl = requestUrl(listCall[0]);
|
|
expect(listUrl.searchParams.get("pageSize")).toBe("2");
|
|
expect(listUrl.searchParams.get("filter")).toBe('space.name = "spaces/abc-defg-hij"');
|
|
});
|
|
|
|
it("exports linked Google Docs bodies when requested", async () => {
|
|
const fetchMock = stubMeetArtifactsApi();
|
|
|
|
await expect(
|
|
fetchGoogleMeetArtifacts({
|
|
accessToken: "token",
|
|
conferenceRecord: "rec-1",
|
|
includeDocumentBodies: true,
|
|
}),
|
|
).resolves.toMatchObject({
|
|
artifacts: [
|
|
{
|
|
transcripts: [{ documentText: "Transcript document body." }],
|
|
smartNotes: [{ documentText: "Smart note document body." }],
|
|
},
|
|
],
|
|
});
|
|
const driveCalls = fetchMock.mock.calls
|
|
.map(([input]) => requestUrl(input))
|
|
.filter((url) => url.pathname.startsWith("/drive/v3/files/"));
|
|
expect(driveCalls.map((url) => url.pathname)).toEqual([
|
|
"/drive/v3/files/doc-1/export",
|
|
"/drive/v3/files/doc-2/export",
|
|
]);
|
|
expect(driveCalls.every((url) => url.searchParams.get("mimeType") === "text/plain")).toBe(true);
|
|
});
|
|
|
|
it("fetches only the latest Meet conference record for a meeting", async () => {
|
|
const fetchMock = stubMeetArtifactsApi();
|
|
|
|
await expect(
|
|
fetchLatestGoogleMeetConferenceRecord({
|
|
accessToken: "token",
|
|
meeting: "abc-defg-hij",
|
|
}),
|
|
).resolves.toMatchObject({
|
|
input: "abc-defg-hij",
|
|
space: { name: "spaces/abc-defg-hij" },
|
|
conferenceRecord: { name: "conferenceRecords/rec-1" },
|
|
});
|
|
|
|
const listCall = fetchMock.mock.calls.find(([input]) => {
|
|
const url = requestUrl(input);
|
|
return url.pathname === "/v2/conferenceRecords";
|
|
});
|
|
if (!listCall) {
|
|
throw new Error("Expected conferenceRecords.list fetch call");
|
|
}
|
|
const listUrl = requestUrl(listCall[0]);
|
|
expect(listUrl.searchParams.get("pageSize")).toBe("1");
|
|
expect(listUrl.searchParams.get("filter")).toBe('space.name = "spaces/abc-defg-hij"');
|
|
});
|
|
|
|
it("lists Meet attendance rows with participant sessions", async () => {
|
|
const fetchMock = stubMeetArtifactsApi();
|
|
|
|
await expect(
|
|
fetchGoogleMeetAttendance({
|
|
accessToken: "token",
|
|
conferenceRecord: "rec-1",
|
|
pageSize: 3,
|
|
}),
|
|
).resolves.toMatchObject({
|
|
input: "rec-1",
|
|
conferenceRecords: [{ name: "conferenceRecords/rec-1" }],
|
|
attendance: [
|
|
{
|
|
conferenceRecord: "conferenceRecords/rec-1",
|
|
participant: "conferenceRecords/rec-1/participants/p1",
|
|
displayName: "Alice",
|
|
user: "users/alice",
|
|
sessions: [
|
|
{
|
|
name: "conferenceRecords/rec-1/participants/p1/participantSessions/s1",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
"https://meet.googleapis.com/v2/conferenceRecords/rec-1",
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({ Authorization: "Bearer token" }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("merges duplicate attendance participants and annotates timing", async () => {
|
|
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = requestUrl(input);
|
|
if (url.pathname === "/v2/conferenceRecords/rec-1") {
|
|
return jsonResponse({
|
|
name: "conferenceRecords/rec-1",
|
|
startTime: "2026-04-25T10:00:00Z",
|
|
endTime: "2026-04-25T11:00:00Z",
|
|
});
|
|
}
|
|
if (url.pathname === "/v2/conferenceRecords/rec-1/participants") {
|
|
return jsonResponse({
|
|
participants: [
|
|
{
|
|
name: "conferenceRecords/rec-1/participants/p1",
|
|
signedinUser: { user: "users/alice", displayName: "Alice" },
|
|
},
|
|
{
|
|
name: "conferenceRecords/rec-1/participants/p2",
|
|
signedinUser: { user: "users/alice", 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:10:00Z",
|
|
endTime: "2026-04-25T10:30:00Z",
|
|
},
|
|
],
|
|
});
|
|
}
|
|
if (url.pathname === "/v2/conferenceRecords/rec-1/participants/p2/participantSessions") {
|
|
return jsonResponse({
|
|
participantSessions: [
|
|
{
|
|
name: "conferenceRecords/rec-1/participants/p2/participantSessions/s1",
|
|
startTime: "2026-04-25T10:40:00Z",
|
|
endTime: "2026-04-25T10:50:00Z",
|
|
},
|
|
],
|
|
});
|
|
}
|
|
return new Response(`unexpected ${url.pathname}`, { status: 404 });
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
await expect(
|
|
fetchGoogleMeetAttendance({
|
|
accessToken: "token",
|
|
conferenceRecord: "rec-1",
|
|
}),
|
|
).resolves.toMatchObject({
|
|
attendance: [
|
|
{
|
|
displayName: "Alice",
|
|
participants: [
|
|
"conferenceRecords/rec-1/participants/p1",
|
|
"conferenceRecords/rec-1/participants/p2",
|
|
],
|
|
firstJoinTime: "2026-04-25T10:10:00.000Z",
|
|
lastLeaveTime: "2026-04-25T10:50:00.000Z",
|
|
durationMs: 1_800_000,
|
|
late: true,
|
|
earlyLeave: true,
|
|
sessions: [
|
|
{ name: expect.stringContaining("/p1/") },
|
|
{ name: expect.stringContaining("/p2/") },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("surfaces Developer Preview acknowledgment blockers in preflight reports", () => {
|
|
expect(
|
|
buildGoogleMeetPreflightReport({
|
|
input: "abc-defg-hij",
|
|
space: { name: "spaces/abc-defg-hij" },
|
|
previewAcknowledged: false,
|
|
tokenSource: "cached-access-token",
|
|
}),
|
|
).toMatchObject({
|
|
resolvedSpaceName: "spaces/abc-defg-hij",
|
|
previewAcknowledged: false,
|
|
blockers: [expect.stringContaining("Developer Preview Program")],
|
|
});
|
|
});
|
|
|
|
it("builds Twilio dial plans from a PIN", () => {
|
|
expect(normalizeDialInNumber("+1 (555) 123-4567")).toBe("+15551234567");
|
|
expect(buildMeetDtmfSequence({ pin: "123 456" })).toBe("123456#");
|
|
expect(buildMeetDtmfSequence({ dtmfSequence: "ww123#" })).toBe("ww123#");
|
|
});
|
|
|
|
it("joins a Twilio session through the tool without page parsing", async () => {
|
|
const { tools } = setup({ defaultTransport: "twilio" });
|
|
const tool = tools[0] as {
|
|
execute: (id: string, params: unknown) => Promise<{ details: { session: unknown } }>;
|
|
};
|
|
const result = await tool.execute("id", {
|
|
action: "join",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
dialInNumber: "+15551234567",
|
|
pin: "123456",
|
|
});
|
|
|
|
expect(result.details.session).toMatchObject({
|
|
transport: "twilio",
|
|
mode: "realtime",
|
|
twilio: {
|
|
dialInNumber: "+15551234567",
|
|
pinProvided: true,
|
|
dtmfSequence: "123456#",
|
|
voiceCallId: "call-1",
|
|
dtmfSent: true,
|
|
},
|
|
});
|
|
expect(voiceCallMocks.joinMeetViaVoiceCallGateway).toHaveBeenCalledWith({
|
|
config: expect.objectContaining({ defaultTransport: "twilio" }),
|
|
dialInNumber: "+15551234567",
|
|
dtmfSequence: "123456#",
|
|
});
|
|
});
|
|
|
|
it("hangs up delegated Twilio calls on leave", async () => {
|
|
const { tools } = setup({ defaultTransport: "twilio" });
|
|
const tool = tools[0] as {
|
|
execute: (id: string, params: unknown) => Promise<{ details: { session: { id: string } } }>;
|
|
};
|
|
const joined = await tool.execute("id", {
|
|
action: "join",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
dialInNumber: "+15551234567",
|
|
pin: "123456",
|
|
});
|
|
|
|
await tool.execute("id", { action: "leave", sessionId: joined.details.session.id });
|
|
|
|
expect(voiceCallMocks.endMeetVoiceCallGatewayCall).toHaveBeenCalledWith({
|
|
config: expect.objectContaining({ defaultTransport: "twilio" }),
|
|
callId: "call-1",
|
|
});
|
|
});
|
|
|
|
it("reports setup status through the tool", async () => {
|
|
const originalPlatform = process.platform;
|
|
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
try {
|
|
const { tools } = setup({
|
|
chrome: {
|
|
audioInputCommand: ["openclaw-audio-bridge", "capture"],
|
|
audioOutputCommand: ["openclaw-audio-bridge", "play"],
|
|
},
|
|
});
|
|
const tool = tools[0] as {
|
|
execute: (id: string, params: unknown) => Promise<{ details: { ok?: boolean } }>;
|
|
};
|
|
|
|
const result = await tool.execute("id", { action: "setup_status" });
|
|
|
|
expect(result.details.ok).toBe(true);
|
|
} finally {
|
|
Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
}
|
|
});
|
|
|
|
it("reports attendance through the tool", async () => {
|
|
stubMeetArtifactsApi();
|
|
const { tools } = setup();
|
|
const tool = tools[0] as {
|
|
execute: (
|
|
id: string,
|
|
params: unknown,
|
|
) => Promise<{ details: { attendance?: Array<{ displayName?: string }> } }>;
|
|
};
|
|
|
|
const result = await tool.execute("id", {
|
|
action: "attendance",
|
|
accessToken: "token",
|
|
expiresAt: Date.now() + 120_000,
|
|
conferenceRecord: "rec-1",
|
|
pageSize: 3,
|
|
});
|
|
|
|
expect(result.details.attendance).toEqual([expect.objectContaining({ displayName: "Alice" })]);
|
|
});
|
|
|
|
it("writes export bundles through the tool", async () => {
|
|
stubMeetArtifactsApi();
|
|
const tempDir = mkdtempSync(path.join(tmpdir(), "openclaw-google-meet-tool-export-"));
|
|
const { tools } = setup();
|
|
const tool = tools[0] as {
|
|
execute: (
|
|
id: string,
|
|
params: unknown,
|
|
) => Promise<{ details: { files?: string[]; zipFile?: string } }>;
|
|
};
|
|
|
|
try {
|
|
const result = await tool.execute("id", {
|
|
action: "export",
|
|
accessToken: "token",
|
|
expiresAt: Date.now() + 120_000,
|
|
conferenceRecord: "rec-1",
|
|
includeDocumentBodies: true,
|
|
outputDir: tempDir,
|
|
zip: true,
|
|
});
|
|
|
|
expect(result.details.files).toEqual(
|
|
expect.arrayContaining([path.join(tempDir, "manifest.json")]),
|
|
);
|
|
expect(result.details.zipFile).toBe(`${tempDir}.zip`);
|
|
const manifest = JSON.parse(readFileSync(path.join(tempDir, "manifest.json"), "utf8"));
|
|
expect(manifest).toMatchObject({
|
|
request: {
|
|
conferenceRecord: "rec-1",
|
|
includeDocumentBodies: true,
|
|
},
|
|
counts: {
|
|
attendanceRows: 1,
|
|
warnings: 0,
|
|
},
|
|
files: expect.arrayContaining(["summary.md", "manifest.json"]),
|
|
});
|
|
} finally {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
rmSync(`${tempDir}.zip`, { force: true });
|
|
}
|
|
});
|
|
|
|
it("dry-runs export bundles through the tool", async () => {
|
|
stubMeetArtifactsApi();
|
|
const parentDir = mkdtempSync(path.join(tmpdir(), "openclaw-google-meet-tool-dry-run-"));
|
|
const outputDir = path.join(parentDir, "bundle");
|
|
const { tools } = setup();
|
|
const tool = tools[0] as {
|
|
execute: (
|
|
id: string,
|
|
params: unknown,
|
|
) => Promise<{ details: { dryRun?: boolean; manifest?: { files?: string[] } } }>;
|
|
};
|
|
|
|
try {
|
|
const result = await tool.execute("id", {
|
|
action: "export",
|
|
accessToken: "token",
|
|
expiresAt: Date.now() + 120_000,
|
|
conferenceRecord: "rec-1",
|
|
outputDir,
|
|
dryRun: true,
|
|
});
|
|
|
|
expect(result.details).toMatchObject({
|
|
dryRun: true,
|
|
manifest: {
|
|
files: expect.arrayContaining(["summary.md", "manifest.json"]),
|
|
},
|
|
});
|
|
expect(existsSync(outputDir)).toBe(false);
|
|
} finally {
|
|
rmSync(parentDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("reports the latest conference record through the tool", async () => {
|
|
stubMeetArtifactsApi();
|
|
const { tools } = setup();
|
|
const tool = tools[0] as {
|
|
execute: (
|
|
id: string,
|
|
params: unknown,
|
|
) => Promise<{ details: { conferenceRecord?: { name?: string } } }>;
|
|
};
|
|
|
|
const result = await tool.execute("id", {
|
|
action: "latest",
|
|
accessToken: "token",
|
|
expiresAt: Date.now() + 120_000,
|
|
meeting: "abc-defg-hij",
|
|
});
|
|
|
|
expect(result.details.conferenceRecord).toMatchObject({ name: "conferenceRecords/rec-1" });
|
|
});
|
|
|
|
it("reports the latest conference record from today's calendar through the tool", async () => {
|
|
stubMeetArtifactsApi();
|
|
const { tools } = setup();
|
|
const tool = tools[0] as {
|
|
execute: (
|
|
id: string,
|
|
params: unknown,
|
|
) => Promise<{ details: { calendarEvent?: { meetingUri?: string } } }>;
|
|
};
|
|
|
|
const result = await tool.execute("id", {
|
|
action: "latest",
|
|
accessToken: "token",
|
|
expiresAt: Date.now() + 120_000,
|
|
today: true,
|
|
});
|
|
|
|
expect(result.details.calendarEvent).toMatchObject({
|
|
meetingUri: "https://meet.google.com/abc-defg-hij",
|
|
});
|
|
});
|
|
|
|
it("reports calendar event previews through the tool", async () => {
|
|
stubMeetArtifactsApi();
|
|
const { tools } = setup();
|
|
const tool = tools[0] as {
|
|
execute: (
|
|
id: string,
|
|
params: unknown,
|
|
) => Promise<{ details: { events?: Array<{ selected?: boolean; meetingUri?: string }> } }>;
|
|
};
|
|
|
|
const result = await tool.execute("id", {
|
|
action: "calendar_events",
|
|
accessToken: "token",
|
|
expiresAt: Date.now() + 120_000,
|
|
today: true,
|
|
});
|
|
|
|
expect(result.details.events).toEqual([
|
|
expect.objectContaining({
|
|
selected: true,
|
|
meetingUri: "https://meet.google.com/abc-defg-hij",
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("fails setup status when the configured Chrome node is not connected", async () => {
|
|
const { tools } = setup(
|
|
{
|
|
defaultTransport: "chrome-node",
|
|
chromeNode: { node: "parallels-macos" },
|
|
},
|
|
{
|
|
nodesListResult: {
|
|
nodes: [
|
|
{
|
|
nodeId: "node-1",
|
|
displayName: "parallels-macos",
|
|
connected: false,
|
|
caps: [],
|
|
commands: [],
|
|
remoteIp: "192.168.0.25",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
);
|
|
const tool = tools[0] as {
|
|
execute: (
|
|
id: string,
|
|
params: unknown,
|
|
) => Promise<{ details: { ok?: boolean; checks?: unknown[] } }>;
|
|
};
|
|
|
|
const result = await tool.execute("id", { action: "setup_status" });
|
|
|
|
expect(result.details.ok).toBe(false);
|
|
expect(result.details.checks).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: "chrome-node-connected",
|
|
ok: false,
|
|
message: expect.stringContaining("parallels-macos"),
|
|
}),
|
|
]),
|
|
);
|
|
const check = result.details.checks?.find(
|
|
(item) => (item as { id?: unknown }).id === "chrome-node-connected",
|
|
) as { message?: string } | undefined;
|
|
expect(check?.message).toContain("offline");
|
|
expect(check?.message).toContain("missing googlemeet.chrome");
|
|
expect(check?.message).toContain("missing browser.proxy/browser capability");
|
|
});
|
|
|
|
it("reports missing local Chrome audio prerequisites in setup status", async () => {
|
|
const originalPlatform = process.platform;
|
|
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
try {
|
|
const { tools } = setup(
|
|
{ defaultTransport: "chrome" },
|
|
{
|
|
runCommandWithTimeoutHandler: async (argv) => {
|
|
if (argv[0] === "/usr/sbin/system_profiler") {
|
|
return { code: 0, stdout: "Built-in Output", stderr: "" };
|
|
}
|
|
return { code: 0, stdout: "", stderr: "" };
|
|
},
|
|
},
|
|
);
|
|
const tool = tools[0] as {
|
|
execute: (
|
|
id: string,
|
|
params: unknown,
|
|
) => Promise<{ details: { ok?: boolean; checks?: unknown[] } }>;
|
|
};
|
|
|
|
const result = await tool.execute("id", { action: "setup_status", transport: "chrome" });
|
|
|
|
expect(result.details.ok).toBe(false);
|
|
expect(result.details.checks).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: "chrome-local-audio-device",
|
|
ok: false,
|
|
message: expect.stringContaining("BlackHole 2ch audio device not found"),
|
|
}),
|
|
]),
|
|
);
|
|
} finally {
|
|
Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
}
|
|
});
|
|
|
|
it("reports missing local Chrome audio commands in setup status", async () => {
|
|
const originalPlatform = process.platform;
|
|
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
try {
|
|
const { tools } = setup(
|
|
{ defaultTransport: "chrome" },
|
|
{
|
|
runCommandWithTimeoutHandler: async (argv) => {
|
|
if (argv[0] === "/usr/sbin/system_profiler") {
|
|
return { code: 0, stdout: "BlackHole 2ch", stderr: "" };
|
|
}
|
|
if (argv[0] === "/bin/sh" && argv.at(-1) === "sox") {
|
|
return { code: 1, stdout: "", stderr: "" };
|
|
}
|
|
return { code: 0, stdout: "", stderr: "" };
|
|
},
|
|
},
|
|
);
|
|
const tool = tools[0] as {
|
|
execute: (
|
|
id: string,
|
|
params: unknown,
|
|
) => Promise<{ details: { ok?: boolean; checks?: unknown[] } }>;
|
|
};
|
|
|
|
const result = await tool.execute("id", { action: "setup_status", transport: "chrome" });
|
|
|
|
expect(result.details.ok).toBe(false);
|
|
expect(result.details.checks).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: "chrome-local-audio-commands",
|
|
ok: false,
|
|
message: "Chrome audio command missing: sox",
|
|
}),
|
|
]),
|
|
);
|
|
} finally {
|
|
Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
}
|
|
});
|
|
|
|
it("reports Twilio delegation readiness when voice-call is enabled", async () => {
|
|
vi.stubEnv("TWILIO_ACCOUNT_SID", "AC123");
|
|
vi.stubEnv("TWILIO_AUTH_TOKEN", "secret");
|
|
vi.stubEnv("TWILIO_FROM_NUMBER", "+15550001234");
|
|
const { tools } = setup(
|
|
{
|
|
defaultTransport: "chrome-node",
|
|
chromeNode: { node: "parallels-macos" },
|
|
},
|
|
{
|
|
fullConfig: {
|
|
plugins: {
|
|
allow: ["google-meet", "voice-call"],
|
|
entries: {
|
|
"voice-call": {
|
|
enabled: true,
|
|
config: { provider: "twilio" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
);
|
|
const tool = tools[0] as {
|
|
execute: (
|
|
id: string,
|
|
params: unknown,
|
|
) => Promise<{ details: { ok?: boolean; checks?: unknown[] } }>;
|
|
};
|
|
|
|
const result = await tool.execute("id", { action: "setup_status" });
|
|
|
|
expect(result.details.ok).toBe(true);
|
|
expect(result.details.checks).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: "twilio-voice-call-plugin",
|
|
ok: true,
|
|
}),
|
|
expect.objectContaining({
|
|
id: "twilio-voice-call-credentials",
|
|
ok: true,
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("reports missing voice-call wiring for Twilio transport", async () => {
|
|
vi.stubEnv("TWILIO_ACCOUNT_SID", "");
|
|
vi.stubEnv("TWILIO_AUTH_TOKEN", "");
|
|
vi.stubEnv("TWILIO_FROM_NUMBER", "");
|
|
const { tools } = setup(
|
|
{ defaultTransport: "twilio" },
|
|
{
|
|
fullConfig: {
|
|
plugins: {
|
|
allow: ["google-meet"],
|
|
entries: {
|
|
"voice-call": { enabled: false },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
);
|
|
const tool = tools[0] as {
|
|
execute: (
|
|
id: string,
|
|
params: unknown,
|
|
) => Promise<{ details: { ok?: boolean; checks?: unknown[] } }>;
|
|
};
|
|
|
|
const result = await tool.execute("id", { action: "setup_status" });
|
|
|
|
expect(result.details.ok).toBe(false);
|
|
expect(result.details.checks).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: "twilio-voice-call-plugin",
|
|
ok: false,
|
|
}),
|
|
expect.objectContaining({
|
|
id: "twilio-voice-call-credentials",
|
|
ok: false,
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("opens local Chrome Meet through browser control after the BlackHole check", async () => {
|
|
const originalPlatform = process.platform;
|
|
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
try {
|
|
const { methods, runCommandWithTimeout } = setup({
|
|
defaultMode: "transcribe",
|
|
});
|
|
const callGatewayFromCli = mockLocalMeetBrowserRequest();
|
|
const handler = methods.get("googlemeet.join") as
|
|
| ((ctx: {
|
|
params: Record<string, unknown>;
|
|
respond: ReturnType<typeof vi.fn>;
|
|
}) => Promise<void>)
|
|
| undefined;
|
|
const respond = vi.fn();
|
|
|
|
await handler?.({
|
|
params: { url: "https://meet.google.com/abc-defg-hij" },
|
|
respond,
|
|
});
|
|
|
|
expect(respond.mock.calls[0]?.[0]).toBe(true);
|
|
expect(runCommandWithTimeout).toHaveBeenNthCalledWith(
|
|
1,
|
|
["/usr/sbin/system_profiler", "SPAudioDataType"],
|
|
{ timeoutMs: 10000 },
|
|
);
|
|
expect(runCommandWithTimeout).toHaveBeenCalledTimes(1);
|
|
expect(callGatewayFromCli).toHaveBeenCalledWith(
|
|
"browser.request",
|
|
expect.any(Object),
|
|
expect.objectContaining({
|
|
method: "POST",
|
|
path: "/tabs/open",
|
|
body: { url: "https://meet.google.com/abc-defg-hij" },
|
|
}),
|
|
{ progress: false },
|
|
);
|
|
expect(callGatewayFromCli).toHaveBeenCalledWith(
|
|
"browser.request",
|
|
expect.any(Object),
|
|
expect.objectContaining({
|
|
method: "POST",
|
|
path: "/permissions/grant",
|
|
body: expect.objectContaining({
|
|
origin: "https://meet.google.com",
|
|
permissions: ["audioCapture", "videoCapture"],
|
|
optionalPermissions: ["speakerSelection"],
|
|
}),
|
|
}),
|
|
{ progress: false },
|
|
);
|
|
} finally {
|
|
Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
}
|
|
});
|
|
|
|
it("joins Chrome on a paired node without local Chrome or BlackHole", async () => {
|
|
const { methods, nodesList, nodesInvoke } = setup(
|
|
{
|
|
defaultTransport: "chrome-node",
|
|
defaultMode: "transcribe",
|
|
chromeNode: { node: "parallels-macos" },
|
|
},
|
|
{
|
|
nodesInvokeResult: { payload: { launched: true } },
|
|
},
|
|
);
|
|
const handler = methods.get("googlemeet.join") as
|
|
| ((ctx: {
|
|
params: Record<string, unknown>;
|
|
respond: ReturnType<typeof vi.fn>;
|
|
}) => Promise<void>)
|
|
| undefined;
|
|
const respond = vi.fn();
|
|
|
|
await handler?.({
|
|
params: { url: "https://meet.google.com/abc-defg-hij" },
|
|
respond,
|
|
});
|
|
|
|
expect(respond.mock.calls[0]?.[0]).toBe(true);
|
|
expect(nodesList.mock.calls[0]).toEqual([]);
|
|
expect(nodesInvoke).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
nodeId: "node-1",
|
|
command: "googlemeet.chrome",
|
|
params: expect.objectContaining({
|
|
action: "stopByUrl",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
mode: "transcribe",
|
|
}),
|
|
}),
|
|
);
|
|
expect(nodesInvoke).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
nodeId: "node-1",
|
|
command: "browser.proxy",
|
|
params: expect.objectContaining({
|
|
path: "/tabs/open",
|
|
body: { url: "https://meet.google.com/abc-defg-hij" },
|
|
}),
|
|
}),
|
|
);
|
|
expect(nodesInvoke).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
nodeId: "node-1",
|
|
command: "googlemeet.chrome",
|
|
params: expect.objectContaining({
|
|
action: "start",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
mode: "transcribe",
|
|
launch: false,
|
|
}),
|
|
}),
|
|
);
|
|
expect(respond.mock.calls[0]?.[1]).toMatchObject({
|
|
session: {
|
|
transport: "chrome-node",
|
|
chrome: {
|
|
nodeId: "node-1",
|
|
launched: true,
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("reuses an active Meet session for the same URL and transport", async () => {
|
|
const { methods, nodesInvoke } = setup(
|
|
{
|
|
defaultTransport: "chrome-node",
|
|
defaultMode: "transcribe",
|
|
},
|
|
{
|
|
nodesInvokeResult: {
|
|
payload: {
|
|
launched: true,
|
|
browser: { inCall: true, micMuted: false },
|
|
},
|
|
},
|
|
},
|
|
);
|
|
const handler = methods.get("googlemeet.join") as
|
|
| ((ctx: {
|
|
params: Record<string, unknown>;
|
|
respond: ReturnType<typeof vi.fn>;
|
|
}) => Promise<void>)
|
|
| undefined;
|
|
const first = vi.fn();
|
|
const second = vi.fn();
|
|
|
|
await handler?.({
|
|
params: { url: "https://meet.google.com/abc-defg-hij" },
|
|
respond: first,
|
|
});
|
|
await handler?.({
|
|
params: { url: "https://meet.google.com/abc-defg-hij" },
|
|
respond: second,
|
|
});
|
|
|
|
expect(
|
|
nodesInvoke.mock.calls.filter(([call]) => call.command === "googlemeet.chrome"),
|
|
).toHaveLength(2);
|
|
expect(second.mock.calls[0]?.[1]).toMatchObject({
|
|
session: {
|
|
chrome: { health: { inCall: true, micMuted: false } },
|
|
notes: expect.arrayContaining(["Reused existing active Meet session."]),
|
|
},
|
|
});
|
|
});
|
|
|
|
it("reuses active Meet sessions across URL query differences", async () => {
|
|
const { methods, nodesInvoke } = setup(
|
|
{
|
|
defaultTransport: "chrome-node",
|
|
defaultMode: "transcribe",
|
|
},
|
|
{
|
|
nodesInvokeResult: {
|
|
payload: {
|
|
launched: true,
|
|
browser: { inCall: true, micMuted: false },
|
|
},
|
|
},
|
|
},
|
|
);
|
|
const handler = methods.get("googlemeet.join") as
|
|
| ((ctx: {
|
|
params: Record<string, unknown>;
|
|
respond: ReturnType<typeof vi.fn>;
|
|
}) => Promise<void>)
|
|
| undefined;
|
|
const first = vi.fn();
|
|
const second = vi.fn();
|
|
|
|
await handler?.({
|
|
params: { url: "https://meet.google.com/abc-defg-hij?authuser=me@example.com" },
|
|
respond: first,
|
|
});
|
|
await handler?.({
|
|
params: { url: "https://meet.google.com/abc-defg-hij" },
|
|
respond: second,
|
|
});
|
|
|
|
expect(
|
|
nodesInvoke.mock.calls.filter(([call]) => call.command === "googlemeet.chrome"),
|
|
).toHaveLength(2);
|
|
expect(second.mock.calls[0]?.[1]).toMatchObject({
|
|
session: {
|
|
notes: expect.arrayContaining(["Reused existing active Meet session."]),
|
|
},
|
|
});
|
|
});
|
|
|
|
it("reuses existing Meet browser tabs across URL query differences", async () => {
|
|
const { methods, nodesInvoke } = setup(
|
|
{
|
|
defaultTransport: "chrome-node",
|
|
defaultMode: "transcribe",
|
|
},
|
|
{
|
|
nodesInvokeHandler: async (params) => {
|
|
if (params.command !== "browser.proxy") {
|
|
return { payload: { launched: true } };
|
|
}
|
|
const proxy = params.params as {
|
|
path?: string;
|
|
body?: { targetId?: string; url?: string };
|
|
};
|
|
if (proxy.path === "/tabs") {
|
|
return {
|
|
payload: {
|
|
result: {
|
|
running: true,
|
|
tabs: [
|
|
{
|
|
targetId: "existing-meet-tab",
|
|
title: "Meet",
|
|
url: "https://meet.google.com/abc-defg-hij?authuser=me@example.com",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
};
|
|
}
|
|
if (proxy.path === "/tabs/focus") {
|
|
return { payload: { result: { ok: true } } };
|
|
}
|
|
if (proxy.path === "/act") {
|
|
return {
|
|
payload: {
|
|
result: {
|
|
result: JSON.stringify({
|
|
inCall: true,
|
|
title: "Meet",
|
|
url: "https://meet.google.com/abc-defg-hij?authuser=me@example.com",
|
|
}),
|
|
},
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`unexpected browser proxy path ${proxy.path}`);
|
|
},
|
|
},
|
|
);
|
|
const handler = methods.get("googlemeet.join") as
|
|
| ((ctx: {
|
|
params: Record<string, unknown>;
|
|
respond: ReturnType<typeof vi.fn>;
|
|
}) => Promise<void>)
|
|
| undefined;
|
|
const respond = vi.fn();
|
|
|
|
await handler?.({
|
|
params: { url: "https://meet.google.com/abc-defg-hij" },
|
|
respond,
|
|
});
|
|
|
|
expect(nodesInvoke).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
params: expect.objectContaining({
|
|
path: "/tabs/focus",
|
|
body: { targetId: "existing-meet-tab" },
|
|
}),
|
|
}),
|
|
);
|
|
expect(nodesInvoke).not.toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
params: expect.objectContaining({ path: "/tabs/open" }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("recovers and inspects an existing Meet tab without opening a new one", async () => {
|
|
const { tools, nodesInvoke } = setup(
|
|
{
|
|
defaultTransport: "chrome-node",
|
|
},
|
|
{
|
|
nodesInvokeHandler: async (params) => {
|
|
if (params.command !== "browser.proxy") {
|
|
throw new Error(`unexpected command ${params.command}`);
|
|
}
|
|
const proxy = params.params as { path?: string; body?: { targetId?: string } };
|
|
if (proxy.path === "/tabs") {
|
|
return {
|
|
payload: {
|
|
result: {
|
|
tabs: [
|
|
{
|
|
targetId: "existing-meet-tab",
|
|
title: "Meet",
|
|
url: "https://meet.google.com/abc-defg-hij?authuser=me@example.com",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
};
|
|
}
|
|
if (proxy.path === "/tabs/focus") {
|
|
return { payload: { result: { ok: true } } };
|
|
}
|
|
if (proxy.path === "/act") {
|
|
return {
|
|
payload: {
|
|
result: {
|
|
result: JSON.stringify({
|
|
inCall: false,
|
|
manualActionRequired: true,
|
|
manualActionReason: "meet-admission-required",
|
|
manualActionMessage: "Admit the OpenClaw browser participant in Google Meet.",
|
|
title: "Meet",
|
|
url: "https://meet.google.com/abc-defg-hij?authuser=me@example.com",
|
|
}),
|
|
},
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`unexpected browser proxy path ${proxy.path}`);
|
|
},
|
|
},
|
|
);
|
|
const tool = tools[0] as {
|
|
execute: (
|
|
id: string,
|
|
params: unknown,
|
|
) => Promise<{ details: { found?: boolean; browser?: unknown } }>;
|
|
};
|
|
|
|
const result = await tool.execute("id", {
|
|
action: "recover_current_tab",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
});
|
|
|
|
expect(result.details).toMatchObject({
|
|
found: true,
|
|
targetId: "existing-meet-tab",
|
|
browser: {
|
|
manualActionRequired: true,
|
|
manualActionReason: "meet-admission-required",
|
|
},
|
|
});
|
|
expect(nodesInvoke).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
params: expect.objectContaining({
|
|
path: "/tabs/focus",
|
|
body: { targetId: "existing-meet-tab" },
|
|
}),
|
|
}),
|
|
);
|
|
expect(nodesInvoke).not.toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
params: expect.objectContaining({ path: "/tabs/open" }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("recovers and inspects an existing local Chrome Meet tab", async () => {
|
|
const callGatewayFromCli = vi.fn(
|
|
async (
|
|
_method: string,
|
|
_opts: unknown,
|
|
params?: unknown,
|
|
_extra?: unknown,
|
|
): Promise<Record<string, unknown>> => {
|
|
const request = params as { path?: string; body?: { targetId?: string } };
|
|
if (request.path === "/tabs") {
|
|
return {
|
|
tabs: [
|
|
{
|
|
targetId: "local-meet-tab",
|
|
title: "Meet",
|
|
url: "https://meet.google.com/abc-defg-hij?authuser=me@example.com",
|
|
},
|
|
],
|
|
};
|
|
}
|
|
if (request.path === "/tabs/focus") {
|
|
return { ok: true };
|
|
}
|
|
if (request.path === "/act") {
|
|
return {
|
|
result: JSON.stringify({
|
|
inCall: false,
|
|
manualActionRequired: true,
|
|
manualActionReason: "meet-admission-required",
|
|
manualActionMessage: "Admit the OpenClaw browser participant in Google Meet.",
|
|
title: "Meet",
|
|
url: "https://meet.google.com/abc-defg-hij?authuser=me@example.com",
|
|
}),
|
|
};
|
|
}
|
|
throw new Error(`unexpected browser request path ${request.path}`);
|
|
},
|
|
);
|
|
chromeTransportTesting.setDepsForTest({ callGatewayFromCli });
|
|
const { tools, nodesInvoke } = setup({ defaultTransport: "chrome" });
|
|
const tool = tools[0] as {
|
|
execute: (
|
|
id: string,
|
|
params: unknown,
|
|
) => Promise<{ details: { transport?: string; found?: boolean; browser?: unknown } }>;
|
|
};
|
|
|
|
const result = await tool.execute("id", {
|
|
action: "recover_current_tab",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
});
|
|
|
|
expect(result.details).toMatchObject({
|
|
transport: "chrome",
|
|
found: true,
|
|
targetId: "local-meet-tab",
|
|
browser: {
|
|
manualActionRequired: true,
|
|
manualActionReason: "meet-admission-required",
|
|
},
|
|
});
|
|
expect(callGatewayFromCli).toHaveBeenCalledWith(
|
|
"browser.request",
|
|
expect.any(Object),
|
|
expect.objectContaining({ method: "POST", path: "/tabs/focus" }),
|
|
{ progress: false },
|
|
);
|
|
expect(nodesInvoke).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("exposes a test-speech action that joins the requested meeting", async () => {
|
|
const { tools, nodesInvoke } = setup(
|
|
{
|
|
defaultTransport: "chrome-node",
|
|
},
|
|
{
|
|
nodesInvokeResult: {
|
|
payload: {
|
|
launched: true,
|
|
browser: { inCall: true },
|
|
},
|
|
},
|
|
},
|
|
);
|
|
const tool = tools[0] as {
|
|
execute: (id: string, params: unknown) => Promise<{ details: { createdSession?: boolean } }>;
|
|
};
|
|
|
|
const result = await tool.execute("id", {
|
|
action: "test_speech",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
message: "Say exactly: hello.",
|
|
});
|
|
|
|
expect(nodesInvoke).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
command: "googlemeet.chrome",
|
|
params: expect.objectContaining({ action: "start" }),
|
|
}),
|
|
);
|
|
expect(result.details).toMatchObject({ createdSession: true });
|
|
});
|
|
|
|
it("does not start a second realtime response for test speech", async () => {
|
|
const runtime = new GoogleMeetRuntime({
|
|
config: resolveGoogleMeetConfig({}),
|
|
fullConfig: {} as never,
|
|
runtime: {} as never,
|
|
logger: noopLogger,
|
|
});
|
|
const session: GoogleMeetSession = {
|
|
id: "meet_1",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
transport: "chrome",
|
|
mode: "realtime",
|
|
state: "active",
|
|
createdAt: "2026-04-27T00:00:00.000Z",
|
|
updatedAt: "2026-04-27T00:00:00.000Z",
|
|
participantIdentity: "signed-in Google Chrome profile",
|
|
realtime: { enabled: true, provider: "openai", toolPolicy: "safe-read-only" },
|
|
chrome: { audioBackend: "blackhole-2ch", launched: true },
|
|
notes: [],
|
|
};
|
|
const join = vi.spyOn(runtime, "join").mockResolvedValue({ session, spoken: true });
|
|
const speak = vi.spyOn(runtime, "speak");
|
|
|
|
const result = await runtime.testSpeech({
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
message: "Say exactly: hello.",
|
|
});
|
|
|
|
expect(join).toHaveBeenCalledWith(expect.objectContaining({ message: "Say exactly: hello." }));
|
|
expect(speak).not.toHaveBeenCalled();
|
|
expect(result.spoken).toBe(true);
|
|
});
|
|
|
|
it("reports manual action when the browser profile needs Google login", async () => {
|
|
const { tools } = setup(
|
|
{
|
|
defaultTransport: "chrome-node",
|
|
},
|
|
{
|
|
browserActResult: {
|
|
inCall: false,
|
|
manualActionRequired: true,
|
|
manualActionReason: "google-login-required",
|
|
manualActionMessage:
|
|
"Sign in to Google in the OpenClaw browser profile, then retry the Meet join.",
|
|
title: "Sign in - Google Accounts",
|
|
url: "https://accounts.google.com/signin",
|
|
},
|
|
nodesInvokeResult: {
|
|
payload: {
|
|
launched: true,
|
|
},
|
|
},
|
|
},
|
|
);
|
|
const tool = tools[0] as {
|
|
execute: (
|
|
id: string,
|
|
params: unknown,
|
|
) => Promise<{
|
|
details: {
|
|
manualActionRequired?: boolean;
|
|
manualActionReason?: string;
|
|
session?: { chrome?: { health?: { manualActionRequired?: boolean } } };
|
|
};
|
|
}>;
|
|
};
|
|
|
|
const result = await tool.execute("id", {
|
|
action: "test_speech",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
message: "Say exactly: hello.",
|
|
});
|
|
|
|
expect(result.details).toMatchObject({
|
|
manualActionRequired: true,
|
|
manualActionReason: "google-login-required",
|
|
session: {
|
|
chrome: {
|
|
health: {
|
|
manualActionRequired: true,
|
|
manualActionReason: "google-login-required",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("explains when chrome-node has no capable paired node", async () => {
|
|
const { tools } = setup(
|
|
{
|
|
defaultTransport: "chrome-node",
|
|
defaultMode: "transcribe",
|
|
},
|
|
{
|
|
nodesListResult: { nodes: [] },
|
|
},
|
|
);
|
|
const tool = tools[0] as {
|
|
execute: (id: string, params: unknown) => Promise<{ details: { error?: string } }>;
|
|
};
|
|
|
|
const result = await tool.execute("id", {
|
|
action: "join",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
});
|
|
|
|
expect(result.details.error).toContain("No connected Google Meet-capable node");
|
|
expect(result.details.error).toContain("openclaw node run");
|
|
});
|
|
|
|
it("requires chromeNode.node when multiple capable nodes are connected", async () => {
|
|
const { tools } = setup(
|
|
{
|
|
defaultTransport: "chrome-node",
|
|
defaultMode: "transcribe",
|
|
},
|
|
{
|
|
nodesListResult: {
|
|
nodes: [
|
|
{
|
|
nodeId: "node-1",
|
|
displayName: "parallels-macos",
|
|
connected: true,
|
|
caps: ["browser"],
|
|
commands: ["browser.proxy", "googlemeet.chrome"],
|
|
},
|
|
{
|
|
nodeId: "node-2",
|
|
displayName: "mac-studio-vm",
|
|
connected: true,
|
|
caps: ["browser"],
|
|
commands: ["browser.proxy", "googlemeet.chrome"],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
);
|
|
const tool = tools[0] as {
|
|
execute: (id: string, params: unknown) => Promise<{ details: { error?: string } }>;
|
|
};
|
|
|
|
const result = await tool.execute("id", {
|
|
action: "join",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
});
|
|
|
|
expect(result.details.error).toContain("Multiple Google Meet-capable nodes connected");
|
|
expect(result.details.error).toContain("chromeNode.node");
|
|
});
|
|
|
|
it("runs configured Chrome audio bridge commands before launch", async () => {
|
|
const originalPlatform = process.platform;
|
|
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
try {
|
|
const { methods, runCommandWithTimeout } = setup({
|
|
chrome: {
|
|
audioBridgeHealthCommand: ["bridge", "status"],
|
|
audioBridgeCommand: ["bridge", "start"],
|
|
},
|
|
});
|
|
const callGatewayFromCli = mockLocalMeetBrowserRequest();
|
|
const handler = methods.get("googlemeet.join") as
|
|
| ((ctx: {
|
|
params: Record<string, unknown>;
|
|
respond: ReturnType<typeof vi.fn>;
|
|
}) => Promise<void>)
|
|
| undefined;
|
|
const respond = vi.fn();
|
|
|
|
await handler?.({
|
|
params: { url: "https://meet.google.com/abc-defg-hij" },
|
|
respond,
|
|
});
|
|
|
|
expect(respond.mock.calls[0]?.[0]).toBe(true);
|
|
expect(runCommandWithTimeout).toHaveBeenNthCalledWith(2, ["bridge", "status"], {
|
|
timeoutMs: 30000,
|
|
});
|
|
expect(runCommandWithTimeout).toHaveBeenNthCalledWith(3, ["bridge", "start"], {
|
|
timeoutMs: 30000,
|
|
});
|
|
expect(callGatewayFromCli).toHaveBeenCalledWith(
|
|
"browser.request",
|
|
expect.any(Object),
|
|
expect.objectContaining({
|
|
method: "POST",
|
|
path: "/tabs/open",
|
|
body: { url: "https://meet.google.com/abc-defg-hij" },
|
|
}),
|
|
{ progress: false },
|
|
);
|
|
} finally {
|
|
Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
}
|
|
});
|
|
|
|
it("pipes Chrome command-pair audio through the realtime provider", async () => {
|
|
let callbacks:
|
|
| {
|
|
onAudio: (audio: Buffer) => void;
|
|
onClearAudio: () => void;
|
|
onMark?: (markName: string) => void;
|
|
onToolCall?: (event: {
|
|
itemId: string;
|
|
callId: string;
|
|
name: string;
|
|
args: unknown;
|
|
}) => void;
|
|
onReady?: () => void;
|
|
tools?: unknown[];
|
|
}
|
|
| undefined;
|
|
const sendAudio = vi.fn();
|
|
const bridge = {
|
|
supportsToolResultContinuation: true,
|
|
connect: vi.fn(async () => {}),
|
|
sendAudio,
|
|
setMediaTimestamp: vi.fn(),
|
|
submitToolResult: vi.fn(),
|
|
acknowledgeMark: vi.fn(),
|
|
close: vi.fn(),
|
|
triggerGreeting: vi.fn(),
|
|
isConnected: vi.fn(() => true),
|
|
};
|
|
const provider: RealtimeVoiceProviderPlugin = {
|
|
id: "openai",
|
|
label: "OpenAI",
|
|
autoSelectOrder: 1,
|
|
resolveConfig: ({ rawConfig }) => rawConfig,
|
|
isConfigured: () => true,
|
|
createBridge: (req) => {
|
|
callbacks = req;
|
|
return bridge;
|
|
},
|
|
};
|
|
const inputStdout = new PassThrough();
|
|
const outputStdinWrites: Buffer[] = [];
|
|
const replacementOutputStdinWrites: Buffer[] = [];
|
|
const makeProcess = (stdio: {
|
|
stdin?: { write(chunk: unknown): unknown } | null;
|
|
stdout?: { on(event: "data", listener: (chunk: unknown) => void): unknown } | null;
|
|
}): TestBridgeProcess => {
|
|
const proc = new EventEmitter() as unknown as TestBridgeProcess;
|
|
proc.stdin = stdio.stdin;
|
|
proc.stdout = stdio.stdout;
|
|
proc.stderr = new PassThrough();
|
|
proc.killed = false;
|
|
proc.kill = vi.fn(() => {
|
|
proc.killed = true;
|
|
return true;
|
|
});
|
|
return proc;
|
|
};
|
|
const outputStdin = new Writable({
|
|
write(chunk, _encoding, done) {
|
|
outputStdinWrites.push(Buffer.from(chunk));
|
|
done();
|
|
},
|
|
});
|
|
const replacementOutputStdin = new Writable({
|
|
write(chunk, _encoding, done) {
|
|
replacementOutputStdinWrites.push(Buffer.from(chunk));
|
|
done();
|
|
},
|
|
});
|
|
const inputProcess = makeProcess({ stdout: inputStdout, stdin: null });
|
|
const outputProcess = makeProcess({ stdin: outputStdin, stdout: null });
|
|
const replacementOutputProcess = makeProcess({ stdin: replacementOutputStdin, stdout: null });
|
|
const spawnMock = vi
|
|
.fn()
|
|
.mockReturnValueOnce(outputProcess)
|
|
.mockReturnValueOnce(inputProcess)
|
|
.mockReturnValueOnce(replacementOutputProcess);
|
|
const sessionStore: Record<string, unknown> = {};
|
|
const runtime = {
|
|
agent: {
|
|
resolveAgentDir: vi.fn(() => "/tmp/agent"),
|
|
resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"),
|
|
ensureAgentWorkspace: vi.fn(async () => {}),
|
|
session: {
|
|
resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
|
|
loadSessionStore: vi.fn(() => sessionStore),
|
|
saveSessionStore: vi.fn(async () => {}),
|
|
resolveSessionFilePath: vi.fn(() => "/tmp/session.json"),
|
|
},
|
|
runEmbeddedPiAgent: vi.fn(async () => ({
|
|
payloads: [{ text: "Use the Portugal launch data." }],
|
|
meta: {},
|
|
})),
|
|
resolveAgentTimeoutMs: vi.fn(() => 1000),
|
|
},
|
|
};
|
|
|
|
const handle = await startCommandRealtimeAudioBridge({
|
|
config: resolveGoogleMeetConfig({
|
|
realtime: { provider: "openai", model: "gpt-realtime", agentId: "jay" },
|
|
}),
|
|
fullConfig: {} as never,
|
|
runtime: runtime as never,
|
|
meetingSessionId: "meet-1",
|
|
inputCommand: ["capture-meet"],
|
|
outputCommand: ["play-meet"],
|
|
logger: noopLogger,
|
|
providers: [provider],
|
|
spawn: spawnMock,
|
|
});
|
|
|
|
inputStdout.write(Buffer.from([1, 2, 3]));
|
|
callbacks?.onAudio(Buffer.from([4, 5]));
|
|
callbacks?.onMark?.("mark-1");
|
|
callbacks?.onClearAudio();
|
|
callbacks?.onAudio(Buffer.from([6, 7]));
|
|
callbacks?.onReady?.();
|
|
callbacks?.onToolCall?.({
|
|
itemId: "item-1",
|
|
callId: "tool-call-1",
|
|
name: "openclaw_agent_consult",
|
|
args: { question: "What should I say about launch timing?" },
|
|
});
|
|
expect(bridge.submitToolResult).toHaveBeenNthCalledWith(
|
|
1,
|
|
"tool-call-1",
|
|
expect.objectContaining({
|
|
status: "working",
|
|
tool: "openclaw_agent_consult",
|
|
}),
|
|
{ willContinue: true },
|
|
);
|
|
|
|
expect(spawnMock).toHaveBeenNthCalledWith(1, "play-meet", [], {
|
|
stdio: ["pipe", "ignore", "pipe"],
|
|
});
|
|
expect(spawnMock).toHaveBeenNthCalledWith(2, "capture-meet", [], {
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
expect(sendAudio).toHaveBeenCalledWith(Buffer.from([1, 2, 3]));
|
|
expect(outputStdinWrites).toEqual([Buffer.from([4, 5])]);
|
|
expect(outputProcess.kill).toHaveBeenCalledWith("SIGTERM");
|
|
expect(replacementOutputStdinWrites).toEqual([Buffer.from([6, 7])]);
|
|
outputProcess.emit("error", new Error("stale output process failed after clear"));
|
|
expect(bridge.close).not.toHaveBeenCalled();
|
|
expect(bridge.acknowledgeMark).toHaveBeenCalled();
|
|
expect(bridge.triggerGreeting).not.toHaveBeenCalled();
|
|
handle.speak("Say exactly: hello from the meeting.");
|
|
expect(bridge.triggerGreeting).toHaveBeenLastCalledWith("Say exactly: hello from the meeting.");
|
|
expect(handle.getHealth()).toMatchObject({
|
|
providerConnected: true,
|
|
realtimeReady: true,
|
|
audioInputActive: true,
|
|
audioOutputActive: true,
|
|
lastInputBytes: 3,
|
|
lastOutputBytes: 4,
|
|
clearCount: 1,
|
|
});
|
|
expect(callbacks).toMatchObject({
|
|
audioFormat: {
|
|
encoding: "pcm16",
|
|
sampleRateHz: 24000,
|
|
channels: 1,
|
|
},
|
|
tools: [
|
|
expect.objectContaining({
|
|
name: "openclaw_agent_consult",
|
|
}),
|
|
],
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(bridge.submitToolResult).toHaveBeenLastCalledWith(
|
|
"tool-call-1",
|
|
{
|
|
text: "Use the Portugal launch data.",
|
|
},
|
|
undefined,
|
|
);
|
|
});
|
|
expect(runtime.agent.runEmbeddedPiAgent).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
messageProvider: "google-meet",
|
|
agentId: "jay",
|
|
sessionKey: "agent:jay:google-meet:meet-1",
|
|
sandboxSessionKey: "agent:jay:google-meet:meet-1",
|
|
thinkLevel: "high",
|
|
toolsAllow: ["read", "web_search", "web_fetch", "x_search", "memory_search", "memory_get"],
|
|
}),
|
|
);
|
|
expect(sessionStore).toHaveProperty("agent:jay:google-meet:meet-1");
|
|
|
|
await handle.stop();
|
|
expect(bridge.close).toHaveBeenCalled();
|
|
expect(inputProcess.kill).toHaveBeenCalledWith("SIGTERM");
|
|
expect(outputProcess.kill).toHaveBeenCalledWith("SIGTERM");
|
|
});
|
|
|
|
it("pipes paired-node command-pair audio through the realtime provider", async () => {
|
|
let callbacks:
|
|
| {
|
|
onAudio: (audio: Buffer) => void;
|
|
onClearAudio: () => void;
|
|
onToolCall?: (event: {
|
|
itemId: string;
|
|
callId: string;
|
|
name: string;
|
|
args: unknown;
|
|
}) => void;
|
|
onReady?: () => void;
|
|
tools?: unknown[];
|
|
}
|
|
| undefined;
|
|
const sendAudio = vi.fn();
|
|
const bridge = {
|
|
supportsToolResultContinuation: true,
|
|
connect: vi.fn(async () => {}),
|
|
sendAudio,
|
|
setMediaTimestamp: vi.fn(),
|
|
submitToolResult: vi.fn(),
|
|
acknowledgeMark: vi.fn(),
|
|
close: vi.fn(),
|
|
triggerGreeting: vi.fn(),
|
|
isConnected: vi.fn(() => true),
|
|
};
|
|
const provider: RealtimeVoiceProviderPlugin = {
|
|
id: "openai",
|
|
label: "OpenAI",
|
|
autoSelectOrder: 1,
|
|
resolveConfig: ({ rawConfig }) => rawConfig,
|
|
isConfigured: () => true,
|
|
createBridge: (req) => {
|
|
callbacks = req;
|
|
return bridge;
|
|
},
|
|
};
|
|
let pullCount = 0;
|
|
const runtime = {
|
|
nodes: {
|
|
invoke: vi.fn(async ({ params }: { params?: { action?: string; base64?: string } }) => {
|
|
if (params?.action === "pullAudio") {
|
|
pullCount += 1;
|
|
if (pullCount === 1) {
|
|
return { bridgeId: "bridge-1", base64: Buffer.from([9, 8, 7]).toString("base64") };
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
|
return { bridgeId: "bridge-1" };
|
|
}
|
|
return { ok: true };
|
|
}),
|
|
},
|
|
agent: {
|
|
resolveAgentDir: vi.fn(() => "/tmp/agent"),
|
|
resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"),
|
|
ensureAgentWorkspace: vi.fn(async () => {}),
|
|
session: {
|
|
resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
|
|
loadSessionStore: vi.fn(() => ({})),
|
|
saveSessionStore: vi.fn(async () => {}),
|
|
resolveSessionFilePath: vi.fn(() => "/tmp/session.json"),
|
|
},
|
|
runEmbeddedPiAgent: vi.fn(async () => ({
|
|
payloads: [{ text: "Use the launch update." }],
|
|
meta: {},
|
|
})),
|
|
resolveAgentTimeoutMs: vi.fn(() => 1000),
|
|
},
|
|
};
|
|
|
|
const handle = await startNodeRealtimeAudioBridge({
|
|
config: resolveGoogleMeetConfig({
|
|
realtime: { provider: "openai", model: "gpt-realtime" },
|
|
}),
|
|
fullConfig: {} as never,
|
|
runtime: runtime as never,
|
|
meetingSessionId: "meet-1",
|
|
nodeId: "node-1",
|
|
bridgeId: "bridge-1",
|
|
logger: noopLogger,
|
|
providers: [provider],
|
|
});
|
|
|
|
callbacks?.onAudio(Buffer.from([1, 2, 3]));
|
|
callbacks?.onClearAudio();
|
|
callbacks?.onReady?.();
|
|
callbacks?.onToolCall?.({
|
|
itemId: "item-1",
|
|
callId: "tool-call-1",
|
|
name: "openclaw_agent_consult",
|
|
args: { question: "What should I say?" },
|
|
});
|
|
expect(bridge.submitToolResult).toHaveBeenNthCalledWith(
|
|
1,
|
|
"tool-call-1",
|
|
expect.objectContaining({
|
|
status: "working",
|
|
tool: "openclaw_agent_consult",
|
|
}),
|
|
{ willContinue: true },
|
|
);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(sendAudio).toHaveBeenCalledWith(Buffer.from([9, 8, 7]));
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(runtime.nodes.invoke).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
nodeId: "node-1",
|
|
command: "googlemeet.chrome",
|
|
params: expect.objectContaining({
|
|
action: "pushAudio",
|
|
bridgeId: "bridge-1",
|
|
base64: Buffer.from([1, 2, 3]).toString("base64"),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(runtime.nodes.invoke).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
nodeId: "node-1",
|
|
command: "googlemeet.chrome",
|
|
params: {
|
|
action: "clearAudio",
|
|
bridgeId: "bridge-1",
|
|
},
|
|
timeoutMs: 5_000,
|
|
}),
|
|
);
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(bridge.submitToolResult).toHaveBeenLastCalledWith(
|
|
"tool-call-1",
|
|
{
|
|
text: "Use the launch update.",
|
|
},
|
|
undefined,
|
|
);
|
|
});
|
|
expect(bridge.triggerGreeting).not.toHaveBeenCalled();
|
|
handle.speak("Say exactly: hello from the node.");
|
|
expect(bridge.triggerGreeting).toHaveBeenLastCalledWith("Say exactly: hello from the node.");
|
|
expect(callbacks).toMatchObject({
|
|
audioFormat: {
|
|
encoding: "pcm16",
|
|
sampleRateHz: 24000,
|
|
channels: 1,
|
|
},
|
|
tools: [
|
|
expect.objectContaining({
|
|
name: "openclaw_agent_consult",
|
|
}),
|
|
],
|
|
});
|
|
expect(handle).toMatchObject({
|
|
type: "node-command-pair",
|
|
providerId: "openai",
|
|
nodeId: "node-1",
|
|
bridgeId: "bridge-1",
|
|
});
|
|
expect(handle.getHealth()).toMatchObject({
|
|
providerConnected: true,
|
|
realtimeReady: true,
|
|
audioInputActive: true,
|
|
audioOutputActive: true,
|
|
lastInputBytes: 3,
|
|
lastOutputBytes: 3,
|
|
clearCount: 1,
|
|
});
|
|
|
|
await handle.stop();
|
|
|
|
expect(bridge.close).toHaveBeenCalled();
|
|
expect(runtime.nodes.invoke).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
nodeId: "node-1",
|
|
command: "googlemeet.chrome",
|
|
params: { action: "stop", bridgeId: "bridge-1" },
|
|
timeoutMs: 5_000,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("keeps paired-node realtime audio alive after transient input pull failures", async () => {
|
|
const sendAudio = vi.fn();
|
|
const bridge = {
|
|
connect: vi.fn(async () => {}),
|
|
sendAudio,
|
|
setMediaTimestamp: vi.fn(),
|
|
submitToolResult: vi.fn(),
|
|
acknowledgeMark: vi.fn(),
|
|
close: vi.fn(),
|
|
triggerGreeting: vi.fn(),
|
|
isConnected: vi.fn(() => true),
|
|
};
|
|
const provider: RealtimeVoiceProviderPlugin = {
|
|
id: "openai",
|
|
label: "OpenAI",
|
|
autoSelectOrder: 1,
|
|
resolveConfig: ({ rawConfig }) => rawConfig,
|
|
isConfigured: () => true,
|
|
createBridge: () => bridge,
|
|
};
|
|
let pullCount = 0;
|
|
const runtime = {
|
|
nodes: {
|
|
invoke: vi.fn(async ({ params }: { params?: { action?: string } }) => {
|
|
if (params?.action === "pullAudio") {
|
|
pullCount += 1;
|
|
if (pullCount === 1) {
|
|
throw new Error("transient node timeout");
|
|
}
|
|
if (pullCount === 2) {
|
|
return { bridgeId: "bridge-1", base64: Buffer.from([5, 4, 3]).toString("base64") };
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
|
return { bridgeId: "bridge-1" };
|
|
}
|
|
return { ok: true };
|
|
}),
|
|
},
|
|
};
|
|
|
|
const handle = await startNodeRealtimeAudioBridge({
|
|
config: resolveGoogleMeetConfig({
|
|
realtime: { provider: "openai", model: "gpt-realtime" },
|
|
}),
|
|
fullConfig: {} as never,
|
|
runtime: runtime as never,
|
|
meetingSessionId: "meet-1",
|
|
nodeId: "node-1",
|
|
bridgeId: "bridge-1",
|
|
logger: noopLogger,
|
|
providers: [provider],
|
|
});
|
|
|
|
await vi.waitFor(() => {
|
|
expect(sendAudio).toHaveBeenCalledWith(Buffer.from([5, 4, 3]));
|
|
});
|
|
expect(bridge.close).not.toHaveBeenCalled();
|
|
expect(handle.getHealth()).toMatchObject({
|
|
audioInputActive: true,
|
|
lastInputBytes: 3,
|
|
consecutiveInputErrors: 0,
|
|
});
|
|
|
|
await handle.stop();
|
|
});
|
|
|
|
it("stops paired-node realtime audio after repeated input pull failures", async () => {
|
|
const bridge = {
|
|
connect: vi.fn(async () => {}),
|
|
sendAudio: vi.fn(),
|
|
setMediaTimestamp: vi.fn(),
|
|
submitToolResult: vi.fn(),
|
|
acknowledgeMark: vi.fn(),
|
|
close: vi.fn(),
|
|
triggerGreeting: vi.fn(),
|
|
isConnected: vi.fn(() => true),
|
|
};
|
|
const provider: RealtimeVoiceProviderPlugin = {
|
|
id: "openai",
|
|
label: "OpenAI",
|
|
autoSelectOrder: 1,
|
|
resolveConfig: ({ rawConfig }) => rawConfig,
|
|
isConfigured: () => true,
|
|
createBridge: () => bridge,
|
|
};
|
|
const runtime = {
|
|
nodes: {
|
|
invoke: vi.fn(async ({ params }: { params?: { action?: string } }) => {
|
|
if (params?.action === "pullAudio") {
|
|
throw new Error("node invoke timeout");
|
|
}
|
|
return { ok: true };
|
|
}),
|
|
},
|
|
};
|
|
|
|
const handle = await startNodeRealtimeAudioBridge({
|
|
config: resolveGoogleMeetConfig({
|
|
realtime: { provider: "openai", model: "gpt-realtime" },
|
|
}),
|
|
fullConfig: {} as never,
|
|
runtime: runtime as never,
|
|
meetingSessionId: "meet-1",
|
|
nodeId: "node-1",
|
|
bridgeId: "bridge-1",
|
|
logger: noopLogger,
|
|
providers: [provider],
|
|
});
|
|
|
|
await vi.waitFor(
|
|
() => {
|
|
expect(bridge.close).toHaveBeenCalled();
|
|
},
|
|
{ timeout: 3_000 },
|
|
);
|
|
expect(handle.getHealth()).toMatchObject({
|
|
bridgeClosed: true,
|
|
consecutiveInputErrors: 5,
|
|
lastInputError: "node invoke timeout",
|
|
});
|
|
expect(runtime.nodes.invoke).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
nodeId: "node-1",
|
|
command: "googlemeet.chrome",
|
|
params: { action: "stop", bridgeId: "bridge-1" },
|
|
timeoutMs: 5_000,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("exposes node-host list and stop-by-url bridge actions", async () => {
|
|
const listed = JSON.parse(
|
|
await handleGoogleMeetNodeHostCommand(
|
|
JSON.stringify({ action: "list", url: "https://meet.google.com/abc-defg-hij" }),
|
|
),
|
|
);
|
|
expect(listed).toEqual({ bridges: [] });
|
|
|
|
await expect(
|
|
handleGoogleMeetNodeHostCommand(JSON.stringify({ action: "stopByUrl" })),
|
|
).rejects.toThrow("url required");
|
|
});
|
|
});
|