Compare commits

...
Author SHA1 Message Date
gensokyo-dev 9cb63fd1d1 fix(browser): preserve external defaults during CDP attachment
Use Playwright’s public noDefaults connection option and keep temporary focus under the selected page’s existing environment owner. Preserve unrelated page media and avoid creating a bootstrap target or retrying a failed handshake.

Extend real CLI/MCP environment verification with an independently observed unselected page and stable page inventory. Cover late focus acquisition and cleanup retry; retain verified upstream license notices for the required SDK upgrade.

Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing)
2026-10-26 21:36:12 +00:00
gensokyo-dev 0567e6bc7b Merge branch 'fix/7167-connect-mode-scenario' of https://github.com/akushonkamen/rea into fix/7167-browser-connect 2026-10-26 21:10:42 +00:00
akushonkamen 5736bd9f94 fix(browser): recover attached scenario capture when Chromium restricts context management
Chromium restricts browser-context management over a plain
--remote-debugging-port endpoint, so the Playwright CDP handshake for
browser.mode="connect" fails with Browser.setDownloadBehavior
"Browser context management is not supported" whenever the endpoint's
browser exposes no page targets Playwright can initialize against.
capture_browser_scenario surfaced that as a bare execution_failure.

When the handshake is refused exactly this way, open one about:blank page
target through the same DevTools HTTP discovery plane, retry the connect
once, and release the bootstrap target again during cleanup. Every other
handshake failure keeps its original behavior.

Fixes #7167
2026-10-26 09:35:39 +08:00
10 changed files with 2410 additions and 110 deletions

No files matched your search

