zendriver_transport/observer.rs
1//! [`TargetObserver`] trait — fires on each new attached target while the
2//! target is paused at the debugger.
3
4use crate::connection::Connection;
5use crate::error::CallError;
6
7/// Observer fired on every new [`Target.attachedToTarget`] event before the
8/// debugger releases the target.
9///
10/// The actor walks every registered observer serially (registration order)
11/// on each new target. A failing observer returns `Err` and the actor detaches
12/// the session via `Target.detachFromTarget`; observers that exceed the
13/// observer-timeout are skipped and the actor releases the debugger so Chrome
14/// doesn't hang indefinitely.
15///
16/// `zendriver-stealth::StealthObserver` implements this trait to install
17/// patches on every new page target before the page's first script runs.
18///
19/// [`Target.attachedToTarget`]: https://chromedevtools.github.io/devtools-protocol/tot/Target/#event-attachedToTarget
20#[async_trait::async_trait]
21pub trait TargetObserver: Send + Sync {
22 /// Called once per new target, after attach and before debugger release.
23 /// Observer MUST complete and return before the target resumes execution.
24 /// Observers run serially in registration order; returning Err leaves the
25 /// target paused (the actor logs + force-detaches the session).
26 async fn on_target_attached(&self, session: PausedSession<'_>) -> Result<(), ObserverError>;
27
28 /// Called when a target detaches. Default: no-op.
29 async fn on_target_detached(&self, _session_id: &str) {}
30
31 /// Stable identifier used in actor diagnostics (`error!` / `warn!` records).
32 fn name(&self) -> &'static str;
33}
34
35/// Scope passed to [`TargetObserver::on_target_attached`] — a session that's
36/// currently paused at the debugger, plus a back-reference to the connection
37/// for CDP calls scoped to that session.
38#[derive(Debug)]
39pub struct PausedSession<'a> {
40 /// CDP `sessionId` for the newly attached target.
41 pub session_id: &'a str,
42 /// Decoded `targetInfo` payload (target id, kind, url, ...).
43 pub target_info: &'a TargetInfo,
44 pub(crate) conn: &'a Connection,
45}
46
47impl<'a> PausedSession<'a> {
48 /// Send a CDP command scoped to this paused session's `sessionId`.
49 /// Convenience over reaching for [`PausedSession::connection`] manually.
50 pub async fn call(
51 &self,
52 method: impl Into<String>,
53 params: serde_json::Value,
54 ) -> Result<serde_json::Value, CallError> {
55 self.conn
56 .call_raw(method, params, Some(self.session_id.to_string()))
57 .await
58 }
59
60 /// The underlying [`Connection`]. Observers that need to spawn
61 /// additional [`crate::SessionHandle`]s (e.g. zendriver's
62 /// `TabRegistrar`) clone this to bind a fresh handle for the newly
63 /// attached `sessionId`.
64 #[must_use]
65 pub fn connection(&self) -> &'a Connection {
66 self.conn
67 }
68}
69
70/// Errors an observer may return to indicate it failed to set up its slice of
71/// the new target.
72#[derive(Debug, thiserror::Error)]
73#[non_exhaustive]
74pub enum ObserverError {
75 /// A CDP call dispatched from inside the observer failed.
76 #[error("call failed: {0}")]
77 Call(#[from] CallError),
78
79 /// The observer exceeded its per-target timeout. The actor surfaces this
80 /// when constructing diagnostic output; observers don't construct it
81 /// themselves.
82 #[error("observer timed out after {0:?}")]
83 Timeout(std::time::Duration),
84
85 /// The observer panicked. Carries the downcast panic payload.
86 #[error("observer panicked: {0}")]
87 Panicked(String),
88
89 /// Catch-all for observer-defined failures that don't fit the typed
90 /// variants above.
91 #[error("{0}")]
92 Other(String),
93}
94
95/// Decoded `targetInfo` payload from `Target.attachedToTarget` / `targetCreated`.
96///
97/// Mirrors CDP's [`Target.TargetInfo`] but only deserializes the fields used
98/// downstream by observers + zendriver core.
99///
100/// [`Target.TargetInfo`]: https://chromedevtools.github.io/devtools-protocol/tot/Target/#type-TargetInfo
101#[derive(Debug, Clone, serde::Deserialize)]
102pub struct TargetInfo {
103 /// CDP target id (stable across `attach` / `detach` cycles).
104 #[serde(rename = "targetId")]
105 pub target_id: String,
106 /// Target kind (`"page"`, `"iframe"`, `"worker"`, ...). The stealth
107 /// observer keys off this to skip workers + iframes.
108 #[serde(rename = "type")]
109 pub kind: String,
110 /// Initial URL the target is at — typically `about:blank` at attach time.
111 pub url: String,
112 /// Document title, when present.
113 #[serde(default)]
114 pub title: Option<String>,
115 /// Whether a debugger is currently attached.
116 #[serde(default)]
117 pub attached: bool,
118 /// Browser-context id this target belongs to (incognito / profile split).
119 #[serde(default, rename = "browserContextId")]
120 pub browser_context_id: Option<String>,
121 /// `frameId` of the iframe element that hosts this target, when present.
122 /// Chrome populates this for `kind == "iframe"` OOPIF targets (Chromium
123 /// 90+); used by [`crate::TargetObserver`] implementations to attach the
124 /// OOPIF's child session to its hosting frame in the parent tab's frame
125 /// tree. Not present for `kind == "page"` and may be absent on older
126 /// Chromium versions even for iframe targets, in which case attach
127 /// observers fall back to matching `target_id` against the frame tree.
128 #[serde(default, rename = "openerFrameId")]
129 pub opener_frame_id: Option<String>,
130}
131
132#[cfg(test)]
133#[allow(clippy::panic, clippy::unwrap_used)]
134mod tests {
135 use super::*;
136
137 #[test]
138 fn display_observer_error_timeout_includes_duration() {
139 let e = ObserverError::Timeout(std::time::Duration::from_secs(5));
140 assert_eq!(e.to_string(), "observer timed out after 5s");
141 }
142
143 #[test]
144 fn display_observer_error_panicked_includes_message() {
145 let e = ObserverError::Panicked("oh no".into());
146 assert_eq!(e.to_string(), "observer panicked: oh no");
147 }
148
149 #[test]
150 fn target_info_deserializes_chrome_payload() {
151 let json = r#"{"targetId":"T1","type":"page","url":"about:blank","attached":true}"#;
152 let info: TargetInfo = serde_json::from_str(json).unwrap();
153 assert_eq!(info.target_id, "T1");
154 assert_eq!(info.kind, "page");
155 assert_eq!(info.url, "about:blank");
156 assert!(info.attached);
157 }
158}