Files
moli/moli-protocol/src/domains/debugger.rs

640 lines
22 KiB
Rust

use serde_json::{Map, Value};
use crate::conn::{CdpConnection, Cmd};
use crate::domains::runtime::{RuntimeCommandTaskStep, start_debugger_inspector_command_dispatch};
pub(crate) fn try_start_debugger_command_dispatch(
conn: &mut CdpConnection,
cmd: &Cmd<'_>,
) -> Option<RuntimeCommandTaskStep> {
let mut message = Map::new();
if let Some(command_id) = cmd.id {
message.insert("id".to_owned(), Value::from(command_id));
}
message.insert("method".to_owned(), Value::String(cmd.method.to_owned()));
if let Some(params) = cmd.params {
message.insert("params".to_owned(), Value::Object(params.clone()));
}
Some(start_debugger_inspector_command_dispatch(
conn,
cmd,
Value::Object(message).to_string(),
))
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use serde_json::{Value, json};
use crate::conn::{BrowserContext, CommandOwnerScope};
use crate::testing::TestContext;
async fn with_loaded_document(ctx: &mut TestContext) {
ctx.conn
.insert_browser_context(BrowserContext::new("BID-debugger".into()));
ctx.conn
.browser_context
.as_mut()
.expect("browser context")
.set_active_target_id("TID-debugger");
let page = ctx
.conn
.load_page_via_runtime_async("data:text/html,<body>debugger</body>")
.await
.expect("load debugger test page");
ctx.conn
.browser_context
.as_mut()
.expect("browser context")
.active_page_target_mut()
.runtime_slot
.replace_loaded_page(Some(page));
}
async fn command(ctx: &mut TestContext, message: Value, command_id: u64) -> Value {
ctx.process_and_wait_for_response_async(message).await;
let position = ctx
.sent
.iter()
.position(|message| message["id"] == json!(command_id))
.expect("command response");
ctx.sent.remove(position)
}
#[tokio::test]
async fn debugger_script_events_and_source_use_v8_inspector() {
let mut ctx = TestContext::new();
with_loaded_document(&mut ctx).await;
let enable = command(&mut ctx, json!({"id": 1, "method": "Debugger.enable"}), 1).await;
assert!(
enable["result"]["debuggerId"]
.as_str()
.is_some_and(|id| !id.is_empty()),
"Debugger.enable should return V8's debugger id: {enable:?}"
);
let evaluate = command(
&mut ctx,
json!({
"id": 2,
"method": "Runtime.evaluate",
"params": {
"expression": "function moliDebuggerSource() { return 42; }\n//# sourceURL=moli-debugger-source.js"
}
}),
2,
)
.await;
assert!(evaluate.get("error").is_none(), "{evaluate:?}");
let parsed = ctx
.sent
.iter()
.find(|message| {
message["method"] == json!("Debugger.scriptParsed")
&& message["params"]["url"] == json!("moli-debugger-source.js")
})
.cloned()
.expect("Debugger.scriptParsed for evaluated source");
let script_id = parsed["params"]["scriptId"]
.as_str()
.expect("script id")
.to_owned();
let source = command(
&mut ctx,
json!({
"id": 3,
"method": "Debugger.getScriptSource",
"params": {"scriptId": script_id}
}),
3,
)
.await;
assert!(
source["result"]["scriptSource"]
.as_str()
.is_some_and(|source| source.contains("moliDebuggerSource")),
"{source:?}"
);
}
#[tokio::test]
async fn debugger_interruptible_source_lookup_addresses_suspended_renderer() {
let mut ctx = TestContext::new();
with_loaded_document(&mut ctx).await;
let enable = command(&mut ctx, json!({"id": 71, "method": "Debugger.enable"}), 71).await;
assert!(enable.get("error").is_none(), "{enable:?}");
let evaluate = command(
&mut ctx,
json!({
"id": 72,
"method": "Runtime.evaluate",
"params": {
"expression": "function moliSuspendedDebuggerSource() { return 72; }\n//# sourceURL=moli-suspended-debugger-source.js"
}
}),
72,
)
.await;
assert!(evaluate.get("error").is_none(), "{evaluate:?}");
let script_id = ctx
.sent
.iter()
.find(|message| {
message["method"] == json!("Debugger.scriptParsed")
&& message["params"]["url"] == json!("moli-suspended-debugger-source.js")
})
.and_then(|message| message["params"]["scriptId"].as_str())
.map(str::to_owned)
.expect("Debugger.scriptParsed for the suspended-renderer source");
let navigation_owner = CommandOwnerScope::capture(&ctx.conn, None);
let navigation = ctx
.conn
.start_document_navigation_for_owner(
&navigation_owner,
"LOADER-debugger-interrupt".to_owned(),
)
.expect("start cross-Document navigation");
assert!(
ctx.conn
.renderer_document_navigation_is_suspended_for_session_owner(None)
);
let source = command(
&mut ctx,
json!({
"id": 73,
"method": "Debugger.getScriptSource",
"params": {"scriptId": script_id}
}),
73,
)
.await;
assert!(
source["result"]["scriptSource"]
.as_str()
.is_some_and(|source| source.contains("moliSuspendedDebuggerSource")),
"interruptible Debugger command must address the suspended renderer: {source:?}"
);
let _ = ctx
.conn
.finish_renderer_document_navigation_for_owner(&navigation_owner, &navigation);
ctx.conn
.clear_pending_document_navigation_for_owner_if_loader_matches(
&navigation_owner,
&navigation.loader_id,
);
}
#[tokio::test]
async fn debugger_resume_dispatches_while_renderer_owner_is_paused() {
let mut ctx = TestContext::new();
with_loaded_document(&mut ctx).await;
let enable = command(&mut ctx, json!({"id": 11, "method": "Debugger.enable"}), 11).await;
assert!(enable.get("error").is_none(), "{enable:?}");
let timer = command(
&mut ctx,
json!({
"id": 12,
"method": "Runtime.evaluate",
"params": {
"expression": "setTimeout(() => { debugger; }, 50); true"
}
}),
12,
)
.await;
assert_eq!(timer["result"]["result"]["value"], json!(true));
ctx.wait_for_scheduler_message("Debugger.paused", |message| {
message["method"] == json!("Debugger.paused")
&& message["params"]["callFrames"]
.as_array()
.is_some_and(|frames| !frames.is_empty())
})
.await;
let resume = command(&mut ctx, json!({"id": 13, "method": "Debugger.resume"}), 13).await;
assert_eq!(resume["result"], json!({}), "{resume:?}");
ctx.wait_for_scheduler_message("Debugger.resumed", |message| {
message["method"] == json!("Debugger.resumed")
})
.await;
}
#[tokio::test]
async fn debugger_pause_responds_and_target_close_cancels_pending_pause() {
let mut ctx = TestContext::new();
with_loaded_document(&mut ctx).await;
let enable = command(&mut ctx, json!({"id": 21, "method": "Debugger.enable"}), 21).await;
assert!(enable.get("error").is_none(), "{enable:?}");
let timer = command(
&mut ctx,
json!({
"id": 22,
"method": "Runtime.evaluate",
"params": {
"expression": "setInterval(() => { globalThis.__debuggerPauseTick = (globalThis.__debuggerPauseTick || 0) + 1; }, 50); true"
}
}),
22,
)
.await;
assert_eq!(timer["result"]["result"]["value"], json!(true));
let response_start = ctx.sent.len();
let scheduler_events = ctx
.process_command_only_async(json!({"id": 22, "method": "Debugger.pause"}))
.await;
ctx.wait_for_test_command_response(22, response_start).await;
let pause_position = ctx
.sent
.iter()
.position(|message| message["id"] == json!(22))
.expect("Debugger.pause response must be flushed before deferred renderer activity");
let pause = ctx.sent.remove(pause_position);
assert_eq!(
pause["result"],
json!({}),
"Debugger.pause must respond before the next page statement enters the pause loop: {pause:?}"
);
assert!(
scheduler_events.is_empty(),
"Debugger.pause should not enqueue command-completed snapshot work before its response"
);
let close = tokio::time::timeout(
Duration::from_secs(2),
command(
&mut ctx,
json!({
"id": 24,
"method": "Target.closeTarget",
"params": {"targetId": "TID-debugger"}
}),
24,
),
)
.await
.expect("Target.closeTarget must cancel a pending debugger pause");
assert_eq!(close["result"], json!({"success": true}), "{close:?}");
}
#[tokio::test]
async fn debugger_instrumentation_breakpoint_ignores_internal_snapshot_scripts() {
let mut ctx = TestContext::new();
with_loaded_document(&mut ctx).await;
let enable = command(&mut ctx, json!({"id": 31, "method": "Debugger.enable"}), 31).await;
assert!(enable.get("error").is_none(), "{enable:?}");
let set_breakpoint = command(
&mut ctx,
json!({
"id": 32,
"method": "Debugger.setInstrumentationBreakpoint",
"params": {"instrumentation": "beforeScriptExecution"}
}),
32,
)
.await;
assert!(
set_breakpoint["result"]["breakpointId"]
.as_str()
.is_some_and(|id| !id.is_empty()),
"{set_breakpoint:?}"
);
assert!(
ctx.sent
.iter()
.all(|message| message["method"] != json!("Debugger.paused")),
"protocol snapshot bookkeeping must not trigger instrumentation pauses: {:?}",
ctx.sent
);
let disable = command(
&mut ctx,
json!({"id": 33, "method": "Debugger.disable"}),
33,
)
.await;
assert_eq!(disable["result"], json!({}), "{disable:?}");
}
#[tokio::test]
async fn debugger_instrumentation_navigation_publishes_new_context_and_tears_down_paused() {
let mut ctx = TestContext::new();
with_loaded_document(&mut ctx).await;
let runtime = command(&mut ctx, json!({"id": 34, "method": "Runtime.enable"}), 34).await;
assert_eq!(runtime["result"], json!({}), "{runtime:?}");
let debugger = command(&mut ctx, json!({"id": 35, "method": "Debugger.enable"}), 35).await;
assert!(debugger.get("error").is_none(), "{debugger:?}");
let breakpoint = command(
&mut ctx,
json!({
"id": 36,
"method": "Debugger.setInstrumentationBreakpoint",
"params": {"instrumentation": "beforeScriptExecution"}
}),
36,
)
.await;
assert!(breakpoint.get("error").is_none(), "{breakpoint:?}");
ctx.sent.clear();
let navigate = command(
&mut ctx,
json!({
"id": 37,
"method": "Page.navigate",
"params": {
"url": "data:text/html,<script>globalThis.__instrumentedNavigation = true</script>"
}
}),
37,
)
.await;
assert!(
navigate["result"]["frameId"].as_str().is_some(),
"{navigate:?}"
);
let parsed = ctx
.wait_for_scheduler_message(
"instrumented replacement scriptParsed after Page.navigate response",
|message| {
message["method"] == json!("Debugger.scriptParsed")
&& message["params"]["url"]
== json!(
"data:text/html,<script>globalThis.__instrumentedNavigation = true</script>"
)
},
)
.await;
let created = ctx
.sent
.iter()
.find(|message| message["method"] == json!("Runtime.executionContextCreated"))
.expect("replacement default context must precede its instrumentation pause");
assert_eq!(created["params"]["context"]["origin"], json!("://"));
assert_eq!(
parsed["params"]["url"],
json!("data:text/html,<script>globalThis.__instrumentedNavigation = true</script>"),
"the instrumented replacement script must reach V8"
);
// Keep the instrumentation pause active. Connection teardown must
// close the in-flight replacement Document's Inspector target and
// join its renderer owner without requiring a frontend resume.
drop(ctx);
}
#[tokio::test]
async fn debugger_paused_attached_session_detach_wakes_owner() {
let mut ctx = TestContext::new();
with_loaded_document(&mut ctx).await;
{
let browser_context = ctx.conn.browser_context.as_mut().expect("browser context");
browser_context.attach_active_session("SID-debugger-primary");
assert!(browser_context.assign_attached_session_to_target(
"TID-debugger",
"SID-debugger-attached".to_owned(),
));
}
for (session_id, session_key) in [
(
"SID-debugger-primary",
moli_page_types::DevToolsSessionKey::Primary,
),
(
"SID-debugger-attached",
moli_page_types::DevToolsSessionKey::Attached("SID-debugger-attached".to_owned()),
),
] {
ctx.conn.register_session_route_for_test(
session_id,
crate::conn::CdpSessionRoute::PageTarget {
browser_context_id: "BID-debugger".to_owned(),
target_id: "TID-debugger".to_owned(),
session_key,
},
);
}
let enable = command(
&mut ctx,
json!({
"id": 41,
"sessionId": "SID-debugger-attached",
"method": "Debugger.enable"
}),
41,
)
.await;
assert!(enable.get("error").is_none(), "{enable:?}");
let timer = command(
&mut ctx,
json!({
"id": 42,
"sessionId": "SID-debugger-attached",
"method": "Runtime.evaluate",
"params": {
"expression": "setTimeout(() => { debugger; globalThis.__afterDebuggerDetach = 1; }, 50); true"
}
}),
42,
)
.await;
assert_eq!(timer["result"]["result"]["value"], json!(true));
ctx.wait_for_scheduler_message("attached Debugger.paused", |message| {
message["method"] == json!("Debugger.paused")
&& message["sessionId"] == json!("SID-debugger-attached")
})
.await;
let detach = tokio::time::timeout(
Duration::from_secs(2),
command(
&mut ctx,
json!({
"id": 43,
"method": "Target.detachFromTarget",
"params": {
"targetId": "TID-debugger",
"sessionId": "SID-debugger-attached"
}
}),
43,
),
)
.await
.expect("detaching a paused Debugger session must wake the renderer owner");
assert_eq!(detach["result"], json!({}), "{detach:?}");
let continued = command(
&mut ctx,
json!({
"id": 44,
"sessionId": "SID-debugger-primary",
"method": "Runtime.evaluate",
"params": {"expression": "globalThis.__afterDebuggerDetach"}
}),
44,
)
.await;
assert_eq!(continued["result"]["result"]["value"], json!(1));
}
#[tokio::test]
async fn debugger_pause_events_keep_multi_session_routes() {
let mut ctx = TestContext::new();
with_loaded_document(&mut ctx).await;
{
let browser_context = ctx.conn.browser_context.as_mut().expect("browser context");
browser_context.attach_active_session("SID-debugger-primary");
assert!(browser_context.assign_attached_session_to_target(
"TID-debugger",
"SID-debugger-attached".to_owned(),
));
}
for (session_id, session_key) in [
(
"SID-debugger-primary",
moli_page_types::DevToolsSessionKey::Primary,
),
(
"SID-debugger-attached",
moli_page_types::DevToolsSessionKey::Attached("SID-debugger-attached".to_owned()),
),
] {
ctx.conn.register_session_route_for_test(
session_id,
crate::conn::CdpSessionRoute::PageTarget {
browser_context_id: "BID-debugger".to_owned(),
target_id: "TID-debugger".to_owned(),
session_key,
},
);
}
for (id, session_id) in [(61, "SID-debugger-primary"), (62, "SID-debugger-attached")] {
let enable = command(
&mut ctx,
json!({
"id": id,
"sessionId": session_id,
"method": "Debugger.enable"
}),
id,
)
.await;
assert!(enable.get("error").is_none(), "{enable:?}");
}
let timer = command(
&mut ctx,
json!({
"id": 63,
"sessionId": "SID-debugger-primary",
"method": "Runtime.evaluate",
"params": {"expression": "setTimeout(() => { debugger; }, 50); true"}
}),
63,
)
.await;
assert_eq!(timer["result"]["result"]["value"], json!(true));
for session_id in ["SID-debugger-primary", "SID-debugger-attached"] {
ctx.wait_for_scheduler_message("multi-session Debugger.paused", |message| {
message["method"] == json!("Debugger.paused")
&& message["sessionId"] == json!(session_id)
})
.await;
}
let resume = command(
&mut ctx,
json!({
"id": 64,
"sessionId": "SID-debugger-primary",
"method": "Debugger.resume"
}),
64,
)
.await;
assert_eq!(resume["result"], json!({}), "{resume:?}");
for session_id in ["SID-debugger-primary", "SID-debugger-attached"] {
ctx.wait_for_scheduler_message("multi-session Debugger.resumed", |message| {
message["method"] == json!("Debugger.resumed")
&& message["sessionId"] == json!(session_id)
})
.await;
}
}
#[tokio::test]
async fn debugger_enable_state_is_restored_after_navigation() {
let mut ctx = TestContext::new();
with_loaded_document(&mut ctx).await;
let enable = command(&mut ctx, json!({"id": 51, "method": "Debugger.enable"}), 51).await;
assert!(enable.get("error").is_none(), "{enable:?}");
ctx.sent.clear();
let navigate = command(
&mut ctx,
json!({
"id": 52,
"method": "Page.navigate",
"params": {
"url": "data:text/html,<body>debugger after navigation</body>"
}
}),
52,
)
.await;
assert!(
navigate["result"]["frameId"].as_str().is_some(),
"{navigate:?}"
);
let evaluate = command(
&mut ctx,
json!({
"id": 53,
"method": "Runtime.evaluate",
"params": {
"expression": "globalThis.__debuggerAfterNavigation = 1;\n//# sourceURL=debugger-after-navigation.js"
}
}),
53,
)
.await;
assert!(evaluate.get("error").is_none(), "{evaluate:?}");
assert!(
ctx.sent.iter().any(|message| {
message["method"] == json!("Debugger.scriptParsed")
&& message["params"]["url"] == json!("debugger-after-navigation.js")
}),
"replacement PageVM should restore Debugger.enable before later scripts: {:?}",
ctx.sent
);
let disable = command(
&mut ctx,
json!({"id": 54, "method": "Debugger.disable"}),
54,
)
.await;
assert_eq!(disable["result"], json!({}), "{disable:?}");
}
}