mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-15 07:04:01 -06:00
fa03d9b913
* refactor: consolidate coercion helpers * fix: remove duplicate coercion imports * fix: preserve serialized coercion guard * chore: ratchet coercion helper carve-outs * fix(test): keep gauntlet subprocess startup lean * fix: preserve imported session timestamp semantics * fix: preserve catalog timestamp string semantics * chore: align plugin SDK surface ratchet * fix: preserve trajectory and SDK string contracts * fix(test): preserve QA record assertion semantics * fix: complete standalone record guard rename * refactor(cron): use canonical string coercion * fix(acpx): preserve Pi timestamp parsing * test(channels): adapt custody test harnesses * test(telegram): classify media harness as test support * test(acpx): split timestamp contract coverage * test(channels): support generated custody contracts * chore: ban the full coercion helper name set Extends the declaration guard to all eleven consolidated helper names and renames the cron schedule-identity readNumber wrapper to readScheduleInteger so the banned generic name cannot regrow. * fix(scripts): repair release-validation guard drift and lint cause Restores the renamed isJsonRecord guard in assertTrustedWorkflowHarness after main added isRecord call sites in parallel, and attaches the caught YAML error as the thrown error cause (preserve-caught-error was red on main). * fix: preserve Claude timestamp string semantics * fix: preserve persisted timestamp string semantics * fix: preserve date-first timestamp contracts * fix(openai): harden delegation failure formatting * chore: close coercion helper guard gaps * test(openai): model non-error delegation rejection * chore: refresh plugin SDK API contract * fix(tasks): use canonical string field reader * fix(ai): use canonical provider error field coercion * fix(browser): migrate native bootstrap coercion * docs(plugin-sdk): clarify text record export compatibility * fix(gateway): normalize approval execution identity * test(outbound): isolate message action poll harness
7470 lines
256 KiB
TypeScript
7470 lines
256 KiB
TypeScript
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 {
|
|
convertMeetingTtsAudioForBridge,
|
|
createLocalMeetingRealtimeAudioTransport,
|
|
createMeetingRealtimeEngineBindings,
|
|
createNodeMeetingRealtimeAudioTransport,
|
|
startMeetingAgentRealtimeEngine,
|
|
startMeetingRealtimeEngine,
|
|
type MeetingRealtimeAudioTransport,
|
|
} from "openclaw/plugin-sdk/meeting-runtime";
|
|
import type { RealtimeTranscriptionProviderPlugin } from "openclaw/plugin-sdk/realtime-transcription";
|
|
import type {
|
|
RealtimeVoiceBridge,
|
|
RealtimeVoiceProviderPlugin,
|
|
} from "openclaw/plugin-sdk/realtime-voice";
|
|
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
// Google Meet tests cover index plugin behavior.
|
|
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
|
|
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import plugin, { testing as googleMeetPluginTesting } from "./index.js";
|
|
import { findGoogleMeetCalendarEvent, listGoogleMeetCalendarEvents } from "./src/calendar.js";
|
|
import { resolveGoogleMeetConfig, type GoogleMeetConfig } from "./src/config.js";
|
|
import { normalizeMeetUrl } from "./src/meet-url.js";
|
|
import {
|
|
buildGoogleMeetPreflightReport,
|
|
createGoogleMeetSpace,
|
|
fetchGoogleMeetArtifacts,
|
|
fetchGoogleMeetAttendance,
|
|
fetchLatestGoogleMeetConferenceRecord,
|
|
fetchGoogleMeetSpace,
|
|
} from "./src/meet.js";
|
|
import { handleGoogleMeetNodeHostCommand } from "./src/node-host.js";
|
|
import {
|
|
meetAudioBridge,
|
|
meetBrowserState,
|
|
meetRuntime,
|
|
meetSession,
|
|
MEET_URL,
|
|
MEET_URL_EN,
|
|
stubMeetArtifactsApi,
|
|
testBridgeProcess,
|
|
} from "./src/test-support/fixtures.test-helpers.js";
|
|
import {
|
|
getMeetTool,
|
|
invokeGoogleMeetGatewayMethodForTest,
|
|
noopLogger,
|
|
setupGoogleMeetPlugin,
|
|
} from "./src/test-support/plugin-harness.js";
|
|
import * as chromeTransport from "./src/transports/chrome.js";
|
|
import { GOOGLE_MEET_PLATFORM_ADAPTER } from "./src/transports/google-meet-platform-adapter.js";
|
|
import {
|
|
buildMeetDtmfSequence,
|
|
normalizeDialInNumber,
|
|
prefixDtmfWait,
|
|
} from "./src/transports/twilio.js";
|
|
|
|
type GoogleMeetManifestConfigSchema = JsonSchemaObject & {
|
|
properties?: Record<string, JsonSchemaObject & { properties?: Record<string, unknown> }>;
|
|
};
|
|
|
|
const TEST_TALKBACK_DEBOUNCE_MS = 900;
|
|
const testTempDirs = new Set<string>();
|
|
|
|
function createIsolatedTestDir(prefix: string): string {
|
|
const dir = mkdtempSync(path.join(tmpdir(), prefix));
|
|
testTempDirs.add(dir);
|
|
return dir;
|
|
}
|
|
|
|
type MeetRealtimeAudioSpawn = NonNullable<
|
|
Parameters<typeof createLocalMeetingRealtimeAudioTransport>[0]["spawn"]
|
|
>;
|
|
|
|
function createTestMeetRealtimeAudioTransport() {
|
|
let inputHandler: ((audio: Buffer) => void) | undefined;
|
|
const writeOutput = vi.fn(async () => {});
|
|
const transport: MeetingRealtimeAudioTransport = {
|
|
onFatal: vi.fn(),
|
|
startInput: vi.fn((handler) => {
|
|
inputHandler = handler;
|
|
}),
|
|
stop: vi.fn(async () => {}),
|
|
writeOutput,
|
|
clearOutput: vi.fn(async () => {}),
|
|
dispose: vi.fn(async () => {}),
|
|
};
|
|
return {
|
|
transport,
|
|
writeOutput,
|
|
deliverInput: (audio: Buffer) => {
|
|
if (!inputHandler) {
|
|
throw new Error("Expected Google Meet realtime input to be started");
|
|
}
|
|
inputHandler(audio);
|
|
},
|
|
};
|
|
}
|
|
|
|
type TestMeetVoiceBridgeRequest = Parameters<RealtimeVoiceProviderPlugin["createBridge"]>[0];
|
|
type TestMeetTranscriptionRequest = Parameters<
|
|
RealtimeTranscriptionProviderPlugin["createSession"]
|
|
>[0];
|
|
|
|
function createTestMeetVoiceProvider(
|
|
options: {
|
|
defaultModel?: string;
|
|
supportsToolResultContinuation?: true;
|
|
handleBargeIn?: RealtimeVoiceBridge["handleBargeIn"];
|
|
sendUserMessage?: RealtimeVoiceBridge["sendUserMessage"];
|
|
triggerGreeting?: RealtimeVoiceBridge["triggerGreeting"];
|
|
} = {},
|
|
) {
|
|
let request: TestMeetVoiceBridgeRequest | undefined;
|
|
const bridge = {
|
|
...(options.supportsToolResultContinuation
|
|
? { supportsToolResultContinuation: options.supportsToolResultContinuation }
|
|
: {}),
|
|
connect: vi.fn(async () => {}),
|
|
sendAudio: vi.fn(),
|
|
...(options.sendUserMessage ? { sendUserMessage: options.sendUserMessage } : {}),
|
|
setMediaTimestamp: vi.fn(),
|
|
...(options.handleBargeIn ? { handleBargeIn: options.handleBargeIn } : {}),
|
|
submitToolResult: vi.fn(),
|
|
acknowledgeMark: vi.fn(),
|
|
close: vi.fn(),
|
|
...(options.triggerGreeting ? { triggerGreeting: options.triggerGreeting } : {}),
|
|
isConnected: vi.fn(() => true),
|
|
};
|
|
const provider: RealtimeVoiceProviderPlugin = {
|
|
id: "openai",
|
|
label: "OpenAI",
|
|
...(options.defaultModel ? { defaultModel: options.defaultModel } : {}),
|
|
autoSelectOrder: 1,
|
|
resolveConfig: ({ rawConfig }) => rawConfig,
|
|
isConfigured: () => true,
|
|
createBridge: (nextRequest) => {
|
|
request = nextRequest;
|
|
return bridge;
|
|
},
|
|
};
|
|
return {
|
|
bridge,
|
|
provider,
|
|
sendAudio: bridge.sendAudio,
|
|
requireRequest: () => {
|
|
if (!request) {
|
|
throw new Error("Expected realtime bridge callbacks");
|
|
}
|
|
return request;
|
|
},
|
|
};
|
|
}
|
|
|
|
function createTestMeetTranscriptionProvider(
|
|
options: {
|
|
createSession?: RealtimeTranscriptionProviderPlugin["createSession"];
|
|
} = {},
|
|
) {
|
|
let request: TestMeetTranscriptionRequest | undefined;
|
|
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:
|
|
options.createSession ??
|
|
((nextRequest) => {
|
|
request = nextRequest;
|
|
return sttSession;
|
|
}),
|
|
};
|
|
return {
|
|
provider,
|
|
sttSession,
|
|
sendAudio: sttSession.sendAudio,
|
|
requireRequest: () => {
|
|
if (!request) {
|
|
throw new Error("Expected transcription session callbacks");
|
|
}
|
|
return request;
|
|
},
|
|
};
|
|
}
|
|
|
|
function createGoogleMeetTestEngineBindings(params: {
|
|
config: Parameters<typeof createMeetingRealtimeEngineBindings>[0]["config"];
|
|
fullConfig: Parameters<typeof createMeetingRealtimeEngineBindings>[0]["fullConfig"];
|
|
runtime: Parameters<typeof createMeetingRealtimeEngineBindings>[0]["runtime"];
|
|
logger: Parameters<typeof createMeetingRealtimeEngineBindings>[0]["logger"];
|
|
}) {
|
|
return createMeetingRealtimeEngineBindings({
|
|
platform: GOOGLE_MEET_PLATFORM_ADAPTER,
|
|
...params,
|
|
});
|
|
}
|
|
|
|
type TestLocalAgentEngineParams = Omit<
|
|
Parameters<typeof startMeetingAgentRealtimeEngine>[0],
|
|
"config" | "consultAgent" | "platform" | "transport"
|
|
> & {
|
|
config: GoogleMeetConfig;
|
|
inputCommand: string[];
|
|
outputCommand: string[];
|
|
spawn?: MeetRealtimeAudioSpawn;
|
|
};
|
|
|
|
async function startTestLocalAgentAudioBridge(params: TestLocalAgentEngineParams) {
|
|
const { inputCommand, outputCommand, spawn, ...engineParams } = params;
|
|
const transport = createLocalMeetingRealtimeAudioTransport({
|
|
inputCommand,
|
|
outputCommand,
|
|
bargeInInputCommand: params.config.chrome.bargeInInputCommand,
|
|
bargeInRmsThreshold: params.config.chrome.bargeInRmsThreshold,
|
|
bargeInPeakThreshold: params.config.chrome.bargeInPeakThreshold,
|
|
bargeInCooldownMs: params.config.chrome.bargeInCooldownMs,
|
|
logger: params.logger,
|
|
logScope: "[google-meet]",
|
|
spawn,
|
|
});
|
|
return await startMeetingAgentRealtimeEngine({
|
|
...engineParams,
|
|
...createGoogleMeetTestEngineBindings(engineParams),
|
|
transport,
|
|
});
|
|
}
|
|
|
|
type TestLocalRealtimeEngineParams = Omit<
|
|
Parameters<typeof startMeetingRealtimeEngine>[0],
|
|
"config" | "consultAgent" | "handleToolCall" | "platform" | "tools" | "transport"
|
|
> & {
|
|
config: GoogleMeetConfig;
|
|
inputCommand: string[];
|
|
outputCommand: string[];
|
|
spawn?: MeetRealtimeAudioSpawn;
|
|
};
|
|
|
|
async function startTestLocalRealtimeAudioBridge(params: TestLocalRealtimeEngineParams) {
|
|
const { inputCommand, outputCommand, spawn, ...engineParams } = params;
|
|
const transport = createLocalMeetingRealtimeAudioTransport({
|
|
inputCommand,
|
|
outputCommand,
|
|
bargeInInputCommand: params.config.chrome.bargeInInputCommand,
|
|
bargeInRmsThreshold: params.config.chrome.bargeInRmsThreshold,
|
|
bargeInPeakThreshold: params.config.chrome.bargeInPeakThreshold,
|
|
bargeInCooldownMs: params.config.chrome.bargeInCooldownMs,
|
|
logger: params.logger,
|
|
logScope: "[google-meet]",
|
|
spawn,
|
|
});
|
|
return await startMeetingRealtimeEngine({
|
|
...engineParams,
|
|
...createGoogleMeetTestEngineBindings(engineParams),
|
|
transport,
|
|
});
|
|
}
|
|
|
|
type TestNodeRealtimeEngineParams = Omit<
|
|
Parameters<typeof startMeetingRealtimeEngine>[0],
|
|
"config" | "consultAgent" | "handleToolCall" | "platform" | "tools" | "transport"
|
|
> & {
|
|
config: GoogleMeetConfig;
|
|
nodeId: string;
|
|
bridgeId: string;
|
|
};
|
|
|
|
async function startTestNodeRealtimeAudioBridge(params: TestNodeRealtimeEngineParams) {
|
|
const { nodeId, bridgeId, ...engineParams } = params;
|
|
const transport = createNodeMeetingRealtimeAudioTransport({
|
|
runtime: params.runtime,
|
|
nodeId,
|
|
bridgeId,
|
|
logger: params.logger,
|
|
commandName: "googlemeet.chrome",
|
|
logScope: "[google-meet]",
|
|
logPrefix: "node",
|
|
});
|
|
Reflect.set(transport, Symbol.for("openclaw.internal.meeting-node-output-generation.v1"), true);
|
|
return {
|
|
type: "node-command-pair" as const,
|
|
nodeId,
|
|
bridgeId,
|
|
...(await startMeetingRealtimeEngine({
|
|
...engineParams,
|
|
...createGoogleMeetTestEngineBindings(engineParams),
|
|
logPrefix: "node",
|
|
talkSessionId: `google-meet:${params.meetingSessionId}:${bridgeId}:node-realtime`,
|
|
talkContext: { nodeId, bridgeId },
|
|
transport,
|
|
})),
|
|
};
|
|
}
|
|
|
|
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; state?: string; endedAt?: number; endReason?: 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,
|
|
}));
|
|
|
|
let localBrowserGatewayRequestHandler: NonNullable<
|
|
Parameters<typeof setupGoogleMeetPlugin>[2]
|
|
>["gatewayRequestHandler"];
|
|
|
|
function setup(
|
|
config?: Parameters<typeof setupGoogleMeetPlugin>[1],
|
|
options?: Parameters<typeof setupGoogleMeetPlugin>[2],
|
|
) {
|
|
const harness = setupGoogleMeetPlugin(plugin, config, {
|
|
...options,
|
|
...(localBrowserGatewayRequestHandler
|
|
? {
|
|
gatewayAvailable: true,
|
|
gatewayRequestHandler: localBrowserGatewayRequestHandler,
|
|
}
|
|
: {}),
|
|
});
|
|
googleMeetPluginTesting.setCallGatewayFromCliForTests(
|
|
async (method, _opts, params) =>
|
|
(await invokeGoogleMeetGatewayMethodForTest(harness.methods, method, params)) as Record<
|
|
string,
|
|
unknown
|
|
>,
|
|
);
|
|
googleMeetPluginTesting.setPlatformForTests(() => options?.registerPlatform ?? "darwin");
|
|
return harness;
|
|
}
|
|
|
|
type GoogleMeetSetupOptions = NonNullable<Parameters<typeof setupGoogleMeetPlugin>[2]>;
|
|
type NodeInvokeHandler = NonNullable<GoogleMeetSetupOptions["nodesInvokeHandler"]>;
|
|
type NodeBrowserRequest = {
|
|
path?: string;
|
|
body?: { targetId?: string; url?: string };
|
|
};
|
|
type NodeBrowserTab = {
|
|
targetId: string;
|
|
title: string;
|
|
url: string;
|
|
};
|
|
|
|
function createNodeBrowserScenario(params: {
|
|
tabs: NodeBrowserTab[] | (() => NodeBrowserTab[]);
|
|
targetId?: string;
|
|
open?: (request: NodeBrowserRequest) => NodeBrowserTab;
|
|
focus?: boolean;
|
|
grantPermissions?: boolean;
|
|
navigate?: (request: NodeBrowserRequest) => Record<string, unknown>;
|
|
inspect?: (request: NodeBrowserRequest) => Record<string, unknown>;
|
|
nodeCommand?: (command: string, request: NodeBrowserRequest) => unknown;
|
|
}): NodeInvokeHandler {
|
|
return async ({ command, params: rawParams }) => {
|
|
const request = rawParams as NodeBrowserRequest;
|
|
if (command !== "browser.proxy") {
|
|
if (!params.nodeCommand) {
|
|
throw new Error(`unexpected command ${command}`);
|
|
}
|
|
return params.nodeCommand(command, request);
|
|
}
|
|
if (request.path === "/tabs") {
|
|
const tabs = typeof params.tabs === "function" ? params.tabs() : params.tabs;
|
|
return { payload: { result: { running: true, tabs } } };
|
|
}
|
|
if (request.path === "/tabs/open" && params.open) {
|
|
return { payload: { result: params.open(request) } };
|
|
}
|
|
if (request.path === "/tabs/focus" && params.focus) {
|
|
return { payload: { result: { ok: true } } };
|
|
}
|
|
if (request.path === "/permissions/grant" && params.grantPermissions) {
|
|
return { payload: { result: { ok: true } } };
|
|
}
|
|
if (request.path === "/navigate" && params.navigate) {
|
|
return { payload: { result: params.navigate(request) } };
|
|
}
|
|
if (request.path === "/act" && params.inspect) {
|
|
return {
|
|
payload: {
|
|
result: {
|
|
ok: true,
|
|
targetId: request.body?.targetId ?? params.targetId,
|
|
result: JSON.stringify(params.inspect(request)),
|
|
},
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`unexpected browser proxy path ${request.path}`);
|
|
};
|
|
}
|
|
|
|
async function runGoogleMeetJoinScenario(
|
|
config?: Parameters<typeof setup>[0],
|
|
options?: Parameters<typeof setup>[1],
|
|
params: Record<string, unknown> = { url: MEET_URL },
|
|
) {
|
|
const harness = setup(config, options);
|
|
const payload = requireRecord(
|
|
await invokeGoogleMeetGatewayMethodForTest(harness.methods, "googlemeet.join", params),
|
|
"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");
|
|
return { ...harness, payload, session, chrome, health };
|
|
}
|
|
|
|
function matchingNodeInvocations(
|
|
nodesInvoke: { mock: { calls: unknown[][] } },
|
|
match: { command?: string; action?: string; path?: string },
|
|
): Record<string, unknown>[] {
|
|
return nodesInvoke.mock.calls
|
|
.map(([rawCall]) => requireRecord(rawCall, "node invoke"))
|
|
.filter((call) => {
|
|
const params = requireRecord(call.params, "node invoke params");
|
|
return (
|
|
(match.command === undefined || call.command === match.command) &&
|
|
(match.action === undefined || params.action === match.action) &&
|
|
(match.path === undefined || params.path === match.path)
|
|
);
|
|
});
|
|
}
|
|
|
|
type ChromeMeetLaunchResult = Awaited<ReturnType<typeof chromeTransport.launchChromeMeet>>;
|
|
type ChromeMeetLeaveResult = Awaited<ReturnType<typeof chromeTransport.leaveChromeMeet>>;
|
|
|
|
function mockChromeMeetLifecycle(params: {
|
|
launches: Array<ChromeMeetLaunchResult | Error>;
|
|
leaveResults?: ChromeMeetLeaveResult[];
|
|
watchLeave?: boolean;
|
|
}) {
|
|
const launch = vi.spyOn(chromeTransport, "launchChromeMeet");
|
|
for (const result of params.launches) {
|
|
if (result instanceof Error) {
|
|
launch.mockRejectedValueOnce(result);
|
|
} else {
|
|
launch.mockResolvedValueOnce(result);
|
|
}
|
|
}
|
|
const leave =
|
|
params.watchLeave || params.leaveResults
|
|
? vi.spyOn(chromeTransport, "leaveChromeMeet")
|
|
: undefined;
|
|
for (const result of params.leaveResults ?? []) {
|
|
leave?.mockResolvedValueOnce(result);
|
|
}
|
|
return { launch, leave };
|
|
}
|
|
|
|
function createChromeLifecycleRuntime(config: Record<string, unknown> = {}) {
|
|
return meetRuntime(
|
|
{
|
|
defaultTransport: "chrome",
|
|
defaultMode: "agent",
|
|
realtime: { introMessage: "" },
|
|
...config,
|
|
},
|
|
noopLogger,
|
|
);
|
|
}
|
|
|
|
const GOOGLE_MEET_ENV_KEYS = [
|
|
"OPENCLAW_GOOGLE_MEET_CLIENT_ID",
|
|
"GOOGLE_MEET_CLIENT_ID",
|
|
"OPENCLAW_GOOGLE_MEET_CLIENT_SECRET",
|
|
"GOOGLE_MEET_CLIENT_SECRET",
|
|
"OPENCLAW_GOOGLE_MEET_REFRESH_TOKEN",
|
|
"GOOGLE_MEET_REFRESH_TOKEN",
|
|
"OPENCLAW_GOOGLE_MEET_ACCESS_TOKEN",
|
|
"GOOGLE_MEET_ACCESS_TOKEN",
|
|
"OPENCLAW_GOOGLE_MEET_ACCESS_TOKEN_EXPIRES_AT",
|
|
"GOOGLE_MEET_ACCESS_TOKEN_EXPIRES_AT",
|
|
"OPENCLAW_GOOGLE_MEET_DEFAULT_MEETING",
|
|
"GOOGLE_MEET_DEFAULT_MEETING",
|
|
"OPENCLAW_GOOGLE_MEET_PREVIEW_ACK",
|
|
"GOOGLE_MEET_PREVIEW_ACK",
|
|
] as const;
|
|
|
|
function resolveGoogleMeetConfigFromTestEnv(env: Record<string, string>) {
|
|
for (const key of GOOGLE_MEET_ENV_KEYS) {
|
|
vi.stubEnv(key, undefined);
|
|
}
|
|
for (const [key, value] of Object.entries(env)) {
|
|
vi.stubEnv(key, value);
|
|
}
|
|
return resolveGoogleMeetConfig({});
|
|
}
|
|
|
|
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>;
|
|
}
|
|
|
|
type MockSessionEntry = {
|
|
sessionId?: string;
|
|
updatedAt?: number;
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
function createMockSessionRuntime(sessionStore: Record<string, unknown>) {
|
|
const sessionRoot = createIsolatedTestDir("openclaw-google-meet-session-");
|
|
return {
|
|
resolveStorePath: vi.fn(() => path.join(sessionRoot, "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(() => path.join(sessionRoot, "session.json")),
|
|
};
|
|
}
|
|
|
|
const requireRecord = createRequireRecord("record", "expected-label-object-capitalized");
|
|
|
|
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 withPlatform<T>(platform: NodeJS.Platform, fn: () => Promise<T>): Promise<T>;
|
|
function withPlatform<T>(platform: NodeJS.Platform, fn: () => T): T;
|
|
function withPlatform<T>(platform: NodeJS.Platform, fn: () => T | Promise<T>): T | Promise<T> {
|
|
const originalPlatform = process.platform;
|
|
const restore = () => Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
Object.defineProperty(process, "platform", { value: platform });
|
|
try {
|
|
const result = fn();
|
|
if (result instanceof Promise) {
|
|
return result.finally(restore);
|
|
}
|
|
restore();
|
|
return result;
|
|
} catch (error) {
|
|
restore();
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
type TwilioSetupCredentials = {
|
|
accountSid: string;
|
|
authToken: string;
|
|
fromNumber: string;
|
|
};
|
|
|
|
type TwilioVoiceCallEntry = {
|
|
enabled: boolean;
|
|
config?: {
|
|
provider?: string;
|
|
publicUrl?: string;
|
|
fromNumber?: string;
|
|
twilio?: { accountSid?: string; authToken?: string };
|
|
};
|
|
};
|
|
|
|
async function runTwilioSetupStatus(params: {
|
|
env?: TwilioSetupCredentials;
|
|
googleMeetConfig?: NonNullable<Parameters<typeof setup>[0]>;
|
|
includeVoiceCallInAllowlist?: boolean;
|
|
voiceCallEntry?: TwilioVoiceCallEntry | null;
|
|
request?: Record<string, unknown>;
|
|
}) {
|
|
const env = params.env ?? {
|
|
accountSid: "AC123",
|
|
authToken: "secret",
|
|
fromNumber: "+15550001234",
|
|
};
|
|
vi.stubEnv("TWILIO_ACCOUNT_SID", env.accountSid);
|
|
vi.stubEnv("TWILIO_AUTH_TOKEN", env.authToken);
|
|
vi.stubEnv("TWILIO_FROM_NUMBER", env.fromNumber);
|
|
const voiceCallEntry =
|
|
params.voiceCallEntry === undefined
|
|
? {
|
|
enabled: true,
|
|
config: {
|
|
provider: "twilio",
|
|
publicUrl: "https://voice.example.com/voice/webhook",
|
|
},
|
|
}
|
|
: params.voiceCallEntry;
|
|
const { tools } = setup(params.googleMeetConfig ?? { defaultTransport: "chrome" }, {
|
|
fullConfig: {
|
|
plugins: {
|
|
allow: [
|
|
"google-meet",
|
|
...(params.includeVoiceCallInAllowlist === false ? [] : ["voice-call"]),
|
|
],
|
|
entries: voiceCallEntry ? { "voice-call": voiceCallEntry } : {},
|
|
},
|
|
},
|
|
});
|
|
return await getMeetTool({ tools }).execute("id", {
|
|
action: "setup_status",
|
|
...params.request,
|
|
});
|
|
}
|
|
|
|
async function getTwilioVoiceCallCredentialsCheck(params: {
|
|
env: TwilioSetupCredentials;
|
|
configured?: Partial<TwilioSetupCredentials>;
|
|
}): Promise<Record<string, unknown>> {
|
|
const result = await runTwilioSetupStatus({
|
|
env: params.env,
|
|
googleMeetConfig: {
|
|
defaultTransport: "chrome-node",
|
|
chromeNode: { node: "parallels-macos" },
|
|
},
|
|
voiceCallEntry: {
|
|
enabled: true,
|
|
config: {
|
|
provider: "twilio",
|
|
publicUrl: "https://voice.example.com/voice/webhook",
|
|
fromNumber: params.configured?.fromNumber,
|
|
twilio: {
|
|
accountSid: params.configured?.accountSid,
|
|
authToken: params.configured?.authToken,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
return requireSetupCheck(result.details.checks, "twilio-voice-call-credentials");
|
|
}
|
|
|
|
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>) = meetBrowserState(),
|
|
) {
|
|
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 ?? MEET_URL,
|
|
};
|
|
}
|
|
if (request.path === "/tabs/focus") {
|
|
return { ok: true };
|
|
}
|
|
if (request.path === "/navigate") {
|
|
return {
|
|
targetId: request.body?.targetId ?? "local-meet-tab",
|
|
url: request.body?.url ?? MEET_URL,
|
|
};
|
|
}
|
|
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}`);
|
|
},
|
|
);
|
|
localBrowserGatewayRequestHandler = async (method, params, requestOptions) =>
|
|
await callGatewayFromCli(method, {}, params, requestOptions);
|
|
return callGatewayFromCli;
|
|
}
|
|
|
|
function createCapturedBrowserRuntime(
|
|
request: (params: Record<string, unknown>) => Promise<unknown>,
|
|
) {
|
|
return {
|
|
gateway: {
|
|
isAvailable: async () => true,
|
|
request: async (_method: string, params: Record<string, unknown>) => await request(params),
|
|
},
|
|
system: {
|
|
runCommandWithTimeout: async () => ({ code: 0, stdout: "BlackHole 2ch", stderr: "" }),
|
|
},
|
|
} as never;
|
|
}
|
|
|
|
async function captureMeetStatusScript(params: {
|
|
autoJoin: boolean;
|
|
captionSessionId?: string;
|
|
mode: "agent" | "transcribe";
|
|
}) {
|
|
let script: string | undefined;
|
|
const baseConfig = resolveGoogleMeetConfig({});
|
|
const config = {
|
|
...baseConfig,
|
|
chrome: {
|
|
...baseConfig.chrome,
|
|
autoJoin: params.autoJoin,
|
|
reuseExistingTab: false,
|
|
waitForInCallMs: 0,
|
|
},
|
|
};
|
|
const runtime = createCapturedBrowserRuntime(async (request) => {
|
|
if (request.path === "/tabs" || request.path === "/tabs/open") {
|
|
const tab = {
|
|
targetId: "local-meet-tab",
|
|
title: "Meet",
|
|
url: MEET_URL_EN,
|
|
};
|
|
return request.path === "/tabs" ? { tabs: [tab] } : tab;
|
|
}
|
|
if (request.path === "/tabs/focus" || request.path === "/permissions/grant") {
|
|
return { ok: true };
|
|
}
|
|
if (request.path === "/act") {
|
|
script = requireRecord(request.body, "Meet status request body").fn as string;
|
|
return {
|
|
result: JSON.stringify({
|
|
manualAction: {
|
|
reason: "meet-admission-required",
|
|
message: "Waiting for admission",
|
|
},
|
|
}),
|
|
};
|
|
}
|
|
throw new Error(`unexpected browser request path ${String(request.path)}`);
|
|
});
|
|
if (params.mode === "agent") {
|
|
await chromeTransport.recoverCurrentMeetTab({
|
|
runtime,
|
|
config,
|
|
mode: "agent",
|
|
readOnly: false,
|
|
url: MEET_URL,
|
|
});
|
|
} else {
|
|
await chromeTransport.launchChromeMeet({
|
|
runtime,
|
|
config,
|
|
fullConfig: {},
|
|
meetingSessionId: params.captionSessionId ?? "session-1",
|
|
mode: params.mode,
|
|
url: MEET_URL,
|
|
logger: noopLogger,
|
|
});
|
|
}
|
|
if (!script) {
|
|
throw new Error("Google Meet status script was not sent through browser control");
|
|
}
|
|
return script;
|
|
}
|
|
|
|
async function captureMeetTranscriptScript(sessionId: string, finalize: boolean) {
|
|
let script: string | undefined;
|
|
const config = resolveGoogleMeetConfig({});
|
|
await chromeTransport.readChromeMeetTranscript({
|
|
runtime: createCapturedBrowserRuntime(async (request) => {
|
|
script = requireRecord(request.body, "Meet transcript request body").fn as string;
|
|
return {
|
|
result: JSON.stringify({
|
|
droppedLines: 0,
|
|
lines: [],
|
|
sessionMatched: true,
|
|
urlMatched: true,
|
|
}),
|
|
};
|
|
}),
|
|
config,
|
|
finalize,
|
|
meetingUrl: MEET_URL,
|
|
meetingSessionId: sessionId,
|
|
tab: { targetId: "local-meet-tab", openedByPlugin: true },
|
|
});
|
|
if (!script) {
|
|
throw new Error("Google Meet transcript script was not sent through browser control");
|
|
}
|
|
return script;
|
|
}
|
|
|
|
async function captureMeetLeaveScript() {
|
|
let script: string | undefined;
|
|
const config = resolveGoogleMeetConfig({});
|
|
await chromeTransport.leaveChromeMeet({
|
|
runtime: createCapturedBrowserRuntime(async (request) => {
|
|
if (request.path === "/tabs") {
|
|
return {
|
|
tabs: [
|
|
{
|
|
targetId: "local-meet-tab",
|
|
title: "Meet",
|
|
url: MEET_URL_EN,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
if (request.path === "/act") {
|
|
script = requireRecord(request.body, "Meet leave request body").fn as string;
|
|
return { result: JSON.stringify({ departed: true, urlMatched: true }) };
|
|
}
|
|
throw new Error(`unexpected browser request path ${String(request.path)}`);
|
|
}),
|
|
config,
|
|
meetingSessionId: "session-1",
|
|
meetingUrl: MEET_URL,
|
|
tab: { targetId: "local-meet-tab", openedByPlugin: false },
|
|
});
|
|
if (!script) {
|
|
throw new Error("Google Meet leave script was not sent through browser control");
|
|
}
|
|
return script;
|
|
}
|
|
|
|
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();
|
|
localBrowserGatewayRequestHandler = undefined;
|
|
googleMeetPluginTesting.setCallGatewayFromCliForTests();
|
|
googleMeetPluginTesting.setPlatformForTests();
|
|
for (const dir of testTempDirs) {
|
|
rmSync(dir, { force: true, recursive: true });
|
|
}
|
|
testTempDirs.clear();
|
|
});
|
|
|
|
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 = withPlatform("darwin", () => 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: "auto",
|
|
audioBridgeCommand: undefined,
|
|
audioBridgeHealthCommand: undefined,
|
|
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",
|
|
"-",
|
|
],
|
|
audioInputCommandOverride: undefined,
|
|
audioOutputCommand: [
|
|
"sox",
|
|
"-q",
|
|
"--buffer",
|
|
"4096",
|
|
"-t",
|
|
"raw",
|
|
"-r",
|
|
"24000",
|
|
"-c",
|
|
"1",
|
|
"-e",
|
|
"signed-integer",
|
|
"-b",
|
|
"16",
|
|
"-L",
|
|
"-",
|
|
"-t",
|
|
"coreaudio",
|
|
"BlackHole 2ch",
|
|
],
|
|
audioOutputCommandOverride: undefined,
|
|
bargeInRmsThreshold: 650,
|
|
bargeInPeakThreshold: 2500,
|
|
bargeInCooldownMs: 900,
|
|
bargeInInputCommand: undefined,
|
|
browserProfile: undefined,
|
|
});
|
|
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", async () => {
|
|
let voiceRequest: Parameters<RealtimeVoiceProviderPlugin["createBridge"]>[0] | undefined;
|
|
const voiceBridge = {
|
|
connect: vi.fn(async () => {}),
|
|
sendAudio: vi.fn(),
|
|
sendUserMessage: vi.fn(),
|
|
setMediaTimestamp: vi.fn(),
|
|
submitToolResult: vi.fn(),
|
|
acknowledgeMark: vi.fn(),
|
|
close: vi.fn(),
|
|
triggerGreeting: vi.fn(),
|
|
isConnected: vi.fn(() => true),
|
|
};
|
|
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: (request) => {
|
|
voiceRequest = request;
|
|
return voiceBridge;
|
|
},
|
|
},
|
|
];
|
|
let transcriptionRequest:
|
|
| Parameters<RealtimeTranscriptionProviderPlugin["createSession"]>[0]
|
|
| undefined;
|
|
const transcriptionSession = {
|
|
connect: vi.fn(async () => {}),
|
|
sendAudio: vi.fn(),
|
|
close: vi.fn(),
|
|
isConnected: vi.fn(() => true),
|
|
};
|
|
const transcriptionProviders: RealtimeTranscriptionProviderPlugin[] = [
|
|
{
|
|
id: "openai",
|
|
label: "OpenAI",
|
|
autoSelectOrder: 1,
|
|
isConfigured: () => true,
|
|
createSession: (request) => {
|
|
transcriptionRequest = request;
|
|
return transcriptionSession;
|
|
},
|
|
},
|
|
];
|
|
const config = resolveGoogleMeetConfig({
|
|
realtime: {
|
|
provider: "openai",
|
|
transcriptionProvider: "openai",
|
|
voiceProvider: "google",
|
|
model: "gemini-2.5-flash-native-audio-preview-12-2025",
|
|
},
|
|
});
|
|
|
|
const voiceTransport = createTestMeetRealtimeAudioTransport();
|
|
const voiceHandle = await startMeetingRealtimeEngine({
|
|
config,
|
|
fullConfig: {} as never,
|
|
runtime: {} as never,
|
|
...createGoogleMeetTestEngineBindings({
|
|
config,
|
|
fullConfig: {} as never,
|
|
runtime: {} as never,
|
|
logger: noopLogger,
|
|
}),
|
|
meetingSessionId: "provider-resolution-voice",
|
|
logger: noopLogger,
|
|
providers: voiceProviders,
|
|
transport: voiceTransport.transport,
|
|
});
|
|
expect(voiceHandle.providerId).toBe("google");
|
|
expect(voiceRequest?.providerConfig).toEqual({
|
|
model: "gemini-2.5-flash-native-audio-preview-12-2025",
|
|
});
|
|
await voiceHandle.stop();
|
|
|
|
const transcriptionTransport = createTestMeetRealtimeAudioTransport();
|
|
const transcriptionHandle = await startMeetingAgentRealtimeEngine({
|
|
config,
|
|
fullConfig: {} as never,
|
|
runtime: {} as never,
|
|
...createGoogleMeetTestEngineBindings({
|
|
config,
|
|
fullConfig: {} as never,
|
|
runtime: {} as never,
|
|
logger: noopLogger,
|
|
}),
|
|
meetingSessionId: "provider-resolution-transcription",
|
|
logger: noopLogger,
|
|
providers: transcriptionProviders,
|
|
transport: transcriptionTransport.transport,
|
|
});
|
|
expect(transcriptionHandle.providerId).toBe("openai");
|
|
expect(transcriptionRequest).toBeDefined();
|
|
await transcriptionHandle.stop();
|
|
});
|
|
|
|
it("keeps advanced config metadata manifest-owned", () => {
|
|
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: Record<string, unknown> };
|
|
|
|
expect(entry.configSchema).not.toHaveProperty("uiHints");
|
|
|
|
for (const key of [
|
|
"chrome.audioBufferBytes",
|
|
"chrome.bargeInInputCommand",
|
|
"chrome.bargeInRmsThreshold",
|
|
"chrome.bargeInPeakThreshold",
|
|
"chrome.bargeInCooldownMs",
|
|
"voiceCall.dtmfDelayMs",
|
|
"voiceCall.postDtmfSpeechDelayMs",
|
|
]) {
|
|
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: { audioBackend: "blackhole-2ch", 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: { audioBackend: "blackhole-2ch", 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 = resolveGoogleMeetConfigFromTestEnv({
|
|
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: MEET_URL,
|
|
OPENCLAW_GOOGLE_MEET_PREVIEW_ACK: "true",
|
|
});
|
|
expect(config.defaults).toEqual({ meeting: MEET_URL });
|
|
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 = resolveGoogleMeetConfigFromTestEnv({
|
|
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(MEET_URL)).toBe(MEET_URL);
|
|
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,
|
|
machineOutput: expect.any(Function),
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
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 local Chrome talk-back available on Linux and blocks unsupported hosts", () => {
|
|
const { cliRegistrations, methods, tools } = setup(undefined, { registerPlatform: "linux" });
|
|
|
|
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(false);
|
|
|
|
expect(
|
|
googleMeetPluginTesting.isGoogleMeetAgentToolActionUnsupportedOnHost({
|
|
config: resolveGoogleMeetConfig({}),
|
|
raw: { action: "join", mode: "transcribe" },
|
|
platform: "linux",
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
googleMeetPluginTesting.isGoogleMeetAgentToolActionUnsupportedOnHost({
|
|
config: resolveGoogleMeetConfig({}),
|
|
raw: { action: "join" },
|
|
platform: "win32",
|
|
}),
|
|
).toBe(true);
|
|
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 = getMeetTool({ tools });
|
|
|
|
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", async () => {
|
|
stubMeetArtifactsApi();
|
|
await expect(
|
|
fetchGoogleMeetSpace({ accessToken: "token", meeting: "spaces/abc-defg-hij" }),
|
|
).resolves.toMatchObject({ name: "spaces/abc-defg-hij" });
|
|
await expect(
|
|
fetchGoogleMeetSpace({ accessToken: "token", meeting: "abc-defg-hij" }),
|
|
).resolves.toMatchObject({ name: "spaces/abc-defg-hij" });
|
|
await expect(
|
|
fetchGoogleMeetSpace({
|
|
accessToken: "token",
|
|
meeting: MEET_URL,
|
|
}),
|
|
).resolves.toMatchObject({ name: "spaces/abc-defg-hij" });
|
|
await expect(
|
|
fetchGoogleMeetSpace({
|
|
accessToken: "token",
|
|
meeting: "https://example.com/abc-defg-hij",
|
|
}),
|
|
).rejects.toThrow("meet.google.com");
|
|
});
|
|
|
|
it("finds Google Meet links from Calendar events", async () => {
|
|
const fetchMock = stubMeetArtifactsApi();
|
|
|
|
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(MEET_URL);
|
|
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(MEET_URL);
|
|
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: MEET_URL,
|
|
}),
|
|
{ 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(MEET_URL);
|
|
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 = getMeetTool({ tools });
|
|
const result = await tool.execute("id", {
|
|
action: "join",
|
|
url: MEET_URL,
|
|
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 = getMeetTool({ tools });
|
|
|
|
await tool.execute("id", {
|
|
action: "join",
|
|
url: MEET_URL,
|
|
requesterSessionKey: "agent:main:wrong",
|
|
});
|
|
|
|
const gatewayJoinParams = requireRecord(gatewayParams[0], "gateway join params");
|
|
expect(gatewayJoinParams.url).toBe(MEET_URL);
|
|
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 = getMeetTool({ tools });
|
|
|
|
await tool.execute("id", {
|
|
action: "join",
|
|
url: MEET_URL,
|
|
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 = getMeetTool({ tools });
|
|
|
|
const result = await tool.execute("id", {
|
|
action: "join",
|
|
url: MEET_URL,
|
|
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 = getMeetTool({ tools });
|
|
|
|
await tool.execute("id", {
|
|
action: "join",
|
|
url: MEET_URL,
|
|
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 = getMeetTool({ tools });
|
|
|
|
const result = await tool.execute("id", {
|
|
action: "join",
|
|
url: MEET_URL,
|
|
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: MEET_URL,
|
|
},
|
|
},
|
|
);
|
|
const tool = getMeetTool({ tools });
|
|
|
|
const result = await tool.execute("id", {
|
|
action: "test_listen",
|
|
url: MEET_URL,
|
|
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 = getMeetTool({ tools });
|
|
|
|
const result = await tool.execute("id", {
|
|
action: "join",
|
|
url: MEET_URL,
|
|
});
|
|
|
|
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 = getMeetTool({ tools });
|
|
|
|
const result = await tool.execute("id", {
|
|
action: "join",
|
|
url: MEET_URL,
|
|
});
|
|
|
|
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: MEET_URL,
|
|
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 = getMeetTool({ tools });
|
|
|
|
const result = await tool.execute("id", {
|
|
action: "join",
|
|
url: MEET_URL,
|
|
});
|
|
|
|
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 = getMeetTool({ tools });
|
|
const joined = await tool.execute("id", {
|
|
action: "join",
|
|
url: MEET_URL,
|
|
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 = getMeetTool({ tools });
|
|
const first = await tool.execute("id", {
|
|
action: "join",
|
|
url: MEET_URL,
|
|
dialInNumber: "+15551234567",
|
|
pin: "123456",
|
|
});
|
|
const second = await tool.execute("id", {
|
|
action: "join",
|
|
url: MEET_URL,
|
|
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.each([
|
|
{
|
|
name: "the authoritative end timestamp",
|
|
call: { callId: "call-1", state: "completed", endedAt: 1_780_000_000_000 },
|
|
},
|
|
{
|
|
name: "the authoritative end reason",
|
|
call: { callId: "call-1", state: "completed", endReason: "completed" },
|
|
},
|
|
])("redials a persisted completed Twilio call identified by $name", async ({ call }) => {
|
|
voiceCallMocks.getMeetVoiceCallGatewayCall.mockResolvedValueOnce({ found: true, call });
|
|
const { tools } = setup({ defaultTransport: "twilio" });
|
|
const tool = getMeetTool({ tools });
|
|
const request = {
|
|
action: "join" as const,
|
|
url: MEET_URL,
|
|
dialInNumber: "+15551234567",
|
|
pin: "123456",
|
|
};
|
|
|
|
const first = await tool.execute("first", request);
|
|
const second = await tool.execute("second", request);
|
|
|
|
expect(voiceCallMocks.joinMeetViaVoiceCallGateway).toHaveBeenCalledTimes(2);
|
|
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);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "an active delegated call",
|
|
result: { found: true, call: { callId: "call-1", state: "active" } },
|
|
},
|
|
{
|
|
name: "a terminal-looking state without an authoritative terminal fact",
|
|
result: { found: true, call: { callId: "call-1", state: "completed" } },
|
|
},
|
|
{
|
|
name: "an unknown call record",
|
|
result: { found: true },
|
|
},
|
|
])("reuses the active Meet session for $name", async ({ result }) => {
|
|
voiceCallMocks.getMeetVoiceCallGatewayCall.mockResolvedValueOnce(result);
|
|
const { tools } = setup({ defaultTransport: "twilio" });
|
|
const tool = getMeetTool({ tools });
|
|
const request = {
|
|
action: "join" as const,
|
|
url: MEET_URL,
|
|
dialInNumber: "+15551234567",
|
|
pin: "123456",
|
|
};
|
|
|
|
const first = await tool.execute("first", request);
|
|
const second = await tool.execute("second", request);
|
|
|
|
expect(second.details.session.id).toBe(first.details.session.id);
|
|
expect(first.details.session.state).toBe("active");
|
|
expect(voiceCallMocks.joinMeetViaVoiceCallGateway).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("reuses the active Meet session when delegated call status temporarily rejects", async () => {
|
|
voiceCallMocks.getMeetVoiceCallGatewayCall.mockRejectedValueOnce(
|
|
new Error("temporary voice gateway failure"),
|
|
);
|
|
const { tools } = setup({ defaultTransport: "twilio" });
|
|
const tool = getMeetTool({ tools });
|
|
const request = {
|
|
action: "join" as const,
|
|
url: MEET_URL,
|
|
dialInNumber: "+15551234567",
|
|
pin: "123456",
|
|
};
|
|
|
|
const first = await tool.execute("first", request);
|
|
const second = await tool.execute("second", request);
|
|
|
|
expect(second.details.session.id).toBe(first.details.session.id);
|
|
expect(first.details.session.state).toBe("active");
|
|
expect(voiceCallMocks.joinMeetViaVoiceCallGateway).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("serializes concurrent identical Twilio joins", async () => {
|
|
let finishDial:
|
|
| ((result: { callId: string; dtmfSent: boolean; introSent: boolean }) => void)
|
|
| undefined;
|
|
const dialing = new Promise<{ callId: string; dtmfSent: boolean; introSent: boolean }>(
|
|
(resolve) => {
|
|
finishDial = resolve;
|
|
},
|
|
);
|
|
voiceCallMocks.joinMeetViaVoiceCallGateway.mockReturnValueOnce(dialing);
|
|
const runtime = meetRuntime({ defaultTransport: "twilio" }, noopLogger);
|
|
const request = {
|
|
url: MEET_URL,
|
|
dialInNumber: "+15551234567",
|
|
pin: "123456",
|
|
};
|
|
|
|
const firstJoin = runtime.join(request);
|
|
await vi.waitFor(() => {
|
|
expect(voiceCallMocks.joinMeetViaVoiceCallGateway).toHaveBeenCalledOnce();
|
|
});
|
|
const secondJoin = runtime.join(request);
|
|
await Promise.resolve();
|
|
expect(voiceCallMocks.joinMeetViaVoiceCallGateway).toHaveBeenCalledOnce();
|
|
|
|
finishDial?.({ callId: "call-1", dtmfSent: true, introSent: true });
|
|
const [first, second] = await Promise.all([firstJoin, secondJoin]);
|
|
|
|
expect(second.session.id).toBe(first.session.id);
|
|
expect(voiceCallMocks.joinMeetViaVoiceCallGateway).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("delegates Twilio session speech through voice-call", async () => {
|
|
const { tools } = setup({ defaultTransport: "twilio" });
|
|
const tool = getMeetTool({ tools });
|
|
const joined = await tool.execute("id", {
|
|
action: "join",
|
|
url: MEET_URL,
|
|
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("rejects ended-session speech before invoking voice-call", async () => {
|
|
const { tools } = setup({ defaultTransport: "twilio" });
|
|
const tool = getMeetTool({ tools });
|
|
const joined = await tool.execute("id", {
|
|
action: "join",
|
|
url: MEET_URL,
|
|
dialInNumber: "+15551234567",
|
|
});
|
|
const sessionId = requireRecord(joined.details.session, "joined Twilio session").id;
|
|
await tool.execute("id", { action: "leave", sessionId });
|
|
voiceCallMocks.speakMeetViaVoiceCallGateway.mockClear();
|
|
|
|
const spoken = await tool.execute("id", {
|
|
action: "speak",
|
|
sessionId,
|
|
message: "Do not send this.",
|
|
});
|
|
|
|
expect(spoken.details.found).toBe(true);
|
|
expect(spoken.details.spoken).toBe(false);
|
|
expect(voiceCallMocks.speakMeetViaVoiceCallGateway).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reports setup status through the tool", async () => {
|
|
await withPlatform("darwin", async () => {
|
|
const { tools } = setup({
|
|
chrome: {
|
|
audioInputCommand: ["openclaw-audio-bridge", "capture"],
|
|
audioOutputCommand: ["openclaw-audio-bridge", "play"],
|
|
},
|
|
});
|
|
const tool = getMeetTool({ tools });
|
|
|
|
const result = await tool.execute("id", { action: "setup_status" });
|
|
|
|
expect(result.details.ok).toBe(true);
|
|
});
|
|
});
|
|
|
|
it("rejects agent-mode external audio bridges in setup status", async () => {
|
|
await withPlatform("darwin", async () => {
|
|
const { tools } = setup(
|
|
{
|
|
defaultMode: "agent",
|
|
defaultTransport: "chrome",
|
|
chrome: {
|
|
audioBridgeCommand: ["bridge", "start"],
|
|
audioInputCommand: ["capture-meet"],
|
|
audioOutputCommand: ["play-meet"],
|
|
},
|
|
},
|
|
{
|
|
runCommandWithTimeoutHandler: async (argv) => {
|
|
if (argv[0]?.endsWith("system_profiler")) {
|
|
return { code: 0, stdout: "BlackHole 2ch", stderr: "" };
|
|
}
|
|
return { code: 0, stdout: "", stderr: "" };
|
|
},
|
|
},
|
|
);
|
|
const tool = getMeetTool({ tools });
|
|
|
|
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");
|
|
});
|
|
});
|
|
|
|
it("reports attendance through the tool", async () => {
|
|
stubMeetArtifactsApi();
|
|
const { tools } = setup();
|
|
const tool = getMeetTool({ tools });
|
|
|
|
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 = getMeetTool({ tools });
|
|
|
|
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 = getMeetTool({ tools });
|
|
|
|
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 = getMeetTool({ tools });
|
|
|
|
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 = getMeetTool({ tools });
|
|
|
|
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 = getMeetTool({ tools });
|
|
|
|
const result = await tool.execute("id", {
|
|
action: "latest",
|
|
accessToken: "token",
|
|
expiresAt: Date.now() + 120_000,
|
|
today: true,
|
|
});
|
|
|
|
expect(result.details.calendarEvent?.meetingUri).toBe(MEET_URL);
|
|
});
|
|
|
|
it("reports calendar event previews through the tool", async () => {
|
|
stubMeetArtifactsApi();
|
|
const { tools } = setup();
|
|
const tool = getMeetTool({ tools });
|
|
|
|
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(MEET_URL);
|
|
});
|
|
|
|
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 = getMeetTool({ tools });
|
|
|
|
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.each([
|
|
{
|
|
config: { defaultTransport: "chrome" },
|
|
failingCommand: "system_profiler",
|
|
expectedCheckId: "chrome-local-audio-device",
|
|
expectedMessage: "BlackHole 2ch audio device not found",
|
|
},
|
|
{
|
|
config: { defaultTransport: "chrome" },
|
|
failingCommand: "sox",
|
|
expectedCheckId: "chrome-local-audio-commands",
|
|
expectedMessage: "Chrome audio command missing: sox",
|
|
},
|
|
{
|
|
config: {
|
|
defaultTransport: "chrome",
|
|
chrome: { bargeInInputCommand: ["missing-barge-capture"] },
|
|
},
|
|
failingCommand: "missing-barge-capture",
|
|
expectedCheckId: "chrome-local-audio-commands",
|
|
expectedMessage: "Chrome audio command missing: missing-barge-capture",
|
|
},
|
|
] satisfies Array<{
|
|
config: NonNullable<Parameters<typeof setup>[0]>;
|
|
failingCommand: string;
|
|
expectedCheckId: string;
|
|
expectedMessage: string;
|
|
}>)(
|
|
"reports $expectedMessage",
|
|
async ({ config, failingCommand, expectedCheckId, expectedMessage }) => {
|
|
await withPlatform("darwin", async () => {
|
|
const { tools } = setup(config, {
|
|
runCommandWithTimeoutHandler: async (argv) => {
|
|
if (argv[0]?.endsWith("system_profiler")) {
|
|
const stdout =
|
|
failingCommand === "system_profiler" ? "Built-in Output" : "BlackHole 2ch";
|
|
return { code: 0, stdout, stderr: "" };
|
|
}
|
|
if (argv[0] === "/bin/sh" && argv.at(-1) === failingCommand) {
|
|
return { code: 1, stdout: "", stderr: "" };
|
|
}
|
|
return { code: 0, stdout: "", stderr: "" };
|
|
},
|
|
});
|
|
const tool = getMeetTool({ tools });
|
|
|
|
const result = await tool.execute("id", { action: "setup_status", transport: "chrome" });
|
|
|
|
expect(result.details.ok).toBe(false);
|
|
const check = requireSetupCheck(result.details.checks, expectedCheckId);
|
|
expect(check.ok).toBe(false);
|
|
if (expectedCheckId === "chrome-local-audio-device") {
|
|
expect(check.message).toContain(expectedMessage);
|
|
} else {
|
|
expect(check.message).toBe(expectedMessage);
|
|
}
|
|
});
|
|
},
|
|
);
|
|
|
|
it("skips local Chrome audio prerequisites for observe-only setup status", async () => {
|
|
await withPlatform("darwin", async () => {
|
|
const { tools, runCommandWithTimeout } = setup(
|
|
{ defaultMode: "transcribe", defaultTransport: "chrome" },
|
|
{
|
|
runCommandWithTimeoutHandler: async () => ({
|
|
code: 1,
|
|
stdout: "Built-in Output",
|
|
stderr: "",
|
|
}),
|
|
},
|
|
);
|
|
const tool = getMeetTool({ tools });
|
|
|
|
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();
|
|
});
|
|
});
|
|
|
|
it("reports Twilio delegation readiness when voice-call is enabled", async () => {
|
|
const result = await runTwilioSetupStatus({
|
|
googleMeetConfig: {
|
|
defaultTransport: "chrome-node",
|
|
chromeNode: { node: "parallels-macos" },
|
|
},
|
|
});
|
|
|
|
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.each([
|
|
{
|
|
label: "environment account SID",
|
|
env: { accountSid: " ", authToken: "test-auth-token", fromNumber: "+15550001234" },
|
|
},
|
|
{
|
|
label: "environment auth token",
|
|
env: { accountSid: "AC123", authToken: " ", fromNumber: "+15550001234" },
|
|
},
|
|
{
|
|
label: "environment from number",
|
|
env: { accountSid: "AC123", authToken: "test-auth-token", fromNumber: " " },
|
|
},
|
|
{
|
|
label: "configured account SID",
|
|
env: { accountSid: "", authToken: "", fromNumber: "" },
|
|
configured: { accountSid: " ", authToken: "test-auth-token", fromNumber: "+15550001234" },
|
|
},
|
|
{
|
|
label: "configured auth token",
|
|
env: { accountSid: "", authToken: "", fromNumber: "" },
|
|
configured: { accountSid: "AC123", authToken: " ", fromNumber: "+15550001234" },
|
|
},
|
|
{
|
|
label: "configured from number",
|
|
env: { accountSid: "", authToken: "", fromNumber: "" },
|
|
configured: { accountSid: "AC123", authToken: "test-auth-token", fromNumber: " " },
|
|
},
|
|
])("reports a blank $label as missing", async ({ env, configured }) => {
|
|
const check = await getTwilioVoiceCallCredentialsCheck({ env, configured });
|
|
|
|
expect(check.ok).toBe(false);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
label: "environment",
|
|
env: {
|
|
accountSid: " AC123 ",
|
|
authToken: " test-auth-token ",
|
|
fromNumber: " +15550001234 ",
|
|
},
|
|
},
|
|
{
|
|
label: "configuration",
|
|
env: { accountSid: "", authToken: "", fromNumber: "" },
|
|
configured: {
|
|
accountSid: " AC123 ",
|
|
authToken: " test-auth-token ",
|
|
fromNumber: " +15550001234 ",
|
|
},
|
|
},
|
|
])("accepts padded Twilio credentials from $label", async ({ env, configured }) => {
|
|
const check = await getTwilioVoiceCallCredentialsCheck({ env, configured });
|
|
|
|
expect(check.ok).toBe(true);
|
|
});
|
|
|
|
it("reports missing voice-call wiring for explicit Twilio transport", async () => {
|
|
const result = await runTwilioSetupStatus({
|
|
env: { accountSid: "", authToken: "", fromNumber: "" },
|
|
includeVoiceCallInAllowlist: false,
|
|
voiceCallEntry: { enabled: false },
|
|
request: { 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 () => {
|
|
const result = await runTwilioSetupStatus({
|
|
voiceCallEntry: null,
|
|
request: { 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 () => {
|
|
const result = await runTwilioSetupStatus({ request: { 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 () => {
|
|
const result = await runTwilioSetupStatus({
|
|
request: { 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) => {
|
|
const result = await runTwilioSetupStatus({
|
|
googleMeetConfig: { defaultTransport: "twilio" },
|
|
voiceCallEntry: {
|
|
enabled: true,
|
|
config: { provider: "twilio", publicUrl },
|
|
},
|
|
});
|
|
|
|
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 () => {
|
|
await withPlatform("darwin", async () => {
|
|
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: MEET_URL,
|
|
});
|
|
const { methods, runCommandWithTimeout } = setup({
|
|
defaultMode: "transcribe",
|
|
chrome: {
|
|
browserProfile: "meet-devtools",
|
|
},
|
|
});
|
|
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: MEET_URL },
|
|
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: MEET_URL_EN },
|
|
});
|
|
expect(openCall[3]).toEqual({ timeoutMs: 35_000, scopes: ["operator.admin"] });
|
|
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",
|
|
);
|
|
});
|
|
});
|
|
|
|
it("grants local Chrome Meet media permissions against the opened tab", async () => {
|
|
await withPlatform("darwin", async () => {
|
|
const callGatewayFromCli = mockLocalMeetBrowserRequest(meetBrowserState());
|
|
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: MEET_URL },
|
|
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({ timeoutMs: 10_000, scopes: ["operator.admin"] });
|
|
});
|
|
});
|
|
|
|
function mockLocalMeetBrowserRequestWithTabState(options?: {
|
|
reused?: boolean;
|
|
tabClosesAfterJoin?: boolean;
|
|
tabUrlAfterJoin?: string;
|
|
leaveClicked?: boolean;
|
|
leaveConfirmationRequired?: boolean;
|
|
nonFinalTranscriptGate?: Promise<void>;
|
|
onNonFinalTranscriptRead?: () => void;
|
|
shouldGateNonFinalTranscriptRead?: () => boolean;
|
|
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 ? MEET_URL_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") {
|
|
const script = String(request.body?.fn);
|
|
if (script.includes("const expectedSessionId =")) {
|
|
const finalizing = script.includes("if (true &&");
|
|
if (!finalizing && options?.shouldGateNonFinalTranscriptRead?.() === true) {
|
|
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(meetBrowserState({ micMuted: true })) };
|
|
}
|
|
throw new Error(`unexpected browser request path ${request.path}`);
|
|
},
|
|
);
|
|
localBrowserGatewayRequestHandler = async (method, params, requestOptions) =>
|
|
await callGatewayFromCli(method, {}, params, requestOptions);
|
|
return callGatewayFromCli;
|
|
}
|
|
|
|
async function withLocalChromeMeetSession<T>(
|
|
options: Parameters<typeof mockLocalMeetBrowserRequestWithTabState>[0],
|
|
run: (context: {
|
|
callGatewayFromCli: ReturnType<typeof mockLocalMeetBrowserRequestWithTabState>;
|
|
methods: ReturnType<typeof setup>["methods"];
|
|
joined: { session: { id: string; chrome?: Record<string, unknown> } };
|
|
}) => Promise<T>,
|
|
): Promise<T> {
|
|
return await withPlatform("darwin", async () => {
|
|
const callGatewayFromCli = mockLocalMeetBrowserRequestWithTabState(options);
|
|
const { methods } = setup({ defaultMode: "transcribe", defaultTransport: "chrome" });
|
|
const joined = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.join", {
|
|
url: MEET_URL,
|
|
})) as { session: { id: string; chrome?: Record<string, unknown> } };
|
|
return await run({ callGatewayFromCli, methods, joined });
|
|
});
|
|
}
|
|
|
|
it("reads and snapshots the bounded transcript from the exact tracked tab", async () => {
|
|
await withLocalChromeMeetSession(
|
|
{
|
|
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" },
|
|
],
|
|
},
|
|
},
|
|
async ({ callGatewayFromCli, methods, joined }) => {
|
|
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");
|
|
},
|
|
);
|
|
});
|
|
|
|
it("refuses to read a tracked tab after it navigates away from the meeting", async () => {
|
|
await withLocalChromeMeetSession(
|
|
{
|
|
tabUrlAfterJoin: "https://meet.google.com/lookup/unrelated",
|
|
transcript: { lines: [{ text: "must not leak" }] },
|
|
},
|
|
async ({ methods, joined }) => {
|
|
await expect(
|
|
invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.transcript", {
|
|
sessionId: joined.session.id,
|
|
}),
|
|
).rejects.toThrow("tracked Meet tab no longer shows this session's meeting URL");
|
|
},
|
|
);
|
|
});
|
|
|
|
it("keeps the cursor monotonic when the Meet page transcript epoch resets", async () => {
|
|
const transcript = { epoch: "page-1", lines: [{ text: "before reload" }] };
|
|
await withLocalChromeMeetSession({ transcript }, async ({ methods, joined }) => {
|
|
const first = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.transcript", {
|
|
sessionId: joined.session.id,
|
|
})) as { nextIndex: number; lines: Array<{ text: string }> };
|
|
transcript.epoch = "page-2";
|
|
transcript.lines = [{ text: "after reload" }];
|
|
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"]);
|
|
});
|
|
});
|
|
|
|
it("does not let a late active read replace the finalized leave snapshot", async () => {
|
|
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;
|
|
let gateNonFinalTranscriptReads = false;
|
|
await withLocalChromeMeetSession(
|
|
{
|
|
transcript: { lines: [{ text: "partial" }] },
|
|
finalTranscript: { lines: [{ text: "partial" }, { text: "complete caption" }] },
|
|
nonFinalTranscriptGate: readGate,
|
|
shouldGateNonFinalTranscriptRead: () => gateNonFinalTranscriptReads,
|
|
onNonFinalTranscriptRead: () => {
|
|
activeReads += 1;
|
|
markReadStarted?.();
|
|
},
|
|
},
|
|
async ({ callGatewayFromCli, methods, joined }) => {
|
|
gateNonFinalTranscriptReads = true;
|
|
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("const expectedSessionId =") && 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"]);
|
|
},
|
|
);
|
|
});
|
|
|
|
it("retains only the four most recently ended transcripts", async () => {
|
|
await withLocalChromeMeetSession(
|
|
{
|
|
transcript: { lines: [{ text: "retained line" }] },
|
|
},
|
|
async ({ methods, joined }) => {
|
|
const sessionIds = [joined.session.id];
|
|
await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.leave", {
|
|
sessionId: joined.session.id,
|
|
});
|
|
for (let index = 1; index < 5; index += 1) {
|
|
const nextJoin = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.join", {
|
|
url: MEET_URL,
|
|
})) as { session: { id: string } };
|
|
sessionIds.push(nextJoin.session.id);
|
|
await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.leave", {
|
|
sessionId: nextJoin.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);
|
|
},
|
|
);
|
|
});
|
|
|
|
it("leaves the Meet call in the browser when a chrome session leaves", async () => {
|
|
await withLocalChromeMeetSession(undefined, async ({ callGatewayFromCli, methods, joined }) => {
|
|
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");
|
|
});
|
|
});
|
|
|
|
it("still ends the chrome session when its Meet tab is already closed on leave", async () => {
|
|
await withLocalChromeMeetSession(
|
|
{ tabClosesAfterJoin: true },
|
|
async ({ callGatewayFromCli, methods, joined }) => {
|
|
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);
|
|
},
|
|
);
|
|
});
|
|
|
|
it("confirms host leave and keeps the reused Meet tab open", async () => {
|
|
await withLocalChromeMeetSession(
|
|
{
|
|
reused: true,
|
|
leaveConfirmationRequired: true,
|
|
},
|
|
async ({ callGatewayFromCli, methods, joined }) => {
|
|
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);
|
|
},
|
|
);
|
|
});
|
|
|
|
it("reports browserLeft false when the reused tab's Leave call click fails", async () => {
|
|
await withLocalChromeMeetSession(
|
|
{ reused: true, leaveClicked: false },
|
|
async ({ methods, joined }) => {
|
|
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.",
|
|
);
|
|
},
|
|
);
|
|
});
|
|
|
|
it("does not touch a reused tab after it moves to another meeting", async () => {
|
|
await withLocalChromeMeetSession(
|
|
{
|
|
reused: true,
|
|
tabUrlAfterJoin: "https://meet.google.com/xyz-abcd-efg?hl=en",
|
|
},
|
|
async ({ callGatewayFromCli, methods, joined }) => {
|
|
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);
|
|
},
|
|
);
|
|
});
|
|
|
|
it("does not leave the browser twice when an ended session is retried", async () => {
|
|
await withLocalChromeMeetSession(
|
|
{ reused: true },
|
|
async ({ callGatewayFromCli, methods, joined }) => {
|
|
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);
|
|
},
|
|
);
|
|
});
|
|
|
|
it("meet leave script clicks the enabled Leave call button", async () => {
|
|
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: MEET_URL_EN },
|
|
document,
|
|
});
|
|
const leaveScript = await captureMeetLeaveScript();
|
|
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 () => {
|
|
await withPlatform("darwin", async () => {
|
|
const events: string[] = [];
|
|
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 ?? MEET_URL,
|
|
};
|
|
}
|
|
if (request.path === "/tabs/focus" || request.path === "/permissions/grant") {
|
|
return { ok: true };
|
|
}
|
|
if (request.path === "/act") {
|
|
return { result: JSON.stringify(meetBrowserState()) };
|
|
}
|
|
throw new Error(`unexpected browser request path ${request.path}`);
|
|
},
|
|
);
|
|
localBrowserGatewayRequestHandler = async (method, params, requestOptions) =>
|
|
await callGatewayFromCli(method, {}, params, requestOptions);
|
|
const { methods } = setup(
|
|
{
|
|
defaultMode: "bidi",
|
|
defaultTransport: "chrome",
|
|
chrome: {
|
|
audioBridgeCommand: ["bridge", "start"],
|
|
},
|
|
realtime: { introMessage: "" },
|
|
},
|
|
{
|
|
runCommandWithTimeoutHandler: async (argv) => {
|
|
events.push(`command:${argv.join(" ")}`);
|
|
return argv[0]?.endsWith("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: MEET_URL },
|
|
respond,
|
|
});
|
|
|
|
expectRespondedOk(respond);
|
|
expect(events.indexOf("browser:/act")).toBeGreaterThan(-1);
|
|
expect(events.indexOf("command:bridge start")).toBeGreaterThan(
|
|
events.indexOf("browser:/act"),
|
|
);
|
|
});
|
|
});
|
|
|
|
it("does not start the local realtime audio bridge while Meet admission is pending", async () => {
|
|
await withPlatform("darwin", async () => {
|
|
const events: string[] = [];
|
|
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 ?? MEET_URL,
|
|
};
|
|
}
|
|
if (request.path === "/tabs/focus" || request.path === "/permissions/grant") {
|
|
return { ok: true };
|
|
}
|
|
if (request.path === "/act") {
|
|
return {
|
|
result: JSON.stringify({
|
|
inCall: false,
|
|
lobbyWaiting: true,
|
|
manualAction: {
|
|
reason: "meet-admission-required",
|
|
message: "Admit the OpenClaw browser participant in Google Meet.",
|
|
},
|
|
title: "Meet",
|
|
url: MEET_URL,
|
|
}),
|
|
};
|
|
}
|
|
throw new Error(`unexpected browser request path ${request.path}`);
|
|
},
|
|
);
|
|
localBrowserGatewayRequestHandler = async (method, params, requestOptions) =>
|
|
await callGatewayFromCli(method, {}, params, requestOptions);
|
|
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]?.endsWith("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: MEET_URL },
|
|
respond,
|
|
});
|
|
|
|
expectRespondedOk(respond);
|
|
expect(events).toContain("browser:/act");
|
|
expect(events).not.toContain("command:bridge start");
|
|
});
|
|
});
|
|
|
|
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: MEET_URL_EN,
|
|
},
|
|
],
|
|
}
|
|
: { tabs: [] };
|
|
}
|
|
if (request.path === "/tabs/open") {
|
|
openedTab = true;
|
|
return {
|
|
targetId: "local-meet-tab",
|
|
title: "Meet",
|
|
url: request.body?.url ?? MEET_URL_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 ?? MEET_URL_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: MEET_URL,
|
|
}
|
|
: {
|
|
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: MEET_URL,
|
|
},
|
|
),
|
|
};
|
|
}
|
|
throw new Error(`unexpected browser request path ${request.path}`);
|
|
},
|
|
);
|
|
localBrowserGatewayRequestHandler = async (method, params, requestOptions) =>
|
|
await callGatewayFromCli(method, {}, params, requestOptions);
|
|
const { methods } = setup({
|
|
defaultMode: "transcribe",
|
|
defaultTransport: "chrome",
|
|
});
|
|
|
|
const join = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.join", {
|
|
url: MEET_URL,
|
|
})) 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({ timeoutMs: 10_000, scopes: ["operator.admin"] });
|
|
});
|
|
|
|
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: createNodeBrowserScenario({
|
|
tabs: () => (openedTab ? [{ targetId: "tab-1", title: "Meet", url: MEET_URL_EN }] : []),
|
|
targetId: "tab-1",
|
|
open: (request) => {
|
|
openedTab = true;
|
|
return {
|
|
targetId: "tab-1",
|
|
title: "Meet",
|
|
url: request.body?.url ?? MEET_URL_EN,
|
|
};
|
|
},
|
|
focus: true,
|
|
grantPermissions: true,
|
|
navigate: (request) => ({
|
|
targetId: request.body?.targetId ?? "tab-1",
|
|
url: request.body?.url ?? MEET_URL_EN,
|
|
}),
|
|
inspect: () => ({
|
|
inCall: false,
|
|
manualAction: {
|
|
reason: "meet-audio-choice-required",
|
|
message: "Choose the Meet microphone path manually.",
|
|
},
|
|
title: "Meet",
|
|
url: MEET_URL,
|
|
}),
|
|
nodeCommand: () => ({ payload: { launched: openedTab } }),
|
|
}),
|
|
},
|
|
);
|
|
|
|
const join = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.join", {
|
|
url: MEET_URL,
|
|
})) as { session: { id: string } };
|
|
openedTab = true;
|
|
nodesInvoke.mockClear();
|
|
|
|
const status = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.status", {
|
|
sessionId: join.session.id,
|
|
})) as { session?: { chrome?: { health?: { manualAction?: unknown } } } };
|
|
|
|
expect(status.session?.chrome?.health?.manualAction).toEqual({
|
|
reason: "meet-audio-choice-required",
|
|
message: "Choose the Meet microphone path manually.",
|
|
});
|
|
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: MEET_URL,
|
|
hostname: "meet.google.com",
|
|
},
|
|
MutationObserver: class {
|
|
observe = vi.fn();
|
|
},
|
|
window: windowState,
|
|
});
|
|
const inspect = new Script(
|
|
`(${await captureMeetStatusScript({
|
|
autoJoin: false,
|
|
captionSessionId: "session-1",
|
|
mode: "transcribe",
|
|
})})`,
|
|
).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: MEET_URL, hostname: "meet.google.com" },
|
|
MutationObserver: class {
|
|
observe = vi.fn();
|
|
disconnect = disconnectObserver;
|
|
},
|
|
window: windowState,
|
|
});
|
|
const inspect = new Script(
|
|
`(${await captureMeetStatusScript({
|
|
autoJoin: false,
|
|
captionSessionId: "session-1",
|
|
mode: "transcribe",
|
|
})})`,
|
|
).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 = async (sessionId: string, finalize: boolean) =>
|
|
new Script(`(${await captureMeetTranscriptScript(sessionId, finalize)})`).runInContext(
|
|
context,
|
|
) as () => string;
|
|
const beforeFinalize = JSON.parse((await readTranscript("session-1", false))()) as {
|
|
lines: unknown[];
|
|
};
|
|
expect(beforeFinalize.lines).toHaveLength(1);
|
|
const afterFinalize = JSON.parse((await 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(
|
|
`(${await captureMeetStatusScript({
|
|
autoJoin: false,
|
|
captionSessionId: "session-2",
|
|
mode: "transcribe",
|
|
})})`,
|
|
).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((await 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();
|
|
(await 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({
|
|
crypto: { randomUUID: () => "audio-permission-epoch" },
|
|
JSON,
|
|
document,
|
|
location: {
|
|
href: MEET_URL,
|
|
hostname: "meet.google.com",
|
|
},
|
|
MutationObserver: class {
|
|
observe = vi.fn();
|
|
},
|
|
window: {},
|
|
});
|
|
const inspect = new Script(
|
|
`(${await captureMeetStatusScript({
|
|
autoJoin: false,
|
|
mode: "agent",
|
|
})})`,
|
|
).runInContext(context) as () => string | Promise<string>;
|
|
|
|
const result = JSON.parse(await inspect()) as {
|
|
inCall?: boolean;
|
|
manualAction?: { reason: string; message: string };
|
|
};
|
|
|
|
expect(result.inCall).toBe(true);
|
|
expect(result.manualAction?.reason).toBe("meet-permission-required");
|
|
expect(result.manualAction?.message).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: MEET_URL_EN,
|
|
hostname: "meet.google.com",
|
|
},
|
|
window: {},
|
|
});
|
|
const inspect = new Script(
|
|
`(${await captureMeetStatusScript({
|
|
autoJoin: true,
|
|
mode: "transcribe",
|
|
})})`,
|
|
).runInContext(context) as () => string | Promise<string>;
|
|
|
|
const result = JSON.parse(await inspect()) as {
|
|
clickedJoin?: boolean;
|
|
manualAction?: { reason: string; message: string };
|
|
};
|
|
|
|
expect(result.clickedJoin).toBe(false);
|
|
expect(result.manualAction?.reason).toBe("meet-session-conflict");
|
|
expect(joinElsewhere.click).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not unmute local or remote microphones without a verified virtual input", 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({
|
|
crypto: { randomUUID: () => "microphone-control-epoch" },
|
|
JSON,
|
|
document,
|
|
location: {
|
|
href: MEET_URL,
|
|
hostname: "meet.google.com",
|
|
},
|
|
MutationObserver: class {
|
|
observe = vi.fn();
|
|
},
|
|
window: {},
|
|
});
|
|
const inspect = new Script(
|
|
`(${await captureMeetStatusScript({
|
|
autoJoin: false,
|
|
mode: "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).not.toHaveBeenCalled();
|
|
expect(remoteMute.click).not.toHaveBeenCalled();
|
|
expect(result.notes).not.toContain(
|
|
"Turned on the Meet microphone after verifying the virtual audio input.",
|
|
);
|
|
});
|
|
|
|
it.each(
|
|
[
|
|
{
|
|
name: "blocks realtime speech while the Meet microphone remains muted",
|
|
browser: meetBrowserState({ micMuted: true }),
|
|
config: { realtime: { introMessage: "" } },
|
|
micMuted: true,
|
|
reason: "meet-microphone-muted",
|
|
},
|
|
{
|
|
name: "blocks realtime speech while the Meet microphone state is unknown",
|
|
browser: { inCall: true, title: "Meet call", url: MEET_URL },
|
|
config: { realtime: { introMessage: "" } },
|
|
micMuted: undefined,
|
|
reason: "browser-unverified",
|
|
},
|
|
{
|
|
name: "keeps default intro speech blocked while the Meet microphone is muted",
|
|
browser: meetBrowserState({ micMuted: true }),
|
|
config: {},
|
|
micMuted: true,
|
|
reason: "meet-microphone-muted",
|
|
},
|
|
].map((scenario) => [scenario.name, scenario] as const),
|
|
)("%s", async (_name, { browser, config, micMuted, reason }) => {
|
|
await withPlatform("darwin", async () => {
|
|
mockLocalMeetBrowserRequest(browser);
|
|
const { payload, health } = await runGoogleMeetJoinScenario({
|
|
...config,
|
|
chrome: {
|
|
audioBridgeCommand: ["bridge", "start"],
|
|
waitForInCallMs: 1,
|
|
},
|
|
});
|
|
expect(payload.spoken).toBe(false);
|
|
expect(health.micMuted).toBe(micMuted);
|
|
expect(health.speechReady).toBe(false);
|
|
expect(health.speechBlockedReason).toBe(reason);
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
state: "muted",
|
|
browser: meetBrowserState({ micMuted: true }),
|
|
reason: "meet-microphone-muted",
|
|
},
|
|
{
|
|
state: "unknown",
|
|
browser: {
|
|
inCall: true,
|
|
title: "Meet call",
|
|
url: MEET_URL,
|
|
},
|
|
reason: "browser-unverified",
|
|
},
|
|
])(
|
|
"keeps the paired-node audio bridge stopped while the microphone is $state",
|
|
async ({ browser, reason }) => {
|
|
const { health, nodesInvoke } = await runGoogleMeetJoinScenario(
|
|
{
|
|
defaultTransport: "chrome-node",
|
|
defaultMode: "agent",
|
|
realtime: { introMessage: "" },
|
|
chrome: { waitForInCallMs: 1 },
|
|
},
|
|
{
|
|
browserActResult: browser,
|
|
nodesInvokeResult: {
|
|
payload: { launched: true, audioBridge: { type: "external-command" } },
|
|
},
|
|
},
|
|
);
|
|
expect(
|
|
matchingNodeInvocations(nodesInvoke, { command: "googlemeet.chrome", action: "start" }),
|
|
).toHaveLength(0);
|
|
expect(health.speechReady).toBe(false);
|
|
expect(health.speechBlockedReason).toBe(reason);
|
|
},
|
|
);
|
|
|
|
it("starts the paired-node audio bridge after an explicit unmuted observation", async () => {
|
|
const { chrome, health, nodesInvoke } = await runGoogleMeetJoinScenario(
|
|
{
|
|
defaultTransport: "chrome-node",
|
|
defaultMode: "agent",
|
|
realtime: { introMessage: "" },
|
|
},
|
|
{
|
|
browserActResult: meetBrowserState(),
|
|
nodesInvokeResult: {
|
|
payload: { launched: true, audioBridge: { type: "external-command" } },
|
|
},
|
|
},
|
|
);
|
|
expect(
|
|
matchingNodeInvocations(nodesInvoke, { command: "googlemeet.chrome", action: "start" }),
|
|
).toHaveLength(1);
|
|
expect(requireRecord(chrome.audioBridge, "join audio bridge").type).toBe("external-command");
|
|
expect(health.speechReady).toBe(true);
|
|
});
|
|
|
|
it("does not start an externally managed paired-node bridge without a recoverable tab", async () => {
|
|
const { chrome, nodesInvoke } = await runGoogleMeetJoinScenario(
|
|
{
|
|
defaultTransport: "chrome-node",
|
|
defaultMode: "agent",
|
|
realtime: { introMessage: "" },
|
|
chrome: { launch: false },
|
|
},
|
|
{
|
|
nodesInvokeResult: {
|
|
payload: { launched: true, audioBridge: { type: "external-command" } },
|
|
},
|
|
},
|
|
);
|
|
expect(
|
|
matchingNodeInvocations(nodesInvoke, { command: "googlemeet.chrome", action: "start" }),
|
|
).toHaveLength(0);
|
|
expect(
|
|
nodesInvoke.mock.calls.some(([rawCall]) => {
|
|
const call = requireRecord(rawCall, "node invoke");
|
|
return call.command === "browser.proxy";
|
|
}),
|
|
).toBe(true);
|
|
expect(chrome.audioBridge).toBeUndefined();
|
|
});
|
|
|
|
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: MEET_URL },
|
|
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: MEET_URL,
|
|
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: MEET_URL_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(MEET_URL);
|
|
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.each(
|
|
[
|
|
{
|
|
name: "reuses an active Meet session for the same URL and transport",
|
|
firstUrl: MEET_URL,
|
|
},
|
|
{
|
|
name: "reuses active Meet sessions across URL query differences",
|
|
firstUrl: "https://meet.google.com/abc-defg-hij?authuser=me@example.com",
|
|
},
|
|
].map((scenario) => [scenario.name, scenario] as const),
|
|
)("%s", async (_name, { firstUrl }) => {
|
|
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: firstUrl },
|
|
respond: first,
|
|
});
|
|
await handler?.({
|
|
params: { url: MEET_URL },
|
|
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("opens an English replacement without touching an ambiguous matching tab", async () => {
|
|
const { methods, nodesInvoke } = setup(
|
|
{
|
|
defaultTransport: "chrome-node",
|
|
defaultMode: "transcribe",
|
|
},
|
|
{
|
|
nodesInvokeHandler: createNodeBrowserScenario({
|
|
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",
|
|
},
|
|
],
|
|
open: () => ({
|
|
targetId: "english-meet-tab",
|
|
title: "Meet",
|
|
url: "https://meet.google.com/abc-defg-hij?authuser=me%40example.com&hl=en",
|
|
}),
|
|
grantPermissions: true,
|
|
inspect: () => ({
|
|
inCall: true,
|
|
title: "Meet",
|
|
url: "https://meet.google.com/abc-defg-hij?authuser=me%40example.com&hl=en",
|
|
}),
|
|
nodeCommand: () => ({ 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?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");
|
|
}
|
|
const actParams = requireRecord(
|
|
requireRecord(englishTabActCall[0], "act node invoke").params,
|
|
"act params",
|
|
);
|
|
expect(actParams).toEqual({
|
|
method: "POST",
|
|
path: "/act",
|
|
timeoutMs: expect.any(Number),
|
|
body: {
|
|
kind: "evaluate",
|
|
targetId: "english-meet-tab",
|
|
fn: expect.any(String),
|
|
},
|
|
});
|
|
expect(actParams.timeoutMs).toBeGreaterThan(0);
|
|
expect(actParams.timeoutMs).toBeLessThanOrEqual(10_000);
|
|
});
|
|
|
|
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: createNodeBrowserScenario({
|
|
tabs: [{ targetId: "english-meet-tab", title: "Meet", url: MEET_URL_EN }],
|
|
focus: true,
|
|
inspect: () => ({ inCall: true, title: "Meet", url: MEET_URL_EN }),
|
|
nodeCommand: () => ({ 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: MEET_URL },
|
|
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: createNodeBrowserScenario({
|
|
tabs: [
|
|
{
|
|
targetId: "existing-meet-tab",
|
|
title: "Meet",
|
|
url: "https://meet.google.com/abc-defg-hij?authuser=me@example.com",
|
|
},
|
|
],
|
|
focus: true,
|
|
}),
|
|
},
|
|
);
|
|
const tool = getMeetTool({ tools });
|
|
|
|
const result = await tool.execute("id", {
|
|
action: "recover_current_tab",
|
|
url: MEET_URL,
|
|
});
|
|
|
|
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.manualAction).toMatchObject({
|
|
reason: "meet-locale-required",
|
|
message: expect.stringContaining("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: createNodeBrowserScenario({
|
|
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",
|
|
},
|
|
],
|
|
focus: true,
|
|
inspect: () => ({ inCall: true, title: "Meet", url: MEET_URL_EN }),
|
|
}),
|
|
},
|
|
);
|
|
const tool = getMeetTool({ tools });
|
|
|
|
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 when no meeting tab is recoverable", async () => {
|
|
const { tools } = setup(
|
|
{ defaultTransport: "chrome-node" },
|
|
{
|
|
nodesInvokeHandler: createNodeBrowserScenario({
|
|
tabs: [
|
|
{
|
|
targetId: "google-sign-in-tab",
|
|
title: "Sign in - Google Accounts - Meet",
|
|
url: "https://accounts.google.com/signin",
|
|
},
|
|
],
|
|
focus: true,
|
|
inspect: () => ({
|
|
inCall: false,
|
|
manualAction: {
|
|
reason: "google-login-required",
|
|
message: "Sign in to Google, then retry.",
|
|
},
|
|
url: "https://accounts.google.com/signin",
|
|
}),
|
|
}),
|
|
},
|
|
);
|
|
const tool = getMeetTool({ tools });
|
|
|
|
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.manualAction).toEqual({
|
|
reason: "google-login-required",
|
|
message: "Sign in to Google, then retry.",
|
|
});
|
|
});
|
|
|
|
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,
|
|
manualAction: {
|
|
reason: "meet-admission-required",
|
|
message: "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}`);
|
|
},
|
|
);
|
|
localBrowserGatewayRequestHandler = async (method, params, requestOptions) =>
|
|
await callGatewayFromCli(method, {}, params, requestOptions);
|
|
const { tools, nodesInvoke } = setup({
|
|
defaultTransport: "chrome",
|
|
chrome: {
|
|
browserProfile: "meet-devtools",
|
|
},
|
|
});
|
|
const tool = getMeetTool({ tools });
|
|
|
|
const result = await tool.execute("id", {
|
|
action: "recover_current_tab",
|
|
url: MEET_URL,
|
|
});
|
|
|
|
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.manualAction).toMatchObject({
|
|
reason: "meet-locale-required",
|
|
message: expect.stringContaining("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({ timeoutMs: 10_000, scopes: ["operator.admin"] });
|
|
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 = getMeetTool({ tools });
|
|
|
|
const result = await tool.execute("id", {
|
|
action: "test_speech",
|
|
url: MEET_URL,
|
|
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 () => {
|
|
await withPlatform("darwin", async () => {
|
|
let browserState: Record<string, unknown> = {
|
|
inCall: false,
|
|
title: "Meet",
|
|
url: MEET_URL,
|
|
};
|
|
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: MEET_URL_EN,
|
|
},
|
|
]
|
|
: [],
|
|
};
|
|
}
|
|
if (request.path === "/tabs/open") {
|
|
opened = true;
|
|
return {
|
|
targetId: "local-meet-tab",
|
|
title: "Meet",
|
|
url: request.body?.url ?? MEET_URL_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 ?? MEET_URL_EN,
|
|
};
|
|
}
|
|
if (request.path === "/act") {
|
|
return { result: JSON.stringify(browserState) };
|
|
}
|
|
throw new Error(`unexpected browser request path ${request.path}`);
|
|
},
|
|
);
|
|
localBrowserGatewayRequestHandler = async (method, params, requestOptions) =>
|
|
await callGatewayFromCli(method, {}, params, requestOptions);
|
|
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: MEET_URL },
|
|
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: MEET_URL,
|
|
};
|
|
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");
|
|
});
|
|
});
|
|
|
|
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: MEET_URL,
|
|
},
|
|
nodesInvokeResult: {
|
|
payload: {
|
|
launched: true,
|
|
},
|
|
},
|
|
},
|
|
);
|
|
const tool = getMeetTool({ tools });
|
|
|
|
const result = await tool.execute("id", {
|
|
action: "test_listen",
|
|
url: MEET_URL,
|
|
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: MEET_URL,
|
|
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 = meetRuntime({}, noopLogger);
|
|
const session = meetSession({
|
|
chrome: {
|
|
audioBackend: "blackhole-2ch",
|
|
launched: true,
|
|
health: { audioOutputActive: true, lastOutputBytes: 10 },
|
|
},
|
|
});
|
|
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: MEET_URL,
|
|
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.each([
|
|
["bidi", "bidi"],
|
|
["realtime", "agent"],
|
|
] as const)("normalizes the %s test speech mode to %s", async (mode, expectedMode) => {
|
|
const runtime = meetRuntime({ defaultMode: "bidi" }, noopLogger);
|
|
const session = meetSession({
|
|
mode: expectedMode,
|
|
chrome: {
|
|
audioBackend: "blackhole-2ch",
|
|
launched: true,
|
|
health: { audioOutputActive: true, lastOutputBytes: 10 },
|
|
},
|
|
});
|
|
vi.spyOn(runtime, "list").mockReturnValue([]);
|
|
const join = vi.spyOn(runtime, "join").mockResolvedValue({ session, spoken: true });
|
|
|
|
await runtime.testSpeech({
|
|
url: MEET_URL,
|
|
mode,
|
|
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(expectedMode);
|
|
});
|
|
|
|
it("resets test speech output and loopback baselines when another agent owns the old session", async () => {
|
|
const runtime = meetRuntime({}, noopLogger);
|
|
const oldSession = meetSession({
|
|
id: "meet_old",
|
|
agentId: "support",
|
|
chrome: {
|
|
audioBackend: "blackhole-2ch",
|
|
launched: true,
|
|
health: {
|
|
audioOutputActive: true,
|
|
lastOutputBytes: 100,
|
|
outputLoopbackSignalBytes: 200,
|
|
outputGeneration: 10,
|
|
verifiedOutputGeneration: 10,
|
|
},
|
|
},
|
|
});
|
|
const newSession = meetSession({
|
|
id: "meet_new",
|
|
agentId: "main",
|
|
chrome: {
|
|
audioBackend: "blackhole-2ch",
|
|
launched: true,
|
|
health: {
|
|
audioOutputActive: true,
|
|
lastOutputBytes: 1,
|
|
outputLoopbackSignalBytes: 2,
|
|
outputGeneration: 1,
|
|
verifiedOutputGeneration: 1,
|
|
},
|
|
},
|
|
});
|
|
vi.spyOn(runtime, "list").mockReturnValue([oldSession]);
|
|
vi.spyOn(runtime, "join").mockResolvedValue({ session: newSession, spoken: true });
|
|
|
|
const result = await runtime.testSpeech({
|
|
url: MEET_URL,
|
|
agentId: "main",
|
|
message: "Say exactly: hello.",
|
|
});
|
|
|
|
expect(result.speechOutputVerified).toBe(true);
|
|
expect(result.speechOutputTimedOut).toBe(false);
|
|
});
|
|
|
|
it("keeps the pre-join output and loopback baselines when reusing the same session", async () => {
|
|
const runtime = meetRuntime({}, noopLogger);
|
|
const session = meetSession({
|
|
chrome: {
|
|
audioBackend: "blackhole-2ch",
|
|
launched: true,
|
|
health: {
|
|
audioOutputActive: true,
|
|
lastOutputBytes: 10,
|
|
outputLoopbackSignalBytes: 20,
|
|
outputGeneration: 1,
|
|
verifiedOutputGeneration: 1,
|
|
},
|
|
},
|
|
});
|
|
vi.spyOn(runtime, "list").mockReturnValue([session]);
|
|
vi.spyOn(runtime, "join").mockImplementation(async () => {
|
|
session.chrome!.health!.lastOutputBytes = 11;
|
|
session.chrome!.health!.outputLoopbackSignalBytes = 21;
|
|
session.chrome!.health!.outputGeneration = 2;
|
|
session.chrome!.health!.verifiedOutputGeneration = 2;
|
|
return { session, spoken: true };
|
|
});
|
|
|
|
const result = await runtime.testSpeech({
|
|
url: MEET_URL,
|
|
message: "Say exactly: hello.",
|
|
});
|
|
|
|
expect(result.speechOutputVerified).toBe(true);
|
|
expect(result.speechOutputTimedOut).toBe(false);
|
|
});
|
|
|
|
it("does not verify speech from fresh output bytes without fresh loopback signal", async () => {
|
|
const runtime = meetRuntime({}, noopLogger);
|
|
const session = meetSession({
|
|
chrome: {
|
|
audioBackend: "blackhole-2ch",
|
|
launched: true,
|
|
health: {
|
|
audioOutputActive: true,
|
|
lastOutputBytes: 10,
|
|
outputLoopbackSignalBytes: 20,
|
|
outputGeneration: 1,
|
|
verifiedOutputGeneration: 1,
|
|
},
|
|
},
|
|
});
|
|
vi.spyOn(runtime, "list").mockReturnValue([session]);
|
|
vi.spyOn(runtime, "join").mockImplementation(async () => {
|
|
session.chrome!.health!.lastOutputBytes = 11;
|
|
session.chrome!.health!.outputGeneration = 2;
|
|
return { session, spoken: true };
|
|
});
|
|
|
|
const result = await runtime.testSpeech({
|
|
url: MEET_URL,
|
|
message: "Say exactly: hello.",
|
|
});
|
|
|
|
expect(result.speechOutputVerified).toBe(false);
|
|
expect(result.speechOutputTimedOut).toBe(false);
|
|
});
|
|
|
|
it("rejects observe-only mode for test speech", async () => {
|
|
const runtime = meetRuntime({}, noopLogger);
|
|
|
|
await expect(
|
|
runtime.testSpeech({
|
|
url: MEET_URL,
|
|
mode: "transcribe",
|
|
}),
|
|
).rejects.toThrow("test_speech requires mode: agent or bidi");
|
|
});
|
|
|
|
it("rejects realtime and Twilio modes for test listen", async () => {
|
|
const runtime = meetRuntime({}, noopLogger);
|
|
|
|
await expect(
|
|
runtime.testListen({
|
|
url: MEET_URL,
|
|
mode: "realtime",
|
|
}),
|
|
).rejects.toThrow("test_listen requires mode: transcribe");
|
|
|
|
await expect(
|
|
runtime.testListen({
|
|
url: MEET_URL,
|
|
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 = meetRuntime({}, noopLogger);
|
|
const oldSession = meetSession({
|
|
id: "meet_old",
|
|
mode: "transcribe",
|
|
agentId: "support",
|
|
chrome: {
|
|
audioBackend: "blackhole-2ch",
|
|
launched: true,
|
|
health: { transcriptLines: 10, lastCaptionText: "old caption" },
|
|
},
|
|
});
|
|
const newSession = meetSession({
|
|
id: "meet_new",
|
|
mode: "transcribe",
|
|
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: MEET_URL,
|
|
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 = meetRuntime({}, noopLogger);
|
|
const session = meetSession({
|
|
mode: "transcribe",
|
|
chrome: {
|
|
audioBackend: "blackhole-2ch",
|
|
launched: true,
|
|
health: { transcriptLines: 1, lastCaptionText: "old caption" },
|
|
},
|
|
});
|
|
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: MEET_URL,
|
|
});
|
|
|
|
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(GOOGLE_MEET_PLATFORM_ADAPTER.create!, "browser")
|
|
.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 = meetRuntime(
|
|
{
|
|
defaultTransport: "chrome-node",
|
|
defaultMode: "transcribe",
|
|
},
|
|
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),
|
|
meetingSessionId: expect.any(String),
|
|
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),
|
|
meetingSessionId: expect.any(String),
|
|
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 { launch: launchChromeMeet } = mockChromeMeetLifecycle({
|
|
launches: [
|
|
{
|
|
launched: true,
|
|
tab: { targetId: "shared-meet-tab", openedByPlugin: true },
|
|
browser: { inCall: true, micMuted: false },
|
|
audioBridge: meetAudioBridge(stop),
|
|
},
|
|
{
|
|
launched: true,
|
|
tab: { targetId: "shared-meet-tab", openedByPlugin: false },
|
|
browser: { inCall: true, micMuted: false },
|
|
},
|
|
],
|
|
});
|
|
try {
|
|
const runtime = createChromeLifecycleRuntime();
|
|
|
|
const first = await runtime.join({
|
|
url: MEET_URL,
|
|
agentId: "support",
|
|
});
|
|
const second = await runtime.join({
|
|
url: MEET_URL,
|
|
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 { launch: launchChromeMeet } = mockChromeMeetLifecycle({
|
|
launches: [
|
|
{
|
|
launched: true,
|
|
tab: { targetId: "shared-meet-tab", openedByPlugin: true },
|
|
browser: { inCall: true, micMuted: false },
|
|
audioBridge: meetAudioBridge(stop),
|
|
},
|
|
{
|
|
launched: true,
|
|
tab: { targetId: "shared-meet-tab", openedByPlugin: false },
|
|
browser: { inCall: true, micMuted: false },
|
|
},
|
|
],
|
|
});
|
|
try {
|
|
const runtime = createChromeLifecycleRuntime();
|
|
const first = await runtime.join({
|
|
url: MEET_URL,
|
|
agentId: "main",
|
|
mode: "agent",
|
|
});
|
|
|
|
const second = await runtime.join({
|
|
url: MEET_URL,
|
|
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 { launch: launchChromeMeet, leave: leaveChromeMeet } = mockChromeMeetLifecycle({
|
|
launches: [{ launched: false, audioBridge: meetAudioBridge(stop) }, { launched: false }],
|
|
watchLeave: true,
|
|
});
|
|
try {
|
|
const runtime = createChromeLifecycleRuntime({ chrome: { launch: false } });
|
|
const first = await runtime.join({
|
|
url: MEET_URL,
|
|
agentId: "support",
|
|
});
|
|
|
|
const second = await runtime.join({
|
|
url: MEET_URL,
|
|
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("reassigns an ordinary shared tab to the only compatible active session", async () => {
|
|
const { launch: launchChromeMeet, leave: leaveChromeMeet } = mockChromeMeetLifecycle({
|
|
launches: [
|
|
{
|
|
launched: true,
|
|
tab: { targetId: "shared-meet-tab", openedByPlugin: true },
|
|
browser: { inCall: true, micMuted: true },
|
|
},
|
|
{
|
|
launched: true,
|
|
tab: { targetId: "shared-meet-tab", openedByPlugin: false },
|
|
browser: { inCall: true, micMuted: true },
|
|
},
|
|
],
|
|
leaveResults: [{ left: true, note: "left browser" }],
|
|
});
|
|
try {
|
|
const runtime = createChromeLifecycleRuntime({ defaultMode: "transcribe" });
|
|
|
|
const first = await runtime.join({
|
|
url: MEET_URL,
|
|
agentId: "support",
|
|
});
|
|
const second = await runtime.join({
|
|
url: MEET_URL,
|
|
agentId: "main",
|
|
});
|
|
|
|
expect(second.session.chrome?.browserTab).toEqual({
|
|
targetId: "shared-meet-tab",
|
|
openedByPlugin: true,
|
|
});
|
|
expect(first.session.state).toBe("ended");
|
|
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),
|
|
meetingSessionId: expect.any(String),
|
|
meetingUrl: MEET_URL,
|
|
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 { launch: launchChromeMeet, leave: leaveChromeMeet } = mockChromeMeetLifecycle({
|
|
launches: [
|
|
{
|
|
launched: true,
|
|
tab: { targetId: "old-meet-tab", openedByPlugin: true },
|
|
browser: { inCall: true, micMuted: false },
|
|
audioBridge: meetAudioBridge(stop),
|
|
},
|
|
{
|
|
launched: true,
|
|
tab: { targetId: "new-meet-tab", openedByPlugin: true },
|
|
browser: { inCall: true, micMuted: false },
|
|
},
|
|
],
|
|
leaveResults: [{ left: true, note: "left old browser tab" }],
|
|
});
|
|
try {
|
|
const runtime = createChromeLifecycleRuntime({ chrome: { reuseExistingTab: false } });
|
|
await runtime.join({
|
|
url: MEET_URL,
|
|
agentId: "support",
|
|
});
|
|
|
|
const second = await runtime.join({
|
|
url: MEET_URL,
|
|
agentId: "main",
|
|
});
|
|
|
|
expect(stop).toHaveBeenCalledOnce();
|
|
expect(leaveChromeMeet).toHaveBeenCalledOnce();
|
|
expect(leaveChromeMeet).toHaveBeenCalledWith({
|
|
runtime: expect.any(Object),
|
|
config: expect.any(Object),
|
|
meetingSessionId: expect.any(String),
|
|
meetingUrl: MEET_URL,
|
|
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 { launch: launchChromeMeet, leave: leaveChromeMeet } = mockChromeMeetLifecycle({
|
|
launches: [
|
|
{
|
|
launched: true,
|
|
tab: { targetId: "retained-meet-tab", openedByPlugin: true },
|
|
browser: { inCall: true, micMuted: false },
|
|
audioBridge: meetAudioBridge(stop),
|
|
},
|
|
new Error("replacement launch failed"),
|
|
],
|
|
leaveResults: [{ left: true, note: "released retained tab" }],
|
|
});
|
|
try {
|
|
const runtime = createChromeLifecycleRuntime();
|
|
const first = await runtime.join({
|
|
url: MEET_URL,
|
|
agentId: "support",
|
|
});
|
|
|
|
await expect(
|
|
runtime.join({
|
|
url: MEET_URL,
|
|
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),
|
|
meetingSessionId: expect.any(String),
|
|
meetingUrl: MEET_URL,
|
|
tab: { targetId: "retained-meet-tab", openedByPlugin: true },
|
|
});
|
|
} finally {
|
|
leaveChromeMeet?.mockRestore();
|
|
launchChromeMeet.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("stops the replacement bridge and retries the retained tab after cleanup fails", async () => {
|
|
const oldStop = vi.fn(async () => {});
|
|
const replacementStop = vi.fn(async () => {});
|
|
const { launch: launchChromeMeet, leave: leaveChromeMeet } = mockChromeMeetLifecycle({
|
|
launches: [
|
|
{
|
|
launched: true,
|
|
tab: { targetId: "retained-meet-tab", openedByPlugin: true },
|
|
browser: { inCall: true, micMuted: false },
|
|
audioBridge: meetAudioBridge(oldStop),
|
|
},
|
|
{
|
|
launched: true,
|
|
tab: { targetId: "replacement-meet-tab", openedByPlugin: true },
|
|
browser: { inCall: true, micMuted: false },
|
|
audioBridge: meetAudioBridge(replacementStop),
|
|
},
|
|
],
|
|
leaveResults: [
|
|
{ left: false, note: "old tab cleanup failed" },
|
|
{ left: true, note: "replacement tab released" },
|
|
{ left: true, note: "old tab cleanup retried" },
|
|
],
|
|
});
|
|
try {
|
|
const runtime = createChromeLifecycleRuntime();
|
|
await runtime.join({
|
|
url: MEET_URL,
|
|
agentId: "support",
|
|
});
|
|
|
|
await expect(
|
|
runtime.join({
|
|
url: MEET_URL,
|
|
agentId: "main",
|
|
}),
|
|
).rejects.toThrow("Could not leave the previous Meet browser tab before reassignment.");
|
|
|
|
expect(oldStop).toHaveBeenCalledOnce();
|
|
expect(replacementStop).toHaveBeenCalledOnce();
|
|
expect(leaveChromeMeet).toHaveBeenCalledTimes(3);
|
|
expect(leaveChromeMeet).toHaveBeenNthCalledWith(2, {
|
|
runtime: expect.any(Object),
|
|
config: expect.any(Object),
|
|
meetingSessionId: expect.any(String),
|
|
meetingUrl: MEET_URL,
|
|
tab: { targetId: "replacement-meet-tab", openedByPlugin: true },
|
|
});
|
|
expect(leaveChromeMeet).toHaveBeenLastCalledWith({
|
|
runtime: expect.any(Object),
|
|
config: expect.any(Object),
|
|
meetingSessionId: expect.any(String),
|
|
meetingUrl: MEET_URL,
|
|
tab: { targetId: "retained-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 = meetRuntime(
|
|
{
|
|
defaultTransport: "chrome",
|
|
defaultMode: "transcribe",
|
|
},
|
|
noopLogger,
|
|
);
|
|
const joined = await runtime.join({ url: MEET_URL });
|
|
|
|
const firstLeave = runtime.leave(joined.session.id);
|
|
const secondLeave = runtime.leave(joined.session.id);
|
|
const replacementJoin = runtime.join({
|
|
url: MEET_URL,
|
|
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 = meetRuntime(
|
|
{
|
|
defaultTransport: "chrome",
|
|
defaultMode: "transcribe",
|
|
},
|
|
noopLogger,
|
|
);
|
|
const first = await runtime.join({
|
|
url: MEET_URL,
|
|
agentId: "support",
|
|
});
|
|
|
|
const replacement = runtime.join({
|
|
url: MEET_URL,
|
|
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 { launch: launchChromeMeet, leave: leaveChromeMeet } = mockChromeMeetLifecycle({
|
|
launches: [
|
|
{
|
|
launched: true,
|
|
tab: { targetId: "meet-tab", openedByPlugin: true },
|
|
browser: { inCall: true, micMuted: false },
|
|
audioBridge: meetAudioBridge(stop),
|
|
},
|
|
],
|
|
leaveResults: [{ left: true, note: "left browser" }],
|
|
});
|
|
try {
|
|
const runtime = createChromeLifecycleRuntime();
|
|
const joined = await runtime.join({ url: MEET_URL });
|
|
|
|
await expect(runtime.leave(joined.session.id)).rejects.toThrow("bridge stop failed");
|
|
|
|
expect(leaveChromeMeet).toHaveBeenCalledWith({
|
|
runtime: expect.any(Object),
|
|
config: expect.any(Object),
|
|
meetingSessionId: expect.any(String),
|
|
meetingUrl: MEET_URL,
|
|
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,
|
|
audioInputRouted: true,
|
|
audioOutputRouted: true,
|
|
},
|
|
});
|
|
try {
|
|
const runtime = createChromeLifecycleRuntime();
|
|
|
|
const joined = await runtime.join({
|
|
url: MEET_URL,
|
|
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,
|
|
manualAction: {
|
|
reason: "google-login-required",
|
|
message: "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 = getMeetTool({ tools });
|
|
|
|
const result = await tool.execute("id", {
|
|
action: "test_speech",
|
|
url: MEET_URL,
|
|
message: "Say exactly: hello.",
|
|
});
|
|
|
|
expect(result.details.manualAction).toEqual({
|
|
reason: "google-login-required",
|
|
message: "Sign in to Google in the OpenClaw browser profile, then retry the Meet join.",
|
|
});
|
|
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.manualAction).toEqual({
|
|
reason: "google-login-required",
|
|
message: "Sign in to Google in the OpenClaw browser profile, then retry the Meet join.",
|
|
});
|
|
expect(health.speechReady).toBe(false);
|
|
expect(health.speechBlockedReason).toBe("google-login-required");
|
|
});
|
|
|
|
it("recovers paired-node talkback without reopening the tracked tab", async () => {
|
|
let openedTab = false;
|
|
let browserReady = false;
|
|
const { methods, nodesInvoke } = setup(
|
|
{
|
|
defaultTransport: "chrome-node",
|
|
defaultMode: "agent",
|
|
chrome: { reuseExistingTab: false },
|
|
},
|
|
{
|
|
nodesInvokeHandler: createNodeBrowserScenario({
|
|
tabs: () => (openedTab ? [{ targetId: "tab-1", title: "Meet", url: MEET_URL_EN }] : []),
|
|
targetId: "tab-1",
|
|
open: (request) => {
|
|
openedTab = true;
|
|
return {
|
|
targetId: "tab-1",
|
|
title: "Meet",
|
|
url: request.body?.url ?? MEET_URL_EN,
|
|
};
|
|
},
|
|
focus: true,
|
|
grantPermissions: true,
|
|
navigate: (request) => ({
|
|
targetId: request.body?.targetId ?? "tab-1",
|
|
url: request.body?.url ?? MEET_URL_EN,
|
|
}),
|
|
inspect: () =>
|
|
browserReady
|
|
? {
|
|
inCall: true,
|
|
micMuted: false,
|
|
audioInputRouted: true,
|
|
audioOutputRouted: true,
|
|
title: "Meet call",
|
|
url: MEET_URL,
|
|
}
|
|
: { inCall: true, title: "Meet call", url: MEET_URL },
|
|
nodeCommand: () => ({ payload: { launched: true } }),
|
|
}),
|
|
},
|
|
);
|
|
|
|
const join = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.join", {
|
|
url: MEET_URL,
|
|
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("browser-unverified");
|
|
|
|
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;
|
|
manualAction?: unknown;
|
|
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.manualAction).toBeUndefined();
|
|
expect(retryHealth.speechBlockedReason).toBe("audio-bridge-unavailable");
|
|
const nodeStartCalls = nodesInvoke.mock.calls.filter(([rawCall]) => {
|
|
const call = requireRecord(rawCall, "node invoke");
|
|
const params = requireRecord(call.params, "node invoke params");
|
|
return call.command === "googlemeet.chrome" && params.action === "start";
|
|
});
|
|
expect(nodeStartCalls).toHaveLength(1);
|
|
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" });
|
|
const openCalls = nodesInvoke.mock.calls.filter(([rawCall]) => {
|
|
const call = requireRecord(rawCall, "node invoke");
|
|
const params = requireRecord(call.params, "node invoke params");
|
|
return call.command === "browser.proxy" && params.path === "/tabs/open";
|
|
});
|
|
expect(openCalls).toHaveLength(1);
|
|
});
|
|
|
|
it("explains when chrome-node has no capable paired node", async () => {
|
|
const { tools } = setup(
|
|
{
|
|
defaultTransport: "chrome-node",
|
|
defaultMode: "transcribe",
|
|
},
|
|
{
|
|
nodesListResult: { nodes: [] },
|
|
},
|
|
);
|
|
const tool = getMeetTool({ tools });
|
|
|
|
const result = await tool.execute("id", {
|
|
action: "join",
|
|
url: MEET_URL,
|
|
});
|
|
|
|
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 = getMeetTool({ tools });
|
|
|
|
const result = await tool.execute("id", {
|
|
action: "join",
|
|
url: MEET_URL,
|
|
});
|
|
|
|
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 () => {
|
|
await withPlatform("darwin", async () => {
|
|
const callGatewayFromCli = mockLocalMeetBrowserRequest();
|
|
const { methods, runCommandWithTimeout } = setup({
|
|
defaultMode: "bidi",
|
|
chrome: {
|
|
waitForInCallMs: 1,
|
|
audioBridgeHealthCommand: ["bridge", "status"],
|
|
audioBridgeCommand: ["bridge", "start"],
|
|
},
|
|
});
|
|
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: MEET_URL },
|
|
respond,
|
|
});
|
|
|
|
expectRespondedOk(respond);
|
|
expect(runCommandWithTimeout).toHaveBeenCalledWith(["bridge", "status"], {
|
|
timeoutMs: 30000,
|
|
});
|
|
expect(runCommandWithTimeout).toHaveBeenCalledWith(["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: MEET_URL_EN });
|
|
expect(extra).toStrictEqual({ timeoutMs: 35_000, scopes: ["operator.admin"] });
|
|
});
|
|
});
|
|
|
|
it("uses realtime transcription plus regular TTS in Chrome agent mode", async () => {
|
|
vi.useFakeTimers();
|
|
const { provider, requireRequest, sendAudio } = createTestMeetTranscriptionProvider();
|
|
const inputStdout = new PassThrough();
|
|
const outputStdinWrites: Buffer[] = [];
|
|
const outputStdin = new Writable({
|
|
write(chunk, _encoding, done) {
|
|
outputStdinWrites.push(Buffer.from(chunk));
|
|
done();
|
|
},
|
|
});
|
|
const inputProcess = testBridgeProcess({ stdout: inputStdout, stdin: null });
|
|
const outputProcess = testBridgeProcess({ 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 startTestLocalAgentAudioBridge({
|
|
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,
|
|
});
|
|
const callbacks = requireRequest();
|
|
|
|
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(TEST_TALKBACK_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("closes output and turn lifecycle when agent audio delivery fails", async () => {
|
|
vi.useFakeTimers();
|
|
const { provider, requireRequest } = createTestMeetTranscriptionProvider();
|
|
const { transport, writeOutput } = createTestMeetRealtimeAudioTransport();
|
|
writeOutput.mockRejectedValueOnce(new Error("audio sink failed"));
|
|
const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
const handle = await startMeetingAgentRealtimeEngine({
|
|
config: resolveGoogleMeetConfig({
|
|
realtime: { provider: "openai", agentId: "jay", introMessage: "" },
|
|
}),
|
|
fullConfig: {} as never,
|
|
runtime: {
|
|
tts: {
|
|
textToSpeechTelephony: vi.fn(async () => ({
|
|
success: true,
|
|
audioBuffer: Buffer.from([1, 0, 2, 0]),
|
|
sampleRate: 24_000,
|
|
provider: "test",
|
|
outputFormat: "pcm16",
|
|
})),
|
|
},
|
|
} as never,
|
|
platform: {
|
|
displayName: "Google Meet",
|
|
logScope: "[google-meet]",
|
|
sessionIdPrefix: "google-meet",
|
|
},
|
|
meetingSessionId: "meet-output-failure",
|
|
transport,
|
|
logger,
|
|
providers: [provider],
|
|
consultAgent: async () => ({ text: "Answer from the agent." }),
|
|
});
|
|
const callbacks = requireRequest();
|
|
|
|
callbacks.onTranscript?.("Question from the meeting.");
|
|
await vi.advanceTimersByTimeAsync(TEST_TALKBACK_DEBOUNCE_MS);
|
|
await vi.waitFor(() => {
|
|
expect(logger.warn).toHaveBeenCalledWith("[google-meet] agent TTS failed: audio sink failed");
|
|
});
|
|
|
|
expect(handle.getHealth().recentTalkEvents?.map((event) => event.type)).toEqual([
|
|
"session.started",
|
|
"session.ready",
|
|
"turn.started",
|
|
"input.audio.committed",
|
|
"transcript.done",
|
|
"output.text.done",
|
|
"output.audio.started",
|
|
"output.audio.delta",
|
|
"output.audio.done",
|
|
"turn.ended",
|
|
]);
|
|
await handle.stop();
|
|
});
|
|
|
|
it("aborts agent engine startup when the transport already failed", async () => {
|
|
const createSession = vi.fn();
|
|
const { provider } = createTestMeetTranscriptionProvider({ createSession });
|
|
const stop = vi.fn(async () => {});
|
|
const config = resolveGoogleMeetConfig({
|
|
realtime: { provider: "openai", agentId: "jay", introMessage: "" },
|
|
});
|
|
await expect(
|
|
startMeetingAgentRealtimeEngine({
|
|
config,
|
|
fullConfig: {} as never,
|
|
runtime: {} as never,
|
|
...createGoogleMeetTestEngineBindings({
|
|
config,
|
|
fullConfig: {} as never,
|
|
runtime: {} as never,
|
|
logger: noopLogger,
|
|
}),
|
|
meetingSessionId: "meet-fatal",
|
|
logger: noopLogger,
|
|
providers: [provider],
|
|
transport: {
|
|
// Pre-registration fatal replays synchronously; startup must abort before createSession.
|
|
onFatal: (handler) => handler(),
|
|
startInput: vi.fn(),
|
|
stop,
|
|
writeOutput: vi.fn(async () => {}),
|
|
clearOutput: vi.fn(async () => {}),
|
|
dispose: vi.fn(async () => {}),
|
|
},
|
|
}),
|
|
).rejects.toThrow("transport failed before transcription provider setup");
|
|
expect(createSession).not.toHaveBeenCalled();
|
|
expect(stop).toHaveBeenCalled();
|
|
});
|
|
|
|
it("stops the Chrome agent audio bridge when child stdio streams error", async () => {
|
|
const { provider, sttSession } = createTestMeetTranscriptionProvider();
|
|
const inputStdout = new PassThrough();
|
|
const inputStderr = new PassThrough();
|
|
const outputStderr = new PassThrough();
|
|
const outputStdin = new Writable({
|
|
write(_chunk, _encoding, done) {
|
|
done();
|
|
},
|
|
});
|
|
const outputProcess = testBridgeProcess({
|
|
stdin: outputStdin,
|
|
stdout: null,
|
|
stderr: outputStderr,
|
|
});
|
|
const inputProcess = testBridgeProcess({
|
|
stdin: null,
|
|
stdout: inputStdout,
|
|
stderr: inputStderr,
|
|
});
|
|
const spawnMock = vi.fn().mockReturnValueOnce(outputProcess).mockReturnValueOnce(inputProcess);
|
|
|
|
const handle = await startTestLocalAgentAudioBridge({
|
|
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(
|
|
convertMeetingTtsAudioForBridge(
|
|
ulaw,
|
|
8_000,
|
|
ulawBridgeConfig.chrome.audioFormat,
|
|
"raw-8khz-8bit-mono-mulaw",
|
|
"Google Meet",
|
|
),
|
|
).toEqual(ulaw);
|
|
const pcmForMeet = convertMeetingTtsAudioForBridge(
|
|
ulaw,
|
|
8_000,
|
|
pcmBridgeConfig.chrome.audioFormat,
|
|
"ulaw_8000",
|
|
"Google Meet",
|
|
);
|
|
expect(pcmForMeet.byteLength).toBe(18);
|
|
expect(pcmForMeet).not.toEqual(ulaw);
|
|
expect(() =>
|
|
convertMeetingTtsAudioForBridge(
|
|
Buffer.from([1, 2, 3]),
|
|
8_000,
|
|
pcmBridgeConfig.chrome.audioFormat,
|
|
"mp3",
|
|
"Google Meet",
|
|
),
|
|
).toThrow("Unsupported telephony TTS output format");
|
|
});
|
|
|
|
it("pipes Chrome command-pair audio through the realtime provider", async () => {
|
|
const { bridge, provider, requireRequest, sendAudio } = createTestMeetVoiceProvider({
|
|
defaultModel: "gpt-realtime-2",
|
|
supportsToolResultContinuation: true,
|
|
handleBargeIn: vi.fn(),
|
|
triggerGreeting: vi.fn(),
|
|
});
|
|
const inputStdout = new PassThrough();
|
|
const outputStdinWrites: Buffer[] = [];
|
|
const replacementOutputStdinWrites: Buffer[] = [];
|
|
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 = testBridgeProcess({ stdout: inputStdout, stdin: null });
|
|
const outputProcess = testBridgeProcess({ stdin: outputStdin, stdout: null });
|
|
const replacementOutputProcess = testBridgeProcess({
|
|
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 startTestLocalRealtimeAudioBridge({
|
|
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,
|
|
});
|
|
const callbacks = requireRequest();
|
|
|
|
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]));
|
|
await vi.waitFor(() => {
|
|
expect(outputStdinWrites).toEqual([Buffer.from([4, 5])]);
|
|
});
|
|
callbacks.onMark?.("mark-1");
|
|
callbacks.onClearAudio();
|
|
await vi.waitFor(() => {
|
|
expect(outputProcess.kill).toHaveBeenCalledWith("SIGKILL");
|
|
});
|
|
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.onAudio(Buffer.from([6, 7]));
|
|
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]));
|
|
await vi.waitFor(() => {
|
|
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");
|
|
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, provider } = createTestMeetVoiceProvider({
|
|
handleBargeIn: vi.fn(),
|
|
triggerGreeting: vi.fn(),
|
|
});
|
|
const inputStdout = new PassThrough();
|
|
const outputProcess = testBridgeProcess({
|
|
stdin: new Writable({
|
|
write(_chunk, _encoding, done) {
|
|
done();
|
|
},
|
|
}),
|
|
stdout: null,
|
|
});
|
|
const inputProcess = testBridgeProcess({ stdin: null, stdout: inputStdout });
|
|
const spawnMock = vi.fn().mockReturnValueOnce(outputProcess).mockReturnValueOnce(inputProcess);
|
|
|
|
const handle = await startTestLocalRealtimeAudioBridge({
|
|
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);
|
|
await vi.waitFor(() => {
|
|
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 {
|
|
const sendUserMessage = vi.fn();
|
|
const { provider, requireRequest } = createTestMeetVoiceProvider({
|
|
defaultModel: "gpt-realtime-2",
|
|
sendUserMessage,
|
|
triggerGreeting: vi.fn(),
|
|
});
|
|
const inputStdout = new PassThrough();
|
|
const outputProcess = testBridgeProcess({
|
|
stdin: new Writable({
|
|
write(_chunk, _encoding, done) {
|
|
done();
|
|
},
|
|
}),
|
|
stdout: null,
|
|
});
|
|
const inputProcess = testBridgeProcess({ 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 startTestLocalRealtimeAudioBridge({
|
|
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,
|
|
});
|
|
const callbacks = requireRequest();
|
|
|
|
expect(callbacks.autoRespondToAudio).toBe(false);
|
|
expect(callbacks.tools).toStrictEqual([]);
|
|
callbacks.onTranscript?.(
|
|
"assistant",
|
|
"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.",
|
|
true,
|
|
);
|
|
callbacks.onTranscript?.(
|
|
"user",
|
|
"Let me know if there's anything specific you'd like to cover or if you need any support during the",
|
|
true,
|
|
);
|
|
await vi.advanceTimersByTimeAsync(TEST_TALKBACK_DEBOUNCE_MS);
|
|
expect(runtime.agent.runEmbeddedAgent).not.toHaveBeenCalled();
|
|
|
|
callbacks.onTranscript?.("user", "yes yes yes yes", true);
|
|
callbacks.onTranscript?.("user", "Are we still on track?", true);
|
|
callbacks.onTranscript?.("user", "Please include launch blockers.", true);
|
|
|
|
await vi.advanceTimersByTimeAsync(TEST_TALKBACK_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("yes yes yes yes");
|
|
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("extends realtime input suppression across queued playback", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
vi.setSystemTime(1_000);
|
|
const { bridge, provider, requireRequest } = createTestMeetVoiceProvider({
|
|
sendUserMessage: vi.fn(),
|
|
triggerGreeting: vi.fn(),
|
|
});
|
|
const audioTransport = createTestMeetRealtimeAudioTransport();
|
|
const config = resolveGoogleMeetConfig({
|
|
realtime: { strategy: "bidi", provider: "openai", model: "gpt-realtime" },
|
|
});
|
|
const handle = await startMeetingRealtimeEngine({
|
|
config,
|
|
fullConfig: {} as never,
|
|
runtime: {} as never,
|
|
...createGoogleMeetTestEngineBindings({
|
|
config,
|
|
fullConfig: {} as never,
|
|
runtime: {} as never,
|
|
logger: noopLogger,
|
|
}),
|
|
meetingSessionId: "queued-playback",
|
|
logger: noopLogger,
|
|
providers: [provider],
|
|
transport: audioTransport.transport,
|
|
});
|
|
const callbacks = requireRequest();
|
|
|
|
callbacks.onAudio(Buffer.alloc(48_000));
|
|
vi.setSystemTime(1_100);
|
|
callbacks.onAudio(Buffer.alloc(48_000));
|
|
vi.setSystemTime(5_500);
|
|
audioTransport.deliverInput(Buffer.from([1, 2, 3, 4]));
|
|
expect(bridge.sendAudio).not.toHaveBeenCalled();
|
|
|
|
vi.setSystemTime(6_001);
|
|
audioTransport.deliverInput(Buffer.from([5, 6, 7]));
|
|
expect(bridge.sendAudio).toHaveBeenCalledWith(Buffer.from([5, 6, 7]));
|
|
expect(handle.getHealth()).toMatchObject({
|
|
lastInputBytes: 3,
|
|
lastOutputBytes: 96_000,
|
|
suppressedInputBytes: 4,
|
|
});
|
|
await handle.stop();
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("uses a local barge-in input command to clear active Chrome playback", async () => {
|
|
const { bridge, provider, requireRequest, sendAudio } = createTestMeetVoiceProvider({
|
|
handleBargeIn: vi.fn(),
|
|
});
|
|
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 outputProcess = testBridgeProcess({ stdin: outputStdin, stdout: null });
|
|
const inputProcess = testBridgeProcess({ stdout: inputStdout, stdin: null });
|
|
const bargeInProcess = testBridgeProcess({ stdout: bargeInStdout, stdin: null });
|
|
const replacementOutputProcess = testBridgeProcess({
|
|
stdin: replacementOutputStdin,
|
|
stdout: null,
|
|
});
|
|
const spawnMock = vi
|
|
.fn()
|
|
.mockReturnValueOnce(outputProcess)
|
|
.mockReturnValueOnce(inputProcess)
|
|
.mockReturnValueOnce(bargeInProcess)
|
|
.mockReturnValueOnce(replacementOutputProcess);
|
|
|
|
const handle = await startTestLocalRealtimeAudioBridge({
|
|
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,
|
|
});
|
|
const callbacks = requireRequest();
|
|
|
|
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();
|
|
await vi.waitFor(() => {
|
|
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 () => {
|
|
const { bridge, provider, requireRequest, sendAudio } = createTestMeetVoiceProvider({
|
|
supportsToolResultContinuation: true,
|
|
triggerGreeting: vi.fn(),
|
|
});
|
|
let pullCount = 0;
|
|
let idlePullStarted = false;
|
|
let releaseIdlePull: (() => void) | undefined;
|
|
const fullConfig = { models: { providers: {} } } as never;
|
|
const sessionStore: Record<string, unknown> = {};
|
|
const testRoot = createIsolatedTestDir("openclaw-google-meet-node-agent-");
|
|
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(() => path.join(testRoot, "agent")),
|
|
resolveAgentWorkspaceDir: vi.fn(() => path.join(testRoot, "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 startTestNodeRealtimeAudioBridge({
|
|
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],
|
|
});
|
|
const callbacks = requireRequest();
|
|
|
|
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]));
|
|
await vi.waitFor(() => {
|
|
const pushCall = runtime.nodes.invoke.mock.calls
|
|
.map(([call]) => call)
|
|
.find((call) => isRecord(call.params) && call.params.action === "pushAudio");
|
|
expect(pushCall).toBeDefined();
|
|
});
|
|
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",
|
|
outputGeneration: 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.");
|
|
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 { bridge, provider, sendAudio } = createTestMeetVoiceProvider({
|
|
triggerGreeting: vi.fn(),
|
|
});
|
|
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 startTestNodeRealtimeAudioBridge>> | undefined;
|
|
|
|
try {
|
|
handle = await startTestNodeRealtimeAudioBridge({
|
|
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, provider } = createTestMeetVoiceProvider({ triggerGreeting: vi.fn() });
|
|
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 startTestNodeRealtimeAudioBridge({
|
|
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: MEET_URL })),
|
|
);
|
|
expect(listed).toEqual({ bridges: [] });
|
|
|
|
await expect(
|
|
handleGoogleMeetNodeHostCommand(JSON.stringify({ action: "stopByUrl" })),
|
|
).rejects.toThrow("url required");
|
|
});
|
|
});
|
|
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|