fix(google-meet): force English UI when reusing existing Chrome tabs (#103719)

* fix(google-meet): force English UI when reusing existing Chrome tab

* fix(google-meet): narrow reused tab before forcing English UI

* test(google-meet): assert /navigate target id is adopted in reused tabs

* fix(google-meet): preserve active call tabs and normalize English before recovery

* fix(google-meet): preserve localized active tabs

* test(google-meet): avoid shadowing path import

* test(google-meet): align mocks with English-pinned tab reuse

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
cxbAsDev
2026-07-11 15:11:00 +08:00
committed by GitHub
parent ae2c6117d3
commit 4fcee4f4fd
7 changed files with 641 additions and 40 deletions

View File

@@ -595,6 +595,16 @@ describe("google-meet create flow", () => {
if (proxy.path === "/tabs/focus") {
return { payload: { result: { ok: true } } };
}
if (proxy.path === "/navigate") {
return {
payload: {
result: {
targetId: "navigated-create-tab",
url: "https://meet.google.com/new?hl=en",
},
},
};
}
if (proxy.path === "/act") {
return {
payload: {
@@ -629,7 +639,7 @@ describe("google-meet create flow", () => {
expect(payload.meetingUri).toBe("https://meet.google.com/reu-sedx-tab");
const browser = requireRecord(payload.browser, "browser payload");
expect(browser.nodeId).toBe("node-1");
expect(browser.targetId).toBe("existing-create-tab");
expect(browser.targetId).toBe("navigated-create-tab");
findNodeInvokeParams(nodesInvoke, "focus existing tab", (params) => {
if (!params.params || typeof params.params !== "object") {
return false;
@@ -641,6 +651,32 @@ describe("google-meet create flow", () => {
const body = proxy.body as Record<string, unknown>;
return proxy.path === "/tabs/focus" && body.targetId === "existing-create-tab";
});
findNodeInvokeParams(nodesInvoke, "navigate reused tab to English UI", (params) => {
if (!params.params || typeof params.params !== "object") {
return false;
}
const proxy = params.params as Record<string, unknown>;
if (!proxy.body || typeof proxy.body !== "object") {
return false;
}
const body = proxy.body as Record<string, unknown>;
return (
proxy.path === "/navigate" &&
body.targetId === "existing-create-tab" &&
body.url === "https://meet.google.com/new?hl=en"
);
});
findNodeInvokeParams(nodesInvoke, "act uses navigated target id", (params) => {
if (!params.params || typeof params.params !== "object") {
return false;
}
const proxy = params.params as Record<string, unknown>;
if (!proxy.body || typeof proxy.body !== "object") {
return false;
}
const body = proxy.body as Record<string, unknown>;
return proxy.path === "/act" && body.targetId === "navigated-create-tab";
});
const openedCreateTab = mockCalls(nodesInvoke, "nodes invoke").some(([value]) => {
if (!value || typeof value !== "object") {
return false;
@@ -655,6 +691,76 @@ describe("google-meet create flow", () => {
expect(openedCreateTab).toBe(false);
});
it("does not navigate a reused tab that is already using English UI", 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: "english-create-tab",
title: "Meet",
url: "https://meet.google.com/new?hl=en",
},
],
},
},
};
}
if (proxy.path === "/tabs/focus") {
return { payload: { result: { ok: true } } };
}
if (proxy.path === "/act") {
return {
payload: {
result: {
ok: true,
targetId: proxy.body?.targetId ?? "english-create-tab",
result: {
meetingUri: "https://meet.google.com/eng-lish-tab",
browserUrl: "https://meet.google.com/eng-lish-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 });
const navigated = mockCalls(nodesInvoke, "nodes invoke").some(([value]) => {
if (!value || typeof value !== "object") {
return false;
}
const params = value as Record<string, unknown>;
if (!params.params || typeof params.params !== "object") {
return false;
}
const proxy = params.params as Record<string, unknown>;
return proxy.path === "/navigate";
});
expect(navigated).toBe(false);
});
it.each([
["Use microphone", "Accepted Meet microphone prompt with browser automation."],
[

View File

@@ -288,6 +288,12 @@ function mockLocalMeetBrowserRequest(
if (request.path === "/tabs/focus") {
return { ok: true };
}
if (request.path === "/navigate") {
return {
targetId: request.body?.targetId ?? "local-meet-tab",
url: request.body?.url ?? "https://meet.google.com/abc-defg-hij",
};
}
if (request.path === "/permissions/grant") {
return {
ok: true,
@@ -2744,7 +2750,7 @@ describe("google-meet plugin", () => {
{
targetId: "local-meet-tab",
title: "Meet",
url: "https://meet.google.com/abc-defg-hij",
url: "https://meet.google.com/abc-defg-hij?hl=en",
},
],
}
@@ -2755,12 +2761,18 @@ describe("google-meet plugin", () => {
return {
targetId: "local-meet-tab",
title: "Meet",
url: request.body?.url ?? "https://meet.google.com/abc-defg-hij",
url: request.body?.url ?? "https://meet.google.com/abc-defg-hij?hl=en",
};
}
if (request.path === "/tabs/focus") {
return { ok: true };
}
if (request.path === "/navigate") {
return {
targetId: request.body?.targetId ?? "local-meet-tab",
url: request.body?.url ?? "https://meet.google.com/abc-defg-hij?hl=en",
};
}
if (request.path === "/act") {
actCount += 1;
return {
@@ -2863,7 +2875,7 @@ describe("google-meet plugin", () => {
{
targetId: "tab-1",
title: "Meet",
url: "https://meet.google.com/abc-defg-hij",
url: "https://meet.google.com/abc-defg-hij?hl=en",
},
]
: [],
@@ -2878,7 +2890,7 @@ describe("google-meet plugin", () => {
result: {
targetId: "tab-1",
title: "Meet",
url: raw.body?.url ?? "https://meet.google.com/abc-defg-hij",
url: raw.body?.url ?? "https://meet.google.com/abc-defg-hij?hl=en",
},
},
};
@@ -2886,6 +2898,16 @@ describe("google-meet plugin", () => {
if (raw.path === "/tabs/focus" || raw.path === "/permissions/grant") {
return { payload: { result: { ok: true } } };
}
if (raw.path === "/navigate") {
return {
payload: {
result: {
targetId: raw.body?.targetId ?? "tab-1",
url: raw.body?.url ?? "https://meet.google.com/abc-defg-hij?hl=en",
},
},
};
}
if (raw.path === "/act") {
return {
payload: {
@@ -3074,6 +3096,53 @@ describe("google-meet plugin", () => {
expect(result.manualActionMessage).toContain("Allow microphone/camera/speaker permissions");
});
it("does not auto-join when Meet is already active elsewhere", async () => {
const joinElsewhere = {
disabled: false,
innerText: "Join here too",
textContent: "Join here too",
click: vi.fn(),
getAttribute: vi.fn(() => null),
};
const document = {
body: { innerText: "", textContent: "" },
title: "Meet",
querySelector: vi.fn(() => null),
querySelectorAll: vi.fn((selector: string) => {
if (selector === "button") {
return [joinElsewhere];
}
return [];
}),
};
const context = createContext({
JSON,
document,
location: {
href: "https://meet.google.com/abc-defg-hij?hl=en",
hostname: "meet.google.com",
},
window: {},
});
const inspect = new Script(
`(${chromeTransportTesting.meetStatusScriptForTest({
allowMicrophone: false,
autoJoin: true,
captureCaptions: false,
guestName: "OpenClaw Agent",
})})`,
).runInContext(context) as () => string | Promise<string>;
const result = JSON.parse(await inspect()) as {
clickedJoin?: boolean;
manualActionReason?: string;
};
expect(result.clickedJoin).toBe(false);
expect(result.manualActionReason).toBe("meet-session-conflict");
expect(joinElsewhere.click).not.toHaveBeenCalled();
});
it("uses the local Meet microphone control instead of remote participant mute buttons", async () => {
const makeButton = (label: string, disabled = false) => ({
disabled,
@@ -3392,7 +3461,7 @@ describe("google-meet plugin", () => {
expect(session.notes).toContain("Reused existing active Meet session.");
});
it("reuses existing Meet browser tabs across URL query differences", async () => {
it("opens an English replacement without touching an ambiguous matching tab", async () => {
const { methods, nodesInvoke } = setup(
{
defaultTransport: "chrome-node",
@@ -3413,6 +3482,11 @@ describe("google-meet plugin", () => {
result: {
running: true,
tabs: [
{
targetId: "wrong-account-english-tab",
title: "Meet",
url: "https://meet.google.com/abc-defg-hij?authuser=other%40example.com&hl=en",
},
{
targetId: "existing-meet-tab",
title: "Meet",
@@ -3423,6 +3497,133 @@ describe("google-meet plugin", () => {
},
};
}
if (proxy.path === "/tabs/open") {
return {
payload: {
result: {
targetId: "english-meet-tab",
url: "https://meet.google.com/abc-defg-hij?authuser=me%40example.com&hl=en",
},
},
};
}
if (proxy.path === "/permissions/grant") {
return { payload: { result: { ok: true } } };
}
if (proxy.path === "/act") {
return {
payload: {
result: {
result: JSON.stringify({
inCall: true,
title: "Meet",
url: "https://meet.google.com/abc-defg-hij?authuser=me%40example.com&hl=en",
}),
},
},
};
}
throw new Error(`unexpected browser proxy path ${proxy.path}`);
},
},
);
const handler = methods.get("googlemeet.join") as
| ((ctx: {
params: Record<string, unknown>;
respond: ReturnType<typeof vi.fn>;
}) => Promise<void>)
| undefined;
const respond = vi.fn();
await handler?.({
params: { url: "https://meet.google.com/abc-defg-hij?authuser=me@example.com" },
respond,
});
const openCall = nodesInvoke.mock.calls.find(([rawCall]) => {
const call = requireRecord(rawCall, "node invoke");
const params = requireRecord(call.params, "node invoke params");
return params.path === "/tabs/open";
});
if (!openCall) {
throw new Error("Expected browser.proxy /tabs/open node invoke");
}
expect(
requireRecord(requireRecord(openCall[0], "open node invoke").params, "open params"),
).toEqual({
method: "POST",
path: "/tabs/open",
timeoutMs: 30000,
body: {
url: "https://meet.google.com/abc-defg-hij?authuser=me%40example.com&hl=en",
},
});
expect(
nodesInvoke.mock.calls.some(([rawCall]) => {
const call = requireRecord(rawCall, "node invoke");
const params = requireRecord(call.params, "node invoke params");
return params.path === "/tabs/focus" || params.path === "/navigate";
}),
).toBe(false);
const actCalls = nodesInvoke.mock.calls.filter(([rawCall]) => {
const call = requireRecord(rawCall, "node invoke");
const params = requireRecord(call.params, "node invoke params");
return params.path === "/act";
});
expect(actCalls.length).toBeGreaterThanOrEqual(1);
const englishTabActCall = actCalls.find(([rawCall]) => {
const call = requireRecord(rawCall, "node invoke");
const params = requireRecord(call.params, "node invoke params");
return requireRecord(params.body, "act body").targetId === "english-meet-tab";
});
if (!englishTabActCall) {
throw new Error("Expected browser.proxy /act on the English replacement tab");
}
expect(
requireRecord(requireRecord(englishTabActCall[0], "act node invoke").params, "act params"),
).toEqual({
method: "POST",
path: "/act",
timeoutMs: 10000,
body: {
kind: "evaluate",
targetId: "english-meet-tab",
fn: expect.any(String),
},
});
});
it("does not navigate a reused join tab that is already using English UI", async () => {
const { methods, nodesInvoke } = setup(
{
defaultTransport: "chrome-node",
defaultMode: "transcribe",
},
{
nodesInvokeHandler: async (params) => {
if (params.command !== "browser.proxy") {
return { payload: { launched: true } };
}
const proxy = params.params as {
path?: string;
body?: { targetId?: string; url?: string };
};
if (proxy.path === "/tabs") {
return {
payload: {
result: {
running: true,
tabs: [
{
targetId: "english-meet-tab",
title: "Meet",
url: "https://meet.google.com/abc-defg-hij?hl=en",
},
],
},
},
};
}
if (proxy.path === "/tabs/focus") {
return { payload: { result: { ok: true } } };
}
@@ -3433,7 +3634,7 @@ describe("google-meet plugin", () => {
result: JSON.stringify({
inCall: true,
title: "Meet",
url: "https://meet.google.com/abc-defg-hij?authuser=me@example.com",
url: "https://meet.google.com/abc-defg-hij?hl=en",
}),
},
},
@@ -3456,22 +3657,12 @@ describe("google-meet plugin", () => {
respond,
});
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" },
});
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");
@@ -3480,7 +3671,7 @@ describe("google-meet plugin", () => {
).toBe(false);
});
it("recovers and inspects an existing Meet tab without opening a new one", async () => {
it("reports an ambiguous recovered Meet tab without reloading it", async () => {
const { tools, nodesInvoke } = setup(
{
defaultTransport: "chrome-node",
@@ -3509,6 +3700,16 @@ describe("google-meet plugin", () => {
if (proxy.path === "/tabs/focus") {
return { payload: { result: { ok: true } } };
}
if (proxy.path === "/navigate") {
return {
payload: {
result: {
targetId: proxy.body?.targetId ?? "existing-meet-tab",
url: "https://meet.google.com/abc-defg-hij?authuser=me%40example.com&hl=en",
},
},
};
}
if (proxy.path === "/act") {
return {
payload: {
@@ -3519,7 +3720,7 @@ describe("google-meet plugin", () => {
manualActionReason: "meet-admission-required",
manualActionMessage: "Admit the OpenClaw browser participant in Google Meet.",
title: "Meet",
url: "https://meet.google.com/abc-defg-hij?authuser=me@example.com",
url: "https://meet.google.com/abc-defg-hij?authuser=me%40example.com&hl=en",
}),
},
},
@@ -3545,7 +3746,8 @@ describe("google-meet plugin", () => {
expect(result.details.targetId).toBe("existing-meet-tab");
const browser = requireRecord(result.details.browser, "recovered browser state");
expect(browser.manualActionRequired).toBe(true);
expect(browser.manualActionReason).toBe("meet-admission-required");
expect(browser.manualActionReason).toBe("meet-locale-required");
expect(browser.manualActionMessage).toContain("not pinned to English");
const focusCall = nodesInvoke.mock.calls.find(([rawCall]) => {
const call = requireRecord(rawCall, "node invoke");
const params = requireRecord(call.params, "node invoke params");
@@ -3565,12 +3767,160 @@ describe("google-meet plugin", () => {
expect(
nodesInvoke.mock.calls.some(([rawCall]) => {
const call = requireRecord(rawCall, "node invoke");
return requireRecord(call.params, "node invoke params").path === "/tabs/open";
const requestPath = requireRecord(call.params, "node invoke params").path;
return (
requestPath === "/tabs/open" || requestPath === "/navigate" || requestPath === "/act"
);
}),
).toBe(false);
});
it("recovers and inspects an existing local Chrome Meet tab", async () => {
it("prefers an English replacement when recovering matching Meet tabs", async () => {
const { tools, nodesInvoke } = setup(
{ defaultTransport: "chrome-node" },
{
nodesInvokeHandler: async (params) => {
if (params.command !== "browser.proxy") {
throw new Error(`unexpected command ${params.command}`);
}
const proxy = params.params as { path?: string; body?: { targetId?: string } };
if (proxy.path === "/tabs") {
return {
payload: {
result: {
tabs: [
{
targetId: "wrong-account-english-tab",
title: "Meet",
url: "https://meet.google.com/abc-defg-hij?authuser=other%40example.com&hl=en",
},
{
targetId: "ambiguous-meet-tab",
title: "Meet",
url: "https://meet.google.com/abc-defg-hij?authuser=me%40example.com",
},
{
targetId: "english-meet-tab",
title: "Meet",
url: "https://meet.google.com/abc-defg-hij?authuser=me%40example.com&hl=en",
},
],
},
},
};
}
if (proxy.path === "/tabs/focus") {
return { payload: { result: { ok: true } } };
}
if (proxy.path === "/act") {
return {
payload: {
result: {
result: JSON.stringify({
inCall: true,
title: "Meet",
url: "https://meet.google.com/abc-defg-hij?hl=en",
}),
},
},
};
}
throw new Error(`unexpected browser proxy path ${proxy.path}`);
},
},
);
const tool = tools[0] as {
execute: (
id: string,
params: unknown,
) => Promise<{ details: { targetId?: string; browser?: unknown } }>;
};
const result = await tool.execute("id", {
action: "recover_current_tab",
url: "https://meet.google.com/abc-defg-hij?authuser=me%40example.com",
readOnly: true,
});
expect(result.details.targetId).toBe("english-meet-tab");
expect(requireRecord(result.details.browser, "recovered browser state").inCall).toBe(true);
expect(
nodesInvoke.mock.calls.some(([rawCall]) => {
const call = requireRecord(rawCall, "node invoke");
const params = requireRecord(call.params, "node invoke params");
return (
params.path === "/tabs/focus" &&
requireRecord(params.body, "focus body").targetId === "english-meet-tab"
);
}),
).toBe(true);
});
it("preserves the Google sign-in diagnostic during tab recovery", async () => {
const { tools } = setup(
{ defaultTransport: "chrome-node" },
{
nodesInvokeHandler: async (params) => {
if (params.command !== "browser.proxy") {
throw new Error(`unexpected command ${params.command}`);
}
const proxy = params.params as { path?: string };
if (proxy.path === "/tabs") {
return {
payload: {
result: {
tabs: [
{
targetId: "google-sign-in-tab",
title: "Sign in - Google Accounts - Meet",
url: "https://accounts.google.com/signin",
},
{
targetId: "unrelated-english-meet-tab",
title: "Meet",
url: "https://meet.google.com/xyz-wxyz-xyz?hl=en",
},
],
},
},
};
}
if (proxy.path === "/tabs/focus") {
return { payload: { result: { ok: true } } };
}
if (proxy.path === "/act") {
return {
payload: {
result: {
result: JSON.stringify({
inCall: false,
manualActionRequired: true,
manualActionReason: "google-login-required",
manualActionMessage: "Sign in to Google, then retry.",
url: "https://accounts.google.com/signin",
}),
},
},
};
}
throw new Error(`unexpected browser proxy path ${proxy.path}`);
},
},
);
const tool = tools[0] as {
execute: (
id: string,
params: unknown,
) => Promise<{ details: { browser?: unknown; targetId?: string } }>;
};
const result = await tool.execute("id", { action: "recover_current_tab" });
const browser = requireRecord(result.details.browser, "recovered browser state");
expect(result.details.targetId).toBe("google-sign-in-tab");
expect(browser.manualActionReason).toBe("google-login-required");
});
it("reports an ambiguous local Chrome Meet tab without reloading it", async () => {
const callGatewayFromCli = vi.fn(
async (
_method: string,
@@ -3593,6 +3943,12 @@ describe("google-meet plugin", () => {
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({
@@ -3601,7 +3957,7 @@ describe("google-meet plugin", () => {
manualActionReason: "meet-admission-required",
manualActionMessage: "Admit the OpenClaw browser participant in Google Meet.",
title: "Meet",
url: "https://meet.google.com/abc-defg-hij?authuser=me@example.com",
url: "https://meet.google.com/abc-defg-hij?authuser=me%40example.com&hl=en",
}),
};
}
@@ -3634,7 +3990,8 @@ describe("google-meet plugin", () => {
expect(result.details.targetId).toBe("local-meet-tab");
const browser = requireRecord(result.details.browser, "recovered browser state");
expect(browser.manualActionRequired).toBe(true);
expect(browser.manualActionReason).toBe("meet-admission-required");
expect(browser.manualActionReason).toBe("meet-locale-required");
expect(browser.manualActionMessage).toContain("not pinned to English");
const focusCall = callGatewayFromCli.mock.calls.find(
(call) => requireRecord(call[2], "browser request").path === "/tabs/focus",
);
@@ -3646,6 +4003,12 @@ describe("google-meet plugin", () => {
expect(requireRecord(focusCall[2], "focus request").path).toBe("/tabs/focus");
expect(requireRecord(focusCall[2], "focus request").query).toBeUndefined();
expect(focusCall[3]).toEqual({ progress: false });
expect(
callGatewayFromCli.mock.calls.some((call) => {
const requestPath = requireRecord(call[2], "browser request").path;
return requestPath === "/navigate" || requestPath === "/act";
}),
).toBe(false);
expect(nodesInvoke).not.toHaveBeenCalled();
});
@@ -3712,7 +4075,7 @@ describe("google-meet plugin", () => {
{
targetId: "local-meet-tab",
title: "Meet",
url: "https://meet.google.com/abc-defg-hij",
url: "https://meet.google.com/abc-defg-hij?hl=en",
},
]
: [],
@@ -3723,12 +4086,18 @@ describe("google-meet plugin", () => {
return {
targetId: "local-meet-tab",
title: "Meet",
url: request.body?.url ?? "https://meet.google.com/abc-defg-hij",
url: request.body?.url ?? "https://meet.google.com/abc-defg-hij?hl=en",
};
}
if (request.path === "/tabs/focus" || request.path === "/permissions/grant") {
return { ok: true };
}
if (request.path === "/navigate") {
return {
targetId: request.body?.targetId ?? "local-meet-tab",
url: request.body?.url ?? "https://meet.google.com/abc-defg-hij?hl=en",
};
}
if (request.path === "/act") {
return { result: JSON.stringify(browserState) };
}
@@ -4368,7 +4737,7 @@ describe("google-meet plugin", () => {
{
targetId: "tab-1",
title: "Meet",
url: "https://meet.google.com/abc-defg-hij",
url: "https://meet.google.com/abc-defg-hij?hl=en",
},
]
: [],
@@ -4383,7 +4752,7 @@ describe("google-meet plugin", () => {
result: {
targetId: "tab-1",
title: "Meet",
url: raw.body?.url ?? "https://meet.google.com/abc-defg-hij",
url: raw.body?.url ?? "https://meet.google.com/abc-defg-hij?hl=en",
},
},
};
@@ -4391,6 +4760,16 @@ describe("google-meet plugin", () => {
if (raw.path === "/tabs/focus" || raw.path === "/permissions/grant") {
return { payload: { result: { ok: true } } };
}
if (raw.path === "/navigate") {
return {
payload: {
result: {
targetId: raw.body?.targetId ?? "tab-1",
url: raw.body?.url ?? "https://meet.google.com/abc-defg-hij?hl=en",
},
},
};
}
if (raw.path === "/act") {
return {
payload: {

View File

@@ -2,7 +2,11 @@
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime";
import { describe, expect, it, vi } from "vitest";
import { callBrowserProxyOnNode, forceMeetEnglishUi } from "./chrome-browser-proxy.js";
import {
callBrowserProxyOnNode,
forceMeetEnglishUi,
isEnglishMeetTab,
} from "./chrome-browser-proxy.js";
describe("forceMeetEnglishUi", () => {
it("pins hl=en on Meet URLs", () => {
@@ -21,6 +25,16 @@ describe("forceMeetEnglishUi", () => {
});
});
describe("isEnglishMeetTab", () => {
it("accepts only Meet tabs explicitly pinned to English", () => {
expect(isEnglishMeetTab("https://meet.google.com/abc-defg-hij?hl=en")).toBe(true);
expect(isEnglishMeetTab("https://meet.google.com/abc-defg-hij?hl=EN&authuser=1")).toBe(true);
expect(isEnglishMeetTab("https://meet.google.com/abc-defg-hij")).toBe(false);
expect(isEnglishMeetTab("https://meet.google.com/abc-defg-hij?hl=ja")).toBe(false);
expect(isEnglishMeetTab("https://example.com/?hl=en")).toBe(false);
});
});
describe("Google Meet Chrome browser proxy", () => {
it("reports malformed node proxy payloadJSON with an owned error", async () => {
const invoke = vi.fn(async () => ({

View File

@@ -50,6 +50,22 @@ export function isSameMeetUrlForReuse(a: string | undefined, b: string | undefin
return Boolean(normalizedA && normalizedB && normalizedA === normalizedB);
}
export function isEnglishMeetTab(url: string | undefined): boolean {
if (!url) {
return false;
}
try {
const parsed = new URL(url);
return (
parsed.protocol === "https:" &&
parsed.hostname.toLowerCase() === "meet.google.com" &&
parsed.searchParams.get("hl")?.toLowerCase() === "en"
);
} catch {
return false;
}
}
type GoogleMeetNodeInfo = {
caps?: string[];
commands?: string[];

View File

@@ -284,6 +284,29 @@ export async function createMeetWithBrowserProxyOnNode(params: {
targetId: tab.targetId,
timeoutMs: stepTimeoutMs,
});
// Meet automation scripts match English UI labels; a reused tab may have
// been opened by the browser/profile in a non-English locale. Only force
// English on the /new creation page or sign-in flow; a reused tab that
// already has a meeting code may be an active call, and reloading it would
// interrupt the meeting and replace its target.
const reusedUrl = tab.url ?? "";
const isCreatePage =
/^https:\/\/meet\.google\.com\/new(?:$|[/?#])/i.test(reusedUrl) ||
reusedUrl.startsWith("https://accounts.google.com/");
const englishUrl = isCreatePage && reusedUrl ? forceMeetEnglishUi(reusedUrl) : undefined;
if (englishUrl && englishUrl !== reusedUrl) {
tab =
readBrowserTab(
await callBrowserProxyOnNode({
runtime: params.runtime,
nodeId,
method: "POST",
path: "/navigate",
body: { targetId: tab.targetId, url: englishUrl },
timeoutMs: stepTimeoutMs,
}),
) ?? tab;
}
} else {
tab = readBrowserTab(
await callBrowserProxyOnNode({

View File

@@ -24,6 +24,7 @@ import {
asBrowserTabs,
callBrowserProxyOnNode,
forceMeetEnglishUi,
isEnglishMeetTab,
isSameMeetUrlForReuse,
normalizeMeetUrlForReuse,
readBrowserTab,
@@ -60,6 +61,17 @@ function isGoogleMeetTalkBackMode(mode: GoogleMeetMode): boolean {
return mode === "agent" || mode === "bidi";
}
function readMeetAuthUser(url: string | undefined): string | undefined {
if (!url) {
return undefined;
}
try {
return new URL(url).searchParams.get("authuser") ?? undefined;
} catch {
return undefined;
}
}
export async function assertBlackHole2chAvailable(params: {
runtime: PluginRuntime;
timeoutMs: number;
@@ -433,8 +445,9 @@ function meetStatusScript(params: {
mic.click();
notes.push("Muted Meet microphone for observe-only mode.");
}
const joinElsewhere = findButton(/join here too/i);
const join = !readOnly && ${JSON.stringify(params.autoJoin)}
? findButton(/join now|ask to join|join here too/i)
? findButton(/join now|ask to join/i)
: null;
if (join) join.click();
const microphoneChoice = findButton(/\\buse microphone\\b/i);
@@ -590,6 +603,9 @@ function meetStatusScript(params: {
if (!inCall && (host === "accounts.google.com" || /use your google account|to continue to google meet|choose an account|sign in to (join|continue)/i.test(pageText))) {
manualActionReason = "google-login-required";
manualActionMessage = "Sign in to Google in the OpenClaw browser profile, then retry the Meet join.";
} else if (!inCall && joinElsewhere) {
manualActionReason = "meet-session-conflict";
manualActionMessage = "Meet is already active in another tab or device. Leave that session or reuse an English-pinned tab before retrying.";
} else if (!inCall && /asking to be let in|you.?ll join when someone lets you in|waiting to be let in|ask to join/i.test(pageText)) {
manualActionReason = "meet-admission-required";
manualActionMessage = "Admit the OpenClaw browser participant in Google Meet, then retry speech.";
@@ -667,6 +683,7 @@ async function openMeetWithBrowserRequest(params: {
const timeoutMs = Math.max(1_000, params.config.chrome.joinTimeoutMs);
let targetId: string | undefined;
let tab: BrowserTab | undefined;
let openUrl = params.url;
if (params.config.chrome.reuseExistingTab) {
const tabs = asBrowserTabs(
await params.callBrowser({
@@ -675,9 +692,21 @@ async function openMeetWithBrowserRequest(params: {
timeoutMs: Math.min(timeoutMs, 5_000),
}),
);
tab = tabs.find((entry) => isSameMeetUrlForReuse(entry.url, params.url));
const matchingTabs = tabs.filter((entry) => isSameMeetUrlForReuse(entry.url, params.url));
const requestedAuthUser = readMeetAuthUser(params.url);
tab = matchingTabs.find(
(entry) =>
isEnglishMeetTab(entry.url) &&
(!requestedAuthUser || readMeetAuthUser(entry.url) === requestedAuthUser),
);
if (!tab) {
const requestedUrl = new URL(params.url);
if (!requestedUrl.searchParams.has("authuser")) {
openUrl = matchingTabs.find((entry) => entry.url)?.url ?? params.url;
}
}
targetId = tab?.targetId;
if (targetId) {
if (tab && targetId) {
await params.callBrowser({
method: "POST",
path: "/tabs/focus",
@@ -691,7 +720,7 @@ async function openMeetWithBrowserRequest(params: {
await params.callBrowser({
method: "POST",
path: "/tabs/open",
body: { url: forceMeetEnglishUi(params.url) },
body: { url: forceMeetEnglishUi(openUrl) },
timeoutMs,
}),
);
@@ -790,6 +819,18 @@ function isRecoverableMeetTab(tab: BrowserTab, url?: string): boolean {
);
}
function findRecoverableMeetTab(tabs: BrowserTab[], url?: string): BrowserTab | undefined {
const candidates = tabs.filter((tab) => isRecoverableMeetTab(tab, url));
if (!url) {
return candidates[0];
}
const requestedAuthUser = readMeetAuthUser(url);
const accountCandidates = requestedAuthUser
? candidates.filter((tab) => readMeetAuthUser(tab.url) === requestedAuthUser)
: candidates;
return accountCandidates.find((tab) => isEnglishMeetTab(tab.url)) ?? accountCandidates[0];
}
async function inspectRecoverableMeetTab(params: {
callBrowser: BrowserRequestCaller;
config: GoogleMeetConfig;
@@ -806,6 +847,26 @@ async function inspectRecoverableMeetTab(params: {
body: { targetId: params.targetId },
timeoutMs: Math.min(params.timeoutMs, 5_000),
});
// Recovery must never reload an unknown meeting-code tab: it may be an active
// call. English-only automation can safely inspect only tabs pinned by us.
if (normalizeMeetUrlForReuse(params.tab.url) && !isEnglishMeetTab(params.tab.url)) {
const manualActionMessage =
"The existing Meet tab is not pinned to English. Open the meeting with ?hl=en, then retry recovery.";
return {
found: true,
targetId: params.targetId,
tab: params.tab,
browser: {
status: "browser-control" as const,
browserUrl: params.tab.url,
browserTitle: params.tab.title,
manualActionRequired: true,
manualActionReason: "meet-locale-required" as const,
manualActionMessage,
},
message: manualActionMessage,
};
}
const permissionNotes = params.readOnly
? []
: await grantMeetMediaPermissions({
@@ -873,7 +934,7 @@ export async function recoverCurrentMeetTab(params: {
timeoutMs: Math.min(timeoutMs, 5_000),
}),
);
const tab = tabs.find((entry) => isRecoverableMeetTab(entry, params.url));
const tab = findRecoverableMeetTab(tabs, params.url);
const targetId = tab?.targetId;
if (!tab || !targetId) {
return {
@@ -928,7 +989,7 @@ export async function recoverCurrentMeetTabOnNode(params: {
timeoutMs: Math.min(timeoutMs, 5_000),
}),
);
const tab = tabs.find((entry) => isRecoverableMeetTab(entry, params.url));
const tab = findRecoverableMeetTab(tabs, params.url);
const targetId = tab?.targetId;
if (!tab || !targetId) {
return {

View File

@@ -22,6 +22,8 @@ type GoogleMeetManualActionReason =
| "meet-admission-required"
| "meet-permission-required"
| "meet-audio-choice-required"
| "meet-locale-required"
| "meet-session-conflict"
| "browser-control-unavailable";
type GoogleMeetSpeechBlockedReason =