fix(browser): remote profile tab ops follow-up (#1060) (thanks @mukhtharcm)
Landed via follow-up to #1057. Gate: pnpm lint && pnpm build && pnpm test
This commit is contained in:
committed by
GitHub
parent
e16ce1a0a1
commit
a76cbc43bb
@@ -32,6 +32,7 @@
|
|||||||
- Sessions: repair orphaned user turns before embedded prompts.
|
- Sessions: repair orphaned user turns before embedded prompts.
|
||||||
- Channels: treat replies to the bot as implicit mentions across supported channels.
|
- Channels: treat replies to the bot as implicit mentions across supported channels.
|
||||||
- Browser: remote profile tab operations prefer persistent Playwright and avoid silent HTTP fallbacks. (#1057) — thanks @mukhtharcm.
|
- Browser: remote profile tab operations prefer persistent Playwright and avoid silent HTTP fallbacks. (#1057) — thanks @mukhtharcm.
|
||||||
|
- Browser: remote profile tab ops follow-up: shared Playwright loader, Playwright-based focus, and more coverage (incl. opt-in live Browserless test). (follow-up to #1057) — thanks @mukhtharcm.
|
||||||
- WhatsApp: scope self-chat response prefix; inject pending-only group history and clear after any processed message.
|
- WhatsApp: scope self-chat response prefix; inject pending-only group history and clear after any processed message.
|
||||||
- Agents: drop unsigned Gemini tool calls and avoid JSON Schema `format` keyword collisions.
|
- Agents: drop unsigned Gemini tool calls and avoid JSON Schema `format` keyword collisions.
|
||||||
- Auth: inherit/merge sub-agent auth profiles from the main agent.
|
- Auth: inherit/merge sub-agent auth profiles from the main agent.
|
||||||
|
|||||||
41
src/browser/pw-ai-module.ts
Normal file
41
src/browser/pw-ai-module.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { extractErrorCode, formatErrorMessage } from "../infra/errors.js";
|
||||||
|
|
||||||
|
export type PwAiModule = typeof import("./pw-ai.js");
|
||||||
|
|
||||||
|
type PwAiLoadMode = "soft" | "strict";
|
||||||
|
|
||||||
|
let pwAiModuleSoft: Promise<PwAiModule | null> | null = null;
|
||||||
|
let pwAiModuleStrict: Promise<PwAiModule | null> | null = null;
|
||||||
|
|
||||||
|
function isModuleNotFoundError(err: unknown): boolean {
|
||||||
|
const code = extractErrorCode(err);
|
||||||
|
if (code === "ERR_MODULE_NOT_FOUND") return true;
|
||||||
|
const msg = formatErrorMessage(err);
|
||||||
|
return (
|
||||||
|
msg.includes("Cannot find module") ||
|
||||||
|
msg.includes("Cannot find package") ||
|
||||||
|
msg.includes("Failed to resolve import") ||
|
||||||
|
msg.includes("Failed to resolve entry for package") ||
|
||||||
|
msg.includes("Failed to load url")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPwAiModule(mode: PwAiLoadMode): Promise<PwAiModule | null> {
|
||||||
|
try {
|
||||||
|
return await import("./pw-ai.js");
|
||||||
|
} catch (err) {
|
||||||
|
if (mode === "soft") return null;
|
||||||
|
if (isModuleNotFoundError(err)) return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPwAiModule(opts?: { mode?: PwAiLoadMode }): Promise<PwAiModule | null> {
|
||||||
|
const mode: PwAiLoadMode = opts?.mode ?? "soft";
|
||||||
|
if (mode === "soft") {
|
||||||
|
if (!pwAiModuleSoft) pwAiModuleSoft = loadPwAiModule("soft");
|
||||||
|
return await pwAiModuleSoft;
|
||||||
|
}
|
||||||
|
if (!pwAiModuleStrict) pwAiModuleStrict = loadPwAiModule("strict");
|
||||||
|
return await pwAiModuleStrict;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ export {
|
|||||||
closePlaywrightBrowserConnection,
|
closePlaywrightBrowserConnection,
|
||||||
createPageViaPlaywright,
|
createPageViaPlaywright,
|
||||||
ensurePageState,
|
ensurePageState,
|
||||||
|
focusPageByTargetIdViaPlaywright,
|
||||||
getPageForTargetId,
|
getPageForTargetId,
|
||||||
listPagesViaPlaywright,
|
listPagesViaPlaywright,
|
||||||
refLocator,
|
refLocator,
|
||||||
|
|||||||
49
src/browser/pw-session.browserless.live.test.ts
Normal file
49
src/browser/pw-session.browserless.live.test.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, it } from "vitest";
|
||||||
|
|
||||||
|
const LIVE = process.env.LIVE === "1" || process.env.CLAWDBOT_LIVE_TEST === "1";
|
||||||
|
const CDP_URL = process.env.CLAWDBOT_LIVE_BROWSER_CDP_URL?.trim() || "";
|
||||||
|
const describeLive = LIVE && CDP_URL ? describe : describe.skip;
|
||||||
|
|
||||||
|
async function waitFor(
|
||||||
|
fn: () => Promise<boolean>,
|
||||||
|
opts: { timeoutMs: number; intervalMs: number },
|
||||||
|
): Promise<void> {
|
||||||
|
const deadline = Date.now() + opts.timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (await fn()) return;
|
||||||
|
await new Promise((r) => setTimeout(r, opts.intervalMs));
|
||||||
|
}
|
||||||
|
throw new Error("timed out");
|
||||||
|
}
|
||||||
|
|
||||||
|
describeLive("browser (live): remote CDP tab persistence", () => {
|
||||||
|
it("creates, lists, focuses, and closes tabs via Playwright", { timeout: 60_000 }, async () => {
|
||||||
|
const pw = await import("./pw-ai.js");
|
||||||
|
await pw.closePlaywrightBrowserConnection().catch(() => {});
|
||||||
|
|
||||||
|
const created = await pw.createPageViaPlaywright({ cdpUrl: CDP_URL, url: "about:blank" });
|
||||||
|
try {
|
||||||
|
await waitFor(
|
||||||
|
async () => {
|
||||||
|
const pages = await pw.listPagesViaPlaywright({ cdpUrl: CDP_URL });
|
||||||
|
return pages.some((p) => p.targetId === created.targetId);
|
||||||
|
},
|
||||||
|
{ timeoutMs: 10_000, intervalMs: 250 },
|
||||||
|
);
|
||||||
|
|
||||||
|
await pw.focusPageByTargetIdViaPlaywright({ cdpUrl: CDP_URL, targetId: created.targetId });
|
||||||
|
|
||||||
|
await pw.closePageByTargetIdViaPlaywright({ cdpUrl: CDP_URL, targetId: created.targetId });
|
||||||
|
|
||||||
|
await waitFor(
|
||||||
|
async () => {
|
||||||
|
const pages = await pw.listPagesViaPlaywright({ cdpUrl: CDP_URL });
|
||||||
|
return !pages.some((p) => p.targetId === created.targetId);
|
||||||
|
},
|
||||||
|
{ timeoutMs: 10_000, intervalMs: 250 },
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await pw.closePlaywrightBrowserConnection().catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -480,3 +480,31 @@ export async function closePageByTargetIdViaPlaywright(opts: {
|
|||||||
}
|
}
|
||||||
await page.close();
|
await page.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Focus a page/tab by targetId using the persistent Playwright connection.
|
||||||
|
* Used for remote profiles where HTTP-based /json/activate can be ephemeral.
|
||||||
|
*/
|
||||||
|
export async function focusPageByTargetIdViaPlaywright(opts: {
|
||||||
|
cdpUrl: string;
|
||||||
|
targetId: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const { browser } = await connectBrowser(opts.cdpUrl);
|
||||||
|
const page = await findPageByTargetId(browser, opts.targetId);
|
||||||
|
if (!page) {
|
||||||
|
throw new Error("tab not found");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await page.bringToFront();
|
||||||
|
} catch (err) {
|
||||||
|
const session = await page.context().newCDPSession(page);
|
||||||
|
try {
|
||||||
|
await session.send("Page.bringToFront");
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
await session.detach().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type express from "express";
|
import type express from "express";
|
||||||
|
|
||||||
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
|
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
|
||||||
|
import type { PwAiModule } from "../pw-ai-module.js";
|
||||||
|
import { getPwAiModule as getPwAiModuleBase } from "../pw-ai-module.js";
|
||||||
import { getProfileContext, jsonError } from "./utils.js";
|
import { getProfileContext, jsonError } from "./utils.js";
|
||||||
|
|
||||||
export const SELECTOR_UNSUPPORTED_MESSAGE = [
|
export const SELECTOR_UNSUPPORTED_MESSAGE = [
|
||||||
@@ -38,20 +40,8 @@ export function resolveProfileContext(
|
|||||||
return profileCtx;
|
return profileCtx;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PwAiModule = typeof import("../pw-ai.js");
|
|
||||||
|
|
||||||
let pwAiModule: Promise<PwAiModule | null> | null = null;
|
|
||||||
|
|
||||||
export async function getPwAiModule(): Promise<PwAiModule | null> {
|
export async function getPwAiModule(): Promise<PwAiModule | null> {
|
||||||
if (pwAiModule) return pwAiModule;
|
return await getPwAiModuleBase({ mode: "soft" });
|
||||||
pwAiModule = (async () => {
|
|
||||||
try {
|
|
||||||
return await import("../pw-ai.js");
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return pwAiModule;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requirePwAi(
|
export async function requirePwAi(
|
||||||
|
|||||||
@@ -93,6 +93,94 @@ describe("browser server-context remote profile tab operations", () => {
|
|||||||
expect(fetchMock).not.toHaveBeenCalled();
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("prefers lastTargetId for remote profiles when targetId is omitted", async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
const responses = [
|
||||||
|
// ensureTabAvailable() calls listTabs twice
|
||||||
|
[
|
||||||
|
{ targetId: "A", title: "A", url: "https://a.example", type: "page" },
|
||||||
|
{ targetId: "B", title: "B", url: "https://b.example", type: "page" },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ targetId: "A", title: "A", url: "https://a.example", type: "page" },
|
||||||
|
{ targetId: "B", title: "B", url: "https://b.example", type: "page" },
|
||||||
|
],
|
||||||
|
// second ensureTabAvailable() calls listTabs twice, order flips
|
||||||
|
[
|
||||||
|
{ targetId: "B", title: "B", url: "https://b.example", type: "page" },
|
||||||
|
{ targetId: "A", title: "A", url: "https://a.example", type: "page" },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ targetId: "B", title: "B", url: "https://b.example", type: "page" },
|
||||||
|
{ targetId: "A", title: "A", url: "https://a.example", type: "page" },
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
const listPagesViaPlaywright = vi.fn(async () => {
|
||||||
|
const next = responses.shift();
|
||||||
|
if (!next) throw new Error("no more responses");
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.doMock("./pw-ai.js", () => ({
|
||||||
|
listPagesViaPlaywright,
|
||||||
|
createPageViaPlaywright: vi.fn(async () => {
|
||||||
|
throw new Error("unexpected create");
|
||||||
|
}),
|
||||||
|
closePageByTargetIdViaPlaywright: vi.fn(async () => {
|
||||||
|
throw new Error("unexpected close");
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fetchMock = vi.fn(async () => {
|
||||||
|
throw new Error("unexpected fetch");
|
||||||
|
});
|
||||||
|
// @ts-expect-error test override
|
||||||
|
global.fetch = fetchMock;
|
||||||
|
|
||||||
|
const { createBrowserRouteContext } = await import("./server-context.js");
|
||||||
|
const state = makeState("remote");
|
||||||
|
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||||
|
const remote = ctx.forProfile("remote");
|
||||||
|
|
||||||
|
const first = await remote.ensureTabAvailable();
|
||||||
|
expect(first.targetId).toBe("A");
|
||||||
|
const second = await remote.ensureTabAvailable();
|
||||||
|
expect(second.targetId).toBe("A");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses Playwright focus for remote profiles when available", async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
const listPagesViaPlaywright = vi.fn(async () => [
|
||||||
|
{ targetId: "T1", title: "Tab 1", url: "https://a.example", type: "page" },
|
||||||
|
]);
|
||||||
|
const focusPageByTargetIdViaPlaywright = vi.fn(async () => {});
|
||||||
|
|
||||||
|
vi.doMock("./pw-ai.js", () => ({
|
||||||
|
listPagesViaPlaywright,
|
||||||
|
focusPageByTargetIdViaPlaywright,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fetchMock = vi.fn(async () => {
|
||||||
|
throw new Error("unexpected fetch");
|
||||||
|
});
|
||||||
|
// @ts-expect-error test override
|
||||||
|
global.fetch = fetchMock;
|
||||||
|
|
||||||
|
const { createBrowserRouteContext } = await import("./server-context.js");
|
||||||
|
const state = makeState("remote");
|
||||||
|
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||||
|
const remote = ctx.forProfile("remote");
|
||||||
|
|
||||||
|
await remote.focusTab("T1");
|
||||||
|
expect(focusPageByTargetIdViaPlaywright).toHaveBeenCalledWith({
|
||||||
|
cdpUrl: "https://browserless.example/chrome?token=abc",
|
||||||
|
targetId: "T1",
|
||||||
|
});
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
expect(state.profiles.get("remote")?.lastTargetId).toBe("T1");
|
||||||
|
});
|
||||||
|
|
||||||
it("does not swallow Playwright runtime errors for remote profiles", async () => {
|
it("does not swallow Playwright runtime errors for remote profiles", async () => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
vi.doMock("./pw-ai.js", () => ({
|
vi.doMock("./pw-ai.js", () => ({
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
|
||||||
import { extractErrorCode, formatErrorMessage } from "../infra/errors.js";
|
|
||||||
|
|
||||||
import { appendCdpPath, createTargetViaCdp, getHeadersWithAuth, normalizeCdpWsUrl } from "./cdp.js";
|
import { appendCdpPath, createTargetViaCdp, getHeadersWithAuth, normalizeCdpWsUrl } from "./cdp.js";
|
||||||
import {
|
import {
|
||||||
isChromeCdpReady,
|
isChromeCdpReady,
|
||||||
@@ -24,39 +22,11 @@ import {
|
|||||||
ensureChromeExtensionRelayServer,
|
ensureChromeExtensionRelayServer,
|
||||||
stopChromeExtensionRelayServer,
|
stopChromeExtensionRelayServer,
|
||||||
} from "./extension-relay.js";
|
} from "./extension-relay.js";
|
||||||
|
import type { PwAiModule } from "./pw-ai-module.js";
|
||||||
|
import { getPwAiModule } from "./pw-ai-module.js";
|
||||||
import { resolveTargetIdFromTabs } from "./target-id.js";
|
import { resolveTargetIdFromTabs } from "./target-id.js";
|
||||||
import { movePathToTrash } from "./trash.js";
|
import { movePathToTrash } from "./trash.js";
|
||||||
|
|
||||||
type PwAiModule = typeof import("./pw-ai.js");
|
|
||||||
|
|
||||||
let pwAiModule: Promise<PwAiModule | null> | null = null;
|
|
||||||
|
|
||||||
function isModuleNotFoundError(err: unknown): boolean {
|
|
||||||
const code = extractErrorCode(err);
|
|
||||||
if (code === "ERR_MODULE_NOT_FOUND") return true;
|
|
||||||
const msg = formatErrorMessage(err);
|
|
||||||
return (
|
|
||||||
msg.includes("Cannot find module") ||
|
|
||||||
msg.includes("Cannot find package") ||
|
|
||||||
msg.includes("Failed to resolve import") ||
|
|
||||||
msg.includes("Failed to resolve entry for package") ||
|
|
||||||
msg.includes("Failed to load url")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getPwAiModule(): Promise<PwAiModule | null> {
|
|
||||||
if (pwAiModule) return pwAiModule;
|
|
||||||
pwAiModule = (async () => {
|
|
||||||
try {
|
|
||||||
return await import("./pw-ai.js");
|
|
||||||
} catch (err) {
|
|
||||||
if (isModuleNotFoundError(err)) return null;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return pwAiModule;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
BrowserRouteContext,
|
BrowserRouteContext,
|
||||||
BrowserServerState,
|
BrowserServerState,
|
||||||
@@ -134,7 +104,7 @@ function createProfileContext(
|
|||||||
const listTabs = async (): Promise<BrowserTab[]> => {
|
const listTabs = async (): Promise<BrowserTab[]> => {
|
||||||
// For remote profiles, use Playwright's persistent connection to avoid ephemeral sessions
|
// For remote profiles, use Playwright's persistent connection to avoid ephemeral sessions
|
||||||
if (!profile.cdpIsLoopback) {
|
if (!profile.cdpIsLoopback) {
|
||||||
const mod = await getPwAiModule();
|
const mod = await getPwAiModule({ mode: "strict" });
|
||||||
const listPagesViaPlaywright = (mod as Partial<PwAiModule> | null)?.listPagesViaPlaywright;
|
const listPagesViaPlaywright = (mod as Partial<PwAiModule> | null)?.listPagesViaPlaywright;
|
||||||
if (typeof listPagesViaPlaywright === "function") {
|
if (typeof listPagesViaPlaywright === "function") {
|
||||||
const pages = await listPagesViaPlaywright({ cdpUrl: profile.cdpUrl });
|
const pages = await listPagesViaPlaywright({ cdpUrl: profile.cdpUrl });
|
||||||
@@ -171,7 +141,7 @@ function createProfileContext(
|
|||||||
// For remote profiles, use Playwright's persistent connection to create tabs
|
// For remote profiles, use Playwright's persistent connection to create tabs
|
||||||
// This ensures the tab persists beyond a single request
|
// This ensures the tab persists beyond a single request
|
||||||
if (!profile.cdpIsLoopback) {
|
if (!profile.cdpIsLoopback) {
|
||||||
const mod = await getPwAiModule();
|
const mod = await getPwAiModule({ mode: "strict" });
|
||||||
const createPageViaPlaywright = (mod as Partial<PwAiModule> | null)?.createPageViaPlaywright;
|
const createPageViaPlaywright = (mod as Partial<PwAiModule> | null)?.createPageViaPlaywright;
|
||||||
if (typeof createPageViaPlaywright === "function") {
|
if (typeof createPageViaPlaywright === "function") {
|
||||||
const page = await createPageViaPlaywright({ cdpUrl: profile.cdpUrl, url });
|
const page = await createPageViaPlaywright({ cdpUrl: profile.cdpUrl, url });
|
||||||
@@ -437,6 +407,22 @@ function createProfileContext(
|
|||||||
}
|
}
|
||||||
throw new Error("tab not found");
|
throw new Error("tab not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!profile.cdpIsLoopback) {
|
||||||
|
const mod = await getPwAiModule({ mode: "strict" });
|
||||||
|
const focusPageByTargetIdViaPlaywright = (mod as Partial<PwAiModule> | null)
|
||||||
|
?.focusPageByTargetIdViaPlaywright;
|
||||||
|
if (typeof focusPageByTargetIdViaPlaywright === "function") {
|
||||||
|
await focusPageByTargetIdViaPlaywright({
|
||||||
|
cdpUrl: profile.cdpUrl,
|
||||||
|
targetId: resolved.targetId,
|
||||||
|
});
|
||||||
|
const profileState = getProfileState();
|
||||||
|
profileState.lastTargetId = resolved.targetId;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await fetchOk(appendCdpPath(profile.cdpUrl, `/json/activate/${resolved.targetId}`));
|
await fetchOk(appendCdpPath(profile.cdpUrl, `/json/activate/${resolved.targetId}`));
|
||||||
const profileState = getProfileState();
|
const profileState = getProfileState();
|
||||||
profileState.lastTargetId = resolved.targetId;
|
profileState.lastTargetId = resolved.targetId;
|
||||||
@@ -454,7 +440,7 @@ function createProfileContext(
|
|||||||
|
|
||||||
// For remote profiles, use Playwright's persistent connection to close tabs
|
// For remote profiles, use Playwright's persistent connection to close tabs
|
||||||
if (!profile.cdpIsLoopback) {
|
if (!profile.cdpIsLoopback) {
|
||||||
const mod = await getPwAiModule();
|
const mod = await getPwAiModule({ mode: "strict" });
|
||||||
const closePageByTargetIdViaPlaywright = (mod as Partial<PwAiModule> | null)
|
const closePageByTargetIdViaPlaywright = (mod as Partial<PwAiModule> | null)
|
||||||
?.closePageByTargetIdViaPlaywright;
|
?.closePageByTargetIdViaPlaywright;
|
||||||
if (typeof closePageByTargetIdViaPlaywright === "function") {
|
if (typeof closePageByTargetIdViaPlaywright === "function") {
|
||||||
|
|||||||
Reference in New Issue
Block a user