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