fix(browser): preserve external defaults during CDP attachment (#7188)

Attach to external browsers without implicit default-context overrides, and own temporary focus only on the selected page. Preserve unrelated page media and exact page inventory through CLI/MCP. Remove bootstrap-page retries; retain late/failed cleanup ownership and verified SDK notices.

Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing)
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
This commit is contained in:
akushonkamen
2026-10-27 06:16:14 +08:00
committed by GitHub
parent 779d548c4e
commit c6893d1ac6
10 changed files with 2410 additions and 110 deletions
+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 @@ pkce-challenge@5.0.1 | 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==
plist@5.0.0 | 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,