mirror of
https://github.com/morluto/rea.git
synced 2026-11-09 00:07:18 +00:00
* feat(browser): reuse admitted browser-socket transport and allow discovery_timeout_ms * fix(browser): close unhealthy shared sockets and clamp max discovery timer * fix(browser): retry failed shared-socket cleanup while peer leases remain active * test(browser): race observation readiness with awaitAcknowledgement and join before mock restore * fix(browser): isolate shared-socket discovery aborts and coalesce cold connects * fix(browser): observe pending cold-connect cleanup failures and isolate fake CDP sessions * refactor(browser): carry typed connect cleanup owners and gate concurrent session test * test(browser): cover loopback socket reuse-key normalization * test(browser): verify distinct browser socket path IDs stay separate
1337 lines
47 KiB
TypeScript
1337 lines
47 KiB
TypeScript
import { createServer, type Socket } from "node:net";
|
|
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js";
|
|
import { CdpConnection } from "../../../src/browser/CdpConnection.js";
|
|
import { CdpTargetSessionOwner } from "../../../src/browser/CdpTargetSessionOwner.js";
|
|
import { openCdpEndpoint } from "../../../src/browser/CdpTargetSession.js";
|
|
import { AnalysisError } from "../../../src/domain/analysisErrorBase.js";
|
|
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
|
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
|
|
import {
|
|
browserObservationEndpointSchema,
|
|
inspectWebPageInputSchema,
|
|
listBrowserTargetsInputSchema,
|
|
} from "../../../src/domain/browserObservation.js";
|
|
import { observeWebSessionInputSchema } from "../../../src/domain/browserSession.js";
|
|
import { captureWebScreenshotInputSchema } from "../../../src/domain/webScreenshot.js";
|
|
import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js";
|
|
import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js";
|
|
import { trackBrowser } from "./cdpBrowserProvider.support.js";
|
|
|
|
describe("CdpBrowserProvider: browser socket without HTTP discovery", () => {
|
|
it("reuses one admitted browser socket across sequential operations and closes on provider shutdown", async () => {
|
|
const browser = await startFakeCdpBrowser();
|
|
trackBrowser(browser);
|
|
const provider = new CdpBrowserProvider();
|
|
try {
|
|
const throughHttp = await provider.listTargets(
|
|
listBrowserTargetsInputSchema.parse({ cdp_endpoint: browser.endpoint }),
|
|
);
|
|
if (!throughHttp.ok) throw throughHttp.error;
|
|
const httpRequests = browser.httpRequests.length;
|
|
const openedBefore = browser.openedConnectionCount();
|
|
|
|
const listed = await provider.listTargets(
|
|
listBrowserTargetsInputSchema.parse({
|
|
cdp_endpoint: browser.browserWebSocketUrl,
|
|
}),
|
|
);
|
|
if (!listed.ok) throw listed.error;
|
|
expect(listed.value.targets).toEqual(throughHttp.value.targets);
|
|
expect(listed.value.excluded).toEqual(throughHttp.value.excluded);
|
|
expect(listed.value.browser).toEqual({
|
|
...throughHttp.value.browser,
|
|
revision: "@fake-revision",
|
|
});
|
|
expect(browser.openedConnectionCount()).toBe(openedBefore + 1);
|
|
expect(browser.activeConnectionCount()).toBe(1);
|
|
|
|
const commandsBeforeInspect = browser.commands.length;
|
|
const inspected = await provider.inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.browserWebSocketUrl,
|
|
target_id: "allowed-page",
|
|
observation_ms: 0,
|
|
}),
|
|
);
|
|
if (!inspected.ok) throw inspected.error;
|
|
expect(inspected.value.target.origin).toBe(browser.allowedOrigin);
|
|
expect(
|
|
browser.commands
|
|
.slice(commandsBeforeInspect, commandsBeforeInspect + 3)
|
|
.map(({ method, params }) => ({ method, params })),
|
|
).toEqual([
|
|
{ method: "Browser.getVersion", params: {} },
|
|
{ method: "Target.getTargets", params: {} },
|
|
{
|
|
method: "Target.attachToTarget",
|
|
params: { targetId: "allowed-page", flatten: true },
|
|
},
|
|
]);
|
|
expect(
|
|
browser.commands
|
|
.slice(commandsBeforeInspect)
|
|
.map(({ method }) => method),
|
|
).toContain("Target.detachFromTarget");
|
|
expect(browser.openedConnectionCount()).toBe(openedBefore + 1);
|
|
expect(browser.activeConnectionCount()).toBe(1);
|
|
|
|
const commandsBeforeScreenshot = browser.commands.length;
|
|
const screenshot = await provider.captureScreenshot(
|
|
captureWebScreenshotInputSchema.parse({
|
|
cdp_endpoint: browser.browserWebSocketUrl,
|
|
target_id: "allowed-page",
|
|
}),
|
|
);
|
|
if (!screenshot.ok) throw screenshot.error;
|
|
expect(
|
|
browser.commands
|
|
.slice(commandsBeforeScreenshot)
|
|
.map(({ method }) => method),
|
|
).toContain("Target.detachFromTarget");
|
|
expect(browser.httpRequests).toHaveLength(httpRequests);
|
|
expect(browser.openedConnectionCount()).toBe(openedBefore + 1);
|
|
expect(browser.activeConnectionCount()).toBe(1);
|
|
} finally {
|
|
await provider.close();
|
|
}
|
|
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0));
|
|
});
|
|
});
|
|
|
|
describe("CdpBrowserProvider: browser socket eviction and per-call scoping", () => {
|
|
it("evicts a dropped browser socket and reconnects cleanly on the next call", async () => {
|
|
const browser = await startFakeCdpBrowser();
|
|
trackBrowser(browser);
|
|
const provider = new CdpBrowserProvider();
|
|
try {
|
|
const first = await provider.listTargets(
|
|
listBrowserTargetsInputSchema.parse({
|
|
cdp_endpoint: browser.browserWebSocketUrl,
|
|
}),
|
|
);
|
|
if (!first.ok) throw first.error;
|
|
expect(browser.openedConnectionCount()).toBe(1);
|
|
expect(browser.activeConnectionCount()).toBe(1);
|
|
|
|
browser.closeActiveConnections();
|
|
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0));
|
|
|
|
const second = await provider.inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.browserWebSocketUrl,
|
|
target_id: "allowed-page",
|
|
observation_ms: 0,
|
|
}),
|
|
);
|
|
if (!second.ok) throw second.error;
|
|
expect(second.value.target.origin).toBe(browser.allowedOrigin);
|
|
expect(browser.openedConnectionCount()).toBe(2);
|
|
expect(browser.activeConnectionCount()).toBe(1);
|
|
} finally {
|
|
await provider.close();
|
|
}
|
|
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0));
|
|
});
|
|
|
|
it("closes a shared browser socket when Target.detachFromTarget fails so no target session leaks", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
commandError: ({ method }) =>
|
|
method === "Target.detachFromTarget"
|
|
? { code: -32_000, message: "Detach rejected" }
|
|
: undefined,
|
|
});
|
|
trackBrowser(browser);
|
|
const provider = new CdpBrowserProvider();
|
|
try {
|
|
const inspected = await provider.inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.browserWebSocketUrl,
|
|
target_id: "allowed-page",
|
|
observation_ms: 0,
|
|
}),
|
|
);
|
|
if (!inspected.ok) throw inspected.error;
|
|
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0));
|
|
expect(browser.openedConnectionCount()).toBe(1);
|
|
|
|
const listed = await provider.listTargets(
|
|
listBrowserTargetsInputSchema.parse({
|
|
cdp_endpoint: browser.browserWebSocketUrl,
|
|
}),
|
|
);
|
|
if (!listed.ok) throw listed.error;
|
|
expect(browser.openedConnectionCount()).toBe(2);
|
|
expect(browser.activeConnectionCount()).toBe(1);
|
|
} finally {
|
|
await provider.close();
|
|
}
|
|
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0));
|
|
});
|
|
|
|
it("preserves per-operation command_timeout_ms and operation identity on a reused browser socket", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
hangOnMethod: "Page.enable",
|
|
});
|
|
trackBrowser(browser);
|
|
const provider = new CdpBrowserProvider();
|
|
try {
|
|
const listed = await provider.listTargets(
|
|
listBrowserTargetsInputSchema.parse({
|
|
cdp_endpoint: browser.browserWebSocketUrl,
|
|
}),
|
|
);
|
|
if (!listed.ok) throw listed.error;
|
|
expect(browser.openedConnectionCount()).toBe(1);
|
|
|
|
const timedOut = await provider.inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.browserWebSocketUrl,
|
|
target_id: "allowed-page",
|
|
observation_ms: 0,
|
|
command_timeout_ms: 60,
|
|
}),
|
|
);
|
|
expect(timedOut).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "BrowserObservationError",
|
|
operation: "inspect_web_page",
|
|
reason: "timeout",
|
|
},
|
|
});
|
|
if (timedOut.ok) throw new Error("Expected command timeout");
|
|
expect(timedOut.error.userMessage).toContain("60 ms command_timeout_ms");
|
|
expect(browser.openedConnectionCount()).toBe(1);
|
|
} finally {
|
|
await provider.close();
|
|
}
|
|
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0));
|
|
});
|
|
});
|
|
|
|
it("retains browser-socket siblings and reports invalid attachment as unknown, not omitted", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
commandResult: ({ method }) =>
|
|
method === "Target.getTargets"
|
|
? {
|
|
targetInfos: [
|
|
{
|
|
targetId: "first",
|
|
type: "page",
|
|
title: 3,
|
|
url: "https://example.test/first",
|
|
attached: "unknown",
|
|
},
|
|
{
|
|
targetId: "second",
|
|
type: "page",
|
|
title: "Good",
|
|
url: "https://example.test/second",
|
|
attached: false,
|
|
},
|
|
{
|
|
targetId: 1,
|
|
type: "page",
|
|
url: "https://example.test/invalid",
|
|
},
|
|
{ id: "alias", type: "page", url: "https://example.test/alias" },
|
|
],
|
|
}
|
|
: undefined,
|
|
});
|
|
trackBrowser(browser);
|
|
const provider = new CdpBrowserProvider();
|
|
try {
|
|
const result = await provider.listTargets(
|
|
listBrowserTargetsInputSchema.parse({
|
|
cdp_endpoint: browser.browserWebSocketUrl,
|
|
}),
|
|
);
|
|
if (!result.ok) throw result.error;
|
|
expect(
|
|
result.value.targets.map(({ target_id, attached, title }) => [
|
|
target_id,
|
|
attached,
|
|
title,
|
|
]),
|
|
).toEqual([
|
|
["first", null, ""],
|
|
["second", false, "Good"],
|
|
]);
|
|
const limitations = result.value.limitations.join("\n");
|
|
expect(limitations).toContain(
|
|
'reported malformed attached metadata "unknown"',
|
|
);
|
|
expect(limitations).toContain("reported malformed title metadata 3");
|
|
expect(
|
|
result.value.limitations.filter((item) =>
|
|
item.includes("target identity is malformed"),
|
|
),
|
|
).toHaveLength(2);
|
|
expect(limitations).not.toContain("omitted attachment");
|
|
} finally {
|
|
await provider.close();
|
|
}
|
|
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0));
|
|
});
|
|
|
|
describe("CdpBrowserProvider: browser socket failures and cleanup", () => {
|
|
it("retains an admitted browser socket when the target is missing and closes it on shutdown", async () => {
|
|
const browser = await startFakeCdpBrowser();
|
|
trackBrowser(browser);
|
|
const provider = new CdpBrowserProvider();
|
|
try {
|
|
const result = await provider.inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.browserWebSocketUrl,
|
|
target_id: "missing",
|
|
observation_ms: 0,
|
|
}),
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "BrowserObservationError", reason: "target_not_found" },
|
|
});
|
|
expect(browser.commands.map(({ method }) => method)).toEqual([
|
|
"Browser.getVersion",
|
|
"Target.getTargets",
|
|
]);
|
|
expect(browser.openedConnectionCount()).toBe(1);
|
|
expect(browser.activeConnectionCount()).toBe(1);
|
|
|
|
const recovered = await provider.inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.browserWebSocketUrl,
|
|
target_id: "allowed-page",
|
|
observation_ms: 0,
|
|
}),
|
|
);
|
|
if (!recovered.ok) throw recovered.error;
|
|
expect(browser.openedConnectionCount()).toBe(1);
|
|
} finally {
|
|
await provider.close();
|
|
}
|
|
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0));
|
|
});
|
|
|
|
it("rejects malformed browser-socket discovery and closes the socket", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
commandResult: ({ method }) =>
|
|
method === "Browser.getVersion"
|
|
? { product: "FakeChrome/1.0" }
|
|
: undefined,
|
|
});
|
|
trackBrowser(browser);
|
|
const result = await new CdpBrowserProvider().listTargets(
|
|
listBrowserTargetsInputSchema.parse({
|
|
cdp_endpoint: browser.browserWebSocketUrl,
|
|
}),
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "BrowserObservationError",
|
|
operation: "list_browser_targets",
|
|
reason: "invalid_endpoint_response",
|
|
},
|
|
});
|
|
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0));
|
|
});
|
|
|
|
it("reports a refused browser socket as unreachable", async () => {
|
|
const browser = await startFakeCdpBrowser();
|
|
trackBrowser(browser);
|
|
const result = await new CdpBrowserProvider().listTargets(
|
|
listBrowserTargetsInputSchema.parse({
|
|
cdp_endpoint: browser.browserWebSocketUrl.replace(
|
|
/fake$/u,
|
|
"not-this-browser",
|
|
),
|
|
}),
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "BrowserObservationError",
|
|
reason: "endpoint_unreachable",
|
|
},
|
|
});
|
|
expect(JSON.stringify(result)).not.toContain("not-this-browser");
|
|
});
|
|
|
|
it("cancels browser-socket discovery and releases the socket", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
hangOnMethod: "Target.getTargets",
|
|
});
|
|
trackBrowser(browser);
|
|
const controller = new AbortController();
|
|
const pending = new CdpBrowserProvider().inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.browserWebSocketUrl,
|
|
target_id: "allowed-page",
|
|
observation_ms: 0,
|
|
}),
|
|
{ signal: controller.signal },
|
|
);
|
|
await vi.waitFor(() =>
|
|
expect(browser.commands.map(({ method }) => method)).toContain(
|
|
"Target.getTargets",
|
|
),
|
|
);
|
|
controller.abort();
|
|
|
|
expect(await pending).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisCancelledError", operation: "inspect_web_page" },
|
|
});
|
|
expect(browser.commands.map(({ method }) => method)).not.toContain(
|
|
"Target.attachToTarget",
|
|
);
|
|
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0));
|
|
});
|
|
});
|
|
|
|
/** Expect a discovery timeout that projects publicly as a provider timeout. */
|
|
const expectDiscoveryTimeout = async (url: string): Promise<void> => {
|
|
let failure: unknown;
|
|
try {
|
|
await openCdpEndpoint(
|
|
browserObservationEndpointSchema.parse(url),
|
|
"list_browser_targets",
|
|
undefined,
|
|
50,
|
|
);
|
|
} catch (cause: unknown) {
|
|
failure = cause;
|
|
}
|
|
expect(failure).toMatchObject({
|
|
_tag: "BrowserObservationError",
|
|
reason: "timeout",
|
|
});
|
|
if (!(failure instanceof AnalysisError))
|
|
throw new Error("Expected a typed discovery failure");
|
|
expect(projectAnalysisError(failure)).toMatchObject({
|
|
code: "provider_timeout",
|
|
category: "timeout",
|
|
});
|
|
};
|
|
|
|
describe("CdpBrowserProvider: browser socket discovery deadline", () => {
|
|
it("times out a discovery reply that never arrives and closes the socket", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
hangOnMethod: "Target.getTargets",
|
|
});
|
|
trackBrowser(browser);
|
|
await expectDiscoveryTimeout(browser.browserWebSocketUrl);
|
|
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0));
|
|
});
|
|
|
|
it("times out a WebSocket handshake that never completes", async () => {
|
|
const sockets = new Set<Socket>();
|
|
// Read the upgrade request but never answer it; reading lets the
|
|
// client's close be observed here.
|
|
const server = createServer((socket) => {
|
|
sockets.add(socket);
|
|
socket.on("close", () => sockets.delete(socket));
|
|
socket.resume();
|
|
});
|
|
await new Promise<void>((resolve) =>
|
|
server.listen(0, "127.0.0.1", resolve),
|
|
);
|
|
const address = server.address();
|
|
if (address === null || typeof address === "string")
|
|
throw new Error("Expected a TCP listener address");
|
|
try {
|
|
await expectDiscoveryTimeout(
|
|
`ws://127.0.0.1:${String(address.port)}/devtools/browser/stalled`,
|
|
);
|
|
await vi.waitFor(() => expect(sockets.size).toBe(0));
|
|
} finally {
|
|
for (const socket of sockets) socket.destroy();
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("CdpBrowserProvider: shared browser socket lifecycle regressions", () => {
|
|
it("closes a shared browser socket immediately when detach fails alongside an active observation", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
commandError: ({ method }) =>
|
|
method === "Target.detachFromTarget"
|
|
? { code: -32_000, message: "Detach rejected" }
|
|
: undefined,
|
|
});
|
|
trackBrowser(browser);
|
|
const provider = new CdpBrowserProvider();
|
|
const closeSpy = vi.spyOn(CdpConnection.prototype, "close");
|
|
let armed!: () => void;
|
|
const observationArmed = new Promise<void>((resolve) => {
|
|
armed = resolve;
|
|
});
|
|
const controller = new AbortController();
|
|
let observing: ReturnType<CdpBrowserProvider["observeSession"]> | undefined;
|
|
try {
|
|
observing = provider.observeSession(
|
|
observeWebSessionInputSchema.parse({
|
|
cdp_endpoint: browser.browserWebSocketUrl,
|
|
target_id: "allowed-page",
|
|
observation_ms: 15_000,
|
|
}),
|
|
{
|
|
signal: controller.signal,
|
|
progress: {
|
|
report: async (update) => {
|
|
if (update.completed === 1) armed();
|
|
},
|
|
},
|
|
},
|
|
);
|
|
await awaitAcknowledgement(
|
|
observationArmed,
|
|
observing,
|
|
"peer session observation start",
|
|
);
|
|
expect(browser.openedConnectionCount()).toBe(1);
|
|
expect(browser.activeConnectionCount()).toBe(1);
|
|
|
|
const inspected = await provider.inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.browserWebSocketUrl,
|
|
target_id: "allowed-page",
|
|
observation_ms: 0,
|
|
}),
|
|
);
|
|
if (!inspected.ok) throw inspected.error;
|
|
expect(closeSpy).toHaveBeenCalledTimes(1);
|
|
expect(browser.openedConnectionCount()).toBe(1);
|
|
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0));
|
|
|
|
const session = await observing;
|
|
if (!session.ok) throw session.error;
|
|
expect(session.value.window.end_reason).toBe("transport_disconnected");
|
|
} finally {
|
|
controller.abort(new Error("test teardown"));
|
|
await observing?.catch(() => undefined);
|
|
closeSpy.mockRestore();
|
|
await provider.close();
|
|
}
|
|
});
|
|
|
|
it("surfaces cleanupIncomplete when forced shared-socket close fails after detach rejection", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
commandError: ({ method }) =>
|
|
method === "Target.detachFromTarget"
|
|
? { code: -32_000, message: "Detach rejected" }
|
|
: undefined,
|
|
});
|
|
trackBrowser(browser);
|
|
const provider = new CdpBrowserProvider();
|
|
const originalClose = CdpConnection.prototype.close;
|
|
let denyClose = false;
|
|
const closeSpy = vi
|
|
.spyOn(CdpConnection.prototype, "close")
|
|
.mockImplementation(function (this: CdpConnection) {
|
|
return denyClose
|
|
? Promise.reject(new Error("controlled shared socket close failure"))
|
|
: originalClose.call(this);
|
|
});
|
|
let armed!: () => void;
|
|
const observationArmed = new Promise<void>((resolve) => {
|
|
armed = resolve;
|
|
});
|
|
const controller = new AbortController();
|
|
let observing: ReturnType<CdpBrowserProvider["observeSession"]> | undefined;
|
|
try {
|
|
observing = provider.observeSession(
|
|
observeWebSessionInputSchema.parse({
|
|
cdp_endpoint: browser.browserWebSocketUrl,
|
|
target_id: "allowed-page",
|
|
observation_ms: 15_000,
|
|
}),
|
|
{
|
|
signal: controller.signal,
|
|
progress: {
|
|
report: async (update) => {
|
|
if (update.completed === 1) armed();
|
|
},
|
|
},
|
|
},
|
|
);
|
|
await awaitAcknowledgement(
|
|
observationArmed,
|
|
observing,
|
|
"peer session observation start",
|
|
);
|
|
denyClose = true;
|
|
const inspected = await provider.inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.browserWebSocketUrl,
|
|
target_id: "allowed-page",
|
|
observation_ms: 0,
|
|
}),
|
|
);
|
|
if (inspected.ok)
|
|
throw new Error("Expected incomplete transport cleanup");
|
|
expect(inspected.error.cleanupIncomplete).toBe(true);
|
|
expect(inspected.error.partialObservation).toBeDefined();
|
|
|
|
controller.abort(new Error("end peer observation"));
|
|
await observing;
|
|
denyClose = false;
|
|
await provider.close();
|
|
expect(inspected.error.cleanupIncomplete).toBe(false);
|
|
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0));
|
|
} finally {
|
|
denyClose = false;
|
|
controller.abort(new Error("test teardown"));
|
|
await observing?.catch(() => undefined);
|
|
closeSpy.mockRestore();
|
|
await provider.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("CdpTargetSessionOwner: callback failures and idle protocol eviction", () => {
|
|
it("records cleanup lease ownership when finish() callback rejects and resolves on retryPending", async () => {
|
|
const browser = await startFakeCdpBrowser();
|
|
trackBrowser(browser);
|
|
const owner = new CdpTargetSessionOwner();
|
|
try {
|
|
const connection = await owner.connect(
|
|
browser.browserWebSocketUrl,
|
|
"inspect_web_page",
|
|
{},
|
|
"callback-failed-session",
|
|
);
|
|
const cleanup = vi.fn(async () => {
|
|
throw new Error("controlled cleanup callback failure");
|
|
});
|
|
const failure = await owner
|
|
.finish(connection, cleanup)
|
|
.catch((cause: unknown) => cause);
|
|
expect(failure).toBeInstanceOf(ProviderCleanupError);
|
|
if (!(failure instanceof AnalysisError))
|
|
throw new Error("Expected typed cleanup error");
|
|
expect(failure.cleanupIncomplete).toBe(true);
|
|
expect(failure.cleanupResources).toEqual(["callback-failed-session"]);
|
|
expect(browser.activeConnectionCount()).toBe(1);
|
|
|
|
await owner.retryPending();
|
|
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
expect(failure.cleanupIncomplete).toBe(false);
|
|
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0));
|
|
} finally {
|
|
await owner.close();
|
|
}
|
|
});
|
|
|
|
it("evicts and closes an idle retained browser socket on fatal protocol failure", async () => {
|
|
const browser = await startFakeCdpBrowser();
|
|
trackBrowser(browser);
|
|
const provider = new CdpBrowserProvider();
|
|
const originalClose = CdpConnection.prototype.close;
|
|
let denyClose = true;
|
|
const closeSpy = vi
|
|
.spyOn(CdpConnection.prototype, "close")
|
|
.mockImplementation(function (this: CdpConnection) {
|
|
return denyClose
|
|
? Promise.reject(new Error("controlled idle protocol close failure"))
|
|
: originalClose.call(this);
|
|
});
|
|
try {
|
|
const first = await provider.listTargets(
|
|
listBrowserTargetsInputSchema.parse({
|
|
cdp_endpoint: browser.browserWebSocketUrl,
|
|
}),
|
|
);
|
|
if (!first.ok) throw first.error;
|
|
expect(browser.activeConnectionCount()).toBe(1);
|
|
|
|
browser.emitRawMessage("{not-json");
|
|
await vi.waitFor(() => expect(closeSpy).toHaveBeenCalledTimes(1));
|
|
|
|
const blocked = await provider.listTargets(
|
|
listBrowserTargetsInputSchema.parse({
|
|
cdp_endpoint: browser.browserWebSocketUrl,
|
|
}),
|
|
);
|
|
if (blocked.ok) throw new Error("Expected pending cleanup refusal");
|
|
expect(blocked.error.cleanupIncomplete).toBe(true);
|
|
|
|
denyClose = false;
|
|
const recovered = await provider.listTargets(
|
|
listBrowserTargetsInputSchema.parse({
|
|
cdp_endpoint: browser.browserWebSocketUrl,
|
|
}),
|
|
);
|
|
if (!recovered.ok) throw recovered.error;
|
|
expect(blocked.error.cleanupIncomplete).toBe(false);
|
|
expect(browser.openedConnectionCount()).toBe(2);
|
|
expect(browser.activeConnectionCount()).toBe(1);
|
|
|
|
browser.emitRawMessage("{not-json-idle-second");
|
|
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0));
|
|
} finally {
|
|
closeSpy.mockRestore();
|
|
await provider.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
it("blocks new admissions while forced shared-socket cleanup fails with an active peer, then recovers cleanly", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
commandError: ({ method }) =>
|
|
method === "Target.detachFromTarget"
|
|
? { code: -32_000, message: "Detach rejected" }
|
|
: undefined,
|
|
});
|
|
trackBrowser(browser);
|
|
const provider = new CdpBrowserProvider();
|
|
const originalClose = CdpConnection.prototype.close;
|
|
let denyClose = false;
|
|
const closeSpy = vi
|
|
.spyOn(CdpConnection.prototype, "close")
|
|
.mockImplementation(function (this: CdpConnection) {
|
|
return denyClose
|
|
? Promise.reject(new Error("controlled shared socket close failure"))
|
|
: originalClose.call(this);
|
|
});
|
|
let armed!: () => void;
|
|
const observationArmed = new Promise<void>((resolve) => {
|
|
armed = resolve;
|
|
});
|
|
const controller = new AbortController();
|
|
let observing: ReturnType<CdpBrowserProvider["observeSession"]> | undefined;
|
|
try {
|
|
observing = provider.observeSession(
|
|
observeWebSessionInputSchema.parse({
|
|
cdp_endpoint: browser.browserWebSocketUrl,
|
|
target_id: "allowed-page",
|
|
observation_ms: 15_000,
|
|
}),
|
|
{
|
|
signal: controller.signal,
|
|
progress: {
|
|
report: async (update) => {
|
|
if (update.completed === 1) armed();
|
|
},
|
|
},
|
|
},
|
|
);
|
|
await awaitAcknowledgement(
|
|
observationArmed,
|
|
observing,
|
|
"peer session observation start",
|
|
);
|
|
expect(browser.openedConnectionCount()).toBe(1);
|
|
|
|
denyClose = true;
|
|
const failedInspect = await provider.inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.browserWebSocketUrl,
|
|
target_id: "allowed-page",
|
|
observation_ms: 0,
|
|
}),
|
|
);
|
|
if (failedInspect.ok)
|
|
throw new Error("Expected failed forced shared-socket close");
|
|
expect(failedInspect.error.cleanupIncomplete).toBe(true);
|
|
expect(browser.openedConnectionCount()).toBe(1);
|
|
|
|
// Peer observation is still active; Call C must NOT bypass the unresolved close failure.
|
|
const blocked = await provider.listTargets(
|
|
listBrowserTargetsInputSchema.parse({
|
|
cdp_endpoint: browser.browserWebSocketUrl,
|
|
}),
|
|
);
|
|
if (blocked.ok)
|
|
throw new Error("Expected admission blocked on failed cleanup");
|
|
expect(blocked.error.cleanupIncomplete).toBe(true);
|
|
expect(browser.openedConnectionCount()).toBe(1);
|
|
|
|
// Allow close retry to succeed while peer observation is still unwinding.
|
|
denyClose = false;
|
|
const recovered = await provider.listTargets(
|
|
listBrowserTargetsInputSchema.parse({
|
|
cdp_endpoint: browser.browserWebSocketUrl,
|
|
}),
|
|
);
|
|
if (!recovered.ok) throw recovered.error;
|
|
expect(failedInspect.error.cleanupIncomplete).toBe(false);
|
|
expect(blocked.error.cleanupIncomplete).toBe(false);
|
|
expect(browser.openedConnectionCount()).toBe(2);
|
|
|
|
const session = await observing;
|
|
if (!session.ok) throw session.error;
|
|
expect(session.value.window.end_reason).toBe("transport_disconnected");
|
|
} finally {
|
|
denyClose = false;
|
|
controller.abort(new Error("test teardown"));
|
|
await observing?.catch(() => undefined);
|
|
closeSpy.mockRestore();
|
|
await provider.close();
|
|
}
|
|
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0));
|
|
});
|
|
|
|
it("keeps an admitted shared browser socket alive for concurrent peers when a caller aborts or hits discovery_timeout_ms", async () => {
|
|
let slowTargetDiscovery = false;
|
|
let notifySlowDiscoveryStarted: (() => void) | undefined;
|
|
const browser = await startFakeCdpBrowser({
|
|
commandDelayMs: ({ method }) => {
|
|
if (!slowTargetDiscovery || method !== "Target.getTargets")
|
|
return undefined;
|
|
notifySlowDiscoveryStarted?.();
|
|
return 200;
|
|
},
|
|
});
|
|
trackBrowser(browser);
|
|
const provider = new CdpBrowserProvider();
|
|
let armed!: () => void;
|
|
const observationArmed = new Promise<void>((resolve) => {
|
|
armed = resolve;
|
|
});
|
|
const peerController = new AbortController();
|
|
const cancelController = new AbortController();
|
|
let observing: ReturnType<CdpBrowserProvider["observeSession"]> | undefined;
|
|
let cancelling: ReturnType<CdpBrowserProvider["listTargets"]> | undefined;
|
|
try {
|
|
observing = provider.observeSession(
|
|
observeWebSessionInputSchema.parse({
|
|
cdp_endpoint: browser.browserWebSocketUrl,
|
|
target_id: "allowed-page",
|
|
observation_ms: 400,
|
|
}),
|
|
{
|
|
signal: peerController.signal,
|
|
progress: {
|
|
report: async (update) => {
|
|
if (update.completed === 1) armed();
|
|
},
|
|
},
|
|
},
|
|
);
|
|
await awaitAcknowledgement(
|
|
observationArmed,
|
|
observing,
|
|
"peer session observation start",
|
|
);
|
|
expect(browser.openedConnectionCount()).toBe(1);
|
|
|
|
slowTargetDiscovery = true;
|
|
const timedOut = await provider.inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.browserWebSocketUrl,
|
|
target_id: "allowed-page",
|
|
observation_ms: 0,
|
|
discovery_timeout_ms: 35,
|
|
}),
|
|
);
|
|
expect(timedOut).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "BrowserObservationError",
|
|
operation: "inspect_web_page",
|
|
reason: "timeout",
|
|
},
|
|
});
|
|
if (timedOut.ok) throw new Error("Expected discovery timeout on Call B");
|
|
expect(timedOut.error.userMessage).toContain("35 ms discovery_timeout_ms");
|
|
expect(browser.openedConnectionCount()).toBe(1);
|
|
expect(browser.activeConnectionCount()).toBe(1);
|
|
|
|
let enterCancelDiscovery!: () => void;
|
|
const cancelDiscoveryEntered = new Promise<void>((resolve) => {
|
|
enterCancelDiscovery = resolve;
|
|
});
|
|
notifySlowDiscoveryStarted = enterCancelDiscovery;
|
|
cancelling = provider.listTargets(
|
|
listBrowserTargetsInputSchema.parse({
|
|
cdp_endpoint: browser.browserWebSocketUrl,
|
|
}),
|
|
{ signal: cancelController.signal },
|
|
);
|
|
await awaitAcknowledgement(
|
|
cancelDiscoveryEntered,
|
|
cancelling,
|
|
"shared socket discovery start",
|
|
);
|
|
cancelController.abort(new Error("caller cancelled discovery"));
|
|
expect(await cancelling).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "AnalysisCancelledError",
|
|
operation: "list_browser_targets",
|
|
},
|
|
});
|
|
|
|
slowTargetDiscovery = false;
|
|
const session = await observing;
|
|
if (!session.ok) throw session.error;
|
|
expect(session.value.window.end_reason).toBe("window_elapsed");
|
|
expect(browser.openedConnectionCount()).toBe(1);
|
|
expect(browser.activeConnectionCount()).toBe(1);
|
|
} finally {
|
|
slowTargetDiscovery = false;
|
|
peerController.abort(new Error("test teardown"));
|
|
cancelController.abort(new Error("test teardown"));
|
|
await Promise.allSettled([observing, cancelling]);
|
|
await provider.close();
|
|
}
|
|
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0));
|
|
});
|
|
|
|
describe("CdpBrowserProvider: coalesced cold-start browser sockets", () => {
|
|
it("coalesces concurrent cold-start calls onto one browser WebSocket handshake", async () => {
|
|
const browser = await startFakeCdpBrowser({ handshakeDelayMs: 40 });
|
|
trackBrowser(browser);
|
|
const provider = new CdpBrowserProvider();
|
|
let batch:
|
|
| Promise<
|
|
[
|
|
Awaited<ReturnType<CdpBrowserProvider["listTargets"]>>,
|
|
Awaited<ReturnType<CdpBrowserProvider["inspectPage"]>>,
|
|
Awaited<ReturnType<CdpBrowserProvider["captureScreenshot"]>>,
|
|
]
|
|
>
|
|
| undefined;
|
|
try {
|
|
batch = Promise.all([
|
|
provider.listTargets(
|
|
listBrowserTargetsInputSchema.parse({
|
|
cdp_endpoint: browser.browserWebSocketUrl,
|
|
}),
|
|
),
|
|
provider.inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.browserWebSocketUrl,
|
|
target_id: "allowed-page",
|
|
observation_ms: 0,
|
|
}),
|
|
),
|
|
provider.captureScreenshot(
|
|
captureWebScreenshotInputSchema.parse({
|
|
cdp_endpoint: browser.browserWebSocketUrl,
|
|
target_id: "allowed-page",
|
|
}),
|
|
),
|
|
]);
|
|
const [listed, inspected, screenshot] = await batch;
|
|
if (!listed.ok) throw listed.error;
|
|
if (!inspected.ok) throw inspected.error;
|
|
if (!screenshot.ok) throw screenshot.error;
|
|
expect(listed.value.targets.length).toBeGreaterThan(0);
|
|
expect(inspected.value.target.origin).toBe(browser.allowedOrigin);
|
|
expect(screenshot.value.artifact.media_type).toBe("image/png");
|
|
expect(browser.openedConnectionCount()).toBe(1);
|
|
expect(browser.activeConnectionCount()).toBe(1);
|
|
const targetSessionIds = new Set(
|
|
browser.commands.flatMap((command) =>
|
|
command.sessionId === undefined ? [] : [command.sessionId],
|
|
),
|
|
);
|
|
expect(targetSessionIds).toEqual(new Set(["session-1", "session-2"]));
|
|
const detachedSessionIds = browser.commands
|
|
.filter((command) => command.method === "Target.detachFromTarget")
|
|
.map((command) => command.params.sessionId)
|
|
.sort();
|
|
expect(detachedSessionIds).toEqual(["session-1", "session-2"]);
|
|
} finally {
|
|
await batch?.catch(() => undefined);
|
|
await provider.close();
|
|
}
|
|
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0));
|
|
});
|
|
|
|
it("keeps an in-flight cold-start handshake alive for a longer-deadline peer when one waiter times out", async () => {
|
|
const browser = await startFakeCdpBrowser({ handshakeDelayMs: 120 });
|
|
trackBrowser(browser);
|
|
const provider = new CdpBrowserProvider();
|
|
let shortCall: ReturnType<CdpBrowserProvider["listTargets"]> | undefined;
|
|
let longCall: ReturnType<CdpBrowserProvider["inspectPage"]> | undefined;
|
|
try {
|
|
shortCall = provider.listTargets(
|
|
listBrowserTargetsInputSchema.parse({
|
|
cdp_endpoint: browser.browserWebSocketUrl,
|
|
discovery_timeout_ms: 35,
|
|
}),
|
|
);
|
|
longCall = provider.inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.browserWebSocketUrl,
|
|
target_id: "allowed-page",
|
|
observation_ms: 0,
|
|
discovery_timeout_ms: 5_000,
|
|
}),
|
|
);
|
|
|
|
const shortOutcome = await shortCall;
|
|
expect(shortOutcome).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "BrowserObservationError",
|
|
operation: "list_browser_targets",
|
|
reason: "timeout",
|
|
},
|
|
});
|
|
if (shortOutcome.ok)
|
|
throw new Error("Expected short cold-start waiter to time out");
|
|
expect(shortOutcome.error.userMessage).toContain(
|
|
"35 ms discovery_timeout_ms",
|
|
);
|
|
|
|
const longOutcome = await longCall;
|
|
if (!longOutcome.ok) throw longOutcome.error;
|
|
expect(longOutcome.value.target.origin).toBe(browser.allowedOrigin);
|
|
expect(browser.openedConnectionCount()).toBe(1);
|
|
expect(browser.activeConnectionCount()).toBe(1);
|
|
|
|
const followUp = await provider.listTargets(
|
|
listBrowserTargetsInputSchema.parse({
|
|
cdp_endpoint: browser.browserWebSocketUrl,
|
|
}),
|
|
);
|
|
if (!followUp.ok) throw followUp.error;
|
|
expect(browser.openedConnectionCount()).toBe(1);
|
|
} finally {
|
|
await Promise.allSettled([shortCall, longCall]);
|
|
await provider.close();
|
|
}
|
|
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0));
|
|
});
|
|
});
|
|
|
|
const createDeferredGate = (): {
|
|
readonly promise: Promise<void>;
|
|
readonly open: () => void;
|
|
} => {
|
|
let open = (): void => undefined;
|
|
const promise = new Promise<void>((resolve) => {
|
|
open = resolve;
|
|
});
|
|
return { promise, open };
|
|
};
|
|
|
|
const holdProgressStep = (
|
|
step: number,
|
|
entered: () => void,
|
|
release: Promise<void>,
|
|
) => ({
|
|
async report(update: { readonly completed: number }) {
|
|
if (update.completed === step) {
|
|
entered();
|
|
await release;
|
|
}
|
|
},
|
|
});
|
|
|
|
it("isolates concurrent target sessions on one shared browser socket when a shorter call detaches first", async () => {
|
|
const browser = await startFakeCdpBrowser();
|
|
trackBrowser(browser);
|
|
const provider = new CdpBrowserProvider();
|
|
const controller = new AbortController();
|
|
const observationArmed = createDeferredGate();
|
|
const inspectionHeld = createDeferredGate();
|
|
const peersGate = createDeferredGate();
|
|
let observing: ReturnType<CdpBrowserProvider["observeSession"]> | undefined;
|
|
let inspecting: ReturnType<CdpBrowserProvider["inspectPage"]> | undefined;
|
|
let capturing:
|
|
| ReturnType<CdpBrowserProvider["captureScreenshot"]>
|
|
| undefined;
|
|
try {
|
|
observing = provider.observeSession(
|
|
observeWebSessionInputSchema.parse({
|
|
cdp_endpoint: browser.browserWebSocketUrl,
|
|
target_id: "allowed-page",
|
|
observation_ms: 1,
|
|
}),
|
|
{
|
|
signal: controller.signal,
|
|
progress: holdProgressStep(1, observationArmed.open, peersGate.promise),
|
|
},
|
|
);
|
|
await awaitAcknowledgement(
|
|
observationArmed.promise,
|
|
observing,
|
|
"shared browser-socket observation start",
|
|
);
|
|
|
|
inspecting = provider.inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.browserWebSocketUrl,
|
|
target_id: "allowed-page",
|
|
observation_ms: 0,
|
|
}),
|
|
{
|
|
signal: controller.signal,
|
|
progress: holdProgressStep(2, inspectionHeld.open, peersGate.promise),
|
|
},
|
|
);
|
|
await awaitAcknowledgement(
|
|
inspectionHeld.promise,
|
|
inspecting,
|
|
"shared browser-socket inspection hold",
|
|
);
|
|
|
|
capturing = provider.captureScreenshot(
|
|
captureWebScreenshotInputSchema.parse({
|
|
cdp_endpoint: browser.browserWebSocketUrl,
|
|
target_id: "allowed-page",
|
|
}),
|
|
{ signal: controller.signal },
|
|
);
|
|
const screenshot = await capturing;
|
|
if (!screenshot.ok) throw screenshot.error;
|
|
expect(screenshot.value.artifact.media_type).toBe("image/png");
|
|
const detachesAfterScreenshot = browser.commands
|
|
.filter((command) => command.method === "Target.detachFromTarget")
|
|
.map((command) => command.params.sessionId);
|
|
expect(detachesAfterScreenshot).toEqual(["session-3"]);
|
|
|
|
browser.emitEvent({
|
|
method: "Page.navigatedWithinDocument",
|
|
sessionId: "session-3",
|
|
params: {
|
|
frameId: "frame-main",
|
|
url: `${browser.allowedOrigin}/ignored-detached-peer`,
|
|
navigationType: "historyApi",
|
|
timestamp: 40,
|
|
},
|
|
});
|
|
browser.emitEvent({
|
|
method: "Page.navigatedWithinDocument",
|
|
sessionId: "session-1",
|
|
params: {
|
|
frameId: "frame-main",
|
|
url: `${browser.allowedOrigin}/after-peer-detach`,
|
|
navigationType: "historyApi",
|
|
timestamp: 41,
|
|
},
|
|
});
|
|
// Round-trip over the shared WebSocket so both queued server frames are
|
|
// processed in FIFO order before releasing the held peer operations.
|
|
const barrier = await provider.listTargets(
|
|
listBrowserTargetsInputSchema.parse({
|
|
cdp_endpoint: browser.browserWebSocketUrl,
|
|
}),
|
|
{ signal: controller.signal },
|
|
);
|
|
if (!barrier.ok) throw barrier.error;
|
|
|
|
peersGate.open();
|
|
const [inspected, observed] = await Promise.all([inspecting, observing]);
|
|
if (!inspected.ok) throw inspected.error;
|
|
if (!observed.ok) throw observed.error;
|
|
expect(inspected.value.target.origin).toBe(browser.allowedOrigin);
|
|
expect(observed.value.window.end_reason).toBe("window_elapsed");
|
|
expect(observed.value.timeline).toContainEqual(
|
|
expect.objectContaining({
|
|
type: "same_document_navigation",
|
|
url: `${browser.allowedOrigin}/after-peer-detach`,
|
|
}),
|
|
);
|
|
expect(observed.value.timeline).not.toContainEqual(
|
|
expect.objectContaining({
|
|
url: `${browser.allowedOrigin}/ignored-detached-peer`,
|
|
}),
|
|
);
|
|
expect(browser.openedConnectionCount()).toBe(1);
|
|
expect(browser.activeConnectionCount()).toBe(1);
|
|
|
|
const allTargetSessions = new Set(
|
|
browser.commands.flatMap((command) =>
|
|
command.sessionId === undefined ? [] : [command.sessionId],
|
|
),
|
|
);
|
|
expect(allTargetSessions).toEqual(
|
|
new Set(["session-1", "session-2", "session-3"]),
|
|
);
|
|
const allDetachedSessions = browser.commands
|
|
.filter((command) => command.method === "Target.detachFromTarget")
|
|
.map((command) => command.params.sessionId)
|
|
.sort();
|
|
expect(allDetachedSessions).toEqual([
|
|
"session-1",
|
|
"session-2",
|
|
"session-3",
|
|
]);
|
|
} finally {
|
|
peersGate.open();
|
|
controller.abort(new Error("test teardown"));
|
|
await Promise.allSettled([observing, inspecting, capturing]);
|
|
await provider.close();
|
|
}
|
|
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0));
|
|
});
|
|
|
|
it("reuses one browser socket across equivalent normalized spellings while keeping 127.0.0.1 and [::1] distinct", async () => {
|
|
const ipv4Browser = await startFakeCdpBrowser();
|
|
const ipv6Browser = await startFakeCdpBrowser();
|
|
trackBrowser(ipv4Browser);
|
|
trackBrowser(ipv6Browser);
|
|
const port = new URL(ipv4Browser.endpoint).port;
|
|
const canonicalIpv6Url = `ws://[::1]:${port}/devtools/browser/fake`;
|
|
const originalConnect = CdpConnection.connect.bind(CdpConnection);
|
|
const connectUrls: string[] = [];
|
|
const connectSpy = vi
|
|
.spyOn(CdpConnection, "connect")
|
|
.mockImplementation((url, ...rest) => {
|
|
connectUrls.push(url);
|
|
const wireUrl =
|
|
url === canonicalIpv6Url ? ipv6Browser.browserWebSocketUrl : url;
|
|
return originalConnect(wireUrl, ...rest);
|
|
});
|
|
const provider = new CdpBrowserProvider();
|
|
try {
|
|
const ipv6Listed = await provider.listTargets(
|
|
listBrowserTargetsInputSchema.parse({
|
|
cdp_endpoint: `WS://[0:0:0:0:0:0:0:1]:0${port}/devtools/browser/fake`,
|
|
}),
|
|
);
|
|
if (!ipv6Listed.ok) throw ipv6Listed.error;
|
|
const ipv6Inspected = await provider.inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: canonicalIpv6Url,
|
|
target_id: "allowed-page",
|
|
observation_ms: 0,
|
|
}),
|
|
);
|
|
if (!ipv6Inspected.ok) throw ipv6Inspected.error;
|
|
expect(ipv6Browser.openedConnectionCount()).toBe(1);
|
|
expect(ipv4Browser.openedConnectionCount()).toBe(0);
|
|
|
|
const ipv4Listed = await provider.listTargets(
|
|
listBrowserTargetsInputSchema.parse({
|
|
cdp_endpoint: `WS://127.0.0.1:0${port}/devtools/browser/fake`,
|
|
}),
|
|
);
|
|
if (!ipv4Listed.ok) throw ipv4Listed.error;
|
|
const ipv4Screenshot = await provider.captureScreenshot(
|
|
captureWebScreenshotInputSchema.parse({
|
|
cdp_endpoint: ipv4Browser.browserWebSocketUrl,
|
|
target_id: "allowed-page",
|
|
}),
|
|
);
|
|
if (!ipv4Screenshot.ok) throw ipv4Screenshot.error;
|
|
|
|
expect(ipv6Browser.openedConnectionCount()).toBe(1);
|
|
expect(ipv4Browser.openedConnectionCount()).toBe(1);
|
|
expect(ipv6Browser.activeConnectionCount()).toBe(1);
|
|
expect(ipv4Browser.activeConnectionCount()).toBe(1);
|
|
expect(connectUrls).toEqual([
|
|
canonicalIpv6Url,
|
|
ipv4Browser.browserWebSocketUrl,
|
|
]);
|
|
} finally {
|
|
connectSpy.mockRestore();
|
|
await provider.close();
|
|
}
|
|
await vi.waitFor(() => {
|
|
expect(ipv6Browser.activeConnectionCount()).toBe(0);
|
|
expect(ipv4Browser.activeConnectionCount()).toBe(0);
|
|
});
|
|
});
|
|
|
|
it("keeps distinct /devtools/browser/<id> paths on separate browser sockets while reusing each normalized path", async () => {
|
|
const browserA = await startFakeCdpBrowser();
|
|
const browserB = await startFakeCdpBrowser();
|
|
const browserCaseA = await startFakeCdpBrowser();
|
|
trackBrowser(browserA);
|
|
trackBrowser(browserB);
|
|
trackBrowser(browserCaseA);
|
|
const port = new URL(browserA.endpoint).port;
|
|
const canonicalIdAUrl = `ws://127.0.0.1:${port}/devtools/browser/idA`;
|
|
const canonicalIdBUrl = `ws://127.0.0.1:${port}/devtools/browser/idB`;
|
|
const canonicalCaseIdAUrl = `ws://127.0.0.1:${port}/devtools/browser/IdA`;
|
|
const routedWireUrls = new Map<string, string>([
|
|
[canonicalIdAUrl, browserA.browserWebSocketUrl],
|
|
[canonicalIdBUrl, browserB.browserWebSocketUrl],
|
|
[canonicalCaseIdAUrl, browserCaseA.browserWebSocketUrl],
|
|
]);
|
|
const originalConnect = CdpConnection.connect.bind(CdpConnection);
|
|
const connectUrls: string[] = [];
|
|
const connectSpy = vi
|
|
.spyOn(CdpConnection, "connect")
|
|
.mockImplementation((url, ...rest) => {
|
|
connectUrls.push(url);
|
|
const wireUrl = routedWireUrls.get(url);
|
|
if (wireUrl === undefined) {
|
|
throw new Error(`Unexpected unmapped browser socket URL: ${url}`);
|
|
}
|
|
return originalConnect(wireUrl, ...rest);
|
|
});
|
|
const provider = new CdpBrowserProvider();
|
|
try {
|
|
const firstIdAListed = await provider.listTargets(
|
|
listBrowserTargetsInputSchema.parse({
|
|
cdp_endpoint: canonicalIdAUrl,
|
|
}),
|
|
);
|
|
if (!firstIdAListed.ok) throw firstIdAListed.error;
|
|
|
|
const firstIdBListed = await provider.listTargets(
|
|
listBrowserTargetsInputSchema.parse({
|
|
cdp_endpoint: canonicalIdBUrl,
|
|
}),
|
|
);
|
|
if (!firstIdBListed.ok) throw firstIdBListed.error;
|
|
|
|
const secondIdAInspected = await provider.inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: canonicalIdAUrl,
|
|
target_id: "allowed-page",
|
|
observation_ms: 0,
|
|
}),
|
|
);
|
|
if (!secondIdAInspected.ok) throw secondIdAInspected.error;
|
|
|
|
const leadingZeroIdBListed = await provider.listTargets(
|
|
listBrowserTargetsInputSchema.parse({
|
|
cdp_endpoint: `WS://127.0.0.1:0${port}/devtools/browser/idB`,
|
|
}),
|
|
);
|
|
if (!leadingZeroIdBListed.ok) throw leadingZeroIdBListed.error;
|
|
|
|
const caseDistinctListed = await provider.listTargets(
|
|
listBrowserTargetsInputSchema.parse({
|
|
cdp_endpoint: canonicalCaseIdAUrl,
|
|
}),
|
|
);
|
|
if (!caseDistinctListed.ok) throw caseDistinctListed.error;
|
|
|
|
const caseDistinctScreenshot = await provider.captureScreenshot(
|
|
captureWebScreenshotInputSchema.parse({
|
|
cdp_endpoint: `WS://127.0.0.1:0${port}/devtools/browser/IdA`,
|
|
target_id: "allowed-page",
|
|
}),
|
|
);
|
|
if (!caseDistinctScreenshot.ok) throw caseDistinctScreenshot.error;
|
|
|
|
expect(browserA.openedConnectionCount()).toBe(1);
|
|
expect(browserB.openedConnectionCount()).toBe(1);
|
|
expect(browserCaseA.openedConnectionCount()).toBe(1);
|
|
expect(browserA.activeConnectionCount()).toBe(1);
|
|
expect(browserB.activeConnectionCount()).toBe(1);
|
|
expect(browserCaseA.activeConnectionCount()).toBe(1);
|
|
expect(connectUrls).toEqual([
|
|
canonicalIdAUrl,
|
|
canonicalIdBUrl,
|
|
canonicalCaseIdAUrl,
|
|
]);
|
|
} finally {
|
|
connectSpy.mockRestore();
|
|
await provider.close();
|
|
}
|
|
await vi.waitFor(() => {
|
|
expect(browserA.activeConnectionCount()).toBe(0);
|
|
expect(browserB.activeConnectionCount()).toBe(0);
|
|
expect(browserCaseA.activeConnectionCount()).toBe(0);
|
|
});
|
|
});
|