mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-16 04:06:21 +00:00
7670 lines
266 KiB
TypeScript
7670 lines
266 KiB
TypeScript
// Google Meet tests cover index plugin behavior.
|
|
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 { createContext, Script } from "node:vm";
|
|
import {
|
|
validateJsonSchemaValue,
|
|
type JsonSchemaObject,
|
|
} from "openclaw/plugin-sdk/json-schema-runtime";
|
|
import type { RealtimeTranscriptionProviderPlugin } from "openclaw/plugin-sdk/realtime-transcription";
|
|
import type { RealtimeVoiceProviderPlugin } from "openclaw/plugin-sdk/realtime-voice";
|
|
import { afterAll, 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 {
|
|
convertGoogleMeetTtsAudioForBridge,
|
|
extendGoogleMeetOutputEchoSuppression,
|
|
isGoogleMeetLikelyAssistantEchoTranscript,
|
|
GOOGLE_MEET_AGENT_TRANSCRIPT_DEBOUNCE_MS,
|
|
resolveGoogleMeetRealtimeProvider,
|
|
resolveGoogleMeetRealtimeTranscriptionProvider,
|
|
startCommandAgentAudioBridge,
|
|
startCommandRealtimeAudioBridge,
|
|
} from "./src/realtime.js";
|
|
import { GoogleMeetRuntime, normalizeMeetUrl } from "./src/runtime.js";
|
|
import {
|
|
invokeGoogleMeetGatewayMethodForTest,
|
|
noopLogger,
|
|
setupGoogleMeetPlugin,
|
|
} from "./src/test-support/plugin-harness.js";
|
|
import * as chromeCreateTransport from "./src/transports/chrome-create.js";
|
|
import * as chromeTransport from "./src/transports/chrome.js";
|
|
import { testing as chromeTransportTesting } from "./src/transports/chrome.js";
|
|
import {
|
|
buildMeetDtmfSequence,
|
|
normalizeDialInNumber,
|
|
prefixDtmfWait,
|
|
} from "./src/transports/twilio.js";
|
|
import type { GoogleMeetSession } from "./src/transports/types.js";
|
|
|
|
type GoogleMeetManifestConfigSchema = JsonSchemaObject & {
|
|
properties?: Record<string, JsonSchemaObject & { properties?: Record<string, unknown> }>;
|
|
};
|
|
|
|
const voiceCallMocks = vi.hoisted(() => ({
|
|
createVoiceCallGateway: vi.fn(
|
|
({ runtime }: { runtime: { gateway: unknown } }) => runtime.gateway,
|
|
),
|
|
joinMeetViaVoiceCallGateway: vi.fn(async () => ({
|
|
callId: "call-1",
|
|
dtmfSent: true,
|
|
introSent: true,
|
|
})),
|
|
endMeetVoiceCallGatewayCall: vi.fn(async () => {}),
|
|
getMeetVoiceCallGatewayCall: vi.fn(
|
|
async (): Promise<{ found: boolean; call?: { callId: string } }> => ({
|
|
found: true,
|
|
call: { callId: "call-1" },
|
|
}),
|
|
),
|
|
isVoiceCallMissingError: vi.fn((error: unknown) => String(error).includes("Call not found")),
|
|
speakMeetViaVoiceCallGateway: 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", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/ssrf-runtime")>();
|
|
return {
|
|
...actual,
|
|
fetchWithSsrFGuard: fetchGuardMocks.fetchWithSsrFGuard,
|
|
};
|
|
});
|
|
|
|
vi.mock("./src/voice-call-gateway.js", () => ({
|
|
createVoiceCallGateway: voiceCallMocks.createVoiceCallGateway,
|
|
joinMeetViaVoiceCallGateway: voiceCallMocks.joinMeetViaVoiceCallGateway,
|
|
endMeetVoiceCallGatewayCall: voiceCallMocks.endMeetVoiceCallGatewayCall,
|
|
getMeetVoiceCallGatewayCall: voiceCallMocks.getMeetVoiceCallGatewayCall,
|
|
isVoiceCallMissingError: voiceCallMocks.isVoiceCallMissingError,
|
|
speakMeetViaVoiceCallGateway: voiceCallMocks.speakMeetViaVoiceCallGateway,
|
|
}));
|
|
|
|
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
|
|
>,
|
|
);
|
|
googleMeetPluginTesting.setPlatformForTests(() => options?.registerPlatform ?? "darwin");
|
|
return harness;
|
|
}
|
|
|
|
function jsonResponse(value: unknown): Response {
|
|
return new Response(JSON.stringify(value), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
function requireGoogleMeetManifestConfigSchema(manifest: {
|
|
configSchema?: GoogleMeetManifestConfigSchema;
|
|
}): GoogleMeetManifestConfigSchema {
|
|
if (!manifest.configSchema) {
|
|
throw new Error("Google Meet manifest did not include a config schema");
|
|
}
|
|
return manifest.configSchema;
|
|
}
|
|
|
|
function requireConfigProperty(
|
|
properties: Record<string, unknown> | undefined,
|
|
key: string,
|
|
): Record<string, unknown> {
|
|
const value = properties?.[key];
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
throw new Error(`Expected Google Meet config schema property ${key}`);
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
}
|
|
|
|
type MockSessionEntry = {
|
|
sessionId?: string;
|
|
updatedAt?: number;
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
function createMockSessionRuntime(sessionStore: Record<string, unknown>) {
|
|
return {
|
|
resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
|
|
loadSessionStore: vi.fn(() => sessionStore),
|
|
saveSessionStore: vi.fn(async () => {}),
|
|
updateSessionStore: vi.fn(async (_storePath, mutator: (store: never) => unknown) =>
|
|
mutator(sessionStore as never),
|
|
),
|
|
getSessionEntry: vi.fn(
|
|
({ sessionKey }: { sessionKey: string }) => sessionStore[sessionKey] as MockSessionEntry,
|
|
),
|
|
patchSessionEntry: vi.fn(
|
|
async ({
|
|
sessionKey,
|
|
fallbackEntry,
|
|
update,
|
|
}: {
|
|
sessionKey: string;
|
|
fallbackEntry: MockSessionEntry;
|
|
update: (entry: MockSessionEntry) => Promise<MockSessionEntry> | MockSessionEntry;
|
|
}) => {
|
|
const current = (sessionStore[sessionKey] as MockSessionEntry | undefined) ?? fallbackEntry;
|
|
const patch = await update(current);
|
|
const next = { ...current, ...patch };
|
|
sessionStore[sessionKey] = next;
|
|
return next;
|
|
},
|
|
),
|
|
resolveSessionFilePath: vi.fn(() => "/tmp/session.json"),
|
|
};
|
|
}
|
|
|
|
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
|
if (!isRecord(value)) {
|
|
throw new Error(`Expected ${label} to be an object`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function mockCall(mock: { mock: { calls: unknown[][] } }, callIndex = 0): unknown[] {
|
|
const call = mock.mock.calls.at(callIndex);
|
|
if (!call) {
|
|
throw new Error(`Expected mock call at index ${callIndex}`);
|
|
}
|
|
return call;
|
|
}
|
|
|
|
function mockCallArg(mock: { mock: { calls: unknown[][] } }, argIndex: number, callIndex = 0) {
|
|
return mockCall(mock, callIndex)[argIndex];
|
|
}
|
|
|
|
function expectRespondedOk(respond: { mock: { calls: unknown[][] } }): void {
|
|
expect(mockCallArg(respond, 0)).toBe(true);
|
|
}
|
|
|
|
function requireRespondPayload(
|
|
respond: { mock: { calls: unknown[][] } },
|
|
label: string,
|
|
): Record<string, unknown> {
|
|
return requireRecord(mockCallArg(respond, 1), label);
|
|
}
|
|
|
|
function requireSetupCheck(checks: unknown[] | undefined, id: string): Record<string, unknown> {
|
|
const check = checks
|
|
?.map((item) => requireRecord(item, "setup check"))
|
|
.find((item) => item.id === id);
|
|
if (!check) {
|
|
throw new Error(`Expected setup check ${id}`);
|
|
}
|
|
return check;
|
|
}
|
|
|
|
function requireFetchGuardCall(auditContext: string): Record<string, unknown> {
|
|
const call = (
|
|
fetchGuardMocks.fetchWithSsrFGuard.mock.calls as Array<[Record<string, unknown>]>
|
|
).find(([params]) => params.auditContext === auditContext);
|
|
if (!call) {
|
|
throw new Error(`Expected fetchWithSsrFGuard call for ${auditContext}`);
|
|
}
|
|
return call[0];
|
|
}
|
|
|
|
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> | (() => 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?: { fn?: string; 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 === "/navigate") {
|
|
return {
|
|
targetId: request.body?.targetId ?? "local-meet-tab",
|
|
url: request.body?.url ?? "https://meet.google.com/abc-defg-hij",
|
|
};
|
|
}
|
|
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(
|
|
typeof browserActResult === "function" ? browserActResult() : 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?: PassThrough | null;
|
|
stderr: PassThrough;
|
|
killed: boolean;
|
|
kill: ReturnType<typeof vi.fn>;
|
|
on: EventEmitter["on"];
|
|
emit: EventEmitter["emit"];
|
|
};
|
|
|
|
describe("google-meet plugin", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
voiceCallMocks.joinMeetViaVoiceCallGateway.mockResolvedValue({
|
|
callId: "call-1",
|
|
dtmfSent: true,
|
|
introSent: true,
|
|
});
|
|
voiceCallMocks.endMeetVoiceCallGatewayCall.mockResolvedValue(undefined);
|
|
voiceCallMocks.getMeetVoiceCallGatewayCall.mockResolvedValue({
|
|
found: true,
|
|
call: { callId: "call-1" },
|
|
});
|
|
voiceCallMocks.isVoiceCallMissingError.mockImplementation((error: unknown) =>
|
|
String(error).includes("Call not found"),
|
|
);
|
|
voiceCallMocks.speakMeetViaVoiceCallGateway.mockResolvedValue(undefined);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
vi.unstubAllGlobals();
|
|
vi.unstubAllEnvs();
|
|
chromeTransportTesting.setDepsForTest(null);
|
|
googleMeetPluginTesting.setCallGatewayFromCliForTests();
|
|
googleMeetPluginTesting.setPlatformForTests();
|
|
});
|
|
|
|
afterAll(() => {
|
|
vi.doUnmock("openclaw/plugin-sdk/ssrf-runtime");
|
|
vi.doUnmock("./src/voice-call-gateway.js");
|
|
vi.resetModules();
|
|
});
|
|
|
|
it("defaults to chrome agent mode with safe read-only tools", () => {
|
|
const config = resolveGoogleMeetConfig({});
|
|
|
|
expect(config.enabled).toBe(true);
|
|
expect(config.defaults).toEqual({});
|
|
expect(config.preview).toEqual({ enrollmentAcknowledged: false });
|
|
expect(config.defaultTransport).toBe("chrome");
|
|
expect(config.defaultMode).toBe("agent");
|
|
expect(config.chrome).toEqual({
|
|
audioBackend: "blackhole-2ch",
|
|
launch: true,
|
|
guestName: "OpenClaw Agent",
|
|
reuseExistingTab: true,
|
|
autoJoin: true,
|
|
joinTimeoutMs: 30000,
|
|
waitForInCallMs: 20000,
|
|
audioFormat: "pcm16-24khz",
|
|
audioBufferBytes: 4096,
|
|
audioInputCommand: [
|
|
"sox",
|
|
"-q",
|
|
"--buffer",
|
|
"4096",
|
|
"-t",
|
|
"coreaudio",
|
|
"BlackHole 2ch",
|
|
"-t",
|
|
"raw",
|
|
"-r",
|
|
"24000",
|
|
"-c",
|
|
"1",
|
|
"-e",
|
|
"signed-integer",
|
|
"-b",
|
|
"16",
|
|
"-L",
|
|
"-",
|
|
],
|
|
audioOutputCommand: [
|
|
"sox",
|
|
"-q",
|
|
"--buffer",
|
|
"4096",
|
|
"-t",
|
|
"raw",
|
|
"-r",
|
|
"24000",
|
|
"-c",
|
|
"1",
|
|
"-e",
|
|
"signed-integer",
|
|
"-b",
|
|
"16",
|
|
"-L",
|
|
"-",
|
|
"-t",
|
|
"coreaudio",
|
|
"BlackHole 2ch",
|
|
],
|
|
bargeInRmsThreshold: 650,
|
|
bargeInPeakThreshold: 2500,
|
|
bargeInCooldownMs: 900,
|
|
});
|
|
expect(config.chromeNode).toEqual({});
|
|
expect(config.twilio).toEqual({});
|
|
expect(config.voiceCall).toEqual({
|
|
enabled: true,
|
|
requestTimeoutMs: 30000,
|
|
dtmfDelayMs: 12000,
|
|
postDtmfSpeechDelayMs: 5000,
|
|
});
|
|
expect(config.realtime.strategy).toBe("agent");
|
|
expect(config.realtime.provider).toBe("openai");
|
|
expect(config.realtime.transcriptionProvider).toBe("openai");
|
|
expect(config.realtime.introMessage).toBe("Say exactly: I'm here and listening.");
|
|
expect(config.realtime.toolPolicy).toBe("safe-read-only");
|
|
expect(config.realtime.providers).toEqual({});
|
|
expect(config.realtime.instructions).toContain("openclaw_agent_consult");
|
|
expect(config.oauth).toEqual({});
|
|
expect(config.auth).toEqual({ provider: "google-oauth" });
|
|
|
|
expect(resolveGoogleMeetConfig({ defaultMode: "realtime" }).defaultMode).toBe("agent");
|
|
});
|
|
|
|
it("resolves separate realtime providers for agent transcription and bidi voice", () => {
|
|
const realtime = resolveGoogleMeetConfig({
|
|
realtime: {
|
|
provider: "openai",
|
|
transcriptionProvider: "openai",
|
|
voiceProvider: "google",
|
|
model: "gemini-2.5-flash-native-audio-preview-12-2025",
|
|
},
|
|
});
|
|
expect(realtime.realtime.provider).toBe("openai");
|
|
expect(realtime.realtime.transcriptionProvider).toBe("openai");
|
|
expect(realtime.realtime.voiceProvider).toBe("google");
|
|
expect(realtime.realtime.model).toBe("gemini-2.5-flash-native-audio-preview-12-2025");
|
|
});
|
|
|
|
it("keeps realtime.provider as the transcription compatibility fallback", () => {
|
|
const custom = resolveGoogleMeetConfig({
|
|
realtime: {
|
|
provider: "custom-stt",
|
|
},
|
|
});
|
|
expect(custom.realtime.provider).toBe("custom-stt");
|
|
expect(custom.realtime.transcriptionProvider).toBe("custom-stt");
|
|
|
|
const google = resolveGoogleMeetConfig({
|
|
realtime: {
|
|
provider: "google",
|
|
},
|
|
});
|
|
expect(google.realtime.provider).toBe("google");
|
|
expect(google.realtime.transcriptionProvider).toBe("openai");
|
|
});
|
|
|
|
it("uses voiceProvider for bidi and transcriptionProvider for agent mode resolution", () => {
|
|
const voiceProviders: RealtimeVoiceProviderPlugin[] = [
|
|
{
|
|
id: "openai",
|
|
label: "OpenAI",
|
|
autoSelectOrder: 1,
|
|
isConfigured: () => true,
|
|
createBridge: () => {
|
|
throw new Error("unused");
|
|
},
|
|
},
|
|
{
|
|
id: "google",
|
|
label: "Google",
|
|
autoSelectOrder: 2,
|
|
resolveConfig: ({ rawConfig }) => rawConfig,
|
|
isConfigured: () => true,
|
|
createBridge: () => {
|
|
throw new Error("unused");
|
|
},
|
|
},
|
|
];
|
|
const transcriptionProviders: RealtimeTranscriptionProviderPlugin[] = [
|
|
{
|
|
id: "openai",
|
|
label: "OpenAI",
|
|
autoSelectOrder: 1,
|
|
isConfigured: () => true,
|
|
createSession: () => {
|
|
throw new Error("unused");
|
|
},
|
|
},
|
|
];
|
|
const config = resolveGoogleMeetConfig({
|
|
realtime: {
|
|
provider: "openai",
|
|
transcriptionProvider: "openai",
|
|
voiceProvider: "google",
|
|
model: "gemini-2.5-flash-native-audio-preview-12-2025",
|
|
},
|
|
});
|
|
|
|
const voiceProvider = resolveGoogleMeetRealtimeProvider({
|
|
config,
|
|
fullConfig: {} as never,
|
|
providers: voiceProviders,
|
|
});
|
|
expect(voiceProvider.provider.id).toBe("google");
|
|
expect(voiceProvider.providerConfig).toEqual({
|
|
model: "gemini-2.5-flash-native-audio-preview-12-2025",
|
|
});
|
|
const transcriptionProvider = resolveGoogleMeetRealtimeTranscriptionProvider({
|
|
config,
|
|
fullConfig: {} as never,
|
|
providers: transcriptionProviders,
|
|
});
|
|
expect(transcriptionProvider.provider.id).toBe("openai");
|
|
});
|
|
|
|
it("declares advanced config metadata in the plugin entry and manifest", () => {
|
|
const manifest = JSON.parse(
|
|
readFileSync(new URL("./openclaw.plugin.json", import.meta.url), "utf8"),
|
|
) as {
|
|
uiHints?: Record<string, unknown>;
|
|
configSchema?: GoogleMeetManifestConfigSchema;
|
|
};
|
|
const configSchema = requireGoogleMeetManifestConfigSchema(manifest);
|
|
const entry = plugin as unknown as {
|
|
configSchema: {
|
|
uiHints?: Record<string, unknown>;
|
|
};
|
|
};
|
|
|
|
for (const key of [
|
|
"chrome.audioBufferBytes",
|
|
"chrome.bargeInInputCommand",
|
|
"chrome.bargeInRmsThreshold",
|
|
"chrome.bargeInPeakThreshold",
|
|
"chrome.bargeInCooldownMs",
|
|
"voiceCall.dtmfDelayMs",
|
|
"voiceCall.postDtmfSpeechDelayMs",
|
|
]) {
|
|
expect(entry.configSchema.uiHints?.[key]).toHaveProperty("advanced", true);
|
|
expect(manifest.uiHints?.[key]).toHaveProperty("advanced", true);
|
|
}
|
|
const chromeProperties = configSchema.properties?.chrome?.properties;
|
|
expect(requireConfigProperty(chromeProperties, "audioBufferBytes")).toEqual({
|
|
type: "number",
|
|
default: 4096,
|
|
});
|
|
expect(requireConfigProperty(chromeProperties, "bargeInInputCommand")).toEqual({
|
|
type: "array",
|
|
items: { type: "string" },
|
|
});
|
|
expect(requireConfigProperty(chromeProperties, "bargeInRmsThreshold")).toEqual({
|
|
type: "number",
|
|
default: 650,
|
|
});
|
|
expect(requireConfigProperty(chromeProperties, "bargeInPeakThreshold")).toEqual({
|
|
type: "number",
|
|
default: 2500,
|
|
});
|
|
expect(requireConfigProperty(chromeProperties, "bargeInCooldownMs")).toEqual({
|
|
type: "number",
|
|
default: 900,
|
|
});
|
|
const defaultConfig = resolveGoogleMeetConfig({});
|
|
expect(defaultConfig.voiceCall.dtmfDelayMs).toBe(12000);
|
|
expect(
|
|
requireConfigProperty(configSchema.properties?.voiceCall?.properties, "dtmfDelayMs"),
|
|
).toEqual({
|
|
type: "number",
|
|
default: defaultConfig.voiceCall.dtmfDelayMs,
|
|
});
|
|
expect(
|
|
requireConfigProperty(
|
|
configSchema.properties?.voiceCall?.properties,
|
|
"postDtmfSpeechDelayMs",
|
|
),
|
|
).toEqual({
|
|
type: "number",
|
|
default: 5000,
|
|
});
|
|
const result = validateJsonSchemaValue({
|
|
schema: configSchema,
|
|
cacheKey: "google-meet.manifest.voice-call-post-dtmf-speech-delay",
|
|
value: {
|
|
voiceCall: {
|
|
postDtmfSpeechDelayMs: 750,
|
|
},
|
|
},
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
});
|
|
|
|
it("resolves the realtime consult agent id", () => {
|
|
expect(
|
|
resolveGoogleMeetConfig({
|
|
realtime: {
|
|
agentId: " jay ",
|
|
},
|
|
}).realtime.agentId,
|
|
).toBe("jay");
|
|
});
|
|
|
|
it("preserves an empty realtime intro message for silent joins", () => {
|
|
expect(
|
|
resolveGoogleMeetConfig({
|
|
realtime: {
|
|
introMessage: "",
|
|
},
|
|
}).realtime.introMessage,
|
|
).toBe("");
|
|
});
|
|
|
|
it("keeps legacy command-pair audio format when custom commands omit a format", () => {
|
|
const config = resolveGoogleMeetConfig({
|
|
chrome: {
|
|
audioInputCommand: ["capture-legacy"],
|
|
audioOutputCommand: ["play-legacy"],
|
|
},
|
|
});
|
|
expect(config.chrome.audioFormat).toBe("g711-ulaw-8khz");
|
|
expect(config.chrome.audioInputCommand).toEqual(["capture-legacy"]);
|
|
expect(config.chrome.audioOutputCommand).toEqual(["play-legacy"]);
|
|
});
|
|
|
|
it("lets generated Chrome audio commands use a configured SoX buffer", () => {
|
|
const config = resolveGoogleMeetConfig({ chrome: { audioBufferBytes: 2048 } });
|
|
|
|
expect(config.chrome.audioBufferBytes).toBe(2048);
|
|
expect(config.chrome.audioInputCommand).toEqual([
|
|
"sox",
|
|
"-q",
|
|
"--buffer",
|
|
"2048",
|
|
"-t",
|
|
"coreaudio",
|
|
"BlackHole 2ch",
|
|
"-t",
|
|
"raw",
|
|
"-r",
|
|
"24000",
|
|
"-c",
|
|
"1",
|
|
"-e",
|
|
"signed-integer",
|
|
"-b",
|
|
"16",
|
|
"-L",
|
|
"-",
|
|
]);
|
|
expect(config.chrome.audioOutputCommand?.slice(0, 4)).toEqual([
|
|
"sox",
|
|
"-q",
|
|
"--buffer",
|
|
"2048",
|
|
]);
|
|
});
|
|
|
|
it("clamps configured Chrome audio buffers above SoX's minimum", () => {
|
|
const config = resolveGoogleMeetConfig({ chrome: { audioBufferBytes: 1 } });
|
|
|
|
expect(config.chrome.audioBufferBytes).toBe(17);
|
|
expect(config.chrome.audioInputCommand?.slice(0, 4)).toEqual(["sox", "-q", "--buffer", "17"]);
|
|
expect(config.chrome.audioOutputCommand?.slice(0, 4)).toEqual(["sox", "-q", "--buffer", "17"]);
|
|
});
|
|
|
|
it("uses env fallbacks for OAuth, preview, and default meeting values", () => {
|
|
const config = 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",
|
|
},
|
|
);
|
|
expect(config.defaults).toEqual({ meeting: "https://meet.google.com/abc-defg-hij" });
|
|
expect(config.preview).toEqual({ enrollmentAcknowledged: true });
|
|
expect(config.oauth).toEqual({
|
|
clientId: "client-id",
|
|
clientSecret: "client-secret",
|
|
refreshToken: "refresh-token",
|
|
accessToken: "access-token",
|
|
expiresAt: 123456,
|
|
});
|
|
});
|
|
|
|
it.each(["0x10", "1e3"])("ignores non-decimal env numeric fallbacks: %s", (expiresAt) => {
|
|
const config = resolveGoogleMeetConfigWithEnv(
|
|
{},
|
|
{
|
|
OPENCLAW_GOOGLE_MEET_ACCESS_TOKEN: "access-token",
|
|
OPENCLAW_GOOGLE_MEET_ACCESS_TOKEN_EXPIRES_AT: expiresAt,
|
|
},
|
|
);
|
|
|
|
expect(config.oauth).toEqual({ accessToken: "access-token" });
|
|
});
|
|
|
|
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");
|
|
expect(() => normalizeMeetUrl("https://user@meet.google.com/abc-defg-hij")).toThrow(
|
|
"meet.google.com",
|
|
);
|
|
expect(() => normalizeMeetUrl("https://meet.google.com:444/abc-defg-hij")).toThrow(
|
|
"meet.google.com",
|
|
);
|
|
});
|
|
|
|
it("advertises only the googlemeet CLI descriptor", () => {
|
|
const { cliRegistrations } = setup();
|
|
|
|
expect(cliRegistrations).toEqual([
|
|
{
|
|
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, nodeInvokePolicies } = setup();
|
|
|
|
const command = nodeHostCommands.find(
|
|
(entry): entry is Record<string, unknown> =>
|
|
isRecord(entry) && entry.command === "googlemeet.chrome",
|
|
);
|
|
if (!command) {
|
|
throw new Error("expected googlemeet.chrome node host command");
|
|
}
|
|
expect(command.cap).toBe("google-meet");
|
|
expect(command.dangerous).toBe(true);
|
|
expect(typeof command.handle).toBe("function");
|
|
expect(nodeInvokePolicies).toHaveLength(1);
|
|
expect(nodeInvokePolicies[0]).toMatchObject({
|
|
commands: ["googlemeet.chrome"],
|
|
dangerous: true,
|
|
});
|
|
});
|
|
|
|
it("keeps the agent tool visible on non-macOS hosts but blocks local Chrome talk-back joins", async () => {
|
|
const { cliRegistrations, methods, tools } = setup(undefined, { registerPlatform: "linux" });
|
|
const tool = tools[0] as {
|
|
execute: (id: string, params: unknown) => Promise<{ isError?: boolean; content: unknown }>;
|
|
};
|
|
|
|
expect(tools).toHaveLength(1);
|
|
expect(cliRegistrations).toHaveLength(1);
|
|
expect(methods.has("googlemeet.setup")).toBe(true);
|
|
expect(
|
|
googleMeetPluginTesting.isGoogleMeetAgentToolActionUnsupportedOnHost({
|
|
config: resolveGoogleMeetConfig({}),
|
|
raw: { action: "join" },
|
|
platform: "linux",
|
|
}),
|
|
).toBe(true);
|
|
|
|
const blocked = await tool.execute("id", { action: "join" });
|
|
expect(JSON.stringify(blocked)).toContain("local Chrome talk-back audio is macOS-only");
|
|
|
|
expect(
|
|
googleMeetPluginTesting.isGoogleMeetAgentToolActionUnsupportedOnHost({
|
|
config: resolveGoogleMeetConfig({}),
|
|
raw: { action: "join", mode: "transcribe" },
|
|
platform: "linux",
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
googleMeetPluginTesting.isGoogleMeetAgentToolActionUnsupportedOnHost({
|
|
config: resolveGoogleMeetConfig({}),
|
|
raw: { action: "join", transport: "chrome-node" },
|
|
platform: "linux",
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
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");
|
|
const parameters = requireRecord(tool.parameters, "Google Meet tool parameters");
|
|
expect(parameters.type).toBe("object");
|
|
const properties = requireRecord(
|
|
parameters.properties,
|
|
"Google Meet tool parameter properties",
|
|
);
|
|
const action = requireRecord(properties.action, "Google Meet action parameter");
|
|
expect(action.type).toBe("string");
|
|
expect(action.enum).toEqual([
|
|
"join",
|
|
"create",
|
|
"status",
|
|
"transcript",
|
|
"setup_status",
|
|
"resolve_space",
|
|
"preflight",
|
|
"latest",
|
|
"calendar_events",
|
|
"artifacts",
|
|
"attendance",
|
|
"export",
|
|
"recover_current_tab",
|
|
"leave",
|
|
"end_active_conference",
|
|
"speak",
|
|
"test_speech",
|
|
"test_listen",
|
|
]);
|
|
expect(action.description).toContain("recover_current_tab");
|
|
expect(properties.transport).toEqual({
|
|
type: "string",
|
|
enum: ["chrome", "chrome-node", "twilio"],
|
|
description: "Join transport",
|
|
});
|
|
expect(properties.mode).toEqual({
|
|
type: "string",
|
|
enum: ["agent", "bidi", "transcribe"],
|
|
description:
|
|
"Join mode. agent uses realtime transcription, the configured OpenClaw agent, and regular TTS. bidi uses the realtime voice model directly. transcribe joins observe-only.",
|
|
});
|
|
});
|
|
|
|
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");
|
|
const event = await findGoogleMeetCalendarEvent({
|
|
accessToken: "token",
|
|
now: new Date("2026-04-25T09:50:00Z"),
|
|
timeMin: "2026-04-25T00:00:00Z",
|
|
timeMax: "2026-04-26T00:00:00Z",
|
|
});
|
|
expect(event.calendarId).toBe("primary");
|
|
expect(event.meetingUri).toBe("https://meet.google.com/abc-defg-hij");
|
|
expect(event.event.summary).toBe("Project sync");
|
|
|
|
const calendarEvents = await listGoogleMeetCalendarEvents({
|
|
accessToken: "token",
|
|
now: new Date("2026-04-25T09:50:00Z"),
|
|
timeMin: "2026-04-25T00:00:00Z",
|
|
timeMax: "2026-04-26T00:00:00Z",
|
|
});
|
|
expect(calendarEvents.calendarId).toBe("primary");
|
|
expect(calendarEvents.events).toHaveLength(1);
|
|
expect(calendarEvents.events[0]?.meetingUri).toBe("https://meet.google.com/abc-defg-hij");
|
|
expect(calendarEvents.events[0]?.selected).toBe(true);
|
|
expect(calendarEvents.events[0]?.event.summary).toBe("Project sync");
|
|
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");
|
|
const guardCall = requireFetchGuardCall("google-meet.calendar.events.list");
|
|
expect(guardCall.policy).toEqual({ allowedHostnames: ["www.googleapis.com"] });
|
|
});
|
|
|
|
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);
|
|
|
|
const space = await fetchGoogleMeetSpace({
|
|
accessToken: "token",
|
|
meeting: "spaces/abc-defg-hij",
|
|
});
|
|
expect(space.name).toBe("spaces/abc-defg-hij");
|
|
expect(space.meetingCode).toBe("abc-defg-hij");
|
|
expect(space.meetingUri).toBe("https://meet.google.com/abc-defg-hij");
|
|
const guardCall = requireFetchGuardCall("google-meet.spaces.get");
|
|
expect(guardCall.url).toBe("https://meet.googleapis.com/v2/spaces/abc-defg-hij");
|
|
expect(requireRecord(guardCall.init, "spaces.get init").headers).toEqual({
|
|
Authorization: "Bearer token",
|
|
Accept: "application/json",
|
|
});
|
|
expect(guardCall.policy).toEqual({ allowedHostnames: ["meet.googleapis.com"] });
|
|
expect(fetchMock).toHaveBeenCalledWith("https://meet.googleapis.com/v2/spaces/abc-defg-hij", {
|
|
headers: {
|
|
Authorization: "Bearer token",
|
|
Accept: "application/json",
|
|
},
|
|
});
|
|
});
|
|
|
|
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);
|
|
|
|
const result = await createGoogleMeetSpace({ accessToken: "token" });
|
|
expect(result.meetingUri).toBe("https://meet.google.com/new-abcd-xyz");
|
|
expect(result.space.name).toBe("spaces/new-space");
|
|
expect(result.space.meetingCode).toBe("new-abcd-xyz");
|
|
expect(result.space.meetingUri).toBe("https://meet.google.com/new-abcd-xyz");
|
|
const guardCall = requireFetchGuardCall("google-meet.spaces.create");
|
|
expect(guardCall.url).toBe("https://meet.googleapis.com/v2/spaces");
|
|
expect(guardCall.init).toEqual({
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: "Bearer token",
|
|
Accept: "application/json",
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: "{}",
|
|
});
|
|
expect(guardCall.policy).toEqual({ allowedHostnames: ["meet.googleapis.com"] });
|
|
});
|
|
|
|
it("lists Meet artifact metadata for the latest conference record by default", async () => {
|
|
const fetchMock = stubMeetArtifactsApi();
|
|
|
|
const result = await fetchGoogleMeetArtifacts({
|
|
accessToken: "token",
|
|
meeting: "abc-defg-hij",
|
|
pageSize: 2,
|
|
});
|
|
expect(result.input).toBe("abc-defg-hij");
|
|
expect(result.space?.name).toBe("spaces/abc-defg-hij");
|
|
expect(result.conferenceRecords.map((record) => record.name)).toEqual([
|
|
"conferenceRecords/rec-1",
|
|
]);
|
|
expect(result.artifacts).toHaveLength(1);
|
|
const artifact = result.artifacts[0];
|
|
expect(artifact?.conferenceRecord.name).toBe("conferenceRecords/rec-1");
|
|
expect(artifact?.participants.map((participant) => participant.name)).toEqual([
|
|
"conferenceRecords/rec-1/participants/p1",
|
|
]);
|
|
expect(artifact?.recordings.map((recording) => recording.name)).toEqual([
|
|
"conferenceRecords/rec-1/recordings/r1",
|
|
]);
|
|
expect(artifact?.transcripts.map((transcript) => transcript.name)).toEqual([
|
|
"conferenceRecords/rec-1/transcripts/t1",
|
|
]);
|
|
expect(artifact?.transcriptEntries).toHaveLength(1);
|
|
expect(artifact?.transcriptEntries[0]?.transcript).toBe(
|
|
"conferenceRecords/rec-1/transcripts/t1",
|
|
);
|
|
expect(artifact?.transcriptEntries[0]?.entries).toEqual([
|
|
{
|
|
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",
|
|
},
|
|
]);
|
|
expect(artifact?.smartNotes.map((smartNote) => smartNote.name)).toEqual([
|
|
"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(requireFetchGuardCall("google-meet.conferenceRecords.smartNotes.list").url).toBe(
|
|
"https://meet.googleapis.com/v2/conferenceRecords/rec-1/smartNotes?pageSize=2",
|
|
);
|
|
expect(
|
|
requireFetchGuardCall("google-meet.conferenceRecords.transcripts.entries.list").url,
|
|
).toBe(
|
|
"https://meet.googleapis.com/v2/conferenceRecords/rec-1/transcripts/t1/entries?pageSize=2",
|
|
);
|
|
});
|
|
|
|
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();
|
|
|
|
const result = await fetchGoogleMeetArtifacts({
|
|
accessToken: "token",
|
|
conferenceRecord: "rec-1",
|
|
includeDocumentBodies: true,
|
|
});
|
|
expect(result.artifacts).toHaveLength(1);
|
|
expect(result.artifacts[0]?.transcripts.map((transcript) => transcript.documentText)).toEqual([
|
|
"Transcript document body.",
|
|
]);
|
|
expect(result.artifacts[0]?.smartNotes.map((smartNote) => smartNote.documentText)).toEqual([
|
|
"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.map((url) => url.searchParams.get("mimeType"))).toEqual([
|
|
"text/plain",
|
|
"text/plain",
|
|
]);
|
|
});
|
|
|
|
it("fetches only the latest Meet conference record for a meeting", async () => {
|
|
const fetchMock = stubMeetArtifactsApi();
|
|
|
|
const result = await fetchLatestGoogleMeetConferenceRecord({
|
|
accessToken: "token",
|
|
meeting: "abc-defg-hij",
|
|
});
|
|
expect(result.input).toBe("abc-defg-hij");
|
|
expect(result.space.name).toBe("spaces/abc-defg-hij");
|
|
expect(result.conferenceRecord?.name).toBe("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();
|
|
|
|
const result = await fetchGoogleMeetAttendance({
|
|
accessToken: "token",
|
|
conferenceRecord: "rec-1",
|
|
pageSize: 3,
|
|
});
|
|
expect(result.input).toBe("rec-1");
|
|
expect(result.conferenceRecords.map((record) => record.name)).toEqual([
|
|
"conferenceRecords/rec-1",
|
|
]);
|
|
expect(result.attendance).toHaveLength(1);
|
|
expect(result.attendance[0]?.conferenceRecord).toBe("conferenceRecords/rec-1");
|
|
expect(result.attendance[0]?.participant).toBe("conferenceRecords/rec-1/participants/p1");
|
|
expect(result.attendance[0]?.displayName).toBe("Alice");
|
|
expect(result.attendance[0]?.user).toBe("users/alice");
|
|
expect(result.attendance[0]?.sessions.map((session) => session.name)).toEqual([
|
|
"conferenceRecords/rec-1/participants/p1/participantSessions/s1",
|
|
]);
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
"https://meet.googleapis.com/v2/conferenceRecords/rec-1",
|
|
{
|
|
headers: {
|
|
Authorization: "Bearer token",
|
|
Accept: "application/json",
|
|
},
|
|
},
|
|
);
|
|
});
|
|
|
|
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);
|
|
|
|
const result = await fetchGoogleMeetAttendance({
|
|
accessToken: "token",
|
|
conferenceRecord: "rec-1",
|
|
});
|
|
expect(result.attendance).toHaveLength(1);
|
|
const row = result.attendance[0];
|
|
expect(row?.displayName).toBe("Alice");
|
|
expect(row?.participants).toEqual([
|
|
"conferenceRecords/rec-1/participants/p1",
|
|
"conferenceRecords/rec-1/participants/p2",
|
|
]);
|
|
expect(row?.firstJoinTime).toBe("2026-04-25T10:10:00.000Z");
|
|
expect(row?.lastLeaveTime).toBe("2026-04-25T10:50:00.000Z");
|
|
expect(row?.durationMs).toBe(1_800_000);
|
|
expect(row?.late).toBe(true);
|
|
expect(row?.earlyLeave).toBe(true);
|
|
expect(row?.sessions.map((session) => session.name)).toEqual([
|
|
"conferenceRecords/rec-1/participants/p1/participantSessions/s1",
|
|
"conferenceRecords/rec-1/participants/p2/participantSessions/s1",
|
|
]);
|
|
});
|
|
|
|
it("surfaces Developer Preview acknowledgment blockers in preflight reports", () => {
|
|
const report = buildGoogleMeetPreflightReport({
|
|
input: "abc-defg-hij",
|
|
space: { name: "spaces/abc-defg-hij" },
|
|
previewAcknowledged: false,
|
|
tokenSource: "cached-access-token",
|
|
});
|
|
expect(report.resolvedSpaceName).toBe("spaces/abc-defg-hij");
|
|
expect(report.previewAcknowledged).toBe(false);
|
|
expect(report.blockers).toHaveLength(1);
|
|
expect(report.blockers[0]).toContain("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#");
|
|
expect(prefixDtmfWait("123456#", 12000)).toBe("wwwwwwwwwwwwwwwwwwwwwwww123456#");
|
|
});
|
|
|
|
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",
|
|
});
|
|
|
|
const session = requireRecord(result.details.session, "Twilio session");
|
|
expect(session.transport).toBe("twilio");
|
|
expect(session.mode).toBe("agent");
|
|
expect(session.twilio).toEqual({
|
|
dialInNumber: "+15551234567",
|
|
pinProvided: true,
|
|
dtmfSequence: "wwwwwwwwwwwwwwwwwwwwwwww123456#",
|
|
voiceCallId: "call-1",
|
|
dtmfSent: true,
|
|
introSent: true,
|
|
});
|
|
const [voiceCallParams] = voiceCallMocks.joinMeetViaVoiceCallGateway.mock
|
|
.calls[0] as unknown as [Record<string, unknown>];
|
|
expect(requireRecord(voiceCallParams.config, "voice-call config").defaultTransport).toBe(
|
|
"twilio",
|
|
);
|
|
expect(voiceCallParams.dialInNumber).toBe("+15551234567");
|
|
expect(voiceCallParams.dtmfSequence).toBe("wwwwwwwwwwwwwwwwwwwwwwww123456#");
|
|
expect(typeof requireRecord(voiceCallParams.logger, "voice-call logger").info).toBe("function");
|
|
expect(voiceCallParams.message).toBe("Say exactly: I'm here and listening.");
|
|
expect(String(voiceCallParams.sessionKey)).toMatch(/^voice:google-meet:meet_/);
|
|
});
|
|
|
|
it("passes the caller session key through tool joins for agent context forking", async () => {
|
|
const { tools } = setup(
|
|
{},
|
|
{ toolContext: { sessionKey: "agent:main:discord:channel:general" } },
|
|
);
|
|
const gatewayParams: unknown[] = [];
|
|
googleMeetPluginTesting.setCallGatewayFromCliForTests(async (_method, _opts, params) => {
|
|
gatewayParams.push(params);
|
|
return { ok: true };
|
|
});
|
|
const tool = tools[0] as {
|
|
execute: (id: string, params: unknown) => Promise<unknown>;
|
|
};
|
|
|
|
await tool.execute("id", {
|
|
action: "join",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
requesterSessionKey: "agent:main:wrong",
|
|
});
|
|
|
|
const gatewayJoinParams = requireRecord(gatewayParams[0], "gateway join params");
|
|
expect(gatewayJoinParams.url).toBe("https://meet.google.com/abc-defg-hij");
|
|
expect(gatewayJoinParams.requesterSessionKey).toBe("agent:main:discord:channel:general");
|
|
});
|
|
|
|
it("requests admin scope only for the bundled tool Gateway fallback", async () => {
|
|
const { tools } = setup(
|
|
{},
|
|
{ toolContext: { sessionKey: "agent:main:discord:channel:general" } },
|
|
);
|
|
const callGatewayFromCli = vi.fn(async () => ({ ok: true }));
|
|
googleMeetPluginTesting.setCallGatewayFromCliForTests(callGatewayFromCli);
|
|
const tool = tools[0] as {
|
|
execute: (id: string, params: unknown) => Promise<unknown>;
|
|
};
|
|
|
|
await tool.execute("id", {
|
|
action: "join",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
mode: "transcribe",
|
|
});
|
|
|
|
expect(callGatewayFromCli).toHaveBeenCalledWith(
|
|
"googlemeet.join",
|
|
expect.any(Object),
|
|
expect.objectContaining({
|
|
requesterSessionKey: "agent:main:discord:channel:general",
|
|
}),
|
|
{ progress: false, scopes: ["operator.admin"] },
|
|
);
|
|
});
|
|
|
|
it("keeps Twilio calls on the agent that invoked the tool", async () => {
|
|
const { tools, gatewayRequest } = setup(
|
|
{ defaultTransport: "twilio" },
|
|
{
|
|
gatewayAvailable: true,
|
|
toolContext: { agentId: "Support", sessionKey: "agent:support:main" },
|
|
},
|
|
);
|
|
const tool = tools[0] as {
|
|
execute: (id: string, params: unknown) => Promise<{ details: { session: { id: string } } }>;
|
|
};
|
|
|
|
const result = await tool.execute("id", {
|
|
action: "join",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
dialInNumber: "+15551234567",
|
|
});
|
|
|
|
expect(gatewayRequest).toHaveBeenCalledWith(
|
|
"googlemeet.join",
|
|
expect.objectContaining({
|
|
agentId: "support",
|
|
requesterSessionKey: "agent:support:main",
|
|
}),
|
|
{ timeoutMs: 60_000, scopes: ["operator.admin"] },
|
|
);
|
|
expect(voiceCallMocks.joinMeetViaVoiceCallGateway).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
agentId: "support",
|
|
sessionKey: `agent:support:google-meet:${result.details.session.id}`,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("derives trusted agent ownership from the invoking session key", async () => {
|
|
const { tools, gatewayRequest } = setup(
|
|
{ defaultTransport: "twilio" },
|
|
{
|
|
gatewayAvailable: true,
|
|
toolContext: { sessionKey: "agent:support:pr103522-live" },
|
|
},
|
|
);
|
|
const tool = tools[0] as {
|
|
execute: (id: string, params: unknown) => Promise<{ details: { session: { id: string } } }>;
|
|
};
|
|
|
|
await tool.execute("id", {
|
|
action: "join",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
dialInNumber: "+15551234567",
|
|
agentId: "spoofed",
|
|
});
|
|
|
|
expect(gatewayRequest).toHaveBeenCalledWith(
|
|
"googlemeet.join",
|
|
expect.objectContaining({
|
|
agentId: "support",
|
|
requesterSessionKey: "agent:support:pr103522-live",
|
|
}),
|
|
{ timeoutMs: 60_000, scopes: ["operator.admin"] },
|
|
);
|
|
});
|
|
|
|
it("keeps Twilio calls on the configured realtime agent when no tool override exists", async () => {
|
|
const { tools } = setup({
|
|
defaultTransport: "twilio",
|
|
realtime: { agentId: "support" },
|
|
});
|
|
const tool = tools[0] as {
|
|
execute: (id: string, params: unknown) => Promise<{ details: { session: { id: string } } }>;
|
|
};
|
|
|
|
const result = await tool.execute("id", {
|
|
action: "join",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
dialInNumber: "+15551234567",
|
|
});
|
|
|
|
expect(voiceCallMocks.joinMeetViaVoiceCallGateway).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
agentId: "support",
|
|
sessionKey: `agent:support:google-meet:${result.details.session.id}`,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("keeps test-listen probes on the agent that invoked the tool", async () => {
|
|
const { tools, gatewayRequest } = setup(
|
|
{ defaultTransport: "chrome-node" },
|
|
{
|
|
gatewayAvailable: true,
|
|
toolContext: { agentId: "Support", sessionKey: "agent:support:main" },
|
|
browserActResult: {
|
|
inCall: true,
|
|
captioning: true,
|
|
transcriptLines: 1,
|
|
lastCaptionText: "hello from the meeting",
|
|
title: "Meet call",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
},
|
|
},
|
|
);
|
|
const tool = tools[0] as {
|
|
execute: (
|
|
id: string,
|
|
params: unknown,
|
|
) => Promise<{ details: { session: { agentId: string }; listenVerified: boolean } }>;
|
|
};
|
|
|
|
const result = await tool.execute("id", {
|
|
action: "test_listen",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
timeoutMs: "100",
|
|
});
|
|
|
|
expect(gatewayRequest).toHaveBeenCalledWith(
|
|
"googlemeet.testListen",
|
|
expect.objectContaining({
|
|
agentId: "support",
|
|
requesterSessionKey: "agent:support:main",
|
|
}),
|
|
{ timeoutMs: 60_000, scopes: ["operator.admin"] },
|
|
);
|
|
expect(result.details.session.agentId).toBe("support");
|
|
expect(result.details.listenVerified).toBe(true);
|
|
});
|
|
|
|
it("fails closed for standalone non-default agent routing", async () => {
|
|
const { tools } = setup(
|
|
{},
|
|
{ toolContext: { agentId: "support", sessionKey: "agent:support:main" } },
|
|
);
|
|
const tool = tools[0] as {
|
|
execute: (id: string, params: unknown) => Promise<{ details: Record<string, unknown> }>;
|
|
};
|
|
|
|
const result = await tool.execute("id", {
|
|
action: "join",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
});
|
|
|
|
expect(result.details.error).toContain("requires a Gateway-hosted agent run");
|
|
});
|
|
|
|
it("preserves structured recovery details from the running Gateway", async () => {
|
|
const { tools } = setup(
|
|
{},
|
|
{ toolContext: { agentId: "main", sessionKey: "agent:main:main" } },
|
|
);
|
|
googleMeetPluginTesting.setCallGatewayFromCliForTests(async () => {
|
|
throw Object.assign(new Error("browser login required"), {
|
|
details: {
|
|
manualActionRequired: true,
|
|
reason: "not-authenticated",
|
|
browser: { profile: "openclaw" },
|
|
},
|
|
});
|
|
});
|
|
const tool = tools[0] as {
|
|
execute: (id: string, params: unknown) => Promise<{ details: Record<string, unknown> }>;
|
|
};
|
|
|
|
const result = await tool.execute("id", {
|
|
action: "join",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
});
|
|
|
|
expect(result.details).toEqual({
|
|
manualActionRequired: true,
|
|
reason: "not-authenticated",
|
|
browser: { profile: "openclaw" },
|
|
});
|
|
});
|
|
|
|
it("does not accept agent routing from an external gateway caller", async () => {
|
|
const { methods } = setup({ defaultTransport: "twilio" });
|
|
|
|
await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.join", {
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
dialInNumber: "+15551234567",
|
|
agentId: "spoofed",
|
|
});
|
|
|
|
expect(voiceCallMocks.joinMeetViaVoiceCallGateway).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
agentId: undefined,
|
|
sessionKey: expect.stringMatching(/^voice:google-meet:meet_/),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("explains that Twilio joins need dial-in details", async () => {
|
|
const { tools } = setup({ defaultTransport: "twilio" });
|
|
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("Twilio transport requires a Meet dial-in phone number");
|
|
expect(result.details.error).toContain("Google Meet URLs do not include dial-in details");
|
|
});
|
|
|
|
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 });
|
|
|
|
const [endParams] = mockCall(voiceCallMocks.endMeetVoiceCallGatewayCall) as [
|
|
Record<string, unknown>,
|
|
];
|
|
expect(endParams.gateway).toBeDefined();
|
|
expect(endParams.callId).toBe("call-1");
|
|
expect(voiceCallMocks.endMeetVoiceCallGatewayCall).toHaveBeenCalledWith({
|
|
gateway: endParams.gateway,
|
|
callId: "call-1",
|
|
});
|
|
});
|
|
|
|
it("does not reuse Twilio Meet sessions whose delegated call is no longer active", async () => {
|
|
voiceCallMocks.getMeetVoiceCallGatewayCall.mockResolvedValueOnce({ found: false });
|
|
const { tools } = setup({ defaultTransport: "twilio" });
|
|
const tool = tools[0] as {
|
|
execute: (
|
|
id: string,
|
|
params: unknown,
|
|
) => Promise<{ details: { session: { id: string; state: string; notes: string[] } } }>;
|
|
};
|
|
const first = await tool.execute("id", {
|
|
action: "join",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
dialInNumber: "+15551234567",
|
|
pin: "123456",
|
|
});
|
|
const second = await tool.execute("id", {
|
|
action: "join",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
dialInNumber: "+15551234567",
|
|
pin: "123456",
|
|
});
|
|
|
|
expect(first.details.session.state).toBe("ended");
|
|
expect(first.details.session.notes).toContain("Voice Call is no longer active.");
|
|
expect(second.details.session.id).not.toBe(first.details.session.id);
|
|
expect(voiceCallMocks.joinMeetViaVoiceCallGateway).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("delegates Twilio session speech through voice-call", 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",
|
|
});
|
|
|
|
const spoken = await tool.execute("id", {
|
|
action: "speak",
|
|
sessionId: joined.details.session.id,
|
|
message: "Say exactly: hello after joining.",
|
|
});
|
|
|
|
expect(requireRecord(spoken.details, "spoken details").spoken).toBe(true);
|
|
const [speakParams] = voiceCallMocks.speakMeetViaVoiceCallGateway.mock.calls.at(
|
|
0,
|
|
) as unknown as [Record<string, unknown>];
|
|
expect(speakParams.gateway).toBeDefined();
|
|
expect(speakParams.callId).toBe("call-1");
|
|
expect(speakParams.message).toBe("Say exactly: hello after joining.");
|
|
expect(voiceCallMocks.speakMeetViaVoiceCallGateway).toHaveBeenCalledWith({
|
|
gateway: speakParams.gateway,
|
|
callId: "call-1",
|
|
message: "Say exactly: hello after joining.",
|
|
});
|
|
});
|
|
|
|
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("rejects agent-mode external audio bridges in setup status", async () => {
|
|
const originalPlatform = process.platform;
|
|
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
try {
|
|
const { tools } = setup(
|
|
{
|
|
defaultMode: "agent",
|
|
defaultTransport: "chrome",
|
|
chrome: {
|
|
audioBridgeCommand: ["bridge", "start"],
|
|
audioInputCommand: ["capture-meet"],
|
|
audioOutputCommand: ["play-meet"],
|
|
},
|
|
},
|
|
{
|
|
runCommandWithTimeoutHandler: async (argv) => {
|
|
if (argv[0] === "/usr/sbin/system_profiler") {
|
|
return { code: 0, stdout: "BlackHole 2ch", 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" });
|
|
|
|
expect(result.details.ok).toBe(false);
|
|
const audioBridgeCheck = result.details.checks
|
|
?.map((check) => requireRecord(check, "setup check"))
|
|
.find((check) => check.id === "audio-bridge");
|
|
if (!audioBridgeCheck) {
|
|
throw new Error("Expected audio-bridge setup check");
|
|
}
|
|
expect(audioBridgeCheck.ok).toBe(false);
|
|
expect(String(audioBridgeCheck.message)).toContain("chrome.audioBridgeCommand is bidi-only");
|
|
} 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).toHaveLength(1);
|
|
expect(result.details.attendance?.[0]?.displayName).toBe("Alice");
|
|
});
|
|
|
|
it("rejects fractional attendance page sizes", async () => {
|
|
stubMeetArtifactsApi();
|
|
const { tools } = setup();
|
|
const tool = tools[0] as {
|
|
execute: (id: string, params: unknown) => Promise<{ details: { error?: string } }>;
|
|
};
|
|
|
|
const result = await tool.execute("id", {
|
|
action: "attendance",
|
|
accessToken: "token",
|
|
expiresAt: Date.now() + 120_000,
|
|
conferenceRecord: "rec-1",
|
|
pageSize: "3.5",
|
|
});
|
|
|
|
expect(result.details.error).toBe("pageSize must be a positive integer");
|
|
});
|
|
|
|
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).toContain(path.join(tempDir, "manifest.json"));
|
|
expect(result.details.zipFile).toBe(`${tempDir}.zip`);
|
|
const manifest = requireRecord(
|
|
JSON.parse(readFileSync(path.join(tempDir, "manifest.json"), "utf8")),
|
|
"export manifest",
|
|
);
|
|
expect(manifest.request).toEqual({
|
|
conferenceRecord: "rec-1",
|
|
includeDocumentBodies: true,
|
|
includeTranscriptEntries: true,
|
|
allConferenceRecords: false,
|
|
mergeDuplicateParticipants: true,
|
|
});
|
|
expect(manifest.counts).toEqual({
|
|
conferenceRecords: 1,
|
|
artifacts: 1,
|
|
recordings: 1,
|
|
transcripts: 1,
|
|
transcriptEntries: 1,
|
|
smartNotes: 1,
|
|
attendanceRows: 1,
|
|
warnings: 0,
|
|
});
|
|
expect(manifest.files).toEqual([
|
|
"summary.md",
|
|
"attendance.csv",
|
|
"transcript.md",
|
|
"artifacts.json",
|
|
"attendance.json",
|
|
"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.dryRun).toBe(true);
|
|
expect(result.details.manifest?.files).toEqual([
|
|
"summary.md",
|
|
"attendance.csv",
|
|
"transcript.md",
|
|
"artifacts.json",
|
|
"attendance.json",
|
|
"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?.name).toBe("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?.meetingUri).toBe("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).toHaveLength(1);
|
|
expect(result.details.events?.[0]?.selected).toBe(true);
|
|
expect(result.details.events?.[0]?.meetingUri).toBe("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);
|
|
const check = requireSetupCheck(result.details.checks, "chrome-node-connected");
|
|
expect(check.ok).toBe(false);
|
|
expect(check.message).toContain("parallels-macos");
|
|
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);
|
|
const check = requireSetupCheck(result.details.checks, "chrome-local-audio-device");
|
|
expect(check.ok).toBe(false);
|
|
expect(check.message).toContain("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);
|
|
const check = requireSetupCheck(result.details.checks, "chrome-local-audio-commands");
|
|
expect(check.ok).toBe(false);
|
|
expect(check.message).toBe("Chrome audio command missing: sox");
|
|
} finally {
|
|
Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
}
|
|
});
|
|
|
|
it("checks a configured local barge-in command in setup status", async () => {
|
|
const originalPlatform = process.platform;
|
|
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
try {
|
|
const { tools } = setup(
|
|
{
|
|
defaultTransport: "chrome",
|
|
chrome: {
|
|
bargeInInputCommand: ["missing-barge-capture"],
|
|
},
|
|
},
|
|
{
|
|
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) === "missing-barge-capture") {
|
|
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);
|
|
const check = requireSetupCheck(result.details.checks, "chrome-local-audio-commands");
|
|
expect(check.ok).toBe(false);
|
|
expect(check.message).toBe("Chrome audio command missing: missing-barge-capture");
|
|
} finally {
|
|
Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
}
|
|
});
|
|
|
|
it("skips local Chrome audio prerequisites for observe-only setup status", async () => {
|
|
const originalPlatform = process.platform;
|
|
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
try {
|
|
const { tools, runCommandWithTimeout } = setup(
|
|
{ defaultMode: "transcribe", defaultTransport: "chrome" },
|
|
{
|
|
runCommandWithTimeoutHandler: async () => ({
|
|
code: 1,
|
|
stdout: "Built-in Output",
|
|
stderr: "",
|
|
}),
|
|
},
|
|
);
|
|
const tool = tools[0] as {
|
|
execute: (
|
|
id: string,
|
|
params: unknown,
|
|
) => Promise<{ details: { ok?: boolean; checks?: Array<{ id?: string; ok?: boolean }> } }>;
|
|
};
|
|
|
|
const result = await tool.execute("id", {
|
|
action: "setup_status",
|
|
transport: "chrome",
|
|
mode: "transcribe",
|
|
});
|
|
|
|
expect(result.details.ok).toBe(true);
|
|
const check = requireSetupCheck(result.details.checks, "audio-bridge");
|
|
expect(check.ok).toBe(true);
|
|
expect(check.message).toBe(
|
|
"Chrome observe-only mode does not require a realtime audio bridge",
|
|
);
|
|
expect(
|
|
result.details.checks?.filter(
|
|
(checkLocal) => checkLocal.id === "chrome-local-audio-device",
|
|
),
|
|
).toStrictEqual([]);
|
|
expect(runCommandWithTimeout).not.toHaveBeenCalled();
|
|
} 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",
|
|
publicUrl: "https://voice.example.com/voice/webhook",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
);
|
|
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(requireSetupCheck(result.details.checks, "twilio-voice-call-plugin").ok).toBe(true);
|
|
expect(requireSetupCheck(result.details.checks, "twilio-voice-call-credentials").ok).toBe(true);
|
|
expect(requireSetupCheck(result.details.checks, "twilio-voice-call-webhook").ok).toBe(true);
|
|
});
|
|
|
|
it("reports missing voice-call wiring for explicit Twilio transport", async () => {
|
|
vi.stubEnv("TWILIO_ACCOUNT_SID", "");
|
|
vi.stubEnv("TWILIO_AUTH_TOKEN", "");
|
|
vi.stubEnv("TWILIO_FROM_NUMBER", "");
|
|
const { tools } = setup(
|
|
{ defaultTransport: "chrome" },
|
|
{
|
|
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", transport: "twilio" });
|
|
|
|
expect(result.details.ok).toBe(false);
|
|
expect(requireSetupCheck(result.details.checks, "twilio-voice-call-plugin").ok).toBe(false);
|
|
expect(requireSetupCheck(result.details.checks, "twilio-voice-call-credentials").ok).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it("reports missing voice-call plugin entry for explicit Twilio transport", async () => {
|
|
vi.stubEnv("TWILIO_ACCOUNT_SID", "AC123");
|
|
vi.stubEnv("TWILIO_AUTH_TOKEN", "secret");
|
|
vi.stubEnv("TWILIO_FROM_NUMBER", "+15550001234");
|
|
const { tools } = setup(
|
|
{ defaultTransport: "chrome" },
|
|
{
|
|
fullConfig: {
|
|
plugins: {
|
|
allow: ["google-meet", "voice-call"],
|
|
entries: {},
|
|
},
|
|
},
|
|
},
|
|
);
|
|
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: "twilio" });
|
|
|
|
expect(result.details.ok).toBe(false);
|
|
expect(requireSetupCheck(result.details.checks, "twilio-voice-call-plugin").ok).toBe(false);
|
|
});
|
|
|
|
it("reports missing Twilio dial plan for explicit Twilio setup", async () => {
|
|
vi.stubEnv("TWILIO_ACCOUNT_SID", "AC123");
|
|
vi.stubEnv("TWILIO_AUTH_TOKEN", "secret");
|
|
vi.stubEnv("TWILIO_FROM_NUMBER", "+15550001234");
|
|
const { tools } = setup(
|
|
{ defaultTransport: "chrome" },
|
|
{
|
|
fullConfig: {
|
|
plugins: {
|
|
allow: ["google-meet", "voice-call"],
|
|
entries: {
|
|
"voice-call": {
|
|
enabled: true,
|
|
config: {
|
|
provider: "twilio",
|
|
publicUrl: "https://voice.example.com/voice/webhook",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
);
|
|
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: "twilio" });
|
|
|
|
expect(result.details.ok).toBe(false);
|
|
const check = requireSetupCheck(result.details.checks, "twilio-dial-plan");
|
|
expect(check.ok).toBe(false);
|
|
expect(check.message).toContain("dial-in phone number");
|
|
});
|
|
|
|
it("accepts request-provided Twilio dial-in details during setup", async () => {
|
|
vi.stubEnv("TWILIO_ACCOUNT_SID", "AC123");
|
|
vi.stubEnv("TWILIO_AUTH_TOKEN", "secret");
|
|
vi.stubEnv("TWILIO_FROM_NUMBER", "+15550001234");
|
|
const { tools } = setup(
|
|
{ defaultTransport: "chrome" },
|
|
{
|
|
fullConfig: {
|
|
plugins: {
|
|
allow: ["google-meet", "voice-call"],
|
|
entries: {
|
|
"voice-call": {
|
|
enabled: true,
|
|
config: {
|
|
provider: "twilio",
|
|
publicUrl: "https://voice.example.com/voice/webhook",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
);
|
|
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: "twilio",
|
|
dialInNumber: "+15551234567",
|
|
});
|
|
|
|
expect(result.details.ok).toBe(true);
|
|
const check = requireSetupCheck(result.details.checks, "twilio-dial-plan");
|
|
expect(check.ok).toBe(true);
|
|
expect(check.message).toContain("request includes");
|
|
});
|
|
|
|
it.each([
|
|
"http://127.0.0.1:3334/voice/webhook",
|
|
"http://[::1]:3334/voice/webhook",
|
|
"http://[fd00::1]/voice/webhook",
|
|
])(
|
|
"reports local voice-call publicUrl %s as unusable for Twilio transport",
|
|
async (publicUrl) => {
|
|
vi.stubEnv("TWILIO_ACCOUNT_SID", "AC123");
|
|
vi.stubEnv("TWILIO_AUTH_TOKEN", "secret");
|
|
vi.stubEnv("TWILIO_FROM_NUMBER", "+15550001234");
|
|
const { tools } = setup(
|
|
{ defaultTransport: "twilio" },
|
|
{
|
|
fullConfig: {
|
|
plugins: {
|
|
allow: ["google-meet", "voice-call"],
|
|
entries: {
|
|
"voice-call": {
|
|
enabled: true,
|
|
config: {
|
|
provider: "twilio",
|
|
publicUrl,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
);
|
|
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(requireSetupCheck(result.details.checks, "twilio-voice-call-webhook").ok).toBe(false);
|
|
},
|
|
);
|
|
|
|
it("opens local Chrome Meet in observe-only mode without BlackHole checks", async () => {
|
|
const originalPlatform = process.platform;
|
|
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
try {
|
|
const { methods, runCommandWithTimeout } = setup({
|
|
defaultMode: "transcribe",
|
|
chrome: {
|
|
browserProfile: "meet-devtools",
|
|
},
|
|
});
|
|
const callGatewayFromCli = mockLocalMeetBrowserRequest({
|
|
inCall: true,
|
|
micMuted: true,
|
|
captioning: true,
|
|
captionsEnabledAttempted: true,
|
|
transcriptLines: 1,
|
|
lastCaptionAt: "2026-04-27T10:00:00.000Z",
|
|
lastCaptionSpeaker: "Alice",
|
|
lastCaptionText: "Can everyone hear the agent?",
|
|
recentTranscript: [
|
|
{
|
|
at: "2026-04-27T10:00:00.000Z",
|
|
speaker: "Alice",
|
|
text: "Can everyone hear the agent?",
|
|
},
|
|
],
|
|
title: "Meet call",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
});
|
|
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,
|
|
});
|
|
|
|
expectRespondedOk(respond);
|
|
expect(runCommandWithTimeout).not.toHaveBeenCalled();
|
|
const openCall = callGatewayFromCli.mock.calls.find(
|
|
(call) => requireRecord(call[2], "browser request").path === "/tabs/open",
|
|
);
|
|
if (!openCall) {
|
|
throw new Error("Expected browser /tabs/open request");
|
|
}
|
|
expect(openCall[0]).toBe("browser.request");
|
|
expect(openCall[2]).toEqual({
|
|
method: "POST",
|
|
path: "/tabs/open",
|
|
timeoutMs: 30000,
|
|
body: { url: "https://meet.google.com/abc-defg-hij?hl=en" },
|
|
});
|
|
expect(openCall[3]).toEqual({ progress: false });
|
|
expect(
|
|
callGatewayFromCli.mock.calls.some(
|
|
(call) => (call[2] as { path?: string }).path === "/permissions/grant",
|
|
),
|
|
).toBe(false);
|
|
const payload = requireRespondPayload(respond, "join response payload");
|
|
const session = requireRecord(payload.session, "join session");
|
|
const chrome = requireRecord(session.chrome, "join chrome session");
|
|
const health = requireRecord(chrome.health, "join chrome health");
|
|
expect(health.captioning).toBe(true);
|
|
expect(health.captionsEnabledAttempted).toBe(true);
|
|
expect(health.transcriptLines).toBe(1);
|
|
expect(health.lastCaptionSpeaker).toBe("Alice");
|
|
expect(health.lastCaptionText).toBe("Can everyone hear the agent?");
|
|
const recentTranscript = health.recentTranscript as unknown[];
|
|
expect(recentTranscript).toHaveLength(1);
|
|
const transcriptLine = requireRecord(recentTranscript[0], "recent transcript line");
|
|
expect(transcriptLine.speaker).toBe("Alice");
|
|
expect(transcriptLine.text).toBe("Can everyone hear the agent?");
|
|
const actCall = callGatewayFromCli.mock.calls.find(
|
|
(call) => (call[2] as { path?: string }).path === "/act",
|
|
);
|
|
expect(String((actCall?.[2] as { body?: { fn?: string } } | undefined)?.body?.fn)).toContain(
|
|
"const allowMicrophone = false",
|
|
);
|
|
expect(String((actCall?.[2] as { body?: { fn?: string } } | undefined)?.body?.fn)).toContain(
|
|
"const captureCaptions = true",
|
|
);
|
|
} finally {
|
|
Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
}
|
|
});
|
|
|
|
it("grants local Chrome Meet media permissions against the opened tab", async () => {
|
|
const originalPlatform = process.platform;
|
|
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
try {
|
|
const callGatewayFromCli = mockLocalMeetBrowserRequest({
|
|
inCall: true,
|
|
micMuted: false,
|
|
title: "Meet call",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
});
|
|
const { methods } = setup({
|
|
defaultMode: "bidi",
|
|
defaultTransport: "chrome",
|
|
chrome: {
|
|
audioBridgeCommand: ["bridge", "start"],
|
|
},
|
|
realtime: { introMessage: "" },
|
|
});
|
|
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,
|
|
});
|
|
|
|
expectRespondedOk(respond);
|
|
const grantCall = callGatewayFromCli.mock.calls.find(
|
|
(call) => requireRecord(call[2], "browser request").path === "/permissions/grant",
|
|
);
|
|
if (!grantCall) {
|
|
throw new Error("Expected browser /permissions/grant request");
|
|
}
|
|
expect(grantCall[0]).toBe("browser.request");
|
|
const request = requireRecord(grantCall[2], "permissions request");
|
|
expect(request.method).toBe("POST");
|
|
expect(request.path).toBe("/permissions/grant");
|
|
const body = requireRecord(request.body, "permissions request body");
|
|
expect(body.origin).toBe("https://meet.google.com");
|
|
expect(body.permissions).toEqual(["audioCapture", "videoCapture"]);
|
|
expect(body.targetId).toBe("local-meet-tab");
|
|
expect(grantCall[3]).toEqual({ progress: false });
|
|
} finally {
|
|
Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
}
|
|
});
|
|
|
|
function mockLocalMeetBrowserRequestWithTabState(options?: {
|
|
reused?: boolean;
|
|
tabClosesAfterJoin?: boolean;
|
|
tabUrlAfterJoin?: string;
|
|
leaveClicked?: boolean;
|
|
leaveConfirmationRequired?: boolean;
|
|
nonFinalTranscriptGate?: Promise<void>;
|
|
onNonFinalTranscriptRead?: () => void;
|
|
transcript?: {
|
|
droppedLines?: number;
|
|
epoch?: string;
|
|
lines: Array<{ at?: string; speaker?: string; text: string }>;
|
|
};
|
|
finalTranscript?: {
|
|
droppedLines?: number;
|
|
epoch?: string;
|
|
lines: Array<{ at?: string; speaker?: string; text: string }>;
|
|
};
|
|
transcriptSequence?: Array<{
|
|
droppedLines?: number;
|
|
epoch?: string;
|
|
lines: Array<{ at?: string; speaker?: string; text: string }>;
|
|
}>;
|
|
}) {
|
|
let joined = false;
|
|
let leaveStep = 0;
|
|
let transcriptReadIndex = 0;
|
|
let openedTabUrl = options?.reused ? "https://meet.google.com/abc-defg-hij?hl=en" : undefined;
|
|
const callGatewayFromCli = vi.fn(
|
|
async (
|
|
_method: string,
|
|
_opts: unknown,
|
|
params?: unknown,
|
|
_extra?: unknown,
|
|
): Promise<Record<string, unknown>> => {
|
|
const request = params as {
|
|
method?: string;
|
|
path?: string;
|
|
body?: { fn?: string; targetId?: string; url?: string };
|
|
};
|
|
if (request.path === "/tabs") {
|
|
const tabExists = openedTabUrl && !(options?.tabClosesAfterJoin && joined);
|
|
const currentTabUrl = joined ? (options?.tabUrlAfterJoin ?? openedTabUrl) : openedTabUrl;
|
|
return {
|
|
tabs: tabExists
|
|
? [{ targetId: "local-meet-tab", title: "Meet", url: currentTabUrl }]
|
|
: [],
|
|
};
|
|
}
|
|
if (request.path === "/tabs/open") {
|
|
openedTabUrl = request.body?.url;
|
|
return { targetId: "local-meet-tab", title: "Meet", url: openedTabUrl };
|
|
}
|
|
if (request.path === "/tabs/focus") {
|
|
return { ok: true };
|
|
}
|
|
if (request.path === "/navigate") {
|
|
openedTabUrl = request.body?.url ?? openedTabUrl;
|
|
return { targetId: request.body?.targetId, url: openedTabUrl };
|
|
}
|
|
if (request.path === "/permissions/grant") {
|
|
return { ok: true };
|
|
}
|
|
if (request.method === "DELETE" && request.path === "/tabs/local-meet-tab") {
|
|
if (options?.reused) {
|
|
throw new Error("leave must not close a reused user-owned tab");
|
|
}
|
|
openedTabUrl = undefined;
|
|
return { ok: true };
|
|
}
|
|
if (request.path === "/act") {
|
|
if (String(request.body?.fn).includes("state = window.__openclawMeetCaptions")) {
|
|
const finalizing = String(request.body?.fn).includes("if (true &&");
|
|
if (!finalizing) {
|
|
options?.onNonFinalTranscriptRead?.();
|
|
await options?.nonFinalTranscriptGate;
|
|
}
|
|
const transcript = finalizing
|
|
? (options?.finalTranscript ?? options?.transcript)
|
|
: options?.transcript;
|
|
const sequencedTranscript =
|
|
options?.transcriptSequence?.[
|
|
Math.min(transcriptReadIndex, options.transcriptSequence.length - 1)
|
|
];
|
|
transcriptReadIndex += 1;
|
|
const responseTranscript = sequencedTranscript ?? transcript;
|
|
return {
|
|
result: JSON.stringify({
|
|
urlMatched: options?.tabUrlAfterJoin?.includes("/abc-defg-hij") !== false,
|
|
droppedLines: responseTranscript?.droppedLines ?? 0,
|
|
epoch: responseTranscript?.epoch,
|
|
lines: responseTranscript?.lines ?? [],
|
|
}),
|
|
};
|
|
}
|
|
if (String(request.body?.fn).includes("leaveAction")) {
|
|
const currentUrl = options?.tabUrlAfterJoin ?? openedTabUrl;
|
|
const urlMatched = currentUrl?.includes("/abc-defg-hij") === true;
|
|
if (!urlMatched) {
|
|
return { result: JSON.stringify({ departed: true, urlMatched: false }) };
|
|
}
|
|
if (options?.leaveClicked === false) {
|
|
return { result: JSON.stringify({ departed: false, urlMatched: true }) };
|
|
}
|
|
leaveStep += 1;
|
|
if (leaveStep === 1) {
|
|
return {
|
|
result: JSON.stringify({
|
|
departed: false,
|
|
leaveAction: "leave",
|
|
urlMatched: true,
|
|
}),
|
|
};
|
|
}
|
|
if (options?.leaveConfirmationRequired && leaveStep === 2) {
|
|
return {
|
|
result: JSON.stringify({
|
|
departed: false,
|
|
leaveAction: "confirm",
|
|
urlMatched: true,
|
|
}),
|
|
};
|
|
}
|
|
return {
|
|
result: JSON.stringify({ departed: true, urlMatched: true }),
|
|
};
|
|
}
|
|
joined = true;
|
|
return {
|
|
result: JSON.stringify({
|
|
inCall: true,
|
|
micMuted: true,
|
|
title: "Meet call",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
}),
|
|
};
|
|
}
|
|
throw new Error(`unexpected browser request path ${request.path}`);
|
|
},
|
|
);
|
|
chromeTransportTesting.setDepsForTest({ callGatewayFromCli });
|
|
return callGatewayFromCli;
|
|
}
|
|
|
|
it("reads and snapshots the bounded transcript from the exact tracked tab", async () => {
|
|
const originalPlatform = process.platform;
|
|
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
try {
|
|
const callGatewayFromCli = mockLocalMeetBrowserRequestWithTabState({
|
|
transcript: {
|
|
droppedLines: 2,
|
|
lines: [
|
|
{ at: "2026-07-12T06:00:00.000Z", speaker: "Alice", text: "third line" },
|
|
{ at: "2026-07-12T06:00:01.000Z", speaker: "Bob", text: "fourth line" },
|
|
],
|
|
},
|
|
});
|
|
const { methods } = setup({ defaultMode: "transcribe", defaultTransport: "chrome" });
|
|
const joined = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.join", {
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
})) as { session: { id: string } };
|
|
|
|
const beforeRead = callGatewayFromCli.mock.calls.length;
|
|
const transcript = (await invokeGoogleMeetGatewayMethodForTest(
|
|
methods,
|
|
"googlemeet.transcript",
|
|
{ sessionId: joined.session.id, sinceIndex: 3 },
|
|
)) as {
|
|
droppedLines: number;
|
|
startIndex: number;
|
|
nextIndex: number;
|
|
lines: Array<{ text: string }>;
|
|
};
|
|
expect(transcript).toMatchObject({ droppedLines: 2, startIndex: 3, nextIndex: 4 });
|
|
expect(transcript.lines.map((line) => line.text)).toEqual(["fourth line"]);
|
|
const readCalls = callGatewayFromCli.mock.calls.slice(beforeRead);
|
|
expect(readCalls).toHaveLength(1);
|
|
expect(requireRecord(readCalls[0]?.[2], "transcript request")).toMatchObject({
|
|
method: "POST",
|
|
path: "/act",
|
|
body: { targetId: "local-meet-tab" },
|
|
});
|
|
|
|
await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.leave", {
|
|
sessionId: joined.session.id,
|
|
});
|
|
const afterLeave = (await invokeGoogleMeetGatewayMethodForTest(
|
|
methods,
|
|
"googlemeet.transcript",
|
|
{ sessionId: joined.session.id },
|
|
)) as { lines: Array<{ text: string }> };
|
|
expect(afterLeave.lines.map((line) => line.text)).toEqual(["third line", "fourth line"]);
|
|
|
|
await expect(
|
|
invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.transcript", {
|
|
sessionId: joined.session.id,
|
|
sinceIndex: 1.5,
|
|
}),
|
|
).rejects.toThrow("sinceIndex must be a non-negative safe integer");
|
|
} finally {
|
|
Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
}
|
|
});
|
|
|
|
it("refuses to read a tracked tab after it navigates away from the meeting", async () => {
|
|
const originalPlatform = process.platform;
|
|
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
try {
|
|
mockLocalMeetBrowserRequestWithTabState({
|
|
tabUrlAfterJoin: "https://meet.google.com/lookup/unrelated",
|
|
transcript: { lines: [{ text: "must not leak" }] },
|
|
});
|
|
const { methods } = setup({ defaultMode: "transcribe", defaultTransport: "chrome" });
|
|
const joined = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.join", {
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
})) as { session: { id: string } };
|
|
|
|
await expect(
|
|
invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.transcript", {
|
|
sessionId: joined.session.id,
|
|
}),
|
|
).rejects.toThrow("tracked Meet tab no longer shows this session's meeting URL");
|
|
} finally {
|
|
Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
}
|
|
});
|
|
|
|
it("keeps the cursor monotonic when the Meet page transcript epoch resets", async () => {
|
|
const originalPlatform = process.platform;
|
|
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
try {
|
|
mockLocalMeetBrowserRequestWithTabState({
|
|
transcriptSequence: [
|
|
{ epoch: "page-1", lines: [{ text: "before reload" }] },
|
|
{ epoch: "page-2", lines: [{ text: "after reload" }] },
|
|
{ epoch: "page-2", lines: [{ text: "after reload" }] },
|
|
],
|
|
});
|
|
const { methods } = setup({ defaultMode: "transcribe", defaultTransport: "chrome" });
|
|
const joined = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.join", {
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
})) as { session: { id: string } };
|
|
|
|
const first = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.transcript", {
|
|
sessionId: joined.session.id,
|
|
})) as { nextIndex: number; lines: Array<{ text: string }> };
|
|
const second = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.transcript", {
|
|
sessionId: joined.session.id,
|
|
sinceIndex: first.nextIndex,
|
|
})) as { nextIndex: number; lines: Array<{ text: string }> };
|
|
expect(first).toMatchObject({ nextIndex: 1, lines: [{ text: "before reload" }] });
|
|
expect(second).toMatchObject({ nextIndex: 2, lines: [{ text: "after reload" }] });
|
|
|
|
await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.leave", {
|
|
sessionId: joined.session.id,
|
|
});
|
|
const ended = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.transcript", {
|
|
sessionId: joined.session.id,
|
|
})) as { lines: Array<{ text: string }> };
|
|
expect(ended.lines.map((line) => line.text)).toEqual(["before reload", "after reload"]);
|
|
} finally {
|
|
Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
}
|
|
});
|
|
|
|
it("does not let a late active read replace the finalized leave snapshot", async () => {
|
|
const originalPlatform = process.platform;
|
|
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
let releaseRead: (() => void) | undefined;
|
|
let markReadStarted: (() => void) | undefined;
|
|
const readGate = new Promise<void>((resolve) => {
|
|
releaseRead = resolve;
|
|
});
|
|
const readStarted = new Promise<void>((resolve) => {
|
|
markReadStarted = resolve;
|
|
});
|
|
let activeReads = 0;
|
|
try {
|
|
const callGatewayFromCli = mockLocalMeetBrowserRequestWithTabState({
|
|
transcript: { lines: [{ text: "partial" }] },
|
|
finalTranscript: { lines: [{ text: "partial" }, { text: "complete caption" }] },
|
|
nonFinalTranscriptGate: readGate,
|
|
onNonFinalTranscriptRead: () => {
|
|
activeReads += 1;
|
|
markReadStarted?.();
|
|
},
|
|
});
|
|
const { methods } = setup({ defaultMode: "transcribe", defaultTransport: "chrome" });
|
|
const joined = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.join", {
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
})) as { session: { id: string } };
|
|
|
|
const lateRead = invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.transcript", {
|
|
sessionId: joined.session.id,
|
|
});
|
|
await readStarted;
|
|
const secondRead = invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.transcript", {
|
|
sessionId: joined.session.id,
|
|
});
|
|
await new Promise((resolve) => {
|
|
setTimeout(resolve, 0);
|
|
});
|
|
expect(activeReads).toBe(1);
|
|
const leaving = invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.leave", {
|
|
sessionId: joined.session.id,
|
|
});
|
|
const repeatedLeave = invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.leave", {
|
|
sessionId: joined.session.id,
|
|
});
|
|
releaseRead?.();
|
|
await Promise.allSettled([lateRead, secondRead, leaving, repeatedLeave]);
|
|
const finalCaptures = callGatewayFromCli.mock.calls.filter((call) => {
|
|
const request = call[2] as { body?: { fn?: string } };
|
|
const script = String(request.body?.fn);
|
|
return (
|
|
script.includes("state = window.__openclawMeetCaptions") && script.includes("if (true &&")
|
|
);
|
|
});
|
|
expect(finalCaptures).toHaveLength(1);
|
|
const result = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.transcript", {
|
|
sessionId: joined.session.id,
|
|
})) as { lines: Array<{ text: string }> };
|
|
expect(result.lines.map((line) => line.text)).toEqual(["partial", "complete caption"]);
|
|
} finally {
|
|
Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
}
|
|
});
|
|
|
|
it("retains only the four most recently ended transcripts", async () => {
|
|
const originalPlatform = process.platform;
|
|
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
try {
|
|
mockLocalMeetBrowserRequestWithTabState({
|
|
transcript: { lines: [{ text: "retained line" }] },
|
|
});
|
|
const { methods } = setup({ defaultMode: "transcribe", defaultTransport: "chrome" });
|
|
const sessionIds: string[] = [];
|
|
for (let index = 0; index < 5; index += 1) {
|
|
const joined = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.join", {
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
})) as { session: { id: string } };
|
|
sessionIds.push(joined.session.id);
|
|
await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.leave", {
|
|
sessionId: joined.session.id,
|
|
});
|
|
}
|
|
|
|
const oldest = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.transcript", {
|
|
sessionId: sessionIds[0],
|
|
})) as { evicted?: boolean; lines: unknown[] };
|
|
const next = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.transcript", {
|
|
sessionId: sessionIds[1],
|
|
})) as { evicted?: boolean; lines: unknown[] };
|
|
expect(oldest).toMatchObject({ evicted: true, lines: [] });
|
|
expect(next.evicted).toBeUndefined();
|
|
expect(next.lines).toHaveLength(1);
|
|
} finally {
|
|
Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
}
|
|
});
|
|
|
|
it("leaves the Meet call in the browser when a chrome session leaves", async () => {
|
|
const originalPlatform = process.platform;
|
|
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
try {
|
|
const callGatewayFromCli = mockLocalMeetBrowserRequestWithTabState();
|
|
const { methods } = setup({ defaultMode: "transcribe", defaultTransport: "chrome" });
|
|
const joined = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.join", {
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
})) as { session: { id: string; chrome?: { browserTab?: unknown } } };
|
|
expect(joined.session.chrome?.browserTab).toEqual({
|
|
targetId: "local-meet-tab",
|
|
openedByPlugin: true,
|
|
});
|
|
|
|
const left = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.leave", {
|
|
sessionId: joined.session.id,
|
|
})) as { found: boolean; browserLeft?: boolean; session: { state: string; notes: string[] } };
|
|
|
|
expect(left.found).toBe(true);
|
|
expect(left.session.state).toBe("ended");
|
|
expect(left.browserLeft).toBe(true);
|
|
expect(left.session.notes).toContain(
|
|
"Clicked Meet's Leave call button and closed the Meet tab.",
|
|
);
|
|
const leaveActCall = callGatewayFromCli.mock.calls.find((call) =>
|
|
String((call[2] as { body?: { fn?: string } }).body?.fn).includes("leaveAction"),
|
|
);
|
|
expect(
|
|
requireRecord(requireRecord(leaveActCall?.[2], "leave act request").body, "leave act body")
|
|
.targetId,
|
|
).toBe("local-meet-tab");
|
|
const closeCall = callGatewayFromCli.mock.calls.find(
|
|
(call) => (call[2] as { method?: string }).method === "DELETE",
|
|
);
|
|
expect((closeCall?.[2] as { path?: string } | undefined)?.path).toBe("/tabs/local-meet-tab");
|
|
} finally {
|
|
Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
}
|
|
});
|
|
|
|
it("still ends the chrome session when its Meet tab is already closed on leave", async () => {
|
|
const originalPlatform = process.platform;
|
|
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
try {
|
|
const callGatewayFromCli = mockLocalMeetBrowserRequestWithTabState({
|
|
tabClosesAfterJoin: true,
|
|
});
|
|
const { methods } = setup({ defaultMode: "transcribe", defaultTransport: "chrome" });
|
|
const joined = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.join", {
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
})) as { session: { id: string } };
|
|
|
|
const left = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.leave", {
|
|
sessionId: joined.session.id,
|
|
})) as { found: boolean; browserLeft?: boolean; session: { state: string; notes: string[] } };
|
|
|
|
expect(left.found).toBe(true);
|
|
expect(left.session.state).toBe("ended");
|
|
expect(left.browserLeft).toBe(true);
|
|
expect(left.session.notes).toContain("Meet tab is already closed.");
|
|
expect(
|
|
callGatewayFromCli.mock.calls.some(
|
|
(call) => (call[2] as { method?: string }).method === "DELETE",
|
|
),
|
|
).toBe(false);
|
|
} finally {
|
|
Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
}
|
|
});
|
|
|
|
it("confirms host leave and keeps the reused Meet tab open", async () => {
|
|
const originalPlatform = process.platform;
|
|
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
try {
|
|
const callGatewayFromCli = mockLocalMeetBrowserRequestWithTabState({
|
|
reused: true,
|
|
leaveConfirmationRequired: true,
|
|
});
|
|
const { methods } = setup({ defaultMode: "transcribe", defaultTransport: "chrome" });
|
|
const joined = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.join", {
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
})) as { session: { id: string; chrome?: Record<string, unknown> } };
|
|
expect(joined.session.chrome?.browserTab).toEqual({
|
|
targetId: "local-meet-tab",
|
|
openedByPlugin: false,
|
|
});
|
|
|
|
const left = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.leave", {
|
|
sessionId: joined.session.id,
|
|
})) as { found: boolean; browserLeft?: boolean; session: { state: string; notes: string[] } };
|
|
|
|
expect(left.found).toBe(true);
|
|
expect(left.session.state).toBe("ended");
|
|
expect(left.browserLeft).toBe(true);
|
|
expect(left.session.notes).toContain(
|
|
"Clicked Meet's Leave call button; kept the reused browser tab open.",
|
|
);
|
|
const leaveActCall = callGatewayFromCli.mock.calls.find((call) =>
|
|
String((call[2] as { body?: { fn?: string } }).body?.fn).includes("leaveAction"),
|
|
);
|
|
expect(
|
|
requireRecord(requireRecord(leaveActCall?.[2], "leave act request").body, "leave act body")
|
|
.targetId,
|
|
).toBe("local-meet-tab");
|
|
const leaveSteps = callGatewayFromCli.mock.calls.filter((call) =>
|
|
String((call[2] as { body?: { fn?: string } }).body?.fn).includes("leaveAction"),
|
|
);
|
|
expect(leaveSteps).toHaveLength(3);
|
|
} finally {
|
|
Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
}
|
|
});
|
|
|
|
it("reports browserLeft false when the reused tab's Leave call click fails", async () => {
|
|
const originalPlatform = process.platform;
|
|
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
try {
|
|
mockLocalMeetBrowserRequestWithTabState({
|
|
reused: true,
|
|
leaveClicked: false,
|
|
});
|
|
const { methods } = setup({ defaultMode: "transcribe", defaultTransport: "chrome" });
|
|
const joined = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.join", {
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
})) as { session: { id: string } };
|
|
|
|
const left = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.leave", {
|
|
sessionId: joined.session.id,
|
|
})) as { found: boolean; browserLeft?: boolean; session: { state: string; notes: string[] } };
|
|
|
|
expect(left.found).toBe(true);
|
|
expect(left.session.state).toBe("ended");
|
|
expect(left.browserLeft).toBe(false);
|
|
expect(left.session.notes).toContain(
|
|
"Could not find Meet's Leave call button in the reused browser tab; leave it manually.",
|
|
);
|
|
} finally {
|
|
Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
}
|
|
});
|
|
|
|
it("does not touch a reused tab after it moves to another meeting", async () => {
|
|
const originalPlatform = process.platform;
|
|
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
try {
|
|
const callGatewayFromCli = mockLocalMeetBrowserRequestWithTabState({
|
|
reused: true,
|
|
tabUrlAfterJoin: "https://meet.google.com/xyz-abcd-efg?hl=en",
|
|
});
|
|
const { methods } = setup({ defaultMode: "transcribe", defaultTransport: "chrome" });
|
|
const joined = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.join", {
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
})) as { session: { id: string } };
|
|
|
|
const left = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.leave", {
|
|
sessionId: joined.session.id,
|
|
})) as { browserLeft?: boolean; session: { notes: string[] } };
|
|
|
|
expect(left.browserLeft).toBe(true);
|
|
expect(left.session.notes).toContain(
|
|
"Meet tab moved away from this session; left its current page untouched.",
|
|
);
|
|
expect(
|
|
callGatewayFromCli.mock.calls.some((call) =>
|
|
String((call[2] as { body?: { fn?: string } }).body?.fn).includes("leaveAction"),
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
callGatewayFromCli.mock.calls.some(
|
|
(call) => (call[2] as { method?: string }).method === "DELETE",
|
|
),
|
|
).toBe(false);
|
|
} finally {
|
|
Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
}
|
|
});
|
|
|
|
it("does not leave the browser twice when an ended session is retried", async () => {
|
|
const originalPlatform = process.platform;
|
|
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
try {
|
|
const callGatewayFromCli = mockLocalMeetBrowserRequestWithTabState({ reused: true });
|
|
const { methods } = setup({ defaultMode: "transcribe", defaultTransport: "chrome" });
|
|
const joined = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.join", {
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
})) as { session: { id: string } };
|
|
|
|
const first = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.leave", {
|
|
sessionId: joined.session.id,
|
|
})) as { browserLeft?: boolean };
|
|
const second = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.leave", {
|
|
sessionId: joined.session.id,
|
|
})) as { browserLeft?: boolean };
|
|
|
|
expect(first.browserLeft).toBe(true);
|
|
expect(second.browserLeft).toBe(true);
|
|
const leaveCalls = callGatewayFromCli.mock.calls.filter((call) =>
|
|
String((call[2] as { body?: { fn?: string } }).body?.fn).includes("leaveAction"),
|
|
);
|
|
expect(leaveCalls).toHaveLength(2);
|
|
} finally {
|
|
Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
}
|
|
});
|
|
|
|
it("meet leave script clicks the enabled Leave call button", () => {
|
|
const makeButton = (label: string, disabled = false, iconText?: string) => ({
|
|
disabled,
|
|
innerText: "",
|
|
textContent: "",
|
|
click: vi.fn(),
|
|
getAttribute: vi.fn((name: string) => (name === "aria-label" ? label : null)),
|
|
querySelector: vi.fn((selector: string) =>
|
|
selector === "i" && iconText !== undefined ? { textContent: iconText } : null,
|
|
),
|
|
});
|
|
const leaveButton = makeButton("Leave call");
|
|
const document = {
|
|
querySelectorAll: vi.fn((selector: string) =>
|
|
selector === "button" ? [makeButton("Turn on captions"), leaveButton] : [],
|
|
),
|
|
};
|
|
const context = createContext({
|
|
JSON,
|
|
String,
|
|
URL,
|
|
location: { href: "https://meet.google.com/abc-defg-hij?hl=en" },
|
|
document,
|
|
});
|
|
const leaveScript = chromeTransportTesting.meetLeaveScriptForTest(
|
|
"https://meet.google.com/abc-defg-hij",
|
|
);
|
|
const run = new Script(`(${leaveScript})()`).runInContext(context) as string;
|
|
|
|
expect(leaveButton.click).toHaveBeenCalledTimes(1);
|
|
expect(JSON.parse(run)).toEqual({
|
|
departed: false,
|
|
leaveAction: "leave",
|
|
urlMatched: true,
|
|
});
|
|
|
|
document.querySelectorAll.mockImplementation((selector: string) =>
|
|
selector === "button" ? [makeButton("Leave call", true)] : [],
|
|
);
|
|
const runDisabled = new Script(`(${leaveScript})()`).runInContext(context) as string;
|
|
expect(JSON.parse(runDisabled)).toEqual({ departed: false, urlMatched: true });
|
|
|
|
// Localized UI: no English label anywhere, but the Material Symbols
|
|
// "call_end" icon ligature identifies the leave control in any language.
|
|
const localizedLeave = makeButton("Anruf verlassen", false, "call_end");
|
|
document.querySelectorAll.mockImplementation((selector: string) =>
|
|
selector === "button"
|
|
? [makeButton("Untertitel aktivieren", false, "closed_caption_off"), localizedLeave]
|
|
: [],
|
|
);
|
|
const runLocalized = new Script(`(${leaveScript})()`).runInContext(context) as string;
|
|
expect(localizedLeave.click).toHaveBeenCalledTimes(1);
|
|
expect(JSON.parse(runLocalized)).toEqual({
|
|
departed: false,
|
|
leaveAction: "leave",
|
|
urlMatched: true,
|
|
});
|
|
|
|
const confirmLeave = makeButton("Leave meeting");
|
|
document.querySelectorAll.mockImplementation((selector: string) =>
|
|
selector === "button" ? [makeButton("End meeting for all"), confirmLeave] : [],
|
|
);
|
|
const runConfirmation = new Script(`(${leaveScript})()`).runInContext(context) as string;
|
|
expect(confirmLeave.click).toHaveBeenCalledTimes(1);
|
|
expect(JSON.parse(runConfirmation)).toEqual({
|
|
departed: false,
|
|
leaveAction: "confirm",
|
|
urlMatched: true,
|
|
});
|
|
|
|
document.querySelectorAll.mockImplementation((selector: string) =>
|
|
selector === "button" ? [makeButton("Rejoin")] : [],
|
|
);
|
|
const runDeparted = new Script(`(${leaveScript})()`).runInContext(context) as string;
|
|
expect(JSON.parse(runDeparted)).toEqual({ departed: true, urlMatched: true });
|
|
|
|
leaveButton.click.mockClear();
|
|
context.location.href = "https://meet.google.com/xyz-abcd-efg?hl=en";
|
|
document.querySelectorAll.mockImplementation((selector: string) =>
|
|
selector === "button" ? [leaveButton] : [],
|
|
);
|
|
const runMoved = new Script(`(${leaveScript})()`).runInContext(context) as string;
|
|
expect(leaveButton.click).not.toHaveBeenCalled();
|
|
expect(JSON.parse(runMoved)).toEqual({ departed: true, urlMatched: false });
|
|
});
|
|
|
|
it("starts the local realtime audio bridge after Meet is inspected", async () => {
|
|
const originalPlatform = process.platform;
|
|
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
const events: string[] = [];
|
|
try {
|
|
const callGatewayFromCli = vi.fn(
|
|
async (
|
|
_method: string,
|
|
_opts: unknown,
|
|
params?: unknown,
|
|
_extra?: unknown,
|
|
): Promise<Record<string, unknown>> => {
|
|
const request = params as {
|
|
path?: string;
|
|
body?: { fn?: string; targetId?: string; url?: string };
|
|
};
|
|
events.push(`browser:${request.path}`);
|
|
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" || request.path === "/permissions/grant") {
|
|
return { ok: true };
|
|
}
|
|
if (request.path === "/act") {
|
|
return {
|
|
result: JSON.stringify({
|
|
inCall: true,
|
|
micMuted: false,
|
|
title: "Meet call",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
}),
|
|
};
|
|
}
|
|
throw new Error(`unexpected browser request path ${request.path}`);
|
|
},
|
|
);
|
|
chromeTransportTesting.setDepsForTest({ callGatewayFromCli });
|
|
const { methods } = setup(
|
|
{
|
|
defaultMode: "bidi",
|
|
defaultTransport: "chrome",
|
|
chrome: {
|
|
audioBridgeCommand: ["bridge", "start"],
|
|
},
|
|
realtime: { introMessage: "" },
|
|
},
|
|
{
|
|
runCommandWithTimeoutHandler: async (argv) => {
|
|
events.push(`command:${argv.join(" ")}`);
|
|
return argv[0] === "/usr/sbin/system_profiler"
|
|
? { code: 0, stdout: "BlackHole 2ch", stderr: "" }
|
|
: { code: 0, stdout: "", stderr: "" };
|
|
},
|
|
},
|
|
);
|
|
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,
|
|
});
|
|
|
|
expectRespondedOk(respond);
|
|
expect(events.indexOf("browser:/act")).toBeGreaterThan(-1);
|
|
expect(events.indexOf("command:bridge start")).toBeGreaterThan(
|
|
events.indexOf("browser:/act"),
|
|
);
|
|
} finally {
|
|
Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
}
|
|
});
|
|
|
|
it("does not start the local realtime audio bridge while Meet admission is pending", async () => {
|
|
const originalPlatform = process.platform;
|
|
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
const events: string[] = [];
|
|
try {
|
|
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 } };
|
|
events.push(`browser:${request.path}`);
|
|
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" || request.path === "/permissions/grant") {
|
|
return { ok: true };
|
|
}
|
|
if (request.path === "/act") {
|
|
return {
|
|
result: JSON.stringify({
|
|
inCall: false,
|
|
lobbyWaiting: true,
|
|
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",
|
|
}),
|
|
};
|
|
}
|
|
throw new Error(`unexpected browser request path ${request.path}`);
|
|
},
|
|
);
|
|
chromeTransportTesting.setDepsForTest({ callGatewayFromCli });
|
|
const { methods } = setup(
|
|
{
|
|
defaultMode: "bidi",
|
|
defaultTransport: "chrome",
|
|
chrome: {
|
|
audioBridgeCommand: ["bridge", "start"],
|
|
waitForInCallMs: 1,
|
|
},
|
|
realtime: { introMessage: "" },
|
|
},
|
|
{
|
|
runCommandWithTimeoutHandler: async (argv) => {
|
|
events.push(`command:${argv.join(" ")}`);
|
|
return argv[0] === "/usr/sbin/system_profiler"
|
|
? { code: 0, stdout: "BlackHole 2ch", stderr: "" }
|
|
: { code: 0, stdout: "", stderr: "" };
|
|
},
|
|
},
|
|
);
|
|
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,
|
|
});
|
|
|
|
expectRespondedOk(respond);
|
|
expect(events).toContain("browser:/act");
|
|
expect(events).not.toContain("command:bridge start");
|
|
} finally {
|
|
Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
}
|
|
});
|
|
|
|
it("refreshes observe-only caption health when status is requested", async () => {
|
|
let openedTab = false;
|
|
let actCount = 0;
|
|
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 openedTab
|
|
? {
|
|
tabs: [
|
|
{
|
|
targetId: "local-meet-tab",
|
|
title: "Meet",
|
|
url: "https://meet.google.com/abc-defg-hij?hl=en",
|
|
},
|
|
],
|
|
}
|
|
: { tabs: [] };
|
|
}
|
|
if (request.path === "/tabs/open") {
|
|
openedTab = true;
|
|
return {
|
|
targetId: "local-meet-tab",
|
|
title: "Meet",
|
|
url: request.body?.url ?? "https://meet.google.com/abc-defg-hij?hl=en",
|
|
};
|
|
}
|
|
if (request.path === "/tabs/focus") {
|
|
return { ok: true };
|
|
}
|
|
if (request.path === "/navigate") {
|
|
return {
|
|
targetId: request.body?.targetId ?? "local-meet-tab",
|
|
url: request.body?.url ?? "https://meet.google.com/abc-defg-hij?hl=en",
|
|
};
|
|
}
|
|
if (request.path === "/act") {
|
|
actCount += 1;
|
|
return {
|
|
result: JSON.stringify(
|
|
actCount === 1
|
|
? {
|
|
inCall: true,
|
|
captioning: false,
|
|
captionsEnabledAttempted: true,
|
|
transcriptLines: 0,
|
|
title: "Meet call",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
}
|
|
: {
|
|
inCall: true,
|
|
captioning: true,
|
|
captionsEnabledAttempted: true,
|
|
transcriptLines: 1,
|
|
lastCaptionAt: "2026-04-27T10:00:00.000Z",
|
|
lastCaptionSpeaker: "Alice",
|
|
lastCaptionText: "Please capture this.",
|
|
recentTranscript: [
|
|
{
|
|
at: "2026-04-27T10:00:00.000Z",
|
|
speaker: "Alice",
|
|
text: "Please capture this.",
|
|
},
|
|
],
|
|
title: "Meet call",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
},
|
|
),
|
|
};
|
|
}
|
|
throw new Error(`unexpected browser request path ${request.path}`);
|
|
},
|
|
);
|
|
chromeTransportTesting.setDepsForTest({ callGatewayFromCli });
|
|
const { methods } = setup({
|
|
defaultMode: "transcribe",
|
|
defaultTransport: "chrome",
|
|
});
|
|
|
|
const join = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.join", {
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
})) as { session: { id: string; chrome?: { health?: { transcriptLines?: number } } } };
|
|
expect(join.session.chrome?.health?.transcriptLines).toBe(0);
|
|
|
|
const status = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.status", {
|
|
sessionId: join.session.id,
|
|
})) as {
|
|
session?: {
|
|
chrome?: {
|
|
health?: {
|
|
captioning?: boolean;
|
|
transcriptLines?: number;
|
|
lastCaptionText?: string;
|
|
};
|
|
};
|
|
};
|
|
};
|
|
|
|
expect(status.session?.chrome?.health?.captioning).toBe(true);
|
|
expect(status.session?.chrome?.health?.transcriptLines).toBe(1);
|
|
expect(status.session?.chrome?.health?.lastCaptionText).toBe("Please capture this.");
|
|
const focusCall = callGatewayFromCli.mock.calls.find(
|
|
(call) => requireRecord(call[2], "browser request").path === "/tabs/focus",
|
|
);
|
|
if (!focusCall) {
|
|
throw new Error("Expected browser /tabs/focus request");
|
|
}
|
|
expect(focusCall[0]).toBe("browser.request");
|
|
expect(focusCall[2]).toEqual({
|
|
method: "POST",
|
|
path: "/tabs/focus",
|
|
timeoutMs: 5000,
|
|
body: { targetId: "local-meet-tab" },
|
|
});
|
|
expect(focusCall[3]).toEqual({ progress: false });
|
|
});
|
|
|
|
it("refreshes blocked realtime browser health read-only when status is requested", async () => {
|
|
let openedTab = false;
|
|
const { methods, nodesInvoke } = setup(
|
|
{
|
|
defaultMode: "agent",
|
|
defaultTransport: "chrome-node",
|
|
},
|
|
{
|
|
nodesInvokeHandler: async ({ command, params }) => {
|
|
const raw = params as { path?: string; body?: { url?: string; targetId?: string } };
|
|
if (command === "browser.proxy") {
|
|
if (raw.path === "/tabs") {
|
|
return {
|
|
payload: {
|
|
result: {
|
|
running: true,
|
|
tabs: openedTab
|
|
? [
|
|
{
|
|
targetId: "tab-1",
|
|
title: "Meet",
|
|
url: "https://meet.google.com/abc-defg-hij?hl=en",
|
|
},
|
|
]
|
|
: [],
|
|
},
|
|
},
|
|
};
|
|
}
|
|
if (raw.path === "/tabs/open") {
|
|
openedTab = true;
|
|
return {
|
|
payload: {
|
|
result: {
|
|
targetId: "tab-1",
|
|
title: "Meet",
|
|
url: raw.body?.url ?? "https://meet.google.com/abc-defg-hij?hl=en",
|
|
},
|
|
},
|
|
};
|
|
}
|
|
if (raw.path === "/tabs/focus" || raw.path === "/permissions/grant") {
|
|
return { payload: { result: { ok: true } } };
|
|
}
|
|
if (raw.path === "/navigate") {
|
|
return {
|
|
payload: {
|
|
result: {
|
|
targetId: raw.body?.targetId ?? "tab-1",
|
|
url: raw.body?.url ?? "https://meet.google.com/abc-defg-hij?hl=en",
|
|
},
|
|
},
|
|
};
|
|
}
|
|
if (raw.path === "/act") {
|
|
return {
|
|
payload: {
|
|
result: {
|
|
ok: true,
|
|
targetId: raw.body?.targetId ?? "tab-1",
|
|
result: JSON.stringify({
|
|
inCall: false,
|
|
manualActionRequired: true,
|
|
manualActionReason: "meet-audio-choice-required",
|
|
manualActionMessage: "Choose the Meet microphone path manually.",
|
|
title: "Meet",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
}),
|
|
},
|
|
},
|
|
};
|
|
}
|
|
}
|
|
if (command === "googlemeet.chrome") {
|
|
return { payload: { launched: openedTab } };
|
|
}
|
|
throw new Error(`unexpected invoke ${command}`);
|
|
},
|
|
},
|
|
);
|
|
|
|
const join = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.join", {
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
})) as { session: { id: string } };
|
|
openedTab = true;
|
|
nodesInvoke.mockClear();
|
|
|
|
const status = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.status", {
|
|
sessionId: join.session.id,
|
|
})) as { session?: { chrome?: { health?: { manualActionRequired?: boolean } } } };
|
|
|
|
expect(status.session?.chrome?.health?.manualActionRequired).toBe(true);
|
|
const actCall = nodesInvoke.mock.calls.find(([rawCall]) => {
|
|
const call = requireRecord(rawCall, "node invoke");
|
|
const params = requireRecord(call.params, "node invoke params");
|
|
return call.command === "browser.proxy" && params.path === "/act";
|
|
});
|
|
if (!actCall) {
|
|
throw new Error("Expected browser.proxy /act node invoke");
|
|
}
|
|
const actParams = requireRecord(
|
|
requireRecord(actCall[0], "act node invoke").params,
|
|
"act params",
|
|
);
|
|
expect(requireRecord(actParams.body, "act body").targetId).toBe("tab-1");
|
|
expect(
|
|
nodesInvoke.mock.calls.some(([rawCall]) => {
|
|
const call = requireRecord(rawCall, "node invoke");
|
|
const params = requireRecord(call.params, "node invoke params");
|
|
return call.command === "browser.proxy" && params.path === "/permissions/grant";
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("retries caption enable until the captions button is available", async () => {
|
|
const makeButton = (label: string) => ({
|
|
disabled: false,
|
|
innerText: "",
|
|
textContent: "",
|
|
click: vi.fn(),
|
|
getAttribute: vi.fn((name: string) => (name === "aria-label" ? label : null)),
|
|
});
|
|
const leaveButton = makeButton("Leave call");
|
|
const captionButton = makeButton("Turn on captions");
|
|
const page = {
|
|
buttons: [leaveButton],
|
|
};
|
|
const windowState: Record<string, unknown> = {};
|
|
const document = {
|
|
body: { innerText: "", textContent: "" },
|
|
title: "Meet",
|
|
querySelector: vi.fn(() => null),
|
|
querySelectorAll: vi.fn((selector: string) => {
|
|
if (selector === "button") {
|
|
return page.buttons;
|
|
}
|
|
if (selector === "input") {
|
|
return [];
|
|
}
|
|
return [];
|
|
}),
|
|
};
|
|
const context = createContext({
|
|
Date,
|
|
JSON,
|
|
String,
|
|
crypto: { randomUUID: () => "caption-epoch" },
|
|
document,
|
|
location: {
|
|
href: "https://meet.google.com/abc-defg-hij",
|
|
hostname: "meet.google.com",
|
|
},
|
|
MutationObserver: class {
|
|
observe = vi.fn();
|
|
},
|
|
window: windowState,
|
|
});
|
|
const inspect = new Script(
|
|
`(${chromeTransportTesting.meetStatusScriptForTest({
|
|
allowMicrophone: false,
|
|
autoJoin: false,
|
|
captionSessionId: "session-1",
|
|
captureCaptions: true,
|
|
guestName: "OpenClaw Agent",
|
|
})})`,
|
|
).runInContext(context) as () => string | Promise<string>;
|
|
|
|
const first = JSON.parse(await inspect()) as { captionsEnabledAttempted?: boolean };
|
|
const captionsStateKey = "__openclawMeetCaptions";
|
|
const stateAfterFirst = windowState[captionsStateKey] as {
|
|
enabledAttempted?: boolean;
|
|
};
|
|
expect(first.captionsEnabledAttempted).toBe(false);
|
|
expect(stateAfterFirst.enabledAttempted).toBe(false);
|
|
expect(captionButton.click).not.toHaveBeenCalled();
|
|
|
|
page.buttons = [leaveButton, captionButton];
|
|
const second = JSON.parse(await inspect()) as { captionsEnabledAttempted?: boolean };
|
|
const stateAfterSecond = windowState[captionsStateKey] as {
|
|
enabledAttempted?: boolean;
|
|
};
|
|
expect(second.captionsEnabledAttempted).toBe(true);
|
|
expect(stateAfterSecond.enabledAttempted).toBe(true);
|
|
expect(captionButton.click).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("coalesces progressive captions and ignores end phrases while still in call", async () => {
|
|
const leaveButton = {
|
|
disabled: false,
|
|
innerText: "",
|
|
textContent: "",
|
|
click: vi.fn(),
|
|
getAttribute: vi.fn((name: string) => (name === "aria-label" ? "Leave call" : null)),
|
|
};
|
|
const page = { caption: "Alice\nmeeting ended", visible: true };
|
|
const makeRegion = () => ({
|
|
get innerText() {
|
|
return page.caption;
|
|
},
|
|
get textContent() {
|
|
return page.caption;
|
|
},
|
|
});
|
|
let region = makeRegion();
|
|
const windowState: Record<string, unknown> = {};
|
|
const disconnectObserver = vi.fn();
|
|
const clearCaptionTimer = vi.fn();
|
|
let settleCaption: (() => void) | undefined;
|
|
const randomUUID = vi
|
|
.fn()
|
|
.mockReturnValueOnce("epoch-1")
|
|
.mockReturnValueOnce("epoch-2")
|
|
.mockReturnValueOnce("epoch-3");
|
|
const document = {
|
|
body: { innerText: "meeting ended", textContent: "meeting ended" },
|
|
title: "Meet",
|
|
querySelector: vi.fn((selector: string) =>
|
|
selector.includes("aria-live") && page.visible ? region : null,
|
|
),
|
|
querySelectorAll: vi.fn((selector: string) => {
|
|
if (selector === "button") {
|
|
return [leaveButton];
|
|
}
|
|
if (selector === "input") {
|
|
return [];
|
|
}
|
|
if (selector.includes("aria-live")) {
|
|
return page.visible ? [region] : [];
|
|
}
|
|
return [];
|
|
}),
|
|
};
|
|
const context = createContext({
|
|
Date,
|
|
JSON,
|
|
clearTimeout: clearCaptionTimer,
|
|
setTimeout: (callback: () => void) => {
|
|
settleCaption = callback;
|
|
return 1;
|
|
},
|
|
String,
|
|
URL,
|
|
crypto: { randomUUID },
|
|
document,
|
|
location: { href: "https://meet.google.com/abc-defg-hij", hostname: "meet.google.com" },
|
|
MutationObserver: class {
|
|
observe = vi.fn();
|
|
disconnect = disconnectObserver;
|
|
},
|
|
window: windowState,
|
|
});
|
|
const inspect = new Script(
|
|
`(${chromeTransportTesting.meetStatusScriptForTest({
|
|
allowMicrophone: false,
|
|
autoJoin: false,
|
|
captionSessionId: "session-1",
|
|
captureCaptions: true,
|
|
guestName: "OpenClaw Agent",
|
|
})})`,
|
|
).runInContext(context) as () => string | Promise<string>;
|
|
|
|
const first = JSON.parse(await inspect()) as { leaveReason?: string };
|
|
expect(first.leaveReason).toBeUndefined();
|
|
page.caption = "Alice\nmeeting ended after the recap";
|
|
await inspect();
|
|
const state = windowState["__openclawMeetCaptions"] as {
|
|
epoch: string;
|
|
lines: Array<{ text: string }>;
|
|
visible: Array<{ text: string }>;
|
|
};
|
|
expect(state.lines).toEqual([]);
|
|
expect(state.visible.map((line) => line.text)).toEqual(["meeting ended after the recap"]);
|
|
region = makeRegion();
|
|
await inspect();
|
|
expect(state.visible).toHaveLength(1);
|
|
|
|
page.visible = false;
|
|
await inspect();
|
|
page.visible = true;
|
|
await inspect();
|
|
expect(clearCaptionTimer).toHaveBeenCalledWith(1);
|
|
expect(state.lines).toEqual([]);
|
|
|
|
page.visible = false;
|
|
await inspect();
|
|
settleCaption?.();
|
|
page.visible = true;
|
|
await inspect();
|
|
expect(state.lines.map((line) => line.text)).toEqual(["meeting ended after the recap"]);
|
|
expect(state.visible.map((line) => line.text)).toEqual(["meeting ended after the recap"]);
|
|
const readTranscript = (sessionId: string, finalize: boolean) =>
|
|
new Script(
|
|
`(${chromeTransportTesting.meetTranscriptScriptForTest(
|
|
"https://meet.google.com/abc-defg-hij",
|
|
sessionId,
|
|
finalize,
|
|
)})`,
|
|
).runInContext(context) as () => string;
|
|
const beforeFinalize = JSON.parse(readTranscript("session-1", false)()) as { lines: unknown[] };
|
|
expect(beforeFinalize.lines).toHaveLength(1);
|
|
const afterFinalize = JSON.parse(readTranscript("session-1", true)()) as { lines: unknown[] };
|
|
expect(afterFinalize.lines).toHaveLength(2);
|
|
|
|
delete windowState["__openclawMeetCaptions"];
|
|
await inspect();
|
|
const reloadedState = windowState["__openclawMeetCaptions"] as { epoch: string };
|
|
expect(reloadedState.epoch).not.toBe(state.epoch);
|
|
|
|
const inspectNextSession = new Script(
|
|
`(${chromeTransportTesting.meetStatusScriptForTest({
|
|
allowMicrophone: false,
|
|
autoJoin: false,
|
|
captionSessionId: "session-2",
|
|
captureCaptions: true,
|
|
guestName: "OpenClaw Agent",
|
|
})})`,
|
|
).runInContext(context) as () => string | Promise<string>;
|
|
await inspectNextSession();
|
|
const nextState = windowState["__openclawMeetCaptions"] as {
|
|
droppedLines: number;
|
|
epoch: string;
|
|
sessionId?: string;
|
|
lines: Array<{ text: string }>;
|
|
visible: Array<{ text: string }>;
|
|
};
|
|
expect(nextState.sessionId).toBe("session-2");
|
|
expect(nextState.epoch).not.toBe(state.epoch);
|
|
expect(nextState.lines).toEqual([]);
|
|
expect(nextState.visible.map((line) => line.text)).toEqual(["meeting ended after the recap"]);
|
|
expect(disconnectObserver).toHaveBeenCalledTimes(1);
|
|
expect(JSON.parse(readTranscript("session-1", false)())).toMatchObject({
|
|
sessionMatched: false,
|
|
});
|
|
|
|
nextState.lines = Array.from({ length: 2_000 }, (_, index) => ({ text: `line-${index}` }));
|
|
nextState.visible = [];
|
|
page.caption = "Alice\nline-2000";
|
|
await inspectNextSession();
|
|
readTranscript("session-2", true)();
|
|
expect(nextState.lines).toHaveLength(2_000);
|
|
expect(nextState.lines[0]?.text).toBe("line-1");
|
|
expect(nextState.lines.at(-1)?.text).toBe("line-2000");
|
|
expect(nextState.droppedLines).toBe(1);
|
|
});
|
|
|
|
it("reports in-call Meet audio permission problems from button labels", async () => {
|
|
const makeButton = (label: string) => ({
|
|
disabled: false,
|
|
innerText: "",
|
|
textContent: "",
|
|
click: vi.fn(),
|
|
getAttribute: vi.fn((name: string) => (name === "aria-label" ? label : null)),
|
|
});
|
|
const document = {
|
|
body: { innerText: "", textContent: "" },
|
|
title: "Meet",
|
|
querySelector: vi.fn(() => null),
|
|
querySelectorAll: vi.fn((selector: string) => {
|
|
if (selector === "button") {
|
|
return [
|
|
makeButton("Leave call"),
|
|
makeButton("Microphone problem. Show more info"),
|
|
makeButton("Microphone: Permission needed"),
|
|
makeButton("Speaker: Permission needed"),
|
|
];
|
|
}
|
|
if (selector === "input") {
|
|
return [];
|
|
}
|
|
return [];
|
|
}),
|
|
};
|
|
const context = createContext({
|
|
JSON,
|
|
document,
|
|
location: {
|
|
href: "https://meet.google.com/abc-defg-hij",
|
|
hostname: "meet.google.com",
|
|
},
|
|
window: {},
|
|
});
|
|
const inspect = new Script(
|
|
`(${chromeTransportTesting.meetStatusScriptForTest({
|
|
allowMicrophone: true,
|
|
autoJoin: false,
|
|
captureCaptions: false,
|
|
guestName: "OpenClaw Agent",
|
|
})})`,
|
|
).runInContext(context) as () => string | Promise<string>;
|
|
|
|
const result = JSON.parse(await inspect()) as {
|
|
inCall?: boolean;
|
|
manualActionRequired?: boolean;
|
|
manualActionReason?: string;
|
|
manualActionMessage?: string;
|
|
};
|
|
|
|
expect(result.inCall).toBe(true);
|
|
expect(result.manualActionRequired).toBe(true);
|
|
expect(result.manualActionReason).toBe("meet-permission-required");
|
|
expect(result.manualActionMessage).toContain("Allow microphone/camera/speaker permissions");
|
|
});
|
|
|
|
it("does not auto-join when Meet is already active elsewhere", async () => {
|
|
const joinElsewhere = {
|
|
disabled: false,
|
|
innerText: "Join here too",
|
|
textContent: "Join here too",
|
|
click: vi.fn(),
|
|
getAttribute: vi.fn(() => null),
|
|
};
|
|
const document = {
|
|
body: { innerText: "", textContent: "" },
|
|
title: "Meet",
|
|
querySelector: vi.fn(() => null),
|
|
querySelectorAll: vi.fn((selector: string) => {
|
|
if (selector === "button") {
|
|
return [joinElsewhere];
|
|
}
|
|
return [];
|
|
}),
|
|
};
|
|
const context = createContext({
|
|
JSON,
|
|
document,
|
|
location: {
|
|
href: "https://meet.google.com/abc-defg-hij?hl=en",
|
|
hostname: "meet.google.com",
|
|
},
|
|
window: {},
|
|
});
|
|
const inspect = new Script(
|
|
`(${chromeTransportTesting.meetStatusScriptForTest({
|
|
allowMicrophone: false,
|
|
autoJoin: true,
|
|
captureCaptions: false,
|
|
guestName: "OpenClaw Agent",
|
|
})})`,
|
|
).runInContext(context) as () => string | Promise<string>;
|
|
|
|
const result = JSON.parse(await inspect()) as {
|
|
clickedJoin?: boolean;
|
|
manualActionReason?: string;
|
|
};
|
|
|
|
expect(result.clickedJoin).toBe(false);
|
|
expect(result.manualActionReason).toBe("meet-session-conflict");
|
|
expect(joinElsewhere.click).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("uses the local Meet microphone control instead of remote participant mute buttons", async () => {
|
|
const makeButton = (label: string, disabled = false) => ({
|
|
disabled,
|
|
innerText: "",
|
|
textContent: "",
|
|
click: vi.fn(),
|
|
getAttribute: vi.fn((name: string) => (name === "aria-label" ? label : null)),
|
|
});
|
|
const remoteMute = makeButton("You can't remotely mute Peter Steinberger's microphone", true);
|
|
const localMic = makeButton("Turn on microphone");
|
|
const document = {
|
|
body: { innerText: "", textContent: "" },
|
|
title: "Meet",
|
|
querySelector: vi.fn(() => null),
|
|
querySelectorAll: vi.fn((selector: string) => {
|
|
if (selector === "button") {
|
|
return [makeButton("Leave call"), remoteMute, localMic];
|
|
}
|
|
if (selector === "input") {
|
|
return [];
|
|
}
|
|
return [];
|
|
}),
|
|
};
|
|
const context = createContext({
|
|
JSON,
|
|
document,
|
|
location: {
|
|
href: "https://meet.google.com/abc-defg-hij",
|
|
hostname: "meet.google.com",
|
|
},
|
|
window: {},
|
|
});
|
|
const inspect = new Script(
|
|
`(${chromeTransportTesting.meetStatusScriptForTest({
|
|
allowMicrophone: true,
|
|
autoJoin: false,
|
|
captureCaptions: false,
|
|
guestName: "OpenClaw Agent",
|
|
})})`,
|
|
).runInContext(context) as () => string | Promise<string>;
|
|
|
|
const result = JSON.parse(await inspect()) as { micMuted?: boolean; notes?: string[] };
|
|
|
|
expect(result.micMuted).toBe(true);
|
|
expect(localMic.click).toHaveBeenCalledTimes(1);
|
|
expect(remoteMute.click).not.toHaveBeenCalled();
|
|
expect(result.notes).toContain("Attempted to turn on the Meet microphone for talk-back mode.");
|
|
});
|
|
|
|
it("blocks realtime speech while the Meet microphone remains muted", async () => {
|
|
const originalPlatform = process.platform;
|
|
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
try {
|
|
mockLocalMeetBrowserRequest({
|
|
inCall: true,
|
|
micMuted: true,
|
|
title: "Meet call",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
});
|
|
const { methods } = setup({
|
|
realtime: { introMessage: "" },
|
|
chrome: {
|
|
audioBridgeCommand: ["bridge", "start"],
|
|
waitForInCallMs: 1,
|
|
},
|
|
});
|
|
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,
|
|
});
|
|
|
|
const payload = requireRespondPayload(respond, "join response payload");
|
|
expect(payload.spoken).toBe(false);
|
|
const session = requireRecord(payload.session, "join session");
|
|
const chrome = requireRecord(session.chrome, "join chrome session");
|
|
const health = requireRecord(chrome.health, "join chrome health");
|
|
expect(health.micMuted).toBe(true);
|
|
expect(health.speechReady).toBe(false);
|
|
expect(health.speechBlockedReason).toBe("meet-microphone-muted");
|
|
} finally {
|
|
Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
}
|
|
});
|
|
|
|
it("keeps waiting while the Meet microphone is muted during intro readiness", async () => {
|
|
const originalPlatform = process.platform;
|
|
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
try {
|
|
let inspectCount = 0;
|
|
mockLocalMeetBrowserRequest(() => {
|
|
inspectCount += 1;
|
|
return {
|
|
inCall: true,
|
|
micMuted: true,
|
|
title: "Meet call",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
};
|
|
});
|
|
const { methods } = setup({
|
|
chrome: {
|
|
audioBridgeCommand: ["bridge", "start"],
|
|
waitForInCallMs: 1000,
|
|
},
|
|
});
|
|
const handler = methods.get("googlemeet.join") as
|
|
| ((ctx: {
|
|
params: Record<string, unknown>;
|
|
respond: ReturnType<typeof vi.fn>;
|
|
}) => Promise<void>)
|
|
| undefined;
|
|
const respond = vi.fn();
|
|
vi.useFakeTimers();
|
|
|
|
const run = handler?.({
|
|
params: { url: "https://meet.google.com/abc-defg-hij" },
|
|
respond,
|
|
});
|
|
await vi.advanceTimersByTimeAsync(2_000);
|
|
await run;
|
|
|
|
const payload = requireRespondPayload(respond, "join response payload");
|
|
expect(payload.spoken).toBe(false);
|
|
const session = requireRecord(payload.session, "join session");
|
|
const chrome = requireRecord(session.chrome, "join chrome session");
|
|
const health = requireRecord(chrome.health, "join chrome health");
|
|
expect(health.micMuted).toBe(true);
|
|
expect(health.speechReady).toBe(false);
|
|
expect(health.speechBlockedReason).toBe("meet-microphone-muted");
|
|
expect(inspectCount).toBeGreaterThanOrEqual(2);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
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,
|
|
});
|
|
|
|
expectRespondedOk(respond);
|
|
expect(mockCall(nodesList)).toStrictEqual([]);
|
|
const stopCall = nodesInvoke.mock.calls.find(([rawCall]) => {
|
|
const call = requireRecord(rawCall, "node invoke");
|
|
const params = requireRecord(call.params, "node invoke params");
|
|
return call.command === "googlemeet.chrome" && params.action === "stopByUrl";
|
|
});
|
|
if (!stopCall) {
|
|
throw new Error("Expected googlemeet.chrome stopByUrl node invoke");
|
|
}
|
|
expect(requireRecord(stopCall[0], "stop node invoke").nodeId).toBe("node-1");
|
|
expect(requireRecord(stopCall[0], "stop node invoke").command).toBe("googlemeet.chrome");
|
|
expect(
|
|
requireRecord(requireRecord(stopCall[0], "stop node invoke").params, "stop params"),
|
|
).toEqual({
|
|
action: "stopByUrl",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
mode: "transcribe",
|
|
});
|
|
const openCall = nodesInvoke.mock.calls.find(([rawCall]) => {
|
|
const call = requireRecord(rawCall, "node invoke");
|
|
const params = requireRecord(call.params, "node invoke params");
|
|
return call.command === "browser.proxy" && params.path === "/tabs/open";
|
|
});
|
|
if (!openCall) {
|
|
throw new Error("Expected browser.proxy /tabs/open node invoke");
|
|
}
|
|
expect(requireRecord(openCall[0], "open node invoke").nodeId).toBe("node-1");
|
|
expect(requireRecord(openCall[0], "open node invoke").command).toBe("browser.proxy");
|
|
expect(
|
|
requireRecord(requireRecord(openCall[0], "open node invoke").params, "open params"),
|
|
).toEqual({
|
|
method: "POST",
|
|
path: "/tabs/open",
|
|
timeoutMs: 30000,
|
|
body: { url: "https://meet.google.com/abc-defg-hij?hl=en" },
|
|
});
|
|
const startCall = nodesInvoke.mock.calls.find(([rawCall]) => {
|
|
const call = requireRecord(rawCall, "node invoke");
|
|
const params = requireRecord(call.params, "node invoke params");
|
|
return call.command === "googlemeet.chrome" && params.action === "start";
|
|
});
|
|
if (!startCall) {
|
|
throw new Error("Expected googlemeet.chrome start node invoke");
|
|
}
|
|
expect(requireRecord(startCall[0], "start node invoke").nodeId).toBe("node-1");
|
|
expect(requireRecord(startCall[0], "start node invoke").command).toBe("googlemeet.chrome");
|
|
const startParams = requireRecord(
|
|
requireRecord(startCall[0], "start node invoke").params,
|
|
"start params",
|
|
);
|
|
expect(startParams.action).toBe("start");
|
|
expect(startParams.url).toBe("https://meet.google.com/abc-defg-hij");
|
|
expect(startParams.mode).toBe("transcribe");
|
|
expect(startParams.launch).toBe(false);
|
|
expect(startParams.joinTimeoutMs).toBe(30000);
|
|
const payload = requireRespondPayload(respond, "join response payload");
|
|
const session = requireRecord(payload.session, "join session");
|
|
expect(session.transport).toBe("chrome-node");
|
|
const chrome = requireRecord(session.chrome, "join chrome session");
|
|
expect(chrome.nodeId).toBe("node-1");
|
|
expect(chrome.launched).toBe(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);
|
|
const payload = requireRespondPayload(second, "second join response payload");
|
|
const session = requireRecord(payload.session, "second join session");
|
|
const chrome = requireRecord(session.chrome, "second join chrome session");
|
|
const health = requireRecord(chrome.health, "second join chrome health");
|
|
expect(health.inCall).toBe(true);
|
|
expect(health.micMuted).toBe(false);
|
|
expect(session.notes).toContain("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);
|
|
const payload = requireRespondPayload(second, "second join response payload");
|
|
const session = requireRecord(payload.session, "second join session");
|
|
expect(session.notes).toContain("Reused existing active Meet session.");
|
|
});
|
|
|
|
it("opens an English replacement without touching an ambiguous matching tab", 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: "wrong-account-english-tab",
|
|
title: "Meet",
|
|
url: "https://meet.google.com/abc-defg-hij?authuser=other%40example.com&hl=en",
|
|
},
|
|
{
|
|
targetId: "existing-meet-tab",
|
|
title: "Meet",
|
|
url: "https://meet.google.com/abc-defg-hij?authuser=me@example.com",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
};
|
|
}
|
|
if (proxy.path === "/tabs/open") {
|
|
return {
|
|
payload: {
|
|
result: {
|
|
targetId: "english-meet-tab",
|
|
url: "https://meet.google.com/abc-defg-hij?authuser=me%40example.com&hl=en",
|
|
},
|
|
},
|
|
};
|
|
}
|
|
if (proxy.path === "/permissions/grant") {
|
|
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%40example.com&hl=en",
|
|
}),
|
|
},
|
|
},
|
|
};
|
|
}
|
|
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?authuser=me@example.com" },
|
|
respond,
|
|
});
|
|
|
|
const openCall = nodesInvoke.mock.calls.find(([rawCall]) => {
|
|
const call = requireRecord(rawCall, "node invoke");
|
|
const params = requireRecord(call.params, "node invoke params");
|
|
return params.path === "/tabs/open";
|
|
});
|
|
if (!openCall) {
|
|
throw new Error("Expected browser.proxy /tabs/open node invoke");
|
|
}
|
|
expect(
|
|
requireRecord(requireRecord(openCall[0], "open node invoke").params, "open params"),
|
|
).toEqual({
|
|
method: "POST",
|
|
path: "/tabs/open",
|
|
timeoutMs: 30000,
|
|
body: {
|
|
url: "https://meet.google.com/abc-defg-hij?authuser=me%40example.com&hl=en",
|
|
},
|
|
});
|
|
expect(
|
|
nodesInvoke.mock.calls.some(([rawCall]) => {
|
|
const call = requireRecord(rawCall, "node invoke");
|
|
const params = requireRecord(call.params, "node invoke params");
|
|
return params.path === "/tabs/focus" || params.path === "/navigate";
|
|
}),
|
|
).toBe(false);
|
|
const actCalls = nodesInvoke.mock.calls.filter(([rawCall]) => {
|
|
const call = requireRecord(rawCall, "node invoke");
|
|
const params = requireRecord(call.params, "node invoke params");
|
|
return params.path === "/act";
|
|
});
|
|
expect(actCalls.length).toBeGreaterThanOrEqual(1);
|
|
const englishTabActCall = actCalls.find(([rawCall]) => {
|
|
const call = requireRecord(rawCall, "node invoke");
|
|
const params = requireRecord(call.params, "node invoke params");
|
|
return requireRecord(params.body, "act body").targetId === "english-meet-tab";
|
|
});
|
|
if (!englishTabActCall) {
|
|
throw new Error("Expected browser.proxy /act on the English replacement tab");
|
|
}
|
|
expect(
|
|
requireRecord(requireRecord(englishTabActCall[0], "act node invoke").params, "act params"),
|
|
).toEqual({
|
|
method: "POST",
|
|
path: "/act",
|
|
timeoutMs: 10000,
|
|
body: {
|
|
kind: "evaluate",
|
|
targetId: "english-meet-tab",
|
|
fn: expect.any(String),
|
|
},
|
|
});
|
|
});
|
|
|
|
it("does not navigate a reused join tab that is already using English UI", 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: "english-meet-tab",
|
|
title: "Meet",
|
|
url: "https://meet.google.com/abc-defg-hij?hl=en",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
};
|
|
}
|
|
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?hl=en",
|
|
}),
|
|
},
|
|
},
|
|
};
|
|
}
|
|
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.mock.calls.some(([rawCall]) => {
|
|
const call = requireRecord(rawCall, "node invoke");
|
|
return requireRecord(call.params, "node invoke params").path === "/navigate";
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
nodesInvoke.mock.calls.some(([rawCall]) => {
|
|
const call = requireRecord(rawCall, "node invoke");
|
|
return requireRecord(call.params, "node invoke params").path === "/tabs/open";
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("reports an ambiguous recovered Meet tab without reloading it", 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 === "/navigate") {
|
|
return {
|
|
payload: {
|
|
result: {
|
|
targetId: proxy.body?.targetId ?? "existing-meet-tab",
|
|
url: "https://meet.google.com/abc-defg-hij?authuser=me%40example.com&hl=en",
|
|
},
|
|
},
|
|
};
|
|
}
|
|
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%40example.com&hl=en",
|
|
}),
|
|
},
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`unexpected browser proxy path ${proxy.path}`);
|
|
},
|
|
},
|
|
);
|
|
const tool = tools[0] as {
|
|
execute: (
|
|
id: string,
|
|
params: unknown,
|
|
) => Promise<{ details: { found?: boolean; targetId?: string; browser?: unknown } }>;
|
|
};
|
|
|
|
const result = await tool.execute("id", {
|
|
action: "recover_current_tab",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
});
|
|
|
|
expect(result.details.found).toBe(true);
|
|
expect(result.details.targetId).toBe("existing-meet-tab");
|
|
const browser = requireRecord(result.details.browser, "recovered browser state");
|
|
expect(browser.manualActionRequired).toBe(true);
|
|
expect(browser.manualActionReason).toBe("meet-locale-required");
|
|
expect(browser.manualActionMessage).toContain("not pinned to English");
|
|
const focusCall = nodesInvoke.mock.calls.find(([rawCall]) => {
|
|
const call = requireRecord(rawCall, "node invoke");
|
|
const params = requireRecord(call.params, "node invoke params");
|
|
return params.path === "/tabs/focus";
|
|
});
|
|
if (!focusCall) {
|
|
throw new Error("Expected browser.proxy /tabs/focus node invoke");
|
|
}
|
|
expect(
|
|
requireRecord(requireRecord(focusCall[0], "focus node invoke").params, "focus params"),
|
|
).toEqual({
|
|
method: "POST",
|
|
path: "/tabs/focus",
|
|
timeoutMs: 5000,
|
|
body: { targetId: "existing-meet-tab" },
|
|
});
|
|
expect(
|
|
nodesInvoke.mock.calls.some(([rawCall]) => {
|
|
const call = requireRecord(rawCall, "node invoke");
|
|
const requestPath = requireRecord(call.params, "node invoke params").path;
|
|
return (
|
|
requestPath === "/tabs/open" || requestPath === "/navigate" || requestPath === "/act"
|
|
);
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("prefers an English replacement when recovering matching Meet tabs", 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: "wrong-account-english-tab",
|
|
title: "Meet",
|
|
url: "https://meet.google.com/abc-defg-hij?authuser=other%40example.com&hl=en",
|
|
},
|
|
{
|
|
targetId: "ambiguous-meet-tab",
|
|
title: "Meet",
|
|
url: "https://meet.google.com/abc-defg-hij?authuser=me%40example.com",
|
|
},
|
|
{
|
|
targetId: "english-meet-tab",
|
|
title: "Meet",
|
|
url: "https://meet.google.com/abc-defg-hij?authuser=me%40example.com&hl=en",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
};
|
|
}
|
|
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?hl=en",
|
|
}),
|
|
},
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`unexpected browser proxy path ${proxy.path}`);
|
|
},
|
|
},
|
|
);
|
|
const tool = tools[0] as {
|
|
execute: (
|
|
id: string,
|
|
params: unknown,
|
|
) => Promise<{ details: { targetId?: string; browser?: unknown } }>;
|
|
};
|
|
|
|
const result = await tool.execute("id", {
|
|
action: "recover_current_tab",
|
|
url: "https://meet.google.com/abc-defg-hij?authuser=me%40example.com",
|
|
readOnly: true,
|
|
});
|
|
|
|
expect(result.details.targetId).toBe("english-meet-tab");
|
|
expect(requireRecord(result.details.browser, "recovered browser state").inCall).toBe(true);
|
|
expect(
|
|
nodesInvoke.mock.calls.some(([rawCall]) => {
|
|
const call = requireRecord(rawCall, "node invoke");
|
|
const params = requireRecord(call.params, "node invoke params");
|
|
return (
|
|
params.path === "/tabs/focus" &&
|
|
requireRecord(params.body, "focus body").targetId === "english-meet-tab"
|
|
);
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("preserves the Google sign-in diagnostic during tab recovery", async () => {
|
|
const { tools } = 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 };
|
|
if (proxy.path === "/tabs") {
|
|
return {
|
|
payload: {
|
|
result: {
|
|
tabs: [
|
|
{
|
|
targetId: "google-sign-in-tab",
|
|
title: "Sign in - Google Accounts - Meet",
|
|
url: "https://accounts.google.com/signin",
|
|
},
|
|
{
|
|
targetId: "unrelated-english-meet-tab",
|
|
title: "Meet",
|
|
url: "https://meet.google.com/xyz-wxyz-xyz?hl=en",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
};
|
|
}
|
|
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: "google-login-required",
|
|
manualActionMessage: "Sign in to Google, then retry.",
|
|
url: "https://accounts.google.com/signin",
|
|
}),
|
|
},
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`unexpected browser proxy path ${proxy.path}`);
|
|
},
|
|
},
|
|
);
|
|
const tool = tools[0] as {
|
|
execute: (
|
|
id: string,
|
|
params: unknown,
|
|
) => Promise<{ details: { browser?: unknown; targetId?: string } }>;
|
|
};
|
|
|
|
const result = await tool.execute("id", { action: "recover_current_tab" });
|
|
const browser = requireRecord(result.details.browser, "recovered browser state");
|
|
expect(result.details.targetId).toBe("google-sign-in-tab");
|
|
expect(browser.manualActionReason).toBe("google-login-required");
|
|
});
|
|
|
|
it("reports an ambiguous local Chrome Meet tab without reloading it", 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 === "/navigate") {
|
|
return {
|
|
targetId: request.body?.targetId ?? "local-meet-tab",
|
|
url: "https://meet.google.com/abc-defg-hij?authuser=me%40example.com&hl=en",
|
|
};
|
|
}
|
|
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%40example.com&hl=en",
|
|
}),
|
|
};
|
|
}
|
|
throw new Error(`unexpected browser request path ${request.path}`);
|
|
},
|
|
);
|
|
chromeTransportTesting.setDepsForTest({ callGatewayFromCli });
|
|
const { tools, nodesInvoke } = setup({
|
|
defaultTransport: "chrome",
|
|
chrome: {
|
|
browserProfile: "meet-devtools",
|
|
},
|
|
});
|
|
const tool = tools[0] as {
|
|
execute: (
|
|
id: string,
|
|
params: unknown,
|
|
) => Promise<{
|
|
details: { transport?: string; found?: boolean; targetId?: string; browser?: unknown };
|
|
}>;
|
|
};
|
|
|
|
const result = await tool.execute("id", {
|
|
action: "recover_current_tab",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
});
|
|
|
|
expect(result.details.transport).toBe("chrome");
|
|
expect(result.details.found).toBe(true);
|
|
expect(result.details.targetId).toBe("local-meet-tab");
|
|
const browser = requireRecord(result.details.browser, "recovered browser state");
|
|
expect(browser.manualActionRequired).toBe(true);
|
|
expect(browser.manualActionReason).toBe("meet-locale-required");
|
|
expect(browser.manualActionMessage).toContain("not pinned to English");
|
|
const focusCall = callGatewayFromCli.mock.calls.find(
|
|
(call) => requireRecord(call[2], "browser request").path === "/tabs/focus",
|
|
);
|
|
if (!focusCall) {
|
|
throw new Error("Expected browser /tabs/focus request");
|
|
}
|
|
expect(focusCall[0]).toBe("browser.request");
|
|
expect(requireRecord(focusCall[2], "focus request").method).toBe("POST");
|
|
expect(requireRecord(focusCall[2], "focus request").path).toBe("/tabs/focus");
|
|
expect(requireRecord(focusCall[2], "focus request").query).toBeUndefined();
|
|
expect(focusCall[3]).toEqual({ progress: false });
|
|
expect(
|
|
callGatewayFromCli.mock.calls.some((call) => {
|
|
const requestPath = requireRecord(call[2], "browser request").path;
|
|
return requestPath === "/navigate" || requestPath === "/act";
|
|
}),
|
|
).toBe(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.",
|
|
});
|
|
|
|
const startCall = nodesInvoke.mock.calls.find(([rawCall]) => {
|
|
const call = requireRecord(rawCall, "node invoke");
|
|
const params = requireRecord(call.params, "node invoke params");
|
|
return call.command === "googlemeet.chrome" && params.action === "start";
|
|
});
|
|
if (!startCall) {
|
|
throw new Error("Expected googlemeet.chrome start node invoke");
|
|
}
|
|
expect(result.details.createdSession).toBe(true);
|
|
});
|
|
|
|
it("refreshes realtime browser state in status after a delayed Meet join", async () => {
|
|
const originalPlatform = process.platform;
|
|
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
try {
|
|
let browserState: Record<string, unknown> = {
|
|
inCall: false,
|
|
title: "Meet",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
};
|
|
let opened = false;
|
|
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: opened
|
|
? [
|
|
{
|
|
targetId: "local-meet-tab",
|
|
title: "Meet",
|
|
url: "https://meet.google.com/abc-defg-hij?hl=en",
|
|
},
|
|
]
|
|
: [],
|
|
};
|
|
}
|
|
if (request.path === "/tabs/open") {
|
|
opened = true;
|
|
return {
|
|
targetId: "local-meet-tab",
|
|
title: "Meet",
|
|
url: request.body?.url ?? "https://meet.google.com/abc-defg-hij?hl=en",
|
|
};
|
|
}
|
|
if (request.path === "/tabs/focus" || request.path === "/permissions/grant") {
|
|
return { ok: true };
|
|
}
|
|
if (request.path === "/navigate") {
|
|
return {
|
|
targetId: request.body?.targetId ?? "local-meet-tab",
|
|
url: request.body?.url ?? "https://meet.google.com/abc-defg-hij?hl=en",
|
|
};
|
|
}
|
|
if (request.path === "/act") {
|
|
return { result: JSON.stringify(browserState) };
|
|
}
|
|
throw new Error(`unexpected browser request path ${request.path}`);
|
|
},
|
|
);
|
|
chromeTransportTesting.setDepsForTest({ callGatewayFromCli });
|
|
const { methods } = setup({
|
|
chrome: {
|
|
audioBridgeCommand: ["bridge", "start"],
|
|
waitForInCallMs: 1,
|
|
},
|
|
realtime: { introMessage: "" },
|
|
});
|
|
const join = methods.get("googlemeet.join") as
|
|
| ((ctx: {
|
|
params: Record<string, unknown>;
|
|
respond: ReturnType<typeof vi.fn>;
|
|
}) => Promise<void>)
|
|
| undefined;
|
|
const status = methods.get("googlemeet.status") as
|
|
| ((ctx: {
|
|
params: Record<string, unknown>;
|
|
respond: ReturnType<typeof vi.fn>;
|
|
}) => Promise<void>)
|
|
| undefined;
|
|
const joinRespond = vi.fn();
|
|
const statusRespond = vi.fn();
|
|
|
|
await join?.({
|
|
params: { url: "https://meet.google.com/abc-defg-hij" },
|
|
respond: joinRespond,
|
|
});
|
|
const joinPayload = requireRespondPayload(joinRespond, "join response payload");
|
|
const joinSession = requireRecord(joinPayload.session, "join session");
|
|
const joinChrome = requireRecord(joinSession.chrome, "join chrome session");
|
|
expect(requireRecord(joinChrome.health, "join chrome health").inCall).toBe(false);
|
|
browserState = {
|
|
inCall: true,
|
|
micMuted: false,
|
|
title: "Meet",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
};
|
|
await status?.({ params: {}, respond: statusRespond });
|
|
|
|
const statusPayload = requireRespondPayload(statusRespond, "status response payload");
|
|
const sessions = statusPayload.sessions as unknown[];
|
|
expect(sessions).toHaveLength(1);
|
|
const statusSession = requireRecord(sessions[0], "status session");
|
|
const statusChrome = requireRecord(statusSession.chrome, "status chrome session");
|
|
const statusHealth = requireRecord(statusChrome.health, "status chrome health");
|
|
expect(statusHealth.inCall).toBe(true);
|
|
expect(statusHealth.speechReady).toBe(false);
|
|
expect(statusHealth.speechBlockedReason).toBe("audio-bridge-unavailable");
|
|
} finally {
|
|
Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
}
|
|
});
|
|
|
|
it("exposes a test-listen action that proves transcript movement", async () => {
|
|
const { tools, nodesInvoke } = setup(
|
|
{
|
|
defaultTransport: "chrome-node",
|
|
},
|
|
{
|
|
browserActResult: {
|
|
inCall: true,
|
|
captioning: true,
|
|
transcriptLines: 1,
|
|
lastCaptionText: "hello from the meeting",
|
|
title: "Meet call",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
},
|
|
nodesInvokeResult: {
|
|
payload: {
|
|
launched: true,
|
|
},
|
|
},
|
|
},
|
|
);
|
|
const tool = tools[0] as {
|
|
execute: (
|
|
id: string,
|
|
params: unknown,
|
|
) => Promise<{ details: { listenVerified?: boolean; transcriptLines?: number } }>;
|
|
};
|
|
|
|
const result = await tool.execute("id", {
|
|
action: "test_listen",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
timeoutMs: "100",
|
|
});
|
|
|
|
const startCall = nodesInvoke.mock.calls.find(([rawCall]) => {
|
|
const call = requireRecord(rawCall, "node invoke");
|
|
const params = requireRecord(call.params, "node invoke params");
|
|
return call.command === "googlemeet.chrome" && params.action === "start";
|
|
});
|
|
if (!startCall) {
|
|
throw new Error("Expected googlemeet.chrome start node invoke");
|
|
}
|
|
const startParams = requireRecord(
|
|
requireRecord(startCall[0], "start node invoke").params,
|
|
"start params",
|
|
);
|
|
expect(startParams.mode).toBe("transcribe");
|
|
expect(result.details.listenVerified).toBe(true);
|
|
expect(result.details.transcriptLines).toBe(1);
|
|
});
|
|
|
|
it("rejects fractional test-listen gateway timeouts", async () => {
|
|
const { methods } = setup({ defaultTransport: "chrome-node" });
|
|
|
|
await expect(
|
|
invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.testListen", {
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
timeoutMs: "100.5",
|
|
}),
|
|
).rejects.toThrow("timeoutMs must be a positive integer");
|
|
});
|
|
|
|
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: "agent",
|
|
agentId: "main",
|
|
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,
|
|
strategy: "agent",
|
|
transcriptionProvider: "openai",
|
|
toolPolicy: "safe-read-only",
|
|
},
|
|
chrome: {
|
|
audioBackend: "blackhole-2ch",
|
|
launched: true,
|
|
health: { audioOutputActive: true, lastOutputBytes: 10 },
|
|
},
|
|
notes: [],
|
|
};
|
|
vi.spyOn(runtime, "list").mockReturnValue([session]);
|
|
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).toHaveBeenCalledTimes(1);
|
|
const joinArgs = requireRecord(mockCallArg(join, 0), "test speech join args");
|
|
expect(joinArgs.message).toBe("Say exactly: hello.");
|
|
expect(joinArgs.mode).toBe("agent");
|
|
expect(speak).not.toHaveBeenCalled();
|
|
expect(result.spoken).toBe(true);
|
|
expect(result.speechOutputVerified).toBe(false);
|
|
expect(result.speechOutputTimedOut).toBe(false);
|
|
});
|
|
|
|
it("uses the requested bidirectional realtime mode for test speech", async () => {
|
|
const runtime = new GoogleMeetRuntime({
|
|
config: resolveGoogleMeetConfig({ defaultMode: "agent" }),
|
|
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: "bidi",
|
|
agentId: "main",
|
|
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,
|
|
strategy: "bidi",
|
|
provider: "openai",
|
|
toolPolicy: "safe-read-only",
|
|
},
|
|
chrome: {
|
|
audioBackend: "blackhole-2ch",
|
|
launched: true,
|
|
health: { audioOutputActive: true, lastOutputBytes: 10 },
|
|
},
|
|
notes: [],
|
|
};
|
|
vi.spyOn(runtime, "list").mockReturnValue([]);
|
|
const join = vi.spyOn(runtime, "join").mockResolvedValue({ session, spoken: true });
|
|
|
|
await runtime.testSpeech({
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
mode: "bidi",
|
|
message: "Say exactly: hello.",
|
|
});
|
|
|
|
expect(join).toHaveBeenCalledTimes(1);
|
|
const joinArgs = requireRecord(mockCallArg(join, 0), "test speech join args");
|
|
expect(joinArgs.message).toBe("Say exactly: hello.");
|
|
expect(joinArgs.mode).toBe("bidi");
|
|
});
|
|
|
|
it("resets test speech output baselines when another agent owns the old session", async () => {
|
|
const runtime = new GoogleMeetRuntime({
|
|
config: resolveGoogleMeetConfig({}),
|
|
fullConfig: {} as never,
|
|
runtime: {} as never,
|
|
logger: noopLogger,
|
|
});
|
|
const oldSession: GoogleMeetSession = {
|
|
id: "meet_old",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
transport: "chrome",
|
|
mode: "agent",
|
|
agentId: "support",
|
|
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,
|
|
strategy: "agent",
|
|
transcriptionProvider: "openai",
|
|
toolPolicy: "safe-read-only",
|
|
},
|
|
chrome: {
|
|
audioBackend: "blackhole-2ch",
|
|
launched: true,
|
|
health: { audioOutputActive: true, lastOutputBytes: 100 },
|
|
},
|
|
notes: [],
|
|
};
|
|
const newSession: GoogleMeetSession = {
|
|
...oldSession,
|
|
id: "meet_new",
|
|
agentId: "main",
|
|
chrome: {
|
|
audioBackend: "blackhole-2ch",
|
|
launched: true,
|
|
health: { audioOutputActive: true, lastOutputBytes: 1 },
|
|
},
|
|
};
|
|
vi.spyOn(runtime, "list").mockReturnValue([oldSession]);
|
|
vi.spyOn(runtime, "join").mockResolvedValue({ session: newSession, spoken: true });
|
|
|
|
const result = await runtime.testSpeech({
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
agentId: "main",
|
|
message: "Say exactly: hello.",
|
|
});
|
|
|
|
expect(result.speechOutputVerified).toBe(true);
|
|
expect(result.speechOutputTimedOut).toBe(false);
|
|
});
|
|
|
|
it("keeps the pre-join test speech baseline when reusing the same session", 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: "agent",
|
|
agentId: "main",
|
|
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,
|
|
strategy: "agent",
|
|
transcriptionProvider: "openai",
|
|
toolPolicy: "safe-read-only",
|
|
},
|
|
chrome: {
|
|
audioBackend: "blackhole-2ch",
|
|
launched: true,
|
|
health: { audioOutputActive: true, lastOutputBytes: 10 },
|
|
},
|
|
notes: [],
|
|
};
|
|
vi.spyOn(runtime, "list").mockReturnValue([session]);
|
|
vi.spyOn(runtime, "join").mockImplementation(async () => {
|
|
session.chrome!.health!.lastOutputBytes = 11;
|
|
return { session, spoken: true };
|
|
});
|
|
|
|
const result = await runtime.testSpeech({
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
message: "Say exactly: hello.",
|
|
});
|
|
|
|
expect(result.speechOutputVerified).toBe(true);
|
|
expect(result.speechOutputTimedOut).toBe(false);
|
|
});
|
|
|
|
it("rejects observe-only mode for test speech", async () => {
|
|
const runtime = new GoogleMeetRuntime({
|
|
config: resolveGoogleMeetConfig({}),
|
|
fullConfig: {} as never,
|
|
runtime: {} as never,
|
|
logger: noopLogger,
|
|
});
|
|
|
|
await expect(
|
|
runtime.testSpeech({
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
mode: "transcribe",
|
|
}),
|
|
).rejects.toThrow("test_speech requires mode: agent or bidi");
|
|
});
|
|
|
|
it("rejects realtime and Twilio modes for test listen", async () => {
|
|
const runtime = new GoogleMeetRuntime({
|
|
config: resolveGoogleMeetConfig({}),
|
|
fullConfig: {} as never,
|
|
runtime: {} as never,
|
|
logger: noopLogger,
|
|
});
|
|
|
|
await expect(
|
|
runtime.testListen({
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
mode: "agent",
|
|
}),
|
|
).rejects.toThrow("test_listen requires mode: transcribe");
|
|
|
|
await expect(
|
|
runtime.testListen({
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
transport: "twilio",
|
|
}),
|
|
).rejects.toThrow("test_listen supports chrome or chrome-node");
|
|
});
|
|
|
|
it("resets test listen transcript baselines when another agent owns the old session", async () => {
|
|
const runtime = new GoogleMeetRuntime({
|
|
config: resolveGoogleMeetConfig({}),
|
|
fullConfig: {} as never,
|
|
runtime: {} as never,
|
|
logger: noopLogger,
|
|
});
|
|
const oldSession: GoogleMeetSession = {
|
|
id: "meet_old",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
transport: "chrome",
|
|
mode: "transcribe",
|
|
agentId: "support",
|
|
state: "active",
|
|
createdAt: "2026-04-27T00:00:00.000Z",
|
|
updatedAt: "2026-04-27T00:00:00.000Z",
|
|
participantIdentity: "signed-in Google Chrome profile",
|
|
realtime: {
|
|
enabled: false,
|
|
strategy: "agent",
|
|
toolPolicy: "safe-read-only",
|
|
},
|
|
chrome: {
|
|
audioBackend: "blackhole-2ch",
|
|
launched: true,
|
|
health: { transcriptLines: 10, lastCaptionText: "old caption" },
|
|
},
|
|
notes: [],
|
|
};
|
|
const newSession: GoogleMeetSession = {
|
|
...oldSession,
|
|
id: "meet_new",
|
|
agentId: "main",
|
|
chrome: {
|
|
audioBackend: "blackhole-2ch",
|
|
launched: true,
|
|
health: { transcriptLines: 1, lastCaptionText: "fresh caption" },
|
|
},
|
|
};
|
|
vi.spyOn(runtime, "list").mockReturnValue([oldSession]);
|
|
vi.spyOn(runtime, "join").mockResolvedValue({ session: newSession, spoken: false });
|
|
|
|
const result = await runtime.testListen({
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
agentId: "main",
|
|
});
|
|
|
|
expect(result.listenVerified).toBe(true);
|
|
expect(result.listenTimedOut).toBe(false);
|
|
});
|
|
|
|
it("keeps the pre-join test listen baseline when reusing the same session", 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: "transcribe",
|
|
agentId: "main",
|
|
state: "active",
|
|
createdAt: "2026-04-27T00:00:00.000Z",
|
|
updatedAt: "2026-04-27T00:00:00.000Z",
|
|
participantIdentity: "signed-in Google Chrome profile",
|
|
realtime: {
|
|
enabled: false,
|
|
strategy: "agent",
|
|
toolPolicy: "safe-read-only",
|
|
},
|
|
chrome: {
|
|
audioBackend: "blackhole-2ch",
|
|
launched: true,
|
|
health: { transcriptLines: 1, lastCaptionText: "old caption" },
|
|
},
|
|
notes: [],
|
|
};
|
|
vi.spyOn(runtime, "list").mockReturnValue([session]);
|
|
vi.spyOn(runtime, "join").mockImplementation(async () => {
|
|
session.chrome!.health = { transcriptLines: 2, lastCaptionText: "fresh caption" };
|
|
return { session, spoken: false };
|
|
});
|
|
|
|
const result = await runtime.testListen({
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
});
|
|
|
|
expect(result.listenVerified).toBe(true);
|
|
expect(result.listenTimedOut).toBe(false);
|
|
});
|
|
|
|
it("preserves plugin ownership from browser create through join and leave", async () => {
|
|
const createMeet = vi
|
|
.spyOn(chromeCreateTransport, "createMeetWithBrowserProxyOnNode")
|
|
.mockResolvedValueOnce({
|
|
source: "browser",
|
|
nodeId: "meet-node",
|
|
targetId: "created-meet-tab-a",
|
|
openedByPlugin: true,
|
|
meetingUri: "https://meet.google.com/drf-ihtb-pad",
|
|
})
|
|
.mockResolvedValueOnce({
|
|
source: "browser",
|
|
nodeId: "meet-node",
|
|
targetId: "created-meet-tab-b",
|
|
openedByPlugin: true,
|
|
meetingUri: "https://meet.google.com/qwe-rtyu-iop",
|
|
});
|
|
const launchChromeMeetOnNode = vi
|
|
.spyOn(chromeTransport, "launchChromeMeetOnNode")
|
|
.mockResolvedValueOnce({
|
|
nodeId: "meet-node",
|
|
launched: true,
|
|
tab: { targetId: "created-meet-tab-a", openedByPlugin: false },
|
|
browser: { inCall: true, micMuted: true },
|
|
})
|
|
.mockResolvedValueOnce({
|
|
nodeId: "meet-node",
|
|
launched: true,
|
|
tab: { targetId: "created-meet-tab-b", openedByPlugin: false },
|
|
browser: { inCall: true, micMuted: true },
|
|
});
|
|
const leaveChromeMeetOnNode = vi
|
|
.spyOn(chromeTransport, "leaveChromeMeetOnNode")
|
|
.mockResolvedValue({ left: true, note: "left created tab" });
|
|
try {
|
|
const runtime = new GoogleMeetRuntime({
|
|
config: resolveGoogleMeetConfig({
|
|
defaultTransport: "chrome-node",
|
|
defaultMode: "transcribe",
|
|
}),
|
|
fullConfig: {} as never,
|
|
runtime: {} as never,
|
|
logger: noopLogger,
|
|
});
|
|
|
|
const createdA = await runtime.createViaBrowser();
|
|
const createdB = await runtime.createViaBrowser();
|
|
const joinedA = await runtime.join({
|
|
url: createdA.meetingUri,
|
|
transport: "chrome-node",
|
|
});
|
|
const joinedB = await runtime.join({
|
|
url: createdB.meetingUri,
|
|
transport: "chrome-node",
|
|
});
|
|
|
|
expect(joinedA.session.chrome?.browserTab).toEqual({
|
|
targetId: "created-meet-tab-a",
|
|
openedByPlugin: true,
|
|
});
|
|
expect(joinedB.session.chrome?.browserTab).toEqual({
|
|
targetId: "created-meet-tab-b",
|
|
openedByPlugin: true,
|
|
});
|
|
await runtime.leave(joinedA.session.id);
|
|
await runtime.leave(joinedB.session.id);
|
|
expect(leaveChromeMeetOnNode).toHaveBeenNthCalledWith(1, {
|
|
runtime: expect.any(Object),
|
|
nodeId: "meet-node",
|
|
config: expect.any(Object),
|
|
meetingUrl: "https://meet.google.com/drf-ihtb-pad",
|
|
tab: { targetId: "created-meet-tab-a", openedByPlugin: true },
|
|
});
|
|
expect(leaveChromeMeetOnNode).toHaveBeenNthCalledWith(2, {
|
|
runtime: expect.any(Object),
|
|
nodeId: "meet-node",
|
|
config: expect.any(Object),
|
|
meetingUrl: "https://meet.google.com/qwe-rtyu-iop",
|
|
tab: { targetId: "created-meet-tab-b", openedByPlugin: true },
|
|
});
|
|
} finally {
|
|
leaveChromeMeetOnNode.mockRestore();
|
|
launchChromeMeetOnNode.mockRestore();
|
|
createMeet.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("stops the old Chrome bridge before reassigning a Meet tab to another agent", async () => {
|
|
const stop = vi.fn(async () => {});
|
|
const launchChromeMeet = vi
|
|
.spyOn(chromeTransport, "launchChromeMeet")
|
|
.mockResolvedValueOnce({
|
|
launched: true,
|
|
tab: { targetId: "shared-meet-tab", openedByPlugin: true },
|
|
browser: { inCall: true, micMuted: false },
|
|
audioBridge: {
|
|
type: "command-pair",
|
|
providerId: "openai",
|
|
inputCommand: ["capture-meet"],
|
|
outputCommand: ["play-meet"],
|
|
speak: vi.fn(),
|
|
getHealth: vi.fn(() => ({})),
|
|
stop,
|
|
},
|
|
})
|
|
.mockResolvedValueOnce({
|
|
launched: true,
|
|
tab: { targetId: "shared-meet-tab", openedByPlugin: false },
|
|
browser: { inCall: true, micMuted: false },
|
|
});
|
|
try {
|
|
const runtime = new GoogleMeetRuntime({
|
|
config: resolveGoogleMeetConfig({
|
|
defaultTransport: "chrome",
|
|
defaultMode: "agent",
|
|
realtime: { introMessage: "" },
|
|
}),
|
|
fullConfig: {} as never,
|
|
runtime: {} as never,
|
|
logger: noopLogger,
|
|
});
|
|
|
|
const first = await runtime.join({
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
agentId: "support",
|
|
});
|
|
const second = await runtime.join({
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
agentId: "main",
|
|
});
|
|
|
|
expect(first.session.agentId).toBe("support");
|
|
expect(second.session.agentId).toBe("main");
|
|
expect(first.session.state).toBe("ended");
|
|
expect(second.session.chrome?.browserTab).toEqual({
|
|
targetId: "shared-meet-tab",
|
|
openedByPlugin: true,
|
|
});
|
|
expect(first.session.notes).toContain(
|
|
"Ended before the same Meet tab was reassigned to another agent.",
|
|
);
|
|
expect(second.session.id).not.toBe(first.session.id);
|
|
expect(stop).toHaveBeenCalledTimes(1);
|
|
expect(launchChromeMeet).toHaveBeenCalledTimes(2);
|
|
} finally {
|
|
launchChromeMeet.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("stops the old Chrome bridge when the same agent changes talk-back mode", async () => {
|
|
const stop = vi.fn(async () => {});
|
|
const launchChromeMeet = vi
|
|
.spyOn(chromeTransport, "launchChromeMeet")
|
|
.mockResolvedValueOnce({
|
|
launched: true,
|
|
tab: { targetId: "shared-meet-tab", openedByPlugin: true },
|
|
browser: { inCall: true, micMuted: false },
|
|
audioBridge: {
|
|
type: "command-pair",
|
|
providerId: "openai",
|
|
inputCommand: ["capture-meet"],
|
|
outputCommand: ["play-meet"],
|
|
speak: vi.fn(),
|
|
getHealth: vi.fn(() => ({})),
|
|
stop,
|
|
},
|
|
})
|
|
.mockResolvedValueOnce({
|
|
launched: true,
|
|
tab: { targetId: "shared-meet-tab", openedByPlugin: false },
|
|
browser: { inCall: true, micMuted: false },
|
|
});
|
|
try {
|
|
const runtime = new GoogleMeetRuntime({
|
|
config: resolveGoogleMeetConfig({
|
|
defaultTransport: "chrome",
|
|
defaultMode: "agent",
|
|
realtime: { introMessage: "" },
|
|
}),
|
|
fullConfig: {} as never,
|
|
runtime: {} as never,
|
|
logger: noopLogger,
|
|
});
|
|
const first = await runtime.join({
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
agentId: "main",
|
|
mode: "agent",
|
|
});
|
|
|
|
const second = await runtime.join({
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
agentId: "main",
|
|
mode: "bidi",
|
|
});
|
|
|
|
expect(first.session.state).toBe("ended");
|
|
expect(second.session.mode).toBe("bidi");
|
|
expect(second.session.chrome?.browserTab).toEqual({
|
|
targetId: "shared-meet-tab",
|
|
openedByPlugin: true,
|
|
});
|
|
expect(stop).toHaveBeenCalledOnce();
|
|
} finally {
|
|
launchChromeMeet.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("reassigns an externally managed browser participant without requiring a tracked tab", async () => {
|
|
const stop = vi.fn(async () => {});
|
|
const launchChromeMeet = vi
|
|
.spyOn(chromeTransport, "launchChromeMeet")
|
|
.mockResolvedValueOnce({
|
|
launched: false,
|
|
audioBridge: {
|
|
type: "command-pair",
|
|
providerId: "openai",
|
|
inputCommand: ["capture-meet"],
|
|
outputCommand: ["play-meet"],
|
|
speak: vi.fn(),
|
|
getHealth: vi.fn(() => ({})),
|
|
stop,
|
|
},
|
|
})
|
|
.mockResolvedValueOnce({ launched: false });
|
|
const leaveChromeMeet = vi.spyOn(chromeTransport, "leaveChromeMeet");
|
|
try {
|
|
const runtime = new GoogleMeetRuntime({
|
|
config: resolveGoogleMeetConfig({
|
|
defaultTransport: "chrome",
|
|
defaultMode: "agent",
|
|
chrome: { launch: false },
|
|
realtime: { introMessage: "" },
|
|
}),
|
|
fullConfig: {} as never,
|
|
runtime: {} as never,
|
|
logger: noopLogger,
|
|
});
|
|
const first = await runtime.join({
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
agentId: "support",
|
|
});
|
|
|
|
const second = await runtime.join({
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
agentId: "main",
|
|
});
|
|
|
|
expect(first.session.state).toBe("ended");
|
|
expect(second.session.state).toBe("active");
|
|
expect(stop).toHaveBeenCalledOnce();
|
|
expect(leaveChromeMeet).not.toHaveBeenCalled();
|
|
} finally {
|
|
leaveChromeMeet.mockRestore();
|
|
launchChromeMeet.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("carries plugin tab ownership through ordinary shared-tab reuse", async () => {
|
|
const launchChromeMeet = vi
|
|
.spyOn(chromeTransport, "launchChromeMeet")
|
|
.mockResolvedValueOnce({
|
|
launched: true,
|
|
tab: { targetId: "shared-meet-tab", openedByPlugin: true },
|
|
browser: { inCall: true, micMuted: true },
|
|
})
|
|
.mockResolvedValueOnce({
|
|
launched: true,
|
|
tab: { targetId: "shared-meet-tab", openedByPlugin: false },
|
|
browser: { inCall: true, micMuted: true },
|
|
});
|
|
const leaveChromeMeet = vi.spyOn(chromeTransport, "leaveChromeMeet").mockResolvedValue({
|
|
left: true,
|
|
note: "left browser",
|
|
});
|
|
try {
|
|
const runtime = new GoogleMeetRuntime({
|
|
config: resolveGoogleMeetConfig({
|
|
defaultTransport: "chrome",
|
|
defaultMode: "transcribe",
|
|
}),
|
|
fullConfig: {} as never,
|
|
runtime: {} as never,
|
|
logger: noopLogger,
|
|
});
|
|
|
|
const first = await runtime.join({
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
agentId: "support",
|
|
});
|
|
const second = await runtime.join({
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
agentId: "main",
|
|
});
|
|
|
|
expect(second.session.chrome?.browserTab).toEqual({
|
|
targetId: "shared-meet-tab",
|
|
openedByPlugin: true,
|
|
});
|
|
await Promise.all([runtime.leave(first.session.id), runtime.leave(second.session.id)]);
|
|
expect(leaveChromeMeet).toHaveBeenCalledOnce();
|
|
expect(leaveChromeMeet).toHaveBeenCalledWith({
|
|
runtime: expect.any(Object),
|
|
config: expect.any(Object),
|
|
meetingUrl: "https://meet.google.com/abc-defg-hij",
|
|
tab: { targetId: "shared-meet-tab", openedByPlugin: true },
|
|
});
|
|
} finally {
|
|
leaveChromeMeet.mockRestore();
|
|
launchChromeMeet.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("leaves the old tab before reassignment when tab reuse is disabled", async () => {
|
|
const stop = vi.fn(async () => {});
|
|
const launchChromeMeet = vi
|
|
.spyOn(chromeTransport, "launchChromeMeet")
|
|
.mockResolvedValueOnce({
|
|
launched: true,
|
|
tab: { targetId: "old-meet-tab", openedByPlugin: true },
|
|
browser: { inCall: true, micMuted: false },
|
|
audioBridge: {
|
|
type: "command-pair",
|
|
providerId: "openai",
|
|
inputCommand: ["capture-meet"],
|
|
outputCommand: ["play-meet"],
|
|
speak: vi.fn(),
|
|
getHealth: vi.fn(() => ({})),
|
|
stop,
|
|
},
|
|
})
|
|
.mockResolvedValueOnce({
|
|
launched: true,
|
|
tab: { targetId: "new-meet-tab", openedByPlugin: true },
|
|
browser: { inCall: true, micMuted: false },
|
|
});
|
|
const leaveChromeMeet = vi.spyOn(chromeTransport, "leaveChromeMeet").mockResolvedValue({
|
|
left: true,
|
|
note: "left old browser tab",
|
|
});
|
|
try {
|
|
const runtime = new GoogleMeetRuntime({
|
|
config: resolveGoogleMeetConfig({
|
|
defaultTransport: "chrome",
|
|
defaultMode: "agent",
|
|
chrome: { reuseExistingTab: false },
|
|
realtime: { introMessage: "" },
|
|
}),
|
|
fullConfig: {} as never,
|
|
runtime: {} as never,
|
|
logger: noopLogger,
|
|
});
|
|
await runtime.join({
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
agentId: "support",
|
|
});
|
|
|
|
const second = await runtime.join({
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
agentId: "main",
|
|
});
|
|
|
|
expect(stop).toHaveBeenCalledOnce();
|
|
expect(leaveChromeMeet).toHaveBeenCalledOnce();
|
|
expect(leaveChromeMeet).toHaveBeenCalledWith({
|
|
runtime: expect.any(Object),
|
|
config: expect.any(Object),
|
|
meetingUrl: "https://meet.google.com/abc-defg-hij",
|
|
tab: { targetId: "old-meet-tab", openedByPlugin: true },
|
|
});
|
|
expect(second.session.chrome?.browserTab?.targetId).toBe("new-meet-tab");
|
|
} finally {
|
|
leaveChromeMeet.mockRestore();
|
|
launchChromeMeet.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("releases a retained tab when replacement launch fails", async () => {
|
|
const stop = vi.fn(async () => {});
|
|
const launchChromeMeet = vi
|
|
.spyOn(chromeTransport, "launchChromeMeet")
|
|
.mockResolvedValueOnce({
|
|
launched: true,
|
|
tab: { targetId: "retained-meet-tab", openedByPlugin: true },
|
|
browser: { inCall: true, micMuted: false },
|
|
audioBridge: {
|
|
type: "command-pair",
|
|
providerId: "openai",
|
|
inputCommand: ["capture-meet"],
|
|
outputCommand: ["play-meet"],
|
|
speak: vi.fn(),
|
|
getHealth: vi.fn(() => ({})),
|
|
stop,
|
|
},
|
|
})
|
|
.mockRejectedValueOnce(new Error("replacement launch failed"));
|
|
const leaveChromeMeet = vi.spyOn(chromeTransport, "leaveChromeMeet").mockResolvedValue({
|
|
left: true,
|
|
note: "released retained tab",
|
|
});
|
|
try {
|
|
const runtime = new GoogleMeetRuntime({
|
|
config: resolveGoogleMeetConfig({
|
|
defaultTransport: "chrome",
|
|
defaultMode: "agent",
|
|
realtime: { introMessage: "" },
|
|
}),
|
|
fullConfig: {} as never,
|
|
runtime: {} as never,
|
|
logger: noopLogger,
|
|
});
|
|
const first = await runtime.join({
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
agentId: "support",
|
|
});
|
|
|
|
await expect(
|
|
runtime.join({
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
agentId: "main",
|
|
}),
|
|
).rejects.toThrow("replacement launch failed");
|
|
|
|
expect(first.session.state).toBe("ended");
|
|
expect(first.session.browserLeft).toBe(true);
|
|
expect(leaveChromeMeet).toHaveBeenCalledWith({
|
|
runtime: expect.any(Object),
|
|
config: expect.any(Object),
|
|
meetingUrl: "https://meet.google.com/abc-defg-hij",
|
|
tab: { targetId: "retained-meet-tab", openedByPlugin: true },
|
|
});
|
|
} finally {
|
|
leaveChromeMeet.mockRestore();
|
|
launchChromeMeet.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("stops the replacement bridge when retained-tab cleanup fails", async () => {
|
|
const oldStop = vi.fn(async () => {});
|
|
const replacementStop = vi.fn(async () => {});
|
|
const audioBridge = (stop: () => Promise<void>) => ({
|
|
type: "command-pair" as const,
|
|
providerId: "openai",
|
|
inputCommand: ["capture-meet"],
|
|
outputCommand: ["play-meet"],
|
|
speak: vi.fn(),
|
|
getHealth: vi.fn(() => ({})),
|
|
stop,
|
|
});
|
|
const launchChromeMeet = vi
|
|
.spyOn(chromeTransport, "launchChromeMeet")
|
|
.mockResolvedValueOnce({
|
|
launched: true,
|
|
tab: { targetId: "retained-meet-tab", openedByPlugin: true },
|
|
browser: { inCall: true, micMuted: false },
|
|
audioBridge: audioBridge(oldStop),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
launched: true,
|
|
tab: { targetId: "replacement-meet-tab", openedByPlugin: true },
|
|
browser: { inCall: true, micMuted: false },
|
|
audioBridge: audioBridge(replacementStop),
|
|
});
|
|
const leaveChromeMeet = vi
|
|
.spyOn(chromeTransport, "leaveChromeMeet")
|
|
.mockResolvedValueOnce({ left: false, note: "old tab cleanup failed" })
|
|
.mockResolvedValueOnce({ left: true, note: "replacement tab released" });
|
|
try {
|
|
const runtime = new GoogleMeetRuntime({
|
|
config: resolveGoogleMeetConfig({
|
|
defaultTransport: "chrome",
|
|
defaultMode: "agent",
|
|
realtime: { introMessage: "" },
|
|
}),
|
|
fullConfig: {} as never,
|
|
runtime: {} as never,
|
|
logger: noopLogger,
|
|
});
|
|
await runtime.join({
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
agentId: "support",
|
|
});
|
|
|
|
await expect(
|
|
runtime.join({
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
agentId: "main",
|
|
}),
|
|
).rejects.toThrow("Could not leave the previous Meet browser tab before reassignment.");
|
|
|
|
expect(oldStop).toHaveBeenCalledOnce();
|
|
expect(replacementStop).toHaveBeenCalledOnce();
|
|
expect(leaveChromeMeet).toHaveBeenCalledTimes(2);
|
|
expect(leaveChromeMeet).toHaveBeenLastCalledWith({
|
|
runtime: expect.any(Object),
|
|
config: expect.any(Object),
|
|
meetingUrl: "https://meet.google.com/abc-defg-hij",
|
|
tab: { targetId: "replacement-meet-tab", openedByPlugin: true },
|
|
});
|
|
} finally {
|
|
leaveChromeMeet.mockRestore();
|
|
launchChromeMeet.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("shares one in-flight browser leave and blocks a same-meeting join until it settles", async () => {
|
|
let resolveBrowserLeave: ((result: { left: boolean; note: string }) => void) | undefined;
|
|
const browserLeave = new Promise<{ left: boolean; note: string }>((resolve) => {
|
|
resolveBrowserLeave = resolve;
|
|
});
|
|
const launchChromeMeet = vi
|
|
.spyOn(chromeTransport, "launchChromeMeet")
|
|
.mockResolvedValueOnce({
|
|
launched: true,
|
|
tab: { targetId: "leaving-meet-tab", openedByPlugin: true },
|
|
browser: { inCall: true, micMuted: true },
|
|
})
|
|
.mockResolvedValueOnce({
|
|
launched: true,
|
|
tab: { targetId: "replacement-meet-tab", openedByPlugin: true },
|
|
browser: { inCall: true, micMuted: true },
|
|
});
|
|
const leaveChromeMeet = vi
|
|
.spyOn(chromeTransport, "leaveChromeMeet")
|
|
.mockReturnValue(browserLeave);
|
|
try {
|
|
const runtime = new GoogleMeetRuntime({
|
|
config: resolveGoogleMeetConfig({
|
|
defaultTransport: "chrome",
|
|
defaultMode: "transcribe",
|
|
}),
|
|
fullConfig: {} as never,
|
|
runtime: {} as never,
|
|
logger: noopLogger,
|
|
});
|
|
const joined = await runtime.join({ url: "https://meet.google.com/abc-defg-hij" });
|
|
|
|
const firstLeave = runtime.leave(joined.session.id);
|
|
const secondLeave = runtime.leave(joined.session.id);
|
|
const replacementJoin = runtime.join({
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
agentId: "support",
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(leaveChromeMeet).toHaveBeenCalledOnce();
|
|
});
|
|
expect(launchChromeMeet).toHaveBeenCalledOnce();
|
|
resolveBrowserLeave?.({ left: false, note: "browser leave failed" });
|
|
|
|
const [firstResult, secondResult, replacement] = await Promise.all([
|
|
firstLeave,
|
|
secondLeave,
|
|
replacementJoin,
|
|
]);
|
|
expect(firstResult.browserLeft).toBe(false);
|
|
expect(secondResult.browserLeft).toBe(false);
|
|
expect(replacement.session.chrome?.browserTab?.targetId).toBe("replacement-meet-tab");
|
|
expect(launchChromeMeet).toHaveBeenCalledTimes(2);
|
|
} finally {
|
|
leaveChromeMeet.mockRestore();
|
|
launchChromeMeet.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("does not let a leave tear down a tab while another session adopts it", async () => {
|
|
let resolveReplacementLaunch:
|
|
| ((result: {
|
|
launched: true;
|
|
tab: { targetId: string; openedByPlugin: boolean };
|
|
browser: { inCall: true; micMuted: true };
|
|
}) => void)
|
|
| undefined;
|
|
const replacementLaunch = new Promise<{
|
|
launched: true;
|
|
tab: { targetId: string; openedByPlugin: boolean };
|
|
browser: { inCall: true; micMuted: true };
|
|
}>((resolve) => {
|
|
resolveReplacementLaunch = resolve;
|
|
});
|
|
const launchChromeMeet = vi
|
|
.spyOn(chromeTransport, "launchChromeMeet")
|
|
.mockResolvedValueOnce({
|
|
launched: true,
|
|
tab: { targetId: "shared-meet-tab", openedByPlugin: true },
|
|
browser: { inCall: true, micMuted: true },
|
|
})
|
|
.mockReturnValueOnce(replacementLaunch);
|
|
const leaveChromeMeet = vi.spyOn(chromeTransport, "leaveChromeMeet").mockResolvedValue({
|
|
left: true,
|
|
note: "left browser",
|
|
});
|
|
try {
|
|
const runtime = new GoogleMeetRuntime({
|
|
config: resolveGoogleMeetConfig({
|
|
defaultTransport: "chrome",
|
|
defaultMode: "transcribe",
|
|
}),
|
|
fullConfig: {} as never,
|
|
runtime: {} as never,
|
|
logger: noopLogger,
|
|
});
|
|
const first = await runtime.join({
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
agentId: "support",
|
|
});
|
|
|
|
const replacement = runtime.join({
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
agentId: "main",
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(launchChromeMeet).toHaveBeenCalledTimes(2);
|
|
});
|
|
const oldLeave = runtime.leave(first.session.id);
|
|
await Promise.resolve();
|
|
expect(leaveChromeMeet).not.toHaveBeenCalled();
|
|
|
|
resolveReplacementLaunch?.({
|
|
launched: true,
|
|
tab: { targetId: "shared-meet-tab", openedByPlugin: false },
|
|
browser: { inCall: true, micMuted: true },
|
|
});
|
|
const adopted = await replacement;
|
|
await oldLeave;
|
|
|
|
expect(adopted.session.chrome?.browserTab).toEqual({
|
|
targetId: "shared-meet-tab",
|
|
openedByPlugin: true,
|
|
});
|
|
expect(leaveChromeMeet).not.toHaveBeenCalled();
|
|
|
|
await runtime.leave(adopted.session.id);
|
|
expect(leaveChromeMeet).toHaveBeenCalledOnce();
|
|
} finally {
|
|
leaveChromeMeet.mockRestore();
|
|
launchChromeMeet.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("still leaves the browser when the Chrome bridge stop fails", async () => {
|
|
const stop = vi.fn(async () => {
|
|
throw new Error("bridge stop failed");
|
|
});
|
|
const launchChromeMeet = vi.spyOn(chromeTransport, "launchChromeMeet").mockResolvedValue({
|
|
launched: true,
|
|
tab: { targetId: "meet-tab", openedByPlugin: true },
|
|
browser: { inCall: true, micMuted: false },
|
|
audioBridge: {
|
|
type: "command-pair",
|
|
providerId: "openai",
|
|
inputCommand: ["capture-meet"],
|
|
outputCommand: ["play-meet"],
|
|
speak: vi.fn(),
|
|
getHealth: vi.fn(() => ({})),
|
|
stop,
|
|
},
|
|
});
|
|
const leaveChromeMeet = vi.spyOn(chromeTransport, "leaveChromeMeet").mockResolvedValue({
|
|
left: true,
|
|
note: "left browser",
|
|
});
|
|
try {
|
|
const runtime = new GoogleMeetRuntime({
|
|
config: resolveGoogleMeetConfig({
|
|
defaultTransport: "chrome",
|
|
defaultMode: "agent",
|
|
realtime: { introMessage: "" },
|
|
}),
|
|
fullConfig: {} as never,
|
|
runtime: {} as never,
|
|
logger: noopLogger,
|
|
});
|
|
const joined = await runtime.join({ url: "https://meet.google.com/abc-defg-hij" });
|
|
|
|
await expect(runtime.leave(joined.session.id)).rejects.toThrow("bridge stop failed");
|
|
|
|
expect(leaveChromeMeet).toHaveBeenCalledWith({
|
|
runtime: expect.any(Object),
|
|
config: expect.any(Object),
|
|
meetingUrl: "https://meet.google.com/abc-defg-hij",
|
|
tab: { targetId: "meet-tab", openedByPlugin: true },
|
|
});
|
|
expect(joined.session.state).toBe("ended");
|
|
} finally {
|
|
leaveChromeMeet.mockRestore();
|
|
launchChromeMeet.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("reuses the stored session agent when a later Chrome bridge starts", async () => {
|
|
const launchChromeMeet = vi
|
|
.spyOn(chromeTransport, "launchChromeMeet")
|
|
.mockResolvedValueOnce({
|
|
launched: true,
|
|
browser: { inCall: false, micMuted: false },
|
|
})
|
|
.mockResolvedValueOnce({
|
|
launched: false,
|
|
audioBridge: { type: "external-command" },
|
|
});
|
|
const recoverCurrentMeetTab = vi
|
|
.spyOn(chromeTransport, "recoverCurrentMeetTab")
|
|
.mockResolvedValue({
|
|
transport: "chrome",
|
|
found: true,
|
|
message: "Existing Meet tab is in-call.",
|
|
browser: { inCall: true, micMuted: false },
|
|
});
|
|
try {
|
|
const runtime = new GoogleMeetRuntime({
|
|
config: resolveGoogleMeetConfig({
|
|
defaultTransport: "chrome",
|
|
defaultMode: "agent",
|
|
realtime: { introMessage: "" },
|
|
}),
|
|
fullConfig: {} as never,
|
|
runtime: {} as never,
|
|
logger: noopLogger,
|
|
});
|
|
|
|
const joined = await runtime.join({
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
agentId: "support",
|
|
});
|
|
await runtime.speak(joined.session.id, "Say exactly: hello.");
|
|
|
|
const delayedBridgeParams = requireRecord(
|
|
mockCallArg(launchChromeMeet, 0, 1),
|
|
"delayed bridge launch params",
|
|
);
|
|
const delayedBridgeConfig = requireRecord(
|
|
delayedBridgeParams.config,
|
|
"delayed bridge config",
|
|
);
|
|
const delayedRealtime = requireRecord(
|
|
delayedBridgeConfig.realtime,
|
|
"delayed bridge realtime config",
|
|
);
|
|
expect(joined.session.agentId).toBe("support");
|
|
expect(delayedRealtime.agentId).toBe("support");
|
|
} finally {
|
|
recoverCurrentMeetTab.mockRestore();
|
|
launchChromeMeet.mockRestore();
|
|
}
|
|
});
|
|
|
|
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;
|
|
speechReady?: boolean;
|
|
speechBlockedReason?: string;
|
|
spoken?: boolean;
|
|
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.manualActionRequired).toBe(true);
|
|
expect(result.details.manualActionReason).toBe("google-login-required");
|
|
expect(result.details.spoken).toBe(false);
|
|
expect(result.details.speechReady).toBe(false);
|
|
expect(result.details.speechBlockedReason).toBe("google-login-required");
|
|
const session = requireRecord(result.details.session, "manual action session");
|
|
const chrome = requireRecord(session.chrome, "manual action session chrome");
|
|
const health = requireRecord(chrome.health, "manual action chrome health");
|
|
expect(health.manualActionRequired).toBe(true);
|
|
expect(health.manualActionReason).toBe("google-login-required");
|
|
expect(health.speechReady).toBe(false);
|
|
expect(health.speechBlockedReason).toBe("google-login-required");
|
|
});
|
|
|
|
it("refreshes browser health before blocking an explicit speech retry", async () => {
|
|
let openedTab = false;
|
|
let browserReady = false;
|
|
const { methods, nodesInvoke } = setup(
|
|
{
|
|
defaultTransport: "chrome-node",
|
|
defaultMode: "agent",
|
|
},
|
|
{
|
|
nodesInvokeHandler: async ({ command, params }) => {
|
|
const raw = params as { path?: string; body?: { url?: string; targetId?: string } };
|
|
if (command === "browser.proxy") {
|
|
if (raw.path === "/tabs") {
|
|
return {
|
|
payload: {
|
|
result: {
|
|
running: true,
|
|
tabs: openedTab
|
|
? [
|
|
{
|
|
targetId: "tab-1",
|
|
title: "Meet",
|
|
url: "https://meet.google.com/abc-defg-hij?hl=en",
|
|
},
|
|
]
|
|
: [],
|
|
},
|
|
},
|
|
};
|
|
}
|
|
if (raw.path === "/tabs/open") {
|
|
openedTab = true;
|
|
return {
|
|
payload: {
|
|
result: {
|
|
targetId: "tab-1",
|
|
title: "Meet",
|
|
url: raw.body?.url ?? "https://meet.google.com/abc-defg-hij?hl=en",
|
|
},
|
|
},
|
|
};
|
|
}
|
|
if (raw.path === "/tabs/focus" || raw.path === "/permissions/grant") {
|
|
return { payload: { result: { ok: true } } };
|
|
}
|
|
if (raw.path === "/navigate") {
|
|
return {
|
|
payload: {
|
|
result: {
|
|
targetId: raw.body?.targetId ?? "tab-1",
|
|
url: raw.body?.url ?? "https://meet.google.com/abc-defg-hij?hl=en",
|
|
},
|
|
},
|
|
};
|
|
}
|
|
if (raw.path === "/act") {
|
|
return {
|
|
payload: {
|
|
result: {
|
|
ok: true,
|
|
targetId: raw.body?.targetId ?? "tab-1",
|
|
result: JSON.stringify(
|
|
browserReady
|
|
? {
|
|
inCall: true,
|
|
micMuted: false,
|
|
manualActionRequired: false,
|
|
title: "Meet call",
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
}
|
|
: {
|
|
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",
|
|
},
|
|
),
|
|
},
|
|
},
|
|
};
|
|
}
|
|
}
|
|
if (command === "googlemeet.chrome") {
|
|
return { payload: { launched: true } };
|
|
}
|
|
throw new Error(`unexpected invoke ${command}`);
|
|
},
|
|
},
|
|
);
|
|
|
|
const join = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.join", {
|
|
url: "https://meet.google.com/abc-defg-hij",
|
|
message: "Say exactly: hello.",
|
|
})) as {
|
|
session: { id: string; chrome?: { health?: { speechBlockedReason?: string } } };
|
|
spoken: boolean;
|
|
};
|
|
expect(join.spoken).toBe(false);
|
|
expect(join.session.chrome?.health?.speechBlockedReason).toBe("google-login-required");
|
|
|
|
browserReady = true;
|
|
const retry = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.speak", {
|
|
sessionId: join.session.id,
|
|
message: "Say exactly: hello again.",
|
|
})) as {
|
|
found: boolean;
|
|
spoken: boolean;
|
|
session?: {
|
|
chrome?: {
|
|
health?: {
|
|
inCall?: boolean;
|
|
manualActionRequired?: boolean;
|
|
speechBlockedReason?: string;
|
|
};
|
|
};
|
|
};
|
|
};
|
|
|
|
expect(retry.found).toBe(true);
|
|
expect(retry.spoken).toBe(false);
|
|
const retrySession = requireRecord(retry.session, "retry session");
|
|
const retryChrome = requireRecord(retrySession.chrome, "retry session chrome");
|
|
const retryHealth = requireRecord(retryChrome.health, "retry chrome health");
|
|
expect(retryHealth.inCall).toBe(true);
|
|
expect(retryHealth.manualActionRequired).toBe(false);
|
|
expect(retryHealth.speechBlockedReason).toBe("audio-bridge-unavailable");
|
|
const focusCalls = nodesInvoke.mock.calls
|
|
.map(([call]) => call)
|
|
.filter(
|
|
(call): call is { command: string; params: Record<string, unknown> } =>
|
|
call.command === "browser.proxy" &&
|
|
isRecord(call.params) &&
|
|
call.params.path === "/tabs/focus",
|
|
);
|
|
expect(focusCalls.length).toBeGreaterThan(0);
|
|
expect(focusCalls.at(-1)?.params.body).toStrictEqual({ targetId: "tab-1" });
|
|
});
|
|
|
|
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({
|
|
defaultMode: "bidi",
|
|
chrome: {
|
|
waitForInCallMs: 1,
|
|
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,
|
|
});
|
|
|
|
expectRespondedOk(respond);
|
|
expect(runCommandWithTimeout).toHaveBeenNthCalledWith(2, ["bridge", "status"], {
|
|
timeoutMs: 30000,
|
|
});
|
|
expect(runCommandWithTimeout).toHaveBeenNthCalledWith(3, ["bridge", "start"], {
|
|
timeoutMs: 30000,
|
|
});
|
|
const openRequests = callGatewayFromCli.mock.calls.filter((call) => {
|
|
const params = call[2];
|
|
return call[0] === "browser.request" && isRecord(params) && params.path === "/tabs/open";
|
|
});
|
|
expect(openRequests).toHaveLength(1);
|
|
const [method, opts, params, extra] = openRequests[0] ?? [];
|
|
expect(method).toBe("browser.request");
|
|
expect(isRecord(opts)).toBe(true);
|
|
const request = requireRecord(params, "local browser open request");
|
|
expect(request.method).toBe("POST");
|
|
expect(request.path).toBe("/tabs/open");
|
|
expect(request.body).toStrictEqual({ url: "https://meet.google.com/abc-defg-hij?hl=en" });
|
|
expect(extra).toStrictEqual({ progress: false });
|
|
} finally {
|
|
Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
}
|
|
});
|
|
|
|
it("uses realtime transcription plus regular TTS in Chrome agent mode", async () => {
|
|
vi.useFakeTimers();
|
|
let callbacks: Parameters<RealtimeTranscriptionProviderPlugin["createSession"]>[0] | undefined;
|
|
const sendAudio = vi.fn();
|
|
const sttSession = {
|
|
connect: vi.fn(async () => {}),
|
|
sendAudio,
|
|
close: vi.fn(),
|
|
isConnected: vi.fn(() => true),
|
|
};
|
|
const provider: RealtimeTranscriptionProviderPlugin = {
|
|
id: "openai",
|
|
label: "OpenAI",
|
|
defaultModel: "gpt-4o-transcribe",
|
|
autoSelectOrder: 1,
|
|
resolveConfig: ({ rawConfig }) => rawConfig,
|
|
isConfigured: () => true,
|
|
createSession: (req) => {
|
|
callbacks = req;
|
|
return sttSession;
|
|
},
|
|
};
|
|
const inputStdout = new PassThrough();
|
|
const outputStdinWrites: Buffer[] = [];
|
|
const makeProcess = (stdio: {
|
|
stdin?: { write(chunk: unknown): unknown } | null;
|
|
stdout?: PassThrough | 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 inputProcess = makeProcess({ stdout: inputStdout, stdin: null });
|
|
const outputProcess = makeProcess({ stdin: outputStdin, stdout: null });
|
|
const spawnMock = vi.fn().mockReturnValueOnce(outputProcess).mockReturnValueOnce(inputProcess);
|
|
const sessionStore: Record<string, unknown> = {};
|
|
const runtime = {
|
|
tts: {
|
|
textToSpeechTelephony: vi.fn(async () => ({
|
|
success: true,
|
|
audioBuffer: Buffer.from([1, 0, 2, 0]),
|
|
sampleRate: 24_000,
|
|
provider: "elevenlabs",
|
|
providerModel: "eleven_multilingual_v2",
|
|
providerVoice: "pMsXgVXv3BLzUgSXRplE",
|
|
outputFormat: "pcm16",
|
|
})),
|
|
},
|
|
agent: {
|
|
resolveAgentDir: vi.fn(() => "/tmp/agent"),
|
|
resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"),
|
|
ensureAgentWorkspace: vi.fn(async () => {}),
|
|
session: createMockSessionRuntime(sessionStore),
|
|
runEmbeddedAgent: vi.fn(async () => ({
|
|
payloads: [{ text: "Use the Portugal launch data." }],
|
|
meta: {},
|
|
})),
|
|
resolveAgentTimeoutMs: vi.fn(() => 1000),
|
|
},
|
|
};
|
|
|
|
const handle = await startCommandAgentAudioBridge({
|
|
config: resolveGoogleMeetConfig({
|
|
realtime: { provider: "openai", agentId: "jay", introMessage: "" },
|
|
}),
|
|
fullConfig: {} as never,
|
|
runtime: runtime as never,
|
|
meetingSessionId: "meet-1",
|
|
inputCommand: ["capture-meet"],
|
|
outputCommand: ["play-meet"],
|
|
logger: noopLogger,
|
|
providers: [provider],
|
|
spawn: spawnMock,
|
|
});
|
|
|
|
expect(noopLogger.info).toHaveBeenCalledWith(
|
|
"[google-meet] agent audio bridge starting: transcriptionProvider=openai transcriptionModel=gpt-4o-transcribe tts=telephony audioFormat=pcm16-24khz",
|
|
);
|
|
inputStdout.write(Buffer.from([1, 0, 2, 0, 3, 0, 4, 0]));
|
|
callbacks?.onTranscript?.("Please summarize the launch.");
|
|
await vi.advanceTimersByTimeAsync(GOOGLE_MEET_AGENT_TRANSCRIPT_DEBOUNCE_MS);
|
|
|
|
expect(sendAudio).toHaveBeenCalledTimes(1);
|
|
const audioChunk = mockCallArg(sendAudio, 0) as Buffer;
|
|
expect(Buffer.isBuffer(audioChunk)).toBe(true);
|
|
expect(audioChunk.byteLength).toBeGreaterThan(0);
|
|
expect(runtime.agent.runEmbeddedAgent).toHaveBeenCalled();
|
|
expect(runtime.tts.textToSpeechTelephony).toHaveBeenCalledWith({
|
|
text: "Use the Portugal launch data.",
|
|
cfg: {},
|
|
});
|
|
expect(noopLogger.info).toHaveBeenCalledWith(
|
|
"[google-meet] agent TTS: provider=elevenlabs model=eleven_multilingual_v2 voice=pMsXgVXv3BLzUgSXRplE outputFormat=pcm16 sampleRate=24000",
|
|
);
|
|
expect(Buffer.concat(outputStdinWrites)).toEqual(Buffer.from([1, 0, 2, 0]));
|
|
const health = handle.getHealth();
|
|
expect(health.providerConnected).toBe(true);
|
|
expect(health.audioInputActive).toBe(true);
|
|
expect(health.audioOutputActive).toBe(true);
|
|
expect(health.realtimeTranscriptLines).toBe(2);
|
|
expect(health.lastRealtimeTranscriptRole).toBe("assistant");
|
|
const talkEventTypes = health.recentTalkEvents?.map((event) => event.type) ?? [];
|
|
expect(talkEventTypes).toEqual([
|
|
"session.started",
|
|
"session.ready",
|
|
"turn.started",
|
|
"input.audio.delta",
|
|
"input.audio.committed",
|
|
"transcript.done",
|
|
"output.text.done",
|
|
"output.audio.started",
|
|
"output.audio.delta",
|
|
"output.audio.done",
|
|
"turn.ended",
|
|
]);
|
|
expect(talkEventTypes.indexOf("output.text.done")).toBeLessThan(
|
|
talkEventTypes.indexOf("output.audio.started"),
|
|
);
|
|
await handle.stop();
|
|
});
|
|
|
|
it("stops the Chrome agent audio bridge when child stdio streams error", async () => {
|
|
const sttSession = {
|
|
connect: vi.fn(async () => {}),
|
|
sendAudio: vi.fn(),
|
|
close: vi.fn(),
|
|
isConnected: vi.fn(() => true),
|
|
};
|
|
const provider: RealtimeTranscriptionProviderPlugin = {
|
|
id: "openai",
|
|
label: "OpenAI",
|
|
defaultModel: "gpt-4o-transcribe",
|
|
autoSelectOrder: 1,
|
|
resolveConfig: ({ rawConfig }) => rawConfig,
|
|
isConfigured: () => true,
|
|
createSession: () => sttSession,
|
|
};
|
|
const inputStdout = new PassThrough();
|
|
const inputStderr = new PassThrough();
|
|
const outputStderr = new PassThrough();
|
|
const makeProcess = (stdio: {
|
|
stdin?: { write(chunk: unknown): unknown } | null;
|
|
stdout?: PassThrough | null;
|
|
stderr: PassThrough;
|
|
}): TestBridgeProcess => {
|
|
const proc = new EventEmitter() as unknown as TestBridgeProcess;
|
|
proc.stdin = stdio.stdin;
|
|
proc.stdout = stdio.stdout;
|
|
proc.stderr = stdio.stderr;
|
|
proc.killed = false;
|
|
proc.kill = vi.fn(() => {
|
|
proc.killed = true;
|
|
return true;
|
|
});
|
|
return proc;
|
|
};
|
|
const outputStdin = new Writable({
|
|
write(_chunk, _encoding, done) {
|
|
done();
|
|
},
|
|
});
|
|
const outputProcess = makeProcess({
|
|
stdin: outputStdin,
|
|
stdout: null,
|
|
stderr: outputStderr,
|
|
});
|
|
const inputProcess = makeProcess({
|
|
stdin: null,
|
|
stdout: inputStdout,
|
|
stderr: inputStderr,
|
|
});
|
|
const spawnMock = vi.fn().mockReturnValueOnce(outputProcess).mockReturnValueOnce(inputProcess);
|
|
|
|
const handle = await startCommandAgentAudioBridge({
|
|
config: resolveGoogleMeetConfig({
|
|
realtime: { provider: "openai", agentId: "jay", introMessage: "" },
|
|
}),
|
|
fullConfig: {} as never,
|
|
runtime: {} as never,
|
|
meetingSessionId: "meet-1",
|
|
inputCommand: ["capture-meet"],
|
|
outputCommand: ["play-meet"],
|
|
logger: noopLogger,
|
|
providers: [provider],
|
|
spawn: spawnMock,
|
|
});
|
|
|
|
expect(() => inputStdout.emit("error", new Error("EPIPE"))).not.toThrow();
|
|
expect(noopLogger.warn).toHaveBeenCalledWith(
|
|
"[google-meet] audio input command stdout failed: EPIPE",
|
|
);
|
|
expect(handle.getHealth().bridgeClosed).toBe(true);
|
|
expect(sttSession.close).toHaveBeenCalled();
|
|
expect(inputProcess.kill).toHaveBeenCalledWith("SIGTERM");
|
|
expect(outputProcess.kill).toHaveBeenCalledWith("SIGTERM");
|
|
|
|
expect(() => inputStderr.emit("error", new Error("stderr EPIPE"))).not.toThrow();
|
|
expect(() => outputStderr.emit("error", new Error("output EPIPE"))).not.toThrow();
|
|
expect(noopLogger.warn).toHaveBeenCalledWith(
|
|
"[google-meet] audio input command stderr failed: stderr EPIPE",
|
|
);
|
|
expect(noopLogger.warn).toHaveBeenCalledWith(
|
|
"[google-meet] audio output command stderr failed: output EPIPE",
|
|
);
|
|
});
|
|
|
|
it("preserves telephony TTS output formats when routing Google Meet agent audio", () => {
|
|
const ulaw = Buffer.from([0xff, 0x7f, 0x00]);
|
|
const pcmBridgeConfig = resolveGoogleMeetConfig({ chrome: { audioFormat: "pcm16-24khz" } });
|
|
const ulawBridgeConfig = resolveGoogleMeetConfig({ chrome: { audioFormat: "g711-ulaw-8khz" } });
|
|
|
|
expect(
|
|
convertGoogleMeetTtsAudioForBridge(ulaw, 8_000, ulawBridgeConfig, "raw-8khz-8bit-mono-mulaw"),
|
|
).toEqual(ulaw);
|
|
const pcmForMeet = convertGoogleMeetTtsAudioForBridge(
|
|
ulaw,
|
|
8_000,
|
|
pcmBridgeConfig,
|
|
"ulaw_8000",
|
|
);
|
|
expect(pcmForMeet.byteLength).toBe(18);
|
|
expect(pcmForMeet).not.toEqual(ulaw);
|
|
expect(() =>
|
|
convertGoogleMeetTtsAudioForBridge(Buffer.from([1, 2, 3]), 8_000, pcmBridgeConfig, "mp3"),
|
|
).toThrow("Unsupported telephony TTS output format");
|
|
});
|
|
|
|
it("pipes Chrome command-pair audio through the realtime provider", async () => {
|
|
let callbacks: Parameters<RealtimeVoiceProviderPlugin["createBridge"]>[0] | undefined;
|
|
const sendAudio = vi.fn();
|
|
const bridge = {
|
|
supportsToolResultContinuation: true,
|
|
connect: vi.fn(async () => {}),
|
|
sendAudio,
|
|
setMediaTimestamp: vi.fn(),
|
|
handleBargeIn: 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",
|
|
defaultModel: "gpt-realtime-2",
|
|
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?: PassThrough | 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 fullConfig = { models: { providers: {} } } as never;
|
|
const sessionStore: Record<string, unknown> = {};
|
|
const runtime = {
|
|
agent: {
|
|
resolveAgentDir: vi.fn(() => "/tmp/agent"),
|
|
resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"),
|
|
ensureAgentWorkspace: vi.fn(async () => {}),
|
|
session: createMockSessionRuntime(sessionStore),
|
|
runEmbeddedAgent: vi.fn(async (_request: unknown) => ({
|
|
payloads: [{ text: "Use the Portugal launch data." }],
|
|
meta: {},
|
|
})),
|
|
resolveAgentTimeoutMs: vi.fn(() => 1000),
|
|
},
|
|
};
|
|
|
|
const handle = await startCommandRealtimeAudioBridge({
|
|
config: resolveGoogleMeetConfig({
|
|
realtime: { strategy: "bidi", provider: "openai", model: "gpt-realtime", agentId: "jay" },
|
|
}),
|
|
fullConfig,
|
|
runtime: runtime as never,
|
|
meetingSessionId: "meet-1",
|
|
inputCommand: ["capture-meet"],
|
|
outputCommand: ["play-meet"],
|
|
logger: noopLogger,
|
|
providers: [provider],
|
|
spawn: spawnMock,
|
|
});
|
|
|
|
expect(noopLogger.info).toHaveBeenCalledWith(
|
|
"[google-meet] realtime voice bridge starting: strategy=bidi provider=openai model=gpt-realtime audioFormat=pcm16-24khz",
|
|
);
|
|
expect(callbacks?.cfg).toBe(fullConfig);
|
|
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?.onTranscript?.("assistant", "How can I help you?", true);
|
|
callbacks?.onTranscript?.("user", "Please summarize the launch.", true);
|
|
callbacks?.onEvent?.({ direction: "client", type: "response.create" });
|
|
callbacks?.onEvent?.({
|
|
direction: "server",
|
|
type: "response.done",
|
|
detail: "status=completed",
|
|
});
|
|
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).toHaveBeenCalled();
|
|
const firstToolResultCall = mockCall(bridge.submitToolResult);
|
|
expect(firstToolResultCall[0]).toBe("tool-call-1");
|
|
expect(firstToolResultCall[2]).toStrictEqual({ willContinue: true });
|
|
const progressPayload = requireRecord(firstToolResultCall[1], "tool progress payload");
|
|
expect(progressPayload.status).toBe("working");
|
|
expect(progressPayload.tool).toBe("openclaw_agent_consult");
|
|
|
|
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("SIGKILL");
|
|
expect(replacementOutputStdinWrites).toEqual([Buffer.from([6, 7])]);
|
|
outputProcess.emit("error", new Error("stale output process failed after clear"));
|
|
outputStdin.emit("error", new Error("stale output pipe closed after clear"));
|
|
outputProcess.stderr.emit("error", new Error("stale output stderr closed 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.");
|
|
const health = handle.getHealth();
|
|
expect(health.providerConnected).toBe(true);
|
|
expect(health.realtimeReady).toBe(true);
|
|
expect(health.audioInputActive).toBe(true);
|
|
expect(health.audioOutputActive).toBe(true);
|
|
expect(health.lastInputBytes).toBe(3);
|
|
expect(health.lastOutputBytes).toBe(4);
|
|
expect(health.realtimeTranscriptLines).toBe(2);
|
|
expect(health.lastRealtimeTranscriptRole).toBe("user");
|
|
expect(health.lastRealtimeTranscriptText).toBe("Please summarize the launch.");
|
|
expect(health.lastRealtimeEventType).toBe("server:response.done");
|
|
expect(health.lastRealtimeEventDetail).toBe("status=completed");
|
|
expect(health.clearCount).toBe(1);
|
|
expect(health.recentRealtimeTranscript).toHaveLength(2);
|
|
expect(health.recentRealtimeTranscript?.[0]?.role).toBe("assistant");
|
|
expect(health.recentRealtimeTranscript?.[0]?.text).toBe("How can I help you?");
|
|
expect(health.recentRealtimeTranscript?.[1]?.role).toBe("user");
|
|
expect(health.recentRealtimeTranscript?.[1]?.text).toBe("Please summarize the launch.");
|
|
expect(health.recentRealtimeEvents).toHaveLength(2);
|
|
expect(health.recentRealtimeEvents?.[0]?.direction).toBe("client");
|
|
expect(health.recentRealtimeEvents?.[0]?.type).toBe("response.create");
|
|
expect(health.recentRealtimeEvents?.[1]?.direction).toBe("server");
|
|
expect(health.recentRealtimeEvents?.[1]?.type).toBe("response.done");
|
|
expect(health.recentRealtimeEvents?.[1]?.detail).toBe("status=completed");
|
|
if (!callbacks) {
|
|
throw new Error("Expected realtime bridge callbacks");
|
|
}
|
|
expect(callbacks.audioFormat).toStrictEqual({
|
|
encoding: "pcm16",
|
|
sampleRateHz: 24000,
|
|
channels: 1,
|
|
});
|
|
expect(callbacks.autoRespondToAudio).toBe(true);
|
|
expect(callbacks.tools?.map((tool) => tool.name)).toContain("openclaw_agent_consult");
|
|
await vi.waitFor(() => {
|
|
expect(bridge.submitToolResult).toHaveBeenLastCalledWith(
|
|
"tool-call-1",
|
|
{
|
|
text: "Use the Portugal launch data.",
|
|
},
|
|
undefined,
|
|
);
|
|
});
|
|
const talkEventTypes = handle.getHealth().recentTalkEvents?.map((event) => event.type) ?? [];
|
|
for (const type of [
|
|
"session.started",
|
|
"session.ready",
|
|
"input.audio.delta",
|
|
"output.audio.delta",
|
|
"output.audio.done",
|
|
"transcript.done",
|
|
"output.text.done",
|
|
"tool.call",
|
|
"tool.progress",
|
|
"tool.result",
|
|
"turn.ended",
|
|
]) {
|
|
expect(talkEventTypes).toContain(type);
|
|
}
|
|
expect(runtime.agent.runEmbeddedAgent).toHaveBeenCalledTimes(1);
|
|
const agentRequest = requireRecord(
|
|
mockCallArg(runtime.agent.runEmbeddedAgent, 0),
|
|
"embedded agent request",
|
|
);
|
|
expect(agentRequest.messageProvider).toBe("google-meet");
|
|
expect(agentRequest.agentId).toBe("jay");
|
|
expect(agentRequest.spawnedBy).toBe("agent:jay:main");
|
|
expect(agentRequest.sessionKey).toBe("agent:jay:subagent:google-meet:meet-1");
|
|
expect(agentRequest.sandboxSessionKey).toBe("agent:jay:subagent:google-meet:meet-1");
|
|
expect(agentRequest.thinkLevel).toBe("high");
|
|
expect(agentRequest.toolsAllow).toStrictEqual([
|
|
"read",
|
|
"web_search",
|
|
"web_fetch",
|
|
"x_search",
|
|
"memory_search",
|
|
"memory_get",
|
|
]);
|
|
expect(sessionStore).toHaveProperty("agent:jay:subagent:google-meet:meet-1");
|
|
|
|
await handle.stop();
|
|
expect(bridge.close).toHaveBeenCalled();
|
|
expect(inputProcess.kill).toHaveBeenCalledWith("SIGTERM");
|
|
expect(replacementOutputProcess.kill).toHaveBeenCalledWith("SIGTERM");
|
|
});
|
|
|
|
it("stops the Chrome realtime audio bridge when child stdout errors", async () => {
|
|
const bridge = {
|
|
connect: vi.fn(async () => {}),
|
|
sendAudio: vi.fn(),
|
|
setMediaTimestamp: vi.fn(),
|
|
handleBargeIn: 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 inputStdout = new PassThrough();
|
|
const makeProcess = (stdio: {
|
|
stdin?: { write(chunk: unknown): unknown } | null;
|
|
stdout?: PassThrough | 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 outputProcess = makeProcess({
|
|
stdin: new Writable({
|
|
write(_chunk, _encoding, done) {
|
|
done();
|
|
},
|
|
}),
|
|
stdout: null,
|
|
});
|
|
const inputProcess = makeProcess({ stdin: null, stdout: inputStdout });
|
|
const spawnMock = vi.fn().mockReturnValueOnce(outputProcess).mockReturnValueOnce(inputProcess);
|
|
|
|
const handle = await startCommandRealtimeAudioBridge({
|
|
config: resolveGoogleMeetConfig({
|
|
realtime: { strategy: "bidi", provider: "openai", model: "gpt-realtime" },
|
|
}),
|
|
fullConfig: { models: { providers: {} } } as never,
|
|
runtime: {} as never,
|
|
meetingSessionId: "meet-1",
|
|
inputCommand: ["capture-meet"],
|
|
outputCommand: ["play-meet"],
|
|
logger: noopLogger,
|
|
providers: [provider],
|
|
spawn: spawnMock,
|
|
});
|
|
|
|
expect(() => inputStdout.emit("error", new Error("EPIPE"))).not.toThrow();
|
|
expect(noopLogger.warn).toHaveBeenCalledWith(
|
|
"[google-meet] audio input command stdout failed: EPIPE",
|
|
);
|
|
expect(handle.getHealth().bridgeClosed).toBe(true);
|
|
expect(bridge.close).toHaveBeenCalled();
|
|
expect(inputProcess.kill).toHaveBeenCalledWith("SIGTERM");
|
|
expect(outputProcess.kill).toHaveBeenCalledWith("SIGTERM");
|
|
});
|
|
|
|
it("defaults Chrome command-pair realtime to agent-driven talk-back", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
let callbacks: Parameters<RealtimeVoiceProviderPlugin["createBridge"]>[0] | undefined;
|
|
const sendUserMessage = vi.fn();
|
|
const bridge = {
|
|
connect: vi.fn(async () => {}),
|
|
sendAudio: vi.fn(),
|
|
sendUserMessage,
|
|
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",
|
|
defaultModel: "gpt-realtime-2",
|
|
autoSelectOrder: 1,
|
|
resolveConfig: ({ rawConfig }) => rawConfig,
|
|
isConfigured: () => true,
|
|
createBridge: (req) => {
|
|
callbacks = req;
|
|
return bridge;
|
|
},
|
|
};
|
|
const inputStdout = new PassThrough();
|
|
const makeProcess = (stdio: {
|
|
stdin?: { write(chunk: unknown): unknown } | null;
|
|
stdout?: PassThrough | 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 outputProcess = makeProcess({
|
|
stdin: new Writable({
|
|
write(_chunk, _encoding, done) {
|
|
done();
|
|
},
|
|
}),
|
|
stdout: null,
|
|
});
|
|
const inputProcess = makeProcess({ stdout: inputStdout, stdin: null });
|
|
const spawnMock = vi
|
|
.fn()
|
|
.mockReturnValueOnce(outputProcess)
|
|
.mockReturnValueOnce(inputProcess);
|
|
const sessionStore: Record<string, unknown> = {};
|
|
const runtime = {
|
|
agent: {
|
|
resolveAgentDir: vi.fn(() => "/tmp/agent"),
|
|
resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"),
|
|
ensureAgentWorkspace: vi.fn(async () => {}),
|
|
session: createMockSessionRuntime(sessionStore),
|
|
runEmbeddedAgent: vi.fn(async (_request: unknown) => ({
|
|
payloads: [{ text: "The launch is still on track." }],
|
|
meta: {},
|
|
})),
|
|
resolveAgentTimeoutMs: vi.fn(() => 1000),
|
|
},
|
|
};
|
|
|
|
const handle = await startCommandRealtimeAudioBridge({
|
|
config: resolveGoogleMeetConfig({ realtime: { provider: "openai", agentId: "jay" } }),
|
|
fullConfig: {} as never,
|
|
runtime: runtime as never,
|
|
meetingSessionId: "meet-1",
|
|
inputCommand: ["capture-meet"],
|
|
outputCommand: ["play-meet"],
|
|
logger: noopLogger,
|
|
providers: [provider],
|
|
spawn: spawnMock,
|
|
});
|
|
|
|
if (!callbacks) {
|
|
throw new Error("Expected realtime bridge callbacks");
|
|
}
|
|
expect(callbacks.autoRespondToAudio).toBe(false);
|
|
expect(callbacks.tools).toStrictEqual([]);
|
|
callbacks?.onTranscript?.("user", "Are we still on track?", true);
|
|
callbacks?.onTranscript?.("user", "Please include launch blockers.", true);
|
|
|
|
await vi.advanceTimersByTimeAsync(GOOGLE_MEET_AGENT_TRANSCRIPT_DEBOUNCE_MS);
|
|
await vi.waitFor(() => {
|
|
expect(runtime.agent.runEmbeddedAgent).toHaveBeenCalledTimes(1);
|
|
});
|
|
const consultArgs = requireRecord(
|
|
(runtime.agent.runEmbeddedAgent.mock.calls as unknown[][])[0]?.[0],
|
|
"default talk-back agent request",
|
|
);
|
|
expect(consultArgs.agentId).toBe("jay");
|
|
expect(consultArgs.spawnedBy).toBe("agent:jay:main");
|
|
expect(consultArgs.sessionKey).toBe("agent:jay:subagent:google-meet:meet-1");
|
|
expect(consultArgs.sandboxSessionKey).toBe("agent:jay:subagent:google-meet:meet-1");
|
|
expect(JSON.stringify(consultArgs)).toContain(
|
|
"Are we still on track?\\nPlease include launch blockers.",
|
|
);
|
|
expect(sendUserMessage).toHaveBeenCalledTimes(1);
|
|
const sentUserMessage = mockCallArg(sendUserMessage, 0) as string;
|
|
expect(typeof sentUserMessage).toBe("string");
|
|
expect(sentUserMessage).toContain(JSON.stringify("The launch is still on track."));
|
|
expect(sessionStore).toHaveProperty("agent:jay:subagent:google-meet:meet-1");
|
|
|
|
await handle.stop();
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("tracks queued playback time when suppressing realtime input echo", () => {
|
|
const first = extendGoogleMeetOutputEchoSuppression({
|
|
audio: Buffer.alloc(48_000),
|
|
audioFormat: "pcm16-24khz",
|
|
nowMs: 1_000,
|
|
lastOutputPlayableUntilMs: 0,
|
|
suppressInputUntilMs: 0,
|
|
});
|
|
const second = extendGoogleMeetOutputEchoSuppression({
|
|
audio: Buffer.alloc(48_000),
|
|
audioFormat: "pcm16-24khz",
|
|
nowMs: 1_100,
|
|
lastOutputPlayableUntilMs: first.lastOutputPlayableUntilMs,
|
|
suppressInputUntilMs: first.suppressInputUntilMs,
|
|
});
|
|
|
|
expect(first.durationMs).toBe(1_000);
|
|
expect(first.lastOutputPlayableUntilMs).toBe(2_000);
|
|
expect(first.suppressInputUntilMs).toBe(5_000);
|
|
expect(second.durationMs).toBe(1_000);
|
|
expect(second.lastOutputPlayableUntilMs).toBe(3_000);
|
|
expect(second.suppressInputUntilMs).toBe(6_000);
|
|
});
|
|
|
|
it("detects assistant transcript echoes before agent consult", () => {
|
|
const nowMs = Date.parse("2026-05-04T01:00:00.000Z");
|
|
const transcript = [
|
|
{
|
|
at: new Date(nowMs - 1_000).toISOString(),
|
|
role: "assistant" as const,
|
|
text: "Hi Molty, glad to have you here. Let me know if there's anything specific you'd like to cover or if you need any support during the meeting.",
|
|
},
|
|
];
|
|
|
|
expect(
|
|
isGoogleMeetLikelyAssistantEchoTranscript({
|
|
transcript,
|
|
text: "Let me know if there's anything specific you'd like to cover or if you need any support during the",
|
|
nowMs,
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
isGoogleMeetLikelyAssistantEchoTranscript({
|
|
transcript,
|
|
text: "Tell me a story.",
|
|
nowMs,
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
isGoogleMeetLikelyAssistantEchoTranscript({
|
|
transcript,
|
|
text: "yes yes yes yes",
|
|
nowMs,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("uses a local barge-in input command to clear active Chrome playback", async () => {
|
|
let callbacks:
|
|
| {
|
|
onAudio: (audio: Buffer) => void;
|
|
}
|
|
| undefined;
|
|
const sendAudio = vi.fn();
|
|
const bridge = {
|
|
connect: vi.fn(async () => {}),
|
|
sendAudio,
|
|
setMediaTimestamp: vi.fn(),
|
|
handleBargeIn: vi.fn(),
|
|
submitToolResult: vi.fn(),
|
|
acknowledgeMark: vi.fn(),
|
|
close: 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 bargeInStdout = new PassThrough();
|
|
const outputStdin = new Writable({
|
|
write(_chunk, _encoding, done) {
|
|
done();
|
|
},
|
|
});
|
|
const replacementOutputStdin = new Writable({
|
|
write(_chunk, _encoding, done) {
|
|
done();
|
|
},
|
|
});
|
|
const makeProcess = (stdio: {
|
|
stdin?: { write(chunk: unknown): unknown } | null;
|
|
stdout?: PassThrough | 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 outputProcess = makeProcess({ stdin: outputStdin, stdout: null });
|
|
const inputProcess = makeProcess({ stdout: inputStdout, stdin: null });
|
|
const bargeInProcess = makeProcess({ stdout: bargeInStdout, stdin: null });
|
|
const replacementOutputProcess = makeProcess({ stdin: replacementOutputStdin, stdout: null });
|
|
const spawnMock = vi
|
|
.fn()
|
|
.mockReturnValueOnce(outputProcess)
|
|
.mockReturnValueOnce(inputProcess)
|
|
.mockReturnValueOnce(bargeInProcess)
|
|
.mockReturnValueOnce(replacementOutputProcess);
|
|
|
|
const handle = await startCommandRealtimeAudioBridge({
|
|
config: resolveGoogleMeetConfig({
|
|
chrome: {
|
|
bargeInInputCommand: ["capture-human"],
|
|
bargeInRmsThreshold: 10,
|
|
bargeInPeakThreshold: 10,
|
|
bargeInCooldownMs: 1,
|
|
},
|
|
realtime: { provider: "openai", model: "gpt-realtime" },
|
|
}),
|
|
fullConfig: {} as never,
|
|
runtime: {} as never,
|
|
meetingSessionId: "meet-1",
|
|
inputCommand: ["capture-meet"],
|
|
outputCommand: ["play-meet"],
|
|
logger: noopLogger,
|
|
providers: [provider],
|
|
spawn: spawnMock,
|
|
});
|
|
|
|
callbacks?.onAudio(Buffer.alloc(48_000));
|
|
inputStdout.write(Buffer.from([1, 2, 3, 4]));
|
|
bargeInStdout.write(Buffer.from([0xff, 0x7f, 0xff, 0x7f]));
|
|
|
|
expect(spawnMock).toHaveBeenNthCalledWith(3, "capture-human", [], {
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
expect(bridge.handleBargeIn).toHaveBeenCalled();
|
|
expect(outputProcess.kill).toHaveBeenCalledWith("SIGKILL");
|
|
expect(sendAudio).not.toHaveBeenCalledWith(Buffer.from([1, 2, 3, 4]));
|
|
const health = handle.getHealth();
|
|
expect(health.clearCount).toBe(1);
|
|
expect(health.suppressedInputBytes).toBe(4);
|
|
|
|
await handle.stop();
|
|
expect(inputProcess.kill).toHaveBeenCalledWith("SIGTERM");
|
|
expect(bargeInProcess.kill).toHaveBeenCalledWith("SIGTERM");
|
|
expect(replacementOutputProcess.kill).toHaveBeenCalledWith("SIGTERM");
|
|
});
|
|
|
|
it("pipes paired-node command-pair audio through the realtime provider", async () => {
|
|
let callbacks: Parameters<RealtimeVoiceProviderPlugin["createBridge"]>[0] | 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;
|
|
let idlePullStarted = false;
|
|
let releaseIdlePull: (() => void) | undefined;
|
|
const fullConfig = { models: { providers: {} } } as never;
|
|
const sessionStore: Record<string, unknown> = {};
|
|
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") };
|
|
}
|
|
idlePullStarted = true;
|
|
await new Promise<void>((resolve) => {
|
|
releaseIdlePull = resolve;
|
|
});
|
|
return { bridgeId: "bridge-1" };
|
|
}
|
|
releaseIdlePull?.();
|
|
releaseIdlePull = undefined;
|
|
return { ok: true };
|
|
}),
|
|
},
|
|
agent: {
|
|
resolveAgentDir: vi.fn(() => "/tmp/agent"),
|
|
resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"),
|
|
ensureAgentWorkspace: vi.fn(async () => {}),
|
|
session: createMockSessionRuntime(sessionStore),
|
|
runEmbeddedAgent: vi.fn(async () => ({
|
|
payloads: [{ text: "Use the launch update." }],
|
|
meta: {},
|
|
})),
|
|
resolveAgentTimeoutMs: vi.fn(() => 1000),
|
|
},
|
|
};
|
|
|
|
const handle = await startNodeRealtimeAudioBridge({
|
|
config: resolveGoogleMeetConfig({
|
|
realtime: { strategy: "bidi", provider: "openai", model: "gpt-realtime" },
|
|
}),
|
|
fullConfig,
|
|
runtime: runtime as never,
|
|
meetingSessionId: "meet-1",
|
|
nodeId: "node-1",
|
|
bridgeId: "bridge-1",
|
|
logger: noopLogger,
|
|
providers: [provider],
|
|
});
|
|
|
|
expect(noopLogger.info).toHaveBeenCalledWith(
|
|
"[google-meet] realtime voice bridge starting: strategy=bidi provider=openai model=gpt-realtime audioFormat=pcm16-24khz",
|
|
);
|
|
expect(callbacks?.cfg).toBe(fullConfig);
|
|
callbacks?.onAudio(Buffer.from([1, 2, 3]));
|
|
callbacks?.onClearAudio();
|
|
callbacks?.onReady?.();
|
|
callbacks?.onTranscript?.("assistant", "How can I help from the node?", true);
|
|
callbacks?.onEvent?.({
|
|
direction: "server",
|
|
type: "response.done",
|
|
detail: "status=completed",
|
|
});
|
|
callbacks?.onToolCall?.({
|
|
itemId: "item-1",
|
|
callId: "tool-call-1",
|
|
name: "openclaw_agent_consult",
|
|
args: { question: "What should I say?" },
|
|
});
|
|
expect(bridge.submitToolResult).toHaveBeenCalled();
|
|
const firstToolResultCall = mockCall(bridge.submitToolResult);
|
|
expect(firstToolResultCall[0]).toBe("tool-call-1");
|
|
expect(firstToolResultCall[2]).toStrictEqual({ willContinue: true });
|
|
const progressPayload = requireRecord(firstToolResultCall[1], "node tool progress payload");
|
|
expect(progressPayload.status).toBe("working");
|
|
expect(progressPayload.tool).toBe("openclaw_agent_consult");
|
|
|
|
await vi.waitFor(() => {
|
|
expect(sendAudio).toHaveBeenCalledWith(Buffer.from([9, 8, 7]));
|
|
});
|
|
await vi.waitFor(() => {
|
|
const pushCall = runtime.nodes.invoke.mock.calls
|
|
.map(([call]) => call)
|
|
.find((call) => isRecord(call.params) && call.params.action === "pushAudio");
|
|
const push = requireRecord(pushCall, "node push audio call");
|
|
const params = requireRecord(push.params, "node push audio params");
|
|
expect(push.nodeId).toBe("node-1");
|
|
expect(push.command).toBe("googlemeet.chrome");
|
|
expect(params.bridgeId).toBe("bridge-1");
|
|
expect(params.base64).toBe(Buffer.from([1, 2, 3]).toString("base64"));
|
|
});
|
|
await vi.waitFor(() => {
|
|
const clearCall = runtime.nodes.invoke.mock.calls
|
|
.map(([call]) => call)
|
|
.find((call) => isRecord(call.params) && call.params.action === "clearAudio");
|
|
const clear = requireRecord(clearCall, "node clear audio call");
|
|
expect(clear.nodeId).toBe("node-1");
|
|
expect(clear.command).toBe("googlemeet.chrome");
|
|
expect(clear.params).toStrictEqual({ action: "clearAudio", bridgeId: "bridge-1" });
|
|
expect(clear.timeoutMs).toBe(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.");
|
|
if (!callbacks) {
|
|
throw new Error("Expected node realtime callbacks");
|
|
}
|
|
expect(callbacks.audioFormat).toStrictEqual({
|
|
encoding: "pcm16",
|
|
sampleRateHz: 24000,
|
|
channels: 1,
|
|
});
|
|
expect(callbacks.autoRespondToAudio).toBe(true);
|
|
expect(callbacks.tools?.map((tool) => tool.name)).toContain("openclaw_agent_consult");
|
|
expect(handle.type).toBe("node-command-pair");
|
|
expect(handle.providerId).toBe("openai");
|
|
expect(handle.nodeId).toBe("node-1");
|
|
expect(handle.bridgeId).toBe("bridge-1");
|
|
const nodeHealth = handle.getHealth();
|
|
expect(nodeHealth.providerConnected).toBe(true);
|
|
expect(nodeHealth.realtimeReady).toBe(true);
|
|
expect(nodeHealth.audioInputActive).toBe(true);
|
|
expect(nodeHealth.audioOutputActive).toBe(true);
|
|
expect(nodeHealth.lastInputBytes).toBe(3);
|
|
expect(nodeHealth.lastOutputBytes).toBe(3);
|
|
expect(nodeHealth.realtimeTranscriptLines).toBe(1);
|
|
expect(nodeHealth.lastRealtimeTranscriptRole).toBe("assistant");
|
|
expect(nodeHealth.lastRealtimeTranscriptText).toBe("How can I help from the node?");
|
|
expect(nodeHealth.lastRealtimeEventType).toBe("server:response.done");
|
|
expect(nodeHealth.lastRealtimeEventDetail).toBe("status=completed");
|
|
expect(nodeHealth.clearCount).toBe(1);
|
|
const talkEvents = nodeHealth.recentTalkEvents ?? [];
|
|
const talkEventTypes = talkEvents.map((event) => event.type);
|
|
for (const type of [
|
|
"session.started",
|
|
"session.ready",
|
|
"input.audio.delta",
|
|
"output.audio.delta",
|
|
"output.audio.done",
|
|
"output.text.done",
|
|
"tool.call",
|
|
"tool.progress",
|
|
"tool.result",
|
|
"turn.ended",
|
|
]) {
|
|
expect(talkEventTypes).toContain(type);
|
|
}
|
|
expect(talkEvents[0]?.sessionId).toBe("google-meet:meet-1:bridge-1:node-realtime");
|
|
|
|
await vi.waitFor(() => {
|
|
expect(idlePullStarted).toBe(true);
|
|
});
|
|
await handle.stop();
|
|
|
|
expect(bridge.close).toHaveBeenCalled();
|
|
const stopCall = runtime.nodes.invoke.mock.calls
|
|
.map(([call]) => call)
|
|
.find((call) => isRecord(call.params) && call.params.action === "stop");
|
|
const stop = requireRecord(stopCall, "node stop call");
|
|
expect(stop.nodeId).toBe("node-1");
|
|
expect(stop.command).toBe("googlemeet.chrome");
|
|
expect(stop.params).toStrictEqual({ action: "stop", bridgeId: "bridge-1" });
|
|
expect(stop.timeoutMs).toBe(5_000);
|
|
});
|
|
|
|
it("keeps paired-node realtime audio alive after transient input pull failures", async () => {
|
|
vi.useFakeTimers();
|
|
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;
|
|
let idlePullStarted = false;
|
|
let releaseIdlePull: (() => void) | undefined;
|
|
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") };
|
|
}
|
|
idlePullStarted = true;
|
|
await new Promise<void>((resolve) => {
|
|
releaseIdlePull = resolve;
|
|
});
|
|
return { bridgeId: "bridge-1" };
|
|
}
|
|
releaseIdlePull?.();
|
|
releaseIdlePull = undefined;
|
|
return { ok: true };
|
|
}),
|
|
},
|
|
};
|
|
let handle: Awaited<ReturnType<typeof startNodeRealtimeAudioBridge>> | undefined;
|
|
|
|
try {
|
|
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.advanceTimersByTimeAsync(250);
|
|
await vi.waitFor(() => {
|
|
expect(sendAudio).toHaveBeenCalledWith(Buffer.from([5, 4, 3]));
|
|
});
|
|
expect(bridge.close).not.toHaveBeenCalled();
|
|
const health = handle.getHealth();
|
|
expect(health.audioInputActive).toBe(true);
|
|
expect(health.lastInputBytes).toBe(3);
|
|
expect(health.consecutiveInputErrors).toBe(0);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(idlePullStarted).toBe(true);
|
|
});
|
|
await handle.stop();
|
|
} finally {
|
|
await handle?.stop();
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("stops paired-node realtime audio after repeated input pull failures", async () => {
|
|
vi.useFakeTimers();
|
|
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 };
|
|
}),
|
|
},
|
|
};
|
|
|
|
try {
|
|
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.advanceTimersByTimeAsync(1_000);
|
|
await vi.waitFor(
|
|
() => {
|
|
expect(bridge.close).toHaveBeenCalled();
|
|
},
|
|
{ timeout: 3_000 },
|
|
);
|
|
const health = handle.getHealth();
|
|
expect(health.bridgeClosed).toBe(true);
|
|
expect(health.consecutiveInputErrors).toBe(5);
|
|
expect(health.lastInputError).toBe("node invoke timeout");
|
|
const stopCall = runtime.nodes.invoke.mock.calls
|
|
.map(([call]) => call)
|
|
.find((call) => isRecord(call.params) && call.params.action === "stop");
|
|
const stop = requireRecord(stopCall, "failed pull stop call");
|
|
expect(stop.nodeId).toBe("node-1");
|
|
expect(stop.command).toBe("googlemeet.chrome");
|
|
expect(stop.params).toStrictEqual({ action: "stop", bridgeId: "bridge-1" });
|
|
expect(stop.timeoutMs).toBe(5_000);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|