mirror of
https://github.com/lexmount/moli.git
synced 2026-10-01 08:00:38 +00:00
Fix CDP execution context replay on reconnect
This commit is contained in:
@@ -70,6 +70,16 @@ the CDP Network success/failure terminal skeleton. The local Chromium source
|
||||
checkout remains useful for locating the WPT assets but is not counted as an
|
||||
executable pass while its snapshot and binary are mismatched.
|
||||
|
||||
The Puppeteer existing-Page reconnect contract was calibrated on 2026-08-19
|
||||
with the pinned puppeteer-core 24.30.0 against Debian `/usr/bin/chromium`
|
||||
145.0.7632.116. One complete Puppeteer-group run created a second Page through
|
||||
`PUT /json/new`, evaluated in the original Page through one `puppeteer.connect()`
|
||||
session, disconnected that session, and evaluated the same target and retained
|
||||
main-world marker through a replacement connection. Chromium returned `42` in
|
||||
both sessions. The identical probe timed out at the replacement evaluation after
|
||||
10,000 ms when Moli's parent-session renderer-inspector detach fix was removed,
|
||||
and passed after the fix was restored.
|
||||
|
||||
## Current Coverage
|
||||
|
||||
The current suite is a strong core smoke gate, not a complete Playwright compatibility suite.
|
||||
@@ -129,7 +139,7 @@ Covered well:
|
||||
live DOM text; sampled `innerText` is recorded but may remain stale until the
|
||||
next layout refresh by design.
|
||||
- The focused raw `dom-parser-mutations` group holds a parser-blocking head script after an early head-only `DOM.getDocument` and requires Chromium's exact root-agent sequence: commit `DOM.documentUpdated`, parser-tail BODY `DOM.childNodeInserted`, DCL `DOM.documentUpdated`, then `Page.domContentEventFired`. It also proves that the early frontend node id is stale after the DCL barrier and that a refreshed snapshot contains the complete BODY.
|
||||
- Optional Puppeteer over CDP group for `puppeteer.connect()`, browser-target and page-target `CDPSession`, `page.goto()`, `page.reload()`, selector-backed DOM activation navigation, same-document hash and History API navigation, `page.evaluate(fetch)`, keyboard input via `page.type()`, CSS / `$eval` / XPath element selection, and DOM interactions across text input, textarea, label/checkbox, radio, select, details/summary, disabled button, and form submission. It also covers `ElementHandle.boundingBox()` / `evaluate()` / `uploadFile()`, DedicatedWorker `workercreated` / `WebWorker.evaluate()` / exact-once worker-session console routing / explicit terminate / navigation-destroy lifecycle, current-viewport `page.screenshot({captureBeyondViewport:false})`, alert and console events, browser-session download behavior/events/artifact with peer-session event isolation, request interception `respond()` / `continue()`, page-scoped `CDPSession` Network event observation, and layout-backed `page.click()` dispatch.
|
||||
- Optional Puppeteer over CDP group for `puppeteer.connect()`, reconnecting an existing parked-then-promoted Page through a fresh browser session, browser-target and page-target `CDPSession`, `page.goto()`, `page.reload()`, selector-backed DOM activation navigation, same-document hash and History API navigation, `page.evaluate(fetch)`, keyboard input via `page.type()`, CSS / `$eval` / XPath element selection, and DOM interactions across text input, textarea, label/checkbox, radio, select, details/summary, disabled button, and form submission. It also covers `ElementHandle.boundingBox()` / `evaluate()` / `uploadFile()`, DedicatedWorker `workercreated` / `WebWorker.evaluate()` / exact-once worker-session console routing / explicit terminate / navigation-destroy lifecycle, current-viewport `page.screenshot({captureBeyondViewport:false})`, alert and console events, browser-session download behavior/events/artifact with peer-session event isolation, request interception `respond()` / `continue()`, page-scoped `CDPSession` Network event observation, and layout-backed `page.click()` dispatch.
|
||||
- `browser.new_context()`, `context.new_page()`, multiple pages in one context, target switching, popup-scoped `page.route()` plus `evaluate(fetch)`, popup CDPSession response-stage body / stream / fulfill / fail flows, and held multi-context route / response-stage resume without cross-context Network event bleed.
|
||||
- Top-level navigation, redirect final URL/response, reload-like click navigation, and history back/forward.
|
||||
- Reload, same-document hash navigation, `history.pushState()` observation, Playwright `add_init_script()` page/context injection, `page.exposeFunction()` / `page.exposeBinding()` plus context-level exposed functions, and basic `domcontentloaded` / `load` / `networkidle` load-state waits with a parser-discovered delayed image.
|
||||
|
||||
@@ -54,6 +54,147 @@ async function discoverWebSocket(endpoint) {
|
||||
return payload.webSocketDebuggerUrl;
|
||||
}
|
||||
|
||||
async function listPageTargets(endpoint) {
|
||||
const response = await fetch(`${endpoint.replace(/\/$/, '')}/json/list`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`CDP target discovery failed with HTTP ${response.status}`);
|
||||
}
|
||||
const payload = await response.json();
|
||||
if (!Array.isArray(payload)) {
|
||||
throw new Error(`CDP target discovery returned an invalid payload: ${JSON.stringify(payload)}`);
|
||||
}
|
||||
return payload.filter(target => target?.type === 'page' && typeof target.id === 'string');
|
||||
}
|
||||
|
||||
async function createPageTarget(endpoint) {
|
||||
const response = await fetch(
|
||||
`${endpoint.replace(/\/$/, '')}/json/new?${encodeURIComponent('about:blank')}`,
|
||||
{ method: 'PUT' },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`CDP target creation failed with HTTP ${response.status}`);
|
||||
}
|
||||
const target = await response.json();
|
||||
if (target?.type !== 'page' || typeof target.id !== 'string') {
|
||||
throw new Error(`CDP target creation returned an invalid payload: ${JSON.stringify(target)}`);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
async function closePageTarget(endpoint, targetId) {
|
||||
const response = await fetch(
|
||||
`${endpoint.replace(/\/$/, '')}/json/close/${encodeURIComponent(targetId)}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`CDP target ${targetId} cleanup failed with HTTP ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function pageByTargetId(browser, targetId, label) {
|
||||
const target = await withTimeout(
|
||||
`${label} target`,
|
||||
browser.waitForTarget(candidate => {
|
||||
return candidate.type() === 'page' && candidate._targetId === targetId;
|
||||
}, { timeout: 10000 }),
|
||||
);
|
||||
const page = await withTimeout(`${label} page`, target.page());
|
||||
if (!page) {
|
||||
throw new Error(`${label} target ${targetId} did not expose a Page`);
|
||||
}
|
||||
return page;
|
||||
}
|
||||
|
||||
async function runPuppeteerReconnectSmoke(puppeteer, endpoint, browserWSEndpoint) {
|
||||
let pages = await listPageTargets(endpoint);
|
||||
let originalTargetCreated = false;
|
||||
if (pages.length === 0) {
|
||||
pages = [await createPageTarget(endpoint)];
|
||||
originalTargetCreated = true;
|
||||
}
|
||||
const originalTargetId = pages[0].id;
|
||||
// Materializing a second Page parks the original Page in Moli. Accessing the
|
||||
// original through Puppeteer then promotes it again, which is the lifecycle
|
||||
// required to exercise parent-session detach cleanup on disconnect.
|
||||
const temporaryTarget = await createPageTarget(endpoint);
|
||||
if (temporaryTarget.id === originalTargetId) {
|
||||
throw new Error(`CDP target creation reused the existing target id ${originalTargetId}`);
|
||||
}
|
||||
|
||||
let firstBrowser;
|
||||
let replacementBrowser;
|
||||
let completed = false;
|
||||
try {
|
||||
firstBrowser = await withTimeout(
|
||||
'first Puppeteer reconnect probe connect',
|
||||
puppeteer.connect({ browserWSEndpoint, protocolTimeout: 10000 }),
|
||||
);
|
||||
const originalPage = await pageByTargetId(
|
||||
firstBrowser,
|
||||
originalTargetId,
|
||||
'first Puppeteer reconnect probe',
|
||||
);
|
||||
const firstResult = await withTimeout(
|
||||
'first Puppeteer reconnect probe evaluate',
|
||||
originalPage.evaluate(() => {
|
||||
globalThis.__moliPuppeteerReconnectMarker = 29;
|
||||
return 6 * 7;
|
||||
}),
|
||||
);
|
||||
if (firstResult !== 42) {
|
||||
throw new Error(`unexpected first Puppeteer reconnect probe result: ${firstResult}`);
|
||||
}
|
||||
await firstBrowser.disconnect();
|
||||
firstBrowser = undefined;
|
||||
|
||||
replacementBrowser = await withTimeout(
|
||||
'replacement Puppeteer reconnect probe connect',
|
||||
puppeteer.connect({ browserWSEndpoint, protocolTimeout: 10000 }),
|
||||
);
|
||||
const replacementPage = await pageByTargetId(
|
||||
replacementBrowser,
|
||||
originalTargetId,
|
||||
'replacement Puppeteer reconnect probe',
|
||||
);
|
||||
const replacementResult = await withTimeout(
|
||||
'replacement Puppeteer reconnect probe evaluate',
|
||||
replacementPage.evaluate(() => ({
|
||||
answer: 6 * 7,
|
||||
marker: globalThis.__moliPuppeteerReconnectMarker,
|
||||
})),
|
||||
);
|
||||
if (replacementResult?.answer !== 42 || replacementResult?.marker !== 29) {
|
||||
throw new Error(
|
||||
`unexpected replacement Puppeteer reconnect probe result: ${JSON.stringify(replacementResult)}`,
|
||||
);
|
||||
}
|
||||
completed = true;
|
||||
return {
|
||||
existingTargetReused: true,
|
||||
firstEvaluation: firstResult,
|
||||
replacementEvaluation: replacementResult.answer,
|
||||
};
|
||||
} finally {
|
||||
if (replacementBrowser) {
|
||||
await replacementBrowser.disconnect().catch(() => {});
|
||||
}
|
||||
if (firstBrowser) {
|
||||
await firstBrowser.disconnect().catch(() => {});
|
||||
}
|
||||
await closePageTarget(endpoint, temporaryTarget.id).catch(error => {
|
||||
if (completed) {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
if (originalTargetCreated) {
|
||||
await closePageTarget(endpoint, originalTargetId).catch(error => {
|
||||
if (completed) {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createBrowserContext(browser) {
|
||||
if (typeof browser.createBrowserContext === 'function') {
|
||||
return await browser.createBrowserContext();
|
||||
@@ -80,13 +221,20 @@ async function main() {
|
||||
const browserWSEndpoint = await discoverWebSocket(endpoint);
|
||||
const isMoliEndpoint = browserWSEndpoint.endsWith('/devtools/browser/moli-browser');
|
||||
trace(`discovered ${browserWSEndpoint}`);
|
||||
const results = [];
|
||||
const record = (name, data = {}) => results.push({ name, ok: true, ...data });
|
||||
const reconnectResult = await runPuppeteerReconnectSmoke(
|
||||
puppeteer,
|
||||
endpoint,
|
||||
browserWSEndpoint,
|
||||
);
|
||||
record('puppeteer_existing_page_reconnect_runtime_context', reconnectResult);
|
||||
trace('reconnected to existing Page with a fresh Puppeteer session');
|
||||
const browser = await withTimeout(
|
||||
'puppeteer.connect',
|
||||
puppeteer.connect({ browserWSEndpoint, protocolTimeout: 10000 }),
|
||||
);
|
||||
trace('connected');
|
||||
const results = [];
|
||||
const record = (name, data = {}) => results.push({ name, ok: true, ...data });
|
||||
|
||||
let context;
|
||||
let browserCdp;
|
||||
|
||||
@@ -58,6 +58,131 @@ fn response_by_id(messages: &[serde_json::Value], id: u64) -> &serde_json::Value
|
||||
.unwrap_or_else(|| panic!("missing response id {id}: {messages:#?}"))
|
||||
}
|
||||
|
||||
async fn enable_runtime_and_expect_default_context(
|
||||
socket: &mut tokio_tungstenite::WebSocketStream<
|
||||
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
|
||||
>,
|
||||
command_id: u64,
|
||||
session_id: Option<&str>,
|
||||
label: &str,
|
||||
) -> Vec<serde_json::Value> {
|
||||
let mut messages =
|
||||
send_cdp_command(socket, command_id, "Runtime.enable", session_id, json!({})).await;
|
||||
assert_eq!(response_by_id(&messages, command_id)["result"], json!({}));
|
||||
if !messages.iter().any(|message| {
|
||||
message.get("sessionId").and_then(serde_json::Value::as_str) == session_id
|
||||
&& message["method"] == json!("Runtime.executionContextCreated")
|
||||
&& message["params"]["context"]["auxData"]["isDefault"] == json!(true)
|
||||
}) {
|
||||
messages.extend(
|
||||
send_cdp_command(
|
||||
socket,
|
||||
command_id + 1,
|
||||
"Runtime.evaluate",
|
||||
session_id,
|
||||
json!({ "expression": "void 0" }),
|
||||
)
|
||||
.await,
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
messages.iter().any(|message| {
|
||||
message.get("sessionId").and_then(serde_json::Value::as_str) == session_id
|
||||
&& message["method"] == json!("Runtime.executionContextCreated")
|
||||
&& message["params"]["context"]["auxData"]["isDefault"] == json!(true)
|
||||
}),
|
||||
"{label} did not report the existing default context before the next Runtime response: {messages:#?}"
|
||||
);
|
||||
messages
|
||||
}
|
||||
|
||||
async fn puppeteer_auto_attach_existing_page(
|
||||
browser: &mut tokio_tungstenite::WebSocketStream<
|
||||
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
|
||||
>,
|
||||
command_id: u64,
|
||||
page_target_id: &str,
|
||||
) -> String {
|
||||
let tab_target_id = format!("TAB-{page_target_id}");
|
||||
send_cdp_command_without_wait(
|
||||
browser,
|
||||
command_id,
|
||||
"Target.setAutoAttach",
|
||||
None,
|
||||
json!({
|
||||
"autoAttach": true,
|
||||
"waitForDebuggerOnStart": true,
|
||||
"flatten": true,
|
||||
"filter": [
|
||||
{ "type": "page", "exclude": true },
|
||||
{}
|
||||
]
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let mut saw_root_response = false;
|
||||
let mut saw_tab_attach = false;
|
||||
let root_auto_attach = recv_until_match(browser, |message| {
|
||||
saw_root_response |= message["id"] == json!(command_id);
|
||||
saw_tab_attach |= message["method"] == json!("Target.attachedToTarget")
|
||||
&& message["params"]["targetInfo"]["targetId"] == json!(tab_target_id);
|
||||
saw_root_response && saw_tab_attach
|
||||
})
|
||||
.await;
|
||||
assert_eq!(
|
||||
response_by_id(&root_auto_attach, command_id)["result"],
|
||||
json!({})
|
||||
);
|
||||
let tab_session_id = root_auto_attach
|
||||
.iter()
|
||||
.find(|message| {
|
||||
message["method"] == json!("Target.attachedToTarget")
|
||||
&& message["params"]["targetInfo"]["targetId"] == json!(tab_target_id)
|
||||
})
|
||||
.and_then(|message| message["params"]["sessionId"].as_str())
|
||||
.expect("auto-attached tab session")
|
||||
.to_owned();
|
||||
|
||||
let child_command_id = command_id + 1;
|
||||
send_cdp_command_without_wait(
|
||||
browser,
|
||||
child_command_id,
|
||||
"Target.setAutoAttach",
|
||||
Some(&tab_session_id),
|
||||
json!({
|
||||
"autoAttach": true,
|
||||
"waitForDebuggerOnStart": false,
|
||||
"flatten": true,
|
||||
"filter": [{}]
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let mut saw_child_response = false;
|
||||
let mut saw_page_attach = false;
|
||||
let tab_auto_attach = recv_until_match(browser, |message| {
|
||||
saw_child_response |= message["id"] == json!(child_command_id);
|
||||
saw_page_attach |= message["sessionId"] == json!(tab_session_id)
|
||||
&& message["method"] == json!("Target.attachedToTarget")
|
||||
&& message["params"]["targetInfo"]["targetId"] == json!(page_target_id);
|
||||
saw_child_response && saw_page_attach
|
||||
})
|
||||
.await;
|
||||
assert_eq!(
|
||||
response_by_id(&tab_auto_attach, child_command_id)["result"],
|
||||
json!({})
|
||||
);
|
||||
tab_auto_attach
|
||||
.iter()
|
||||
.find(|message| {
|
||||
message["sessionId"] == json!(tab_session_id)
|
||||
&& message["method"] == json!("Target.attachedToTarget")
|
||||
&& message["params"]["targetInfo"]["targetId"] == json!(page_target_id)
|
||||
})
|
||||
.and_then(|message| message["params"]["sessionId"].as_str())
|
||||
.expect("auto-attached page session")
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
async fn fetch_server_json(addr: std::net::SocketAddr, path: &str) -> serde_json::Value {
|
||||
let (status, body) = fetch_server_response(addr, "GET", path).await;
|
||||
assert_eq!(status, 200, "unexpected HTTP status for {path}");
|
||||
@@ -1699,6 +1824,118 @@ async fn websocket_cdp_shared_owner_survives_idle_browser_reconnect() {
|
||||
abort_test_cdp_server(server).await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn websocket_cdp_puppeteer_reconnect_replays_existing_runtime_context() {
|
||||
let (addr, server) = spawn_test_protocol_server().await;
|
||||
let (status, body) = fetch_server_response(addr, "PUT", "/json/new?about%3Ablank").await;
|
||||
assert_eq!(status, 200);
|
||||
let created_target: serde_json::Value =
|
||||
serde_json::from_slice(&body).expect("created target descriptor");
|
||||
let created_target_id = created_target["id"]
|
||||
.as_str()
|
||||
.expect("created target id")
|
||||
.to_owned();
|
||||
assert_ne!(created_target_id, DEFAULT_TARGET_ID);
|
||||
// Materializing a second Page parks the old default Page. Puppeteer's
|
||||
// first Page command promotes that existing target back to the active
|
||||
// slot, which is the reconnect lifecycle that regressed.
|
||||
let target_id = DEFAULT_TARGET_ID.to_owned();
|
||||
|
||||
let (mut browser, _) =
|
||||
connect_async(format!("ws://{addr}/devtools/browser/{DEFAULT_BROWSER_ID}"))
|
||||
.await
|
||||
.expect("connect first browser websocket");
|
||||
let page_session_id = puppeteer_auto_attach_existing_page(&mut browser, 1, &target_id).await;
|
||||
let page_enabled = send_cdp_command(
|
||||
&mut browser,
|
||||
3,
|
||||
"Page.enable",
|
||||
Some(&page_session_id),
|
||||
json!({}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response_by_id(&page_enabled, 3)["result"], json!({}));
|
||||
let _ = enable_runtime_and_expect_default_context(
|
||||
&mut browser,
|
||||
4,
|
||||
Some(&page_session_id),
|
||||
"first Runtime.enable",
|
||||
)
|
||||
.await;
|
||||
let utility_world = send_cdp_command(
|
||||
&mut browser,
|
||||
6,
|
||||
"Page.createIsolatedWorld",
|
||||
Some(&page_session_id),
|
||||
json!({
|
||||
"frameId": target_id,
|
||||
"worldName": "__puppeteer_utility_world__moli_reconnect",
|
||||
"grantUniveralAccess": true
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
response_by_id(&utility_world, 6)["result"]["executionContextId"]
|
||||
.as_i64()
|
||||
.is_some(),
|
||||
"failed to create the persistent Puppeteer utility world: {utility_world:#?}"
|
||||
);
|
||||
|
||||
browser
|
||||
.close(None)
|
||||
.await
|
||||
.expect("close first browser frontend");
|
||||
wait_for_websocket_close(&mut browser, "first browser frontend").await;
|
||||
|
||||
let (mut replacement, _) =
|
||||
connect_async(format!("ws://{addr}/devtools/browser/{DEFAULT_BROWSER_ID}"))
|
||||
.await
|
||||
.expect("connect replacement browser websocket");
|
||||
let replacement_page_session_id =
|
||||
puppeteer_auto_attach_existing_page(&mut replacement, 1, &target_id).await;
|
||||
assert_ne!(replacement_page_session_id, page_session_id);
|
||||
let page_enabled = send_cdp_command(
|
||||
&mut replacement,
|
||||
3,
|
||||
"Page.enable",
|
||||
Some(&replacement_page_session_id),
|
||||
json!({}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response_by_id(&page_enabled, 3)["result"], json!({}));
|
||||
let replay = enable_runtime_and_expect_default_context(
|
||||
&mut replacement,
|
||||
4,
|
||||
Some(&replacement_page_session_id),
|
||||
"replacement Runtime.enable",
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
replay.iter().any(|message| {
|
||||
message["sessionId"] == json!(replacement_page_session_id)
|
||||
&& message["method"] == json!("Runtime.executionContextCreated")
|
||||
&& message["params"]["context"]["name"]
|
||||
== json!("__puppeteer_utility_world__moli_reconnect")
|
||||
}),
|
||||
"replacement Runtime.enable did not replay the existing Puppeteer utility world: {replay:#?}"
|
||||
);
|
||||
|
||||
let evaluated = send_cdp_command(
|
||||
&mut replacement,
|
||||
6,
|
||||
"Runtime.evaluate",
|
||||
Some(&replacement_page_session_id),
|
||||
json!({ "expression": "6 * 7", "returnByValue": true }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
response_by_id(&evaluated, 6)["result"]["result"]["value"],
|
||||
json!(42)
|
||||
);
|
||||
|
||||
abort_test_cdp_server(server).await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn websocket_cdp_browser_reconnect_clears_detached_session_emulated_media() {
|
||||
let (addr, server) = spawn_test_protocol_server().await;
|
||||
|
||||
@@ -2458,6 +2458,19 @@ async fn detach_attached_session_for_owner_async(
|
||||
conn.rollback_auto_attached_session_detach_plan_without_event(&detach_plan);
|
||||
return;
|
||||
};
|
||||
// A parent-session detach cascade bypasses the direct
|
||||
// Target.detachFromTarget path. Release the renderer inspector
|
||||
// here before resetting protocol state so the replacement primary
|
||||
// session gets a fresh Runtime.enable context inventory.
|
||||
conn.fail_pending_inspector_awaits_for_session_owner_background_events_into(
|
||||
out.background_events_mut(),
|
||||
command_context.protocol_events_mut(),
|
||||
Some(session_id),
|
||||
"Target detached",
|
||||
);
|
||||
let _ = conn
|
||||
.detach_runtime_inspector_session_for_session_owner_async(Some(session_id))
|
||||
.await;
|
||||
events::fail_pending_fetch_state_for_target_background_events_async(
|
||||
conn,
|
||||
out.background_events_mut(),
|
||||
|
||||
Reference in New Issue
Block a user