refactor(browser): scope CDP sessions and harden stale target recovery

This commit is contained in:
Peter Steinberger
2026-03-08 19:52:23 +00:00
parent a6131438ea
commit a075baba84
15 changed files with 469 additions and 99 deletions

View File

@@ -166,11 +166,23 @@ describe("cdp.helpers", () => {
expect(url).toBe("https://connect.example.com/?token=abc");
});
it("preserves auth and query params when normalizing secure loopback WebSocket CDP URLs", () => {
const url = normalizeCdpHttpBaseForJsonEndpoints(
"wss://user:pass@127.0.0.1:9222/devtools/browser/ABC?token=abc",
);
expect(url).toBe("https://user:pass@127.0.0.1:9222/?token=abc");
});
it("strips a trailing /cdp suffix when normalizing HTTP bases", () => {
const url = normalizeCdpHttpBaseForJsonEndpoints("ws://127.0.0.1:9222/cdp?token=abc");
expect(url).toBe("http://127.0.0.1:9222/?token=abc");
});
it("preserves base prefixes when stripping a trailing /cdp suffix", () => {
const url = normalizeCdpHttpBaseForJsonEndpoints("ws://127.0.0.1:9222/browser/cdp?token=abc");
expect(url).toBe("http://127.0.0.1:9222/browser?token=abc");
});
it("adds basic auth headers when credentials are present", () => {
const headers = getHeadersWithAuth("https://user:pass@example.com");
expect(headers.Authorization).toBe(`Basic ${Buffer.from("user:pass").toString("base64")}`);

View File

@@ -336,6 +336,26 @@ describe("cdp", () => {
expect(normalized).toBe("ws://192.168.1.202:18850/devtools/browser/ABC");
});
it("keeps existing websocket query params when appending remote CDP query params", () => {
const normalized = normalizeCdpWsUrl(
"ws://127.0.0.1:9222/devtools/browser/ABC?session=1&token=ws-token",
"http://127.0.0.1:9222?token=cdp-token&apiKey=abc",
);
expect(normalized).toBe(
"ws://127.0.0.1:9222/devtools/browser/ABC?session=1&token=ws-token&apiKey=abc",
);
});
it("rewrites wildcard bind addresses to secure remote CDP hosts without clobbering websocket params", () => {
const normalized = normalizeCdpWsUrl(
"ws://0.0.0.0:3000/devtools/browser/ABC?session=1&token=ws-token",
"https://user:pass@example.com:9443?token=cdp-token&apiKey=abc",
);
expect(normalized).toBe(
"wss://user:pass@example.com:9443/devtools/browser/ABC?session=1&token=ws-token&apiKey=abc",
);
});
it("upgrades ws to wss when CDP uses https", () => {
const normalized = normalizeCdpWsUrl(
"ws://production-sfo.browserless.io",

View File

@@ -176,6 +176,28 @@ describe("browser config", () => {
expect(profile?.cdpIsLoopback).toBe(false);
});
it("preserves loopback direct WebSocket cdpUrl for explicit profiles", () => {
const resolved = resolveBrowserConfig({
profiles: {
localws: {
cdpUrl: "ws://127.0.0.1:9222/devtools/browser/ABC?token=test-key",
color: "#0066CC",
},
},
});
const profile = resolveProfile(resolved, "localws");
expect(profile?.cdpUrl).toBe("ws://127.0.0.1:9222/devtools/browser/ABC?token=test-key");
expect(profile?.cdpPort).toBe(9222);
expect(profile?.cdpIsLoopback).toBe(true);
});
it("trims relayBindHost when configured", () => {
const resolved = resolveBrowserConfig({
relayBindHost: " 0.0.0.0 ",
});
expect(resolved.relayBindHost).toBe("0.0.0.0");
});
it("rejects unsupported protocols", () => {
expect(() => resolveBrowserConfig({ cdpUrl: "ftp://127.0.0.1:18791" })).toThrow(
"must be http(s) or ws(s)",

View File

@@ -0,0 +1,49 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { captureEnv } from "../test-utils/env.js";
import {
ensureChromeExtensionRelayServer,
stopChromeExtensionRelayServer,
} from "./extension-relay.js";
import { getFreePort } from "./test-port.js";
describe("chrome extension relay bindHost coordination", () => {
let cdpUrl = "";
let envSnapshot: ReturnType<typeof captureEnv>;
beforeEach(() => {
envSnapshot = captureEnv(["OPENCLAW_GATEWAY_TOKEN"]);
process.env.OPENCLAW_GATEWAY_TOKEN = "test-gateway-token";
});
afterEach(async () => {
if (cdpUrl) {
await stopChromeExtensionRelayServer({ cdpUrl }).catch(() => {});
cdpUrl = "";
}
envSnapshot.restore();
});
it("rebinds the relay when concurrent callers request different bind hosts", async () => {
const port = await getFreePort();
cdpUrl = `http://127.0.0.1:${port}`;
const [first, second] = await Promise.all([
ensureChromeExtensionRelayServer({ cdpUrl }),
ensureChromeExtensionRelayServer({ cdpUrl, bindHost: "0.0.0.0" }),
]);
const settled = await ensureChromeExtensionRelayServer({
cdpUrl,
bindHost: "0.0.0.0",
});
expect(first.port).toBe(port);
expect(second.port).toBe(port);
expect(second).not.toBe(first);
expect(second.bindHost).toBe("0.0.0.0");
expect(settled).toBe(second);
const res = await fetch(`http://127.0.0.1:${port}/`);
expect(res.status).toBe(200);
});
});

View File

@@ -0,0 +1,119 @@
import { chromium } from "playwright-core";
import { afterEach, describe, expect, it, vi } from "vitest";
import * as chromeModule from "./chrome.js";
import { closePlaywrightBrowserConnection, listPagesViaPlaywright } from "./pw-session.js";
const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP");
const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl");
type BrowserMockBundle = {
browser: import("playwright-core").Browser;
browserClose: ReturnType<typeof vi.fn>;
};
function makeBrowser(targetId: string, url: string): BrowserMockBundle {
let context: import("playwright-core").BrowserContext;
const browserClose = vi.fn(async () => {});
const page = {
on: vi.fn(),
context: () => context,
title: vi.fn(async () => `title:${targetId}`),
url: vi.fn(() => url),
} as unknown as import("playwright-core").Page;
context = {
pages: () => [page],
on: vi.fn(),
newCDPSession: vi.fn(async () => ({
send: vi.fn(async (method: string) =>
method === "Target.getTargetInfo" ? { targetInfo: { targetId } } : {},
),
detach: vi.fn(async () => {}),
})),
} as unknown as import("playwright-core").BrowserContext;
const browser = {
contexts: () => [context],
on: vi.fn(),
off: vi.fn(),
close: browserClose,
} as unknown as import("playwright-core").Browser;
return { browser, browserClose };
}
afterEach(async () => {
connectOverCdpSpy.mockReset();
getChromeWebSocketUrlSpy.mockReset();
await closePlaywrightBrowserConnection().catch(() => {});
});
describe("pw-session connection scoping", () => {
it("does not share in-flight connectOverCDP promises across different cdpUrls", async () => {
const browserA = makeBrowser("A", "https://a.example");
const browserB = makeBrowser("B", "https://b.example");
let resolveA: ((value: import("playwright-core").Browser) => void) | undefined;
connectOverCdpSpy.mockImplementation((async (...args: unknown[]) => {
const endpointText = String(args[0]);
if (endpointText === "http://127.0.0.1:9222") {
return await new Promise<import("playwright-core").Browser>((resolve) => {
resolveA = resolve;
});
}
if (endpointText === "http://127.0.0.1:9333") {
return browserB.browser;
}
throw new Error(`unexpected endpoint: ${endpointText}`);
}) as never);
getChromeWebSocketUrlSpy.mockResolvedValue(null);
const pendingA = listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9222" });
await Promise.resolve();
const pendingB = listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9333" });
await vi.waitFor(() => {
expect(connectOverCdpSpy).toHaveBeenCalledTimes(2);
});
expect(connectOverCdpSpy).toHaveBeenNthCalledWith(
1,
"http://127.0.0.1:9222",
expect.any(Object),
);
expect(connectOverCdpSpy).toHaveBeenNthCalledWith(
2,
"http://127.0.0.1:9333",
expect.any(Object),
);
resolveA?.(browserA.browser);
const [pagesA, pagesB] = await Promise.all([pendingA, pendingB]);
expect(pagesA.map((page) => page.targetId)).toEqual(["A"]);
expect(pagesB.map((page) => page.targetId)).toEqual(["B"]);
});
it("closes only the requested scoped connection", async () => {
const browserA = makeBrowser("A", "https://a.example");
const browserB = makeBrowser("B", "https://b.example");
connectOverCdpSpy.mockImplementation((async (...args: unknown[]) => {
const endpointText = String(args[0]);
if (endpointText === "http://127.0.0.1:9222") {
return browserA.browser;
}
if (endpointText === "http://127.0.0.1:9333") {
return browserB.browser;
}
throw new Error(`unexpected endpoint: ${endpointText}`);
}) as never);
getChromeWebSocketUrlSpy.mockResolvedValue(null);
await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9222" });
await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9333" });
await closePlaywrightBrowserConnection({ cdpUrl: "http://127.0.0.1:9222" });
expect(browserA.browserClose).toHaveBeenCalledTimes(1);
expect(browserB.browserClose).not.toHaveBeenCalled();
});
});

View File

@@ -1,11 +1,17 @@
import { chromium } from "playwright-core";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import * as chromeModule from "./chrome.js";
import { closePlaywrightBrowserConnection, getPageForTargetId } from "./pw-session.js";
const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP");
const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl");
afterEach(async () => {
connectOverCdpSpy.mockClear();
getChromeWebSocketUrlSpy.mockClear();
await closePlaywrightBrowserConnection().catch(() => {});
});
describe("pw-session getPageForTargetId", () => {
it("falls back to the only page when CDP session attachment is blocked (extension relays)", async () => {
connectOverCdpSpy.mockClear();
@@ -50,4 +56,63 @@ describe("pw-session getPageForTargetId", () => {
await closePlaywrightBrowserConnection();
expect(browserClose).toHaveBeenCalled();
});
it("uses the shared HTTP-base normalization when falling back to /json/list for direct WebSocket CDP URLs", async () => {
const pageOn = vi.fn();
const contextOn = vi.fn();
const browserOn = vi.fn();
const browserClose = vi.fn(async () => {});
const context = {
pages: () => [],
on: contextOn,
newCDPSession: vi.fn(async () => {
throw new Error("Not allowed");
}),
} as unknown as import("playwright-core").BrowserContext;
const pageA = {
on: pageOn,
context: () => context,
url: () => "https://alpha.example",
} as unknown as import("playwright-core").Page;
const pageB = {
on: pageOn,
context: () => context,
url: () => "https://beta.example",
} as unknown as import("playwright-core").Page;
(context as unknown as { pages: () => unknown[] }).pages = () => [pageA, pageB];
const browser = {
contexts: () => [context],
on: browserOn,
close: browserClose,
} as unknown as import("playwright-core").Browser;
connectOverCdpSpy.mockResolvedValue(browser);
getChromeWebSocketUrlSpy.mockResolvedValue(null);
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => [
{ id: "TARGET_A", url: "https://alpha.example" },
{ id: "TARGET_B", url: "https://beta.example" },
],
} as Response);
try {
const resolved = await getPageForTargetId({
cdpUrl: "ws://127.0.0.1:18792/devtools/browser/SESSION?token=abc",
targetId: "TARGET_B",
});
expect(resolved).toBe(pageB);
expect(fetchSpy).toHaveBeenCalledWith(
"http://127.0.0.1:18792/json/list?token=abc",
expect.any(Object),
);
} finally {
fetchSpy.mockRestore();
}
});
});

View File

@@ -113,8 +113,8 @@ const MAX_CONSOLE_MESSAGES = 500;
const MAX_PAGE_ERRORS = 200;
const MAX_NETWORK_REQUESTS = 500;
let cached: ConnectedBrowser | null = null;
let connecting: Promise<ConnectedBrowser> | null = null;
const cachedByCdpUrl = new Map<string, ConnectedBrowser>();
const connectingByCdpUrl = new Map<string, Promise<ConnectedBrowser>>();
function normalizeCdpUrl(raw: string) {
return raw.replace(/\/$/, "");
@@ -328,9 +328,11 @@ function observeBrowser(browser: Browser) {
async function connectBrowser(cdpUrl: string): Promise<ConnectedBrowser> {
const normalized = normalizeCdpUrl(cdpUrl);
if (cached?.cdpUrl === normalized) {
const cached = cachedByCdpUrl.get(normalized);
if (cached) {
return cached;
}
const connecting = connectingByCdpUrl.get(normalized);
if (connecting) {
return await connecting;
}
@@ -348,12 +350,13 @@ async function connectBrowser(cdpUrl: string): Promise<ConnectedBrowser> {
chromium.connectOverCDP(endpoint, { timeout, headers }),
);
const onDisconnected = () => {
if (cached?.browser === browser) {
cached = null;
const current = cachedByCdpUrl.get(normalized);
if (current?.browser === browser) {
cachedByCdpUrl.delete(normalized);
}
};
const connected: ConnectedBrowser = { browser, cdpUrl: normalized, onDisconnected };
cached = connected;
cachedByCdpUrl.set(normalized, connected);
browser.on("disconnected", onDisconnected);
observeBrowser(browser);
return connected;
@@ -370,11 +373,12 @@ async function connectBrowser(cdpUrl: string): Promise<ConnectedBrowser> {
throw new Error(message);
};
connecting = connectWithRetry().finally(() => {
connecting = null;
const pending = connectWithRetry().finally(() => {
connectingByCdpUrl.delete(normalized);
});
connectingByCdpUrl.set(normalized, pending);
return await connecting;
return await pending;
}
async function getAllPages(browser: Browser): Promise<Page[]> {
@@ -423,34 +427,29 @@ async function findPageByTargetId(
// fall back to URL-based matching using the /json/list endpoint
if (cdpUrl) {
try {
const baseUrl = cdpUrl
.replace(/\/+$/, "")
.replace(/^ws:/, "http:")
.replace(/\/cdp$/, "");
const listUrl = `${baseUrl}/json/list`;
const response = await fetch(listUrl, { headers: getHeadersWithAuth(listUrl) });
if (response.ok) {
const targets = (await response.json()) as Array<{
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(cdpUrl);
const targets = await fetchJson<
Array<{
id: string;
url: string;
title?: string;
}>;
const target = targets.find((t) => t.id === targetId);
if (target) {
// Try to find a page with matching URL
const urlMatch = pages.filter((p) => p.url() === target.url);
if (urlMatch.length === 1) {
return urlMatch[0];
}
// If multiple URL matches, use index-based matching as fallback
// This works when Playwright and the relay enumerate tabs in the same order
if (urlMatch.length > 1) {
const sameUrlTargets = targets.filter((t) => t.url === target.url);
if (sameUrlTargets.length === urlMatch.length) {
const idx = sameUrlTargets.findIndex((t) => t.id === targetId);
if (idx >= 0 && idx < urlMatch.length) {
return urlMatch[idx];
}
}>
>(appendCdpPath(cdpHttpBase, "/json/list"), 2000);
const target = targets.find((t) => t.id === targetId);
if (target) {
// Try to find a page with matching URL
const urlMatch = pages.filter((p) => p.url() === target.url);
if (urlMatch.length === 1) {
return urlMatch[0];
}
// If multiple URL matches, use index-based matching as fallback
// This works when Playwright and the relay enumerate tabs in the same order
if (urlMatch.length > 1) {
const sameUrlTargets = targets.filter((t) => t.url === target.url);
if (sameUrlTargets.length === urlMatch.length) {
const idx = sameUrlTargets.findIndex((t) => t.id === targetId);
if (idx >= 0 && idx < urlMatch.length) {
return urlMatch[idx];
}
}
}
@@ -539,17 +538,32 @@ export function refLocator(page: Page, ref: string) {
return page.locator(`aria-ref=${normalized}`);
}
export async function closePlaywrightBrowserConnection(): Promise<void> {
const cur = cached;
cached = null;
connecting = null;
if (!cur) {
export async function closePlaywrightBrowserConnection(opts?: { cdpUrl?: string }): Promise<void> {
const normalized = opts?.cdpUrl ? normalizeCdpUrl(opts.cdpUrl) : null;
if (normalized) {
const cur = cachedByCdpUrl.get(normalized);
cachedByCdpUrl.delete(normalized);
connectingByCdpUrl.delete(normalized);
if (!cur) {
return;
}
if (cur.onDisconnected && typeof cur.browser.off === "function") {
cur.browser.off("disconnected", cur.onDisconnected);
}
await cur.browser.close().catch(() => {});
return;
}
if (cur.onDisconnected && typeof cur.browser.off === "function") {
cur.browser.off("disconnected", cur.onDisconnected);
const connections = Array.from(cachedByCdpUrl.values());
cachedByCdpUrl.clear();
connectingByCdpUrl.clear();
for (const cur of connections) {
if (cur.onDisconnected && typeof cur.browser.off === "function") {
cur.browser.off("disconnected", cur.onDisconnected);
}
await cur.browser.close().catch(() => {});
}
await cur.browser.close().catch(() => {});
}
function cdpSocketNeedsAttach(wsUrl: string): boolean {
@@ -655,31 +669,29 @@ export async function forceDisconnectPlaywrightForTarget(opts: {
reason?: string;
}): Promise<void> {
const normalized = normalizeCdpUrl(opts.cdpUrl);
if (cached?.cdpUrl !== normalized) {
const cur = cachedByCdpUrl.get(normalized);
if (!cur) {
return;
}
const cur = cached;
cached = null;
// Also clear `connecting` so the next call does a fresh connectOverCDP
cachedByCdpUrl.delete(normalized);
// Also clear the per-url in-flight connect so the next call does a fresh connectOverCDP
// rather than awaiting a stale promise.
connecting = null;
if (cur) {
// Remove the "disconnected" listener to prevent the old browser's teardown
// from racing with a fresh connection and nulling the new `cached`.
if (cur.onDisconnected && typeof cur.browser.off === "function") {
cur.browser.off("disconnected", cur.onDisconnected);
}
// Best-effort: kill any stuck JS to unblock the target's execution context before we
// disconnect Playwright's CDP connection.
const targetId = opts.targetId?.trim() || "";
if (targetId) {
await tryTerminateExecutionViaCdp({ cdpUrl: normalized, targetId }).catch(() => {});
}
// Fire-and-forget: don't await because browser.close() may hang on the stuck CDP pipe.
cur.browser.close().catch(() => {});
connectingByCdpUrl.delete(normalized);
// Remove the "disconnected" listener to prevent the old browser's teardown
// from racing with a fresh connection and nulling the new cached entry.
if (cur.onDisconnected && typeof cur.browser.off === "function") {
cur.browser.off("disconnected", cur.onDisconnected);
}
// Best-effort: kill any stuck JS to unblock the target's execution context before we
// disconnect Playwright's CDP connection.
const targetId = opts.targetId?.trim() || "";
if (targetId) {
await tryTerminateExecutionViaCdp({ cdpUrl: normalized, targetId }).catch(() => {});
}
// Fire-and-forget: don't await because browser.close() may hang on the stuck CDP pipe.
cur.browser.close().catch(() => {});
}
/**

View File

@@ -99,7 +99,7 @@ describe("browser server-context ensureTabAvailable", () => {
expect(second.targetId).toBe("A");
});
it("falls back to the only attached tab when an invalid targetId is provided (extension)", async () => {
it("rejects invalid targetId even when only one extension tab remains", async () => {
const responses = [
[{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }],
[{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }],
@@ -109,8 +109,7 @@ describe("browser server-context ensureTabAvailable", () => {
const ctx = createBrowserRouteContext({ getState: () => state });
const chrome = ctx.forProfile("chrome");
const chosen = await chrome.ensureTabAvailable("NOT_A_TAB");
expect(chosen.targetId).toBe("A");
await expect(chrome.ensureTabAvailable("NOT_A_TAB")).rejects.toThrow(/tab not found/i);
});
it("returns a descriptive message when no extension tabs are attached", async () => {

View File

@@ -97,4 +97,46 @@ describe("browser server-context loopback direct WebSocket profiles", () => {
expect.any(Object),
);
});
it("uses an HTTPS /json base for secure direct WebSocket profiles with a /cdp suffix", async () => {
const fetchMock = vi.fn(async (url: unknown) => {
const u = String(url);
if (u === "https://127.0.0.1:18800/json/list?token=abc") {
return {
ok: true,
json: async () => [
{
id: "T2",
title: "Secure Tab",
url: "https://example.com",
webSocketDebuggerUrl: "wss://127.0.0.1/devtools/page/T2",
type: "page",
},
],
} as unknown as Response;
}
if (u === "https://127.0.0.1:18800/json/activate/T2?token=abc") {
return { ok: true, json: async () => ({}) } as unknown as Response;
}
if (u === "https://127.0.0.1:18800/json/close/T2?token=abc") {
return { ok: true, json: async () => ({}) } as unknown as Response;
}
throw new Error(`unexpected fetch: ${u}`);
});
global.fetch = withFetchPreconnect(fetchMock);
const state = makeState("openclaw");
state.resolved.profiles.openclaw = {
cdpUrl: "wss://127.0.0.1:18800/cdp?token=abc",
color: "#FF4500",
};
const ctx = createBrowserRouteContext({ getState: () => state });
const openclaw = ctx.forProfile("openclaw");
const tabs = await openclaw.listTabs();
expect(tabs.map((tab) => tab.targetId)).toEqual(["T2"]);
await openclaw.focusTab("T2");
await openclaw.closeTab("T2");
});
});

View File

@@ -139,7 +139,7 @@ describe("browser server-context remote profile tab operations", () => {
expect(second.targetId).toBe("A");
});
it("falls back to the only tab for remote profiles when targetId is stale", async () => {
it("rejects stale targetId for remote profiles even when only one tab remains", async () => {
const responses = [
[{ targetId: "T1", title: "Tab 1", url: "https://example.com", type: "page" }],
[{ targetId: "T1", title: "Tab 1", url: "https://example.com", type: "page" }],
@@ -151,8 +151,7 @@ describe("browser server-context remote profile tab operations", () => {
} as unknown as Awaited<ReturnType<typeof pwAiModule.getPwAiModule>>);
const { remote } = createRemoteRouteHarness();
const chosen = await remote.ensureTabAvailable("STALE_TARGET");
expect(chosen.targetId).toBe("T1");
await expect(remote.ensureTabAvailable("STALE_TARGET")).rejects.toThrow(/tab not found/i);
});
it("keeps rejecting stale targetId for remote profiles when multiple tabs exist", async () => {

View File

@@ -112,7 +112,9 @@ describe("createProfileResetOps", () => {
});
expect(isHttpReachable).toHaveBeenCalledWith(300);
expect(stopRunningBrowser).toHaveBeenCalledTimes(1);
expect(pwAiMocks.closePlaywrightBrowserConnection).toHaveBeenCalledTimes(1);
expect(pwAiMocks.closePlaywrightBrowserConnection).toHaveBeenCalledWith({
cdpUrl: "http://127.0.0.1:18800",
});
expect(trashMocks.movePathToTrash).toHaveBeenCalledWith(profileDir);
});
@@ -132,5 +134,11 @@ describe("createProfileResetOps", () => {
await ops.resetProfile();
expect(stopRunningBrowser).not.toHaveBeenCalled();
expect(pwAiMocks.closePlaywrightBrowserConnection).toHaveBeenCalledTimes(2);
expect(pwAiMocks.closePlaywrightBrowserConnection).toHaveBeenNthCalledWith(1, {
cdpUrl: "http://127.0.0.1:18800",
});
expect(pwAiMocks.closePlaywrightBrowserConnection).toHaveBeenNthCalledWith(2, {
cdpUrl: "http://127.0.0.1:18800",
});
});
});

View File

@@ -16,10 +16,10 @@ type ResetOps = {
resetProfile: () => Promise<{ moved: boolean; from: string; to?: string }>;
};
async function closePlaywrightBrowserConnection(): Promise<void> {
async function closePlaywrightBrowserConnectionForProfile(cdpUrl?: string): Promise<void> {
try {
const mod = await import("./pw-ai.js");
await mod.closePlaywrightBrowserConnection();
await mod.closePlaywrightBrowserConnection(cdpUrl ? { cdpUrl } : undefined);
} catch {
// ignore
}
@@ -48,14 +48,14 @@ export function createProfileResetOps({
const httpReachable = await isHttpReachable(300);
if (httpReachable && !profileState.running) {
// Port in use but not by us - kill it.
await closePlaywrightBrowserConnection();
await closePlaywrightBrowserConnectionForProfile(profile.cdpUrl);
}
if (profileState.running) {
await stopRunningBrowser();
}
await closePlaywrightBrowserConnection();
await closePlaywrightBrowserConnectionForProfile(profile.cdpUrl);
if (!fs.existsSync(userDataDir)) {
return { moved: false, from: userDataDir };

View File

@@ -86,16 +86,7 @@ export function createProfileSelectionOps({
return page ?? candidates.at(0) ?? null;
};
let chosen = targetId ? resolveById(targetId) : pickDefault();
if (
!chosen &&
(profile.driver === "extension" || !profile.cdpIsLoopback) &&
candidates.length === 1
) {
// If an agent passes a stale/foreign targetId but only one candidate remains,
// recover by using that tab instead of failing hard.
chosen = candidates[0] ?? null;
}
const chosen = targetId ? resolveById(targetId) : pickDefault();
if (chosen === "AMBIGUOUS") {
throw new Error("ambiguous target id prefix");