mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-20 19:31:42 +00:00
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:
@@ -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."],
|
||||
[
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 () => ({
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user