+6
View File
@@ -173,6 +173,12 @@ distinction: it closes and deletes provider-owned profiles, but only disconnects
from an external CDP browser. Real-browser verification checks both outcomes and
confirms that an attached external browser remains alive.
CDP connections leave Playwright's default-context overrides disabled. The
selected page receives the declared scenario environment and temporary focus
emulation, so actions can complete while that page is in the background.
Its environment owner clears these overrides before disconnecting; other pages
keep their media settings. Connecting does not create a bootstrap page.
That cleanup requires REA to remain running. Abrupt termination, including
Windows process-tree termination or POSIX `SIGKILL`, can leave the launch
profile and Playwright artifacts behind. A profile can retain cookies, history,
+4 -4
View File
@@ -3710,11 +3710,11 @@ [email protected] | runtime | MIT
Package: https://www.npmjs.com/package/pkce-challenge/v/5.0.1
Archive: https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz
Integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==
playwright-core@1.58.2 | runtime | Apache-2.0
playwright-core@1.64.1 | runtime | Apache-2.0
Lockfile path: node_modules/playwright-core
Package: https://www.npmjs.com/package/playwright-core/v/1.58.2
Archive: https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz
Integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==
Package: https://www.npmjs.com/package/playwright-core/v/1.64.1
Archive: https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz
Integrity: sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==
[email protected] | runtime | MIT
Lockfile path: node_modules/plist
Package: https://www.npmjs.com/package/plist/v/5.0.0
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -39,7 +39,7 @@
"openpgp": "6.3.2",
"parse5": "8.0.1",
"pino": "10.4.0",
"playwright-core": "1.58.2",
"playwright-core": "1.64.1",
"plist": "5.0.0",
"saxes": "6.0.0",
"semver": "7.8.5",
@@ -10529,15 +10529,15 @@
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
"node": ">=20"
}
},
"node_modules/plist": {
+1 -1
View File
@@ -368,7 +368,7 @@
"openpgp": "6.3.2",
"parse5": "8.0.1",
"pino": "10.4.0",
"playwright-core": "1.58.2",
"playwright-core": "1.64.1",
"plist": "5.0.0",
"saxes": "6.0.0",
"semver": "7.8.5",
@@ -140,9 +140,54 @@ export async function verifyScenarioEnvironment(endpoint, targetId, origin) {
return JSON.parse(value.result.value);
};
const provider = createBrowserScenarioProvider(process.env);
let unselected;
let unselectedTarget;
return withVerifierCleanup(async () => {
// The fixture owns this extra page. A scenario selecting targetId must not
// initialize Playwright defaults on another caller-owned page.
const created = await connection.send("Target.createTarget", {
url: "about:blank",
});
unselectedTarget = created.targetId;
const pages = await (await fetch(`${endpoint}/json/list`)).json();
const other = pages.find(({ id }) => id === unselectedTarget);
assert.ok(other, "Owned unselected fixture page is missing");
unselected = await CdpConnection.connect(
other.webSocketDebuggerUrl,
"capture_browser_scenario",
);
const pageIds = pages
.filter(({ type }) => type === "page")
.map(({ id }) => id)
.sort();
await unselected.send("Emulation.setEmulatedMedia", {
features: [{ name: "prefers-color-scheme", value: "dark" }],
});
const assertUnselectedState = async () => {
const observed = await unselected.send("Runtime.evaluate", {
expression: "matchMedia('(prefers-color-scheme: dark)').matches",
returnByValue: true,
});
assert.equal(
observed.result.value,
true,
"Scenario attachment changed media on an unselected page",
);
};
await assertUnselectedState();
// Settle the selected tab's actual layout before taking the restoration
// baseline, then leave it in the background for the scenario's actions.
await connection.send("Target.activateTarget", { targetId });
await connection.send("Runtime.evaluate", {
expression:
"new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))",
awaitPromise: true,
});
const baseline = await readEnvironment();
const callerLanguage = await readEnvironment(languageExpression);
await connection.send("Target.activateTarget", {
targetId: unselectedTarget,
});
const scenario = environmentScenario({
endpoint,
targetId,
@@ -157,6 +202,14 @@ export async function verifyScenarioEnvironment(endpoint, targetId, origin) {
);
const remaining = await (await fetch(`${endpoint}/json/list`)).json();
assert.ok(remaining.some(({ id }) => id === targetId));
assert.deepEqual(
remaining
.filter(({ type }) => type === "page")
.map(({ id }) => id)
.sort(),
pageIds,
"Scenario attachment changed the caller's page inventory",
);
};
const cliCapture = (await runScenarioCli(scenario)).normalized_result;
assertCapture(cliCapture);
@@ -195,6 +248,7 @@ export async function verifyScenarioEnvironment(endpoint, targetId, origin) {
targetId,
origin,
assertRestored,
assertUnselectedState,
});
}, [() => client.close(), () => transport.close()]);
await assertRestored();
@@ -235,8 +289,25 @@ export async function verifyScenarioEnvironment(endpoint, targetId, origin) {
action_failure_cleanup: true,
cancellation_cleanup: true,
external_target_preserved: true,
unselected_page_media_preserved: true,
};
}, [() => provider.close(), () => connection.close()]);
}, [
() => provider.close(),
() => unselected?.close(),
async () => {
if (unselectedTarget !== undefined)
assert.equal(
(
await connection.send("Target.closeTarget", {
targetId: unselectedTarget,
})
).success,
true,
"Owned unselected fixture page was not released",
);
},
() => connection.close(),
]);
}
const verifyConcurrentMcpCapture = async ({
@@ -247,6 +318,7 @@ const verifyConcurrentMcpCapture = async ({
targetId,
origin,
assertRestored,
assertUnselectedState,
}) => {
const firstScenario = environmentScenario({
endpoint,
@@ -279,6 +351,7 @@ const verifyConcurrentMcpCapture = async ({
try {
const first = callTool(firstScenario, firstCancellation.signal);
await waitForEnvironment(connection, expected, "A");
await assertUnselectedState();
const second = callTool(secondScenario, secondCancellation.signal);
await client.ping();
+126 -92
View File
@@ -387,6 +387,11 @@ it("restores its environment overrides before releasing an attached browser", as
const cleanupOrder: string[] = [];
const fixture = attachedBrowserFixture({
send: async (method, parameters) => {
if (
method === "Emulation.setFocusEmulationEnabled" &&
(parameters as { enabled?: boolean }).enabled === false
)
cleanupOrder.push("focus");
if (
method === "Emulation.setTimezoneOverride" &&
(parameters as { timezoneId?: string }).timezoneId === ""
@@ -435,6 +440,7 @@ it("restores its environment overrides before releasing an attached browser", as
expect(fixture.environmentSession.detach).toHaveBeenCalledOnce();
expect(fixture.browser.close).toHaveBeenCalledOnce();
expect(cleanupOrder).toEqual([
"focus",
"timezone",
"locale",
"device-metrics",
@@ -488,33 +494,40 @@ it("checks a late stop after attached restoration and before transport disconnec
]);
});
it("retains attached environment ownership until a failed reset can be retried", async () => {
let localeResetAttempts = 0;
const fixture = attachedBrowserFixture({
send: async (method, parameters) => {
if (
method === "Emulation.setLocaleOverride" &&
(parameters as { locale?: string }).locale === ""
) {
localeResetAttempts += 1;
if (localeResetAttempts === 1) throw new Error("reset interrupted");
}
},
});
const opened = await fixture.open();
it.each(["locale", "focus"] as const)(
"retains a failed %s reset for cleanup retry",
async (mutation) => {
let resetAttempts = 0;
const fixture = attachedBrowserFixture({
send: async (method, parameters) => {
if (
(mutation === "locale" &&
method === "Emulation.setLocaleOverride" &&
(parameters as { locale?: string }).locale === "") ||
(mutation === "focus" &&
method === "Emulation.setFocusEmulationEnabled" &&
(parameters as { enabled?: boolean }).enabled === false)
) {
resetAttempts += 1;
if (resetAttempts === 1) throw new Error("reset interrupted");
}
},
});
const opened = await fixture.open();
await expect(opened.cleanup.close()).rejects.toMatchObject({
reason: "cleanup_failed",
cleanup: { reason: "reset interrupted" },
});
expect(fixture.browser.close).not.toHaveBeenCalled();
expect(fixture.environmentSession.detach).not.toHaveBeenCalled();
await expect(opened.cleanup.close()).rejects.toMatchObject({
reason: "cleanup_failed",
cleanup: { reason: "reset interrupted" },
});
expect(fixture.browser.close).not.toHaveBeenCalled();
expect(fixture.environmentSession.detach).not.toHaveBeenCalled();
await opened.cleanup.close();
expect(localeResetAttempts).toBe(2);
expect(fixture.environmentSession.detach).toHaveBeenCalledOnce();
expect(fixture.browser.close).toHaveBeenCalledOnce();
});
await opened.cleanup.close();
expect(resetAttempts).toBe(2);
expect(fixture.environmentSession.detach).toHaveBeenCalledOnce();
expect(fixture.browser.close).toHaveBeenCalledOnce();
},
);
it("does not clear an attached environment override it failed to acquire", async () => {
const fixture = attachedBrowserFixture({
@@ -1260,75 +1273,96 @@ it("closes a browser that connects after its deadline without waiting to report
expect(closed).toBe(true);
});
it("restores an environment mutation that completes after its deadline", async () => {
let restored = false;
const scenario = browserScenarioSchema.parse({
browser: {
mode: "connect",
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "late-environment-target",
},
start_url: { url: "https://example.test" },
environment: { service_workers: "allow" },
command_timeout_ms: 40,
actions: scenarioActions,
});
const discoverySession = {
send: vi.fn(async () => ({
targetInfo: { targetId: "late-environment-target" },
})),
detach: vi.fn(async () => undefined),
} as unknown as CDPSession;
const environmentSession = {
send: vi.fn(async () => undefined),
detach: vi.fn(async () => undefined),
} as unknown as CDPSession;
const page = {
emulateMedia: vi.fn(async (options: { colorScheme?: string | null }) => {
if (options.colorScheme === null) {
restored = true;
return;
}
await new Promise((resolve) => setTimeout(resolve, 80));
}),
isClosed: vi.fn(() => false),
} as unknown as Page;
const context = {
pages: () => [page],
newCDPSession: vi
.fn()
.mockResolvedValueOnce(discoverySession)
.mockResolvedValueOnce(environmentSession),
} as unknown as BrowserContext;
const browser = {
close: vi.fn(async () => undefined),
contexts: () => [context],
isConnected: vi.fn(() => true),
version: () => "late-environment",
};
await expect(
openPlaywrightScenarioBrowser(
scenario,
{},
{
launcher: {
connectOverCDP: async () => browser,
launchPersistentContext: async () => {
throw new Error("Unexpected launch");
it.each(["media", "focus"] as const)(
"restores a late %s mutation after its deadline",
async (mutation) => {
let restored = false;
const scenario = browserScenarioSchema.parse({
browser: {
mode: "connect",
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "late-environment-target",
},
start_url: { url: "https://example.test" },
environment: { service_workers: "allow" },
command_timeout_ms: 40,
actions: scenarioActions,
});
const discoverySession = {
send: vi.fn(async () => ({
targetInfo: { targetId: "late-environment-target" },
})),
detach: vi.fn(async () => undefined),
} as unknown as CDPSession;
const environmentSession = {
send: vi.fn(
async (method: string, parameters?: { enabled?: boolean }) => {
if (method !== "Emulation.setFocusEmulationEnabled") return;
if (parameters?.enabled === false) {
if (mutation === "focus") restored = true;
} else if (mutation === "focus")
await new Promise((resolve) => setTimeout(resolve, 80));
},
),
detach: vi.fn(async () => undefined),
} as unknown as CDPSession;
const page = {
emulateMedia: vi.fn(async (options: { colorScheme?: string | null }) => {
if (options.colorScheme === null) {
if (mutation === "media") restored = true;
return;
}
if (mutation === "media")
await new Promise((resolve) => setTimeout(resolve, 80));
}),
isClosed: vi.fn(() => false),
} as unknown as Page;
const context = {
pages: () => [page],
newCDPSession: vi
.fn()
.mockResolvedValueOnce(discoverySession)
.mockResolvedValueOnce(environmentSession),
} as unknown as BrowserContext;
const browser = {
close: vi.fn(async () => undefined),
contexts: () => [context],
isConnected: vi.fn(() => true),
version: () => "late-environment",
};
await expect(
openPlaywrightScenarioBrowser(
scenario,
{},
{
launcher: {
connectOverCDP: async () => browser,
launchPersistentContext: async () => {
throw new Error("Unexpected launch");
},
},
},
},
),
).rejects.toMatchObject({
reason: "timeout",
userMessage: expect.stringContaining("Emulation.setEmulatedMedia"),
});
expect(restored).toBe(true);
expect(environmentSession.send).not.toHaveBeenCalledWith(
"Emulation.clearDeviceMetricsOverride",
);
expect(browser.close).toHaveBeenCalledOnce();
});
),
).rejects.toMatchObject({
reason: "timeout",
userMessage: expect.stringContaining(
mutation === "media"
? "Emulation.setEmulatedMedia"
: "Emulation.setFocusEmulationEnabled",
),
});
expect(restored).toBe(true);
if (mutation === "media")
expect(environmentSession.send).not.toHaveBeenCalledWith(
"Emulation.clearDeviceMetricsOverride",
);
else
expect(environmentSession.send).toHaveBeenCalledWith(
"Emulation.clearDeviceMetricsOverride",
);
expect(browser.close).toHaveBeenCalledOnce();
},
);
it.each([
new Error("Timeout unrelated launch diagnostic"),
+30 -1
View File
@@ -13,6 +13,7 @@ import type {
Browser,
BrowserContext,
CDPSession,
ConnectOverCDPOptions,
Page,
chromium,
} from "playwright-core";
@@ -435,6 +436,7 @@ const findConnectedPage = async (
/** Owns the target state changed while one scenario uses an attached page. */
class AttachedScenarioEnvironmentOwner {
#focusOverride = false;
#mediaOverride = false;
#deviceMetricsOverride = false;
#localeOverride = false;
@@ -475,6 +477,7 @@ class AttachedScenarioEnvironmentOwner {
get restored(): boolean {
return (
!this.#focusOverride &&
!this.#mediaOverride &&
!this.#deviceMetricsOverride &&
!this.#localeOverride &&
@@ -563,6 +566,18 @@ class AttachedScenarioEnvironmentOwner {
"Emulation.setTimezoneOverride",
);
throwIfScenarioCancelled(signal);
// Keep scenario actions usable in the selected background page without
// Playwright's connection-wide focus override on unrelated pages.
await this.#command(
this.session
.send("Emulation.setFocusEmulationEnabled", { enabled: true })
.then(() => {
this.#focusOverride = true;
}),
"Emulation.setFocusEmulationEnabled",
);
throwIfScenarioCancelled(signal);
}
async close(): Promise<void> {
@@ -577,6 +592,16 @@ class AttachedScenarioEnvironmentOwner {
await settleScenarioCommand(cause);
}
const failures: unknown[] = [];
if (this.#focusOverride) {
try {
await this.session.send("Emulation.setFocusEmulationEnabled", {
enabled: false,
});
this.#focusOverride = false;
} catch (cause: unknown) {
failures.push(cause);
}
}
if (this.#timezoneOverride) {
try {
await this.session.send("Emulation.setTimezoneOverride", {
@@ -669,7 +694,7 @@ type ScenarioBrowserLauncher = Pick<
> & {
readonly connectOverCDP: (
endpoint: string,
options?: { readonly timeout?: number },
options?: Pick<ConnectOverCDPOptions, "timeout" | "noDefaults">,
) => Promise<Pick<Browser, "contexts" | "close" | "isConnected" | "version">>;
};
@@ -698,6 +723,10 @@ const openAttachedScenarioBrowser = async (
() =>
launcher.connectOverCDP(browserRequest.cdp_endpoint, {
timeout: commandTimeoutMs,
// Only the selected target's environment owner may apply overrides.
// Playwright defaults also mutate unrelated external pages and can
// require context management on an otherwise usable CDP endpoint.
noDefaults: true,
}),
commandTimeoutMs,
"handshake",
@@ -341,6 +341,10 @@ describe("request dispatch boundary", () => {
let gate: ((route: Route) => Promise<void>) | undefined;
vi.spyOn(page, "route").mockImplementation(async (_match, handler) => {
gate = handler as typeof gate;
return {
dispose: async () => undefined,
[Symbol.asyncDispose]: async () => undefined,
};
});
const unroute = vi.spyOn(page, "unroute");
const sent = vi.fn();
+4 -1
View File
@@ -101,7 +101,10 @@ export const completedModuleResolverLauncher =
return {
version: () => "controlled-browser",
newContext: async () => ({
route: async () => {},
route: async () => ({
dispose: async () => undefined,
[Symbol.asyncDispose]: async () => undefined,
}),
newPage: async () => ({
on: () => {},
goto: async () => null,