Skip to main content

zendriver_interception/
builder.rs

1//! [`InterceptBuilder`] — fluent rule + pattern registration.
2//!
3//! Two-phase API:
4//! - **Configure**: chain [`block`], [`block_hosts`], [`redirect`], [`respond`],
5//!   [`modify_request`], [`modify_response`] for declarative rules, plus
6//!   [`pattern`] / [`at_request`] / [`at_response`] / [`resource`] to control
7//!   which CDP `Fetch.RequestPattern` entries are sent on `Fetch.enable`.
8//! - **Activate**: [`start`](InterceptBuilder::start) spawns the actor task
9//!   (T6) with the registered rules + patterns, returning an
10//!   [`InterceptHandle`] for RAII teardown. Alternatively,
11//!   [`subscribe`](InterceptBuilder::subscribe) returns a
12//!   `Stream<Item = PausedRequest>` for the manual escape-hatch path —
13//!   callers drive Chrome's interception loop themselves.
14//!
15//! The `tab` field is a borrow of [`SessionHandle`] (not the full `Tab` from
16//! `zendriver` core) — this crate must not depend on `zendriver` (cycle).
17//! `Tab::intercept()` in `zendriver` constructs the builder via
18//! `InterceptBuilder::new(self.session())`.
19//!
20//! [`block`]: InterceptBuilder::block
21//! [`block_hosts`]: InterceptBuilder::block_hosts
22//! [`redirect`]: InterceptBuilder::redirect
23//! [`respond`]: InterceptBuilder::respond
24//! [`modify_request`]: InterceptBuilder::modify_request
25//! [`modify_response`]: InterceptBuilder::modify_response
26//! [`pattern`]: InterceptBuilder::pattern
27//! [`at_request`]: InterceptBuilder::at_request
28//! [`at_response`]: InterceptBuilder::at_response
29//! [`resource`]: InterceptBuilder::resource
30
31use std::sync::Arc;
32
33use futures::stream::{Stream, StreamExt};
34use serde_json::{Value, json};
35use tokio::sync::oneshot;
36use tokio_util::sync::CancellationToken;
37use tracing::warn;
38use zendriver_transport::SessionHandle;
39
40use crate::actor::{
41    InterceptHandle, RequestPausedEvent, build_request_info, build_response_info, run_actor,
42    serialize_pattern,
43};
44use crate::error::InterceptionError;
45use crate::host_matcher::HostMatcher;
46use crate::paused::PausedRequest;
47use crate::rule::Rule;
48use crate::types::{
49    RequestInfo, RequestOverrides, RequestStage, ResourceType, ResponseInfo, ResponseOverrides,
50};
51use crate::url_pattern::UrlPattern;
52
53/// A pending `Fetch.RequestPattern` entry to send on `Fetch.enable`.
54///
55/// CDP's [`Fetch.RequestPattern`] takes an optional `urlPattern`,
56/// `resourceType`, and `requestStage`. We mirror it 1:1 here. The builder
57/// accumulates these via [`InterceptBuilder::pattern`] / `at_request` /
58/// `at_response` / `resource`, mutating the last-pushed entry per chain — so
59/// `builder.pattern("*").at_response().resource(Image)` produces a single
60/// `RequestPattern` with all three fields set.
61///
62/// [`Fetch.RequestPattern`]: https://chromedevtools.github.io/devtools-protocol/tot/Fetch/#type-RequestPattern
63#[derive(Debug, Clone, Default)]
64pub struct RequestPattern {
65    /// URL pattern in CDP wildcard syntax. `None` means "match any URL"
66    /// (CDP default).
67    pub url_pattern: Option<String>,
68    /// Resource type filter (e.g. `Image`, `XHR`). `None` means "all types".
69    pub resource_type: Option<ResourceType>,
70    /// Lifecycle stage at which to pause. `None` means CDP's default
71    /// (`Request`).
72    pub request_stage: Option<RequestStage>,
73}
74
75/// Fluent builder for rule-based interception against a single tab session.
76///
77/// Construct via `Tab::intercept()` (gated `feature = "interception"`, wired
78/// in Task 7). Chain configuration methods to register rules and declare CDP
79/// `Fetch.enable` patterns, then call [`start`](Self::start) (Task 7) to
80/// activate the background actor or [`subscribe`](Self::subscribe) (Task 7)
81/// for the stream-driven escape hatch.
82///
83/// `'tab` ties the builder's lifetime to the tab's session — the borrow lasts
84/// only until `start()` / `subscribe()` consumes the builder.
85//
86// `Debug` works because `Rule` has a hand-written `Debug` impl that renders
87// the closure variant's body as `<closure>`. Inner `Vec<Rule>` derives via
88// that.
89#[derive(Debug)]
90pub struct InterceptBuilder<'tab> {
91    tab: &'tab SessionHandle,
92    patterns: Vec<RequestPattern>,
93    rules: Vec<Rule>,
94    /// Optional proxy/server credentials. When set, `Fetch.enable` is sent
95    /// with `handleAuthRequests: true` and the actor responds to each
96    /// `Fetch.authRequired` event with `Fetch.continueWithAuth` carrying
97    /// these credentials. See cdpdriver/zendriver#208.
98    auth: Option<(String, String)>,
99}
100
101impl<'tab> InterceptBuilder<'tab> {
102    /// Construct a fresh builder bound to `tab`'s session.
103    ///
104    /// `pub` so adapter crates (e.g. `zendriver` core's `Tab::intercept()`
105    /// shim) can construct it from a `&SessionHandle` without going through
106    /// a trait. End users go through `Tab::intercept()` rather than calling
107    /// this directly.
108    ///
109    /// ```no_run
110    /// # async fn ex(tab: &zendriver_transport::SessionHandle)
111    /// #   -> Result<(), zendriver_interception::InterceptionError> {
112    /// use zendriver_interception::InterceptBuilder;
113    ///
114    /// let _handle = InterceptBuilder::new(tab)
115    ///     .block("*/tracker.js")?
116    ///     .start();
117    /// # Ok(()) }
118    /// ```
119    #[must_use]
120    pub fn new(tab: &'tab SessionHandle) -> Self {
121        Self {
122            tab,
123            patterns: Vec::new(),
124            rules: Vec::new(),
125            auth: None,
126        }
127    }
128
129    /// Auto-respond to `Fetch.authRequired` challenges with the given
130    /// credentials.
131    ///
132    /// This is the proxy-auth (and HTTP basic-auth) path: `Fetch.enable` is
133    /// sent with `handleAuthRequests: true` and every `Fetch.authRequired`
134    /// event is answered with `Fetch.continueWithAuth { authChallengeResponse:
135    /// { response: "ProvideCredentials", username, password } }`.
136    ///
137    /// Compose with rules: an `InterceptBuilder` configured with `handle_auth`
138    /// and `block` / `redirect` / `respond` rules handles both paths from the
139    /// same actor. Combine with [`BrowserBuilder::proxy_auth`] in the
140    /// `zendriver` crate if you want the wiring installed automatically on
141    /// every tab.
142    ///
143    /// See cdpdriver/zendriver#208.
144    #[must_use]
145    pub fn handle_auth(mut self, user: impl Into<String>, pass: impl Into<String>) -> Self {
146        self.auth = Some((user.into(), pass.into()));
147        self
148    }
149
150    /// Auto-respond to `Fetch.authRequired` challenges with the given
151    /// credentials, scoped to `Document`-type requests only.
152    ///
153    /// Equivalent to [`handle_auth`](Self::handle_auth) plus
154    /// `.pattern("*").resource(ResourceType::Document)`, packaged as its own
155    /// preset so a proxy/HTTP-auth-only caller doesn't have to discover — by
156    /// reading [`start`](Self::start)'s source — that leaving patterns unset
157    /// pauses *every* request on the page, not just the one carrying the auth
158    /// challenge.
159    ///
160    /// Narrowing to `Document` means only the main-frame navigation pauses and
161    /// round-trips through the interception actor; other requests (XHR,
162    /// subresources, and any parallel/pipelined connection that also needs
163    /// credentials) are not paused and will not have their auth challenge
164    /// answered by this builder. Use plain [`handle_auth`](Self::handle_auth)
165    /// (which falls back to a match-all pattern in [`start`](Self::start)) if
166    /// you need every connection's 407 covered.
167    #[must_use]
168    pub fn handle_auth_scoped_to_document(
169        self,
170        user: impl Into<String>,
171        pass: impl Into<String>,
172    ) -> Self {
173        self.handle_auth(user, pass)
174            .pattern("*")
175            .resource(ResourceType::Document)
176    }
177
178    /// Push a new pattern entry with the given URL pattern string.
179    ///
180    /// Subsequent [`at_request`](Self::at_request) /
181    /// [`at_response`](Self::at_response) / [`resource`](Self::resource) calls
182    /// mutate this newest entry, so a chain like
183    /// `.pattern("*").at_response().resource(ResourceType::XHR)` produces one
184    /// `RequestPattern` with all three fields populated.
185    #[must_use]
186    pub fn pattern(mut self, pattern: impl Into<String>) -> Self {
187        self.patterns.push(RequestPattern {
188            url_pattern: Some(pattern.into()),
189            ..RequestPattern::default()
190        });
191        self
192    }
193
194    /// Pause matching requests at the `Request` stage on the most-recently
195    /// pushed pattern.
196    ///
197    /// If no pattern has been pushed yet, this creates an empty one (matches
198    /// every URL by CDP default) and sets the stage on it.
199    #[must_use]
200    pub fn at_request(mut self) -> Self {
201        self.ensure_pattern().request_stage = Some(RequestStage::Request);
202        self
203    }
204
205    /// Pause matching requests at the `Response` stage on the most-recently
206    /// pushed pattern.
207    #[must_use]
208    pub fn at_response(mut self) -> Self {
209        self.ensure_pattern().request_stage = Some(RequestStage::Response);
210        self
211    }
212
213    /// Restrict the most-recently pushed pattern to a single resource type.
214    #[must_use]
215    pub fn resource(mut self, kind: ResourceType) -> Self {
216        self.ensure_pattern().resource_type = Some(kind);
217        self
218    }
219
220    /// Register a [`Rule::Block`] for `pattern`.
221    ///
222    /// Compiles `pattern` eagerly; an invalid pattern fails the builder chain
223    /// with [`InterceptionError::InvalidPattern`] returned as `Err(Self)` via
224    /// the `Result` wrapper.
225    pub fn block(mut self, pattern: impl Into<String>) -> Result<Self, InterceptionError> {
226        self.rules.push(Rule::Block {
227            pattern: UrlPattern::new(pattern)?,
228        });
229        Ok(self)
230    }
231
232    /// Register a [`Rule::BlockHosts`] backed by `matcher`.
233    ///
234    /// Every request whose host is in `matcher` (exact, or a parent domain on
235    /// a dot boundary) is failed with `BlockedByClient`. Composes with other
236    /// rules in registration order. `zendriver` core's tracker-blocklist
237    /// wiring uses this; most callers reach it via `BrowserBuilder::block_trackers`.
238    #[must_use]
239    pub fn block_hosts(mut self, matcher: Arc<HostMatcher>) -> Self {
240        self.rules.push(Rule::BlockHosts { matcher });
241        self
242    }
243
244    /// Register a [`Rule::Redirect`] that rewrites `from` → `to`.
245    pub fn redirect(
246        mut self,
247        from: impl Into<String>,
248        to: impl Into<String>,
249    ) -> Result<Self, InterceptionError> {
250        self.rules.push(Rule::Redirect {
251            from: UrlPattern::new(from)?,
252            to: to.into(),
253        });
254        Ok(self)
255    }
256
257    /// Register a [`Rule::Respond`] serving a synthesized response.
258    pub fn respond(
259        mut self,
260        pattern: impl Into<String>,
261        status: u16,
262        headers: Vec<(String, String)>,
263        body: Vec<u8>,
264    ) -> Result<Self, InterceptionError> {
265        self.rules.push(Rule::Respond {
266            pattern: UrlPattern::new(pattern)?,
267            status,
268            headers,
269            body,
270        });
271        Ok(self)
272    }
273
274    /// Register a [`Rule::Modify`] driven by a user closure.
275    ///
276    /// The closure runs on the actor task per matching request — it must be
277    /// `Send + Sync` and `'static`. Wrap shared state in `Arc` if needed.
278    pub fn modify_request<F>(
279        mut self,
280        pattern: impl Into<String>,
281        modify: F,
282    ) -> Result<Self, InterceptionError>
283    where
284        F: Fn(&RequestInfo) -> RequestOverrides + Send + Sync + 'static,
285    {
286        self.rules.push(Rule::Modify {
287            pattern: UrlPattern::new(pattern)?,
288            modify: Arc::new(modify),
289        });
290        Ok(self)
291    }
292
293    /// Register a [`Rule::ModifyResponse`] driven by a user closure.
294    ///
295    /// The closure rewrites an upstream response's status/headers (keeping
296    /// Chrome's body) and only fires at the `Response` stage — pair this with
297    /// [`at_response`](Self::at_response) so Chrome actually pauses there.
298    /// Header overrides are *replacement*, not merge (CDP semantics): return
299    /// every header you want forwarded.
300    ///
301    /// Like [`modify_request`](Self::modify_request), the closure runs on the
302    /// actor task per matching response, so it must be `Send + Sync` and
303    /// `'static`. Wrap shared state in `Arc` if needed.
304    pub fn modify_response<F>(
305        mut self,
306        pattern: impl Into<String>,
307        modify: F,
308    ) -> Result<Self, InterceptionError>
309    where
310        F: Fn(&ResponseInfo) -> ResponseOverrides + Send + Sync + 'static,
311    {
312        self.rules.push(Rule::ModifyResponse {
313            pattern: UrlPattern::new(pattern)?,
314            modify: Arc::new(modify),
315        });
316        Ok(self)
317    }
318
319    /// Activate the rule-based interception loop.
320    ///
321    /// Spawns the background actor task with the registered rules and CDP
322    /// `RequestPattern` list, and returns an [`InterceptHandle`] whose
323    /// [`Drop`] (or explicit [`stop`](InterceptHandle::stop)) tears the
324    /// actor down.
325    ///
326    /// If no [`pattern`](Self::pattern) entries were added, a single
327    /// match-all (`"*"`) pattern is sent so Chrome actually pauses requests
328    /// — without it, `Fetch.enable` would attach to nothing and the rule
329    /// list would never fire.
330    #[must_use = "interception stops when the handle is dropped — bind the returned InterceptHandle to keep it alive"]
331    pub fn start(mut self) -> InterceptHandle {
332        if self.patterns.is_empty() && (!self.rules.is_empty() || self.auth.is_some()) {
333            // A match-all pattern is required in two cases:
334            //
335            // 1. Rules need Chrome to actually pause requests — without a
336            //    pattern `Fetch.enable` attaches to nothing and every rule is a
337            //    silent no-op.
338            // 2. `handleAuthRequests: true` (the proxy / HTTP-auth path):
339            //    Chrome (~M132+) REJECTS `Fetch.enable { patterns: [],
340            //    handleAuthRequests: true }` with CDP error -32602 "Can't
341            //    specify empty patterns with handleAuth set". Older Chrome
342            //    tolerated empty patterns — auth challenges still surfaced while
343            //    pausing zero requests — so we used to leave them empty on the
344            //    auth-ONLY path to avoid distorting the page's XHR cadence
345            //    (reese84 / Incapsula read it). Modern Chrome no longer permits
346            //    that, so a pattern is mandatory whenever auth handling is on.
347            //
348            // On the auth-only path this pauses (and immediately auto-continues
349            // — see `handle_paused`'s no-match arm) every request. If that
350            // per-request CDP round-trip regresses a timing-sensitive bot
351            // sensor, a caller can register an explicit narrower pattern (e.g.
352            // `resource_type: Document`, so only the main navigation — which
353            // carries the first proxy 407 — is paused), at the risk of missing
354            // 407s on parallel proxy connections.
355            self.patterns.push(RequestPattern {
356                url_pattern: Some("*".into()),
357                ..RequestPattern::default()
358            });
359        }
360        let cancel = CancellationToken::new();
361        let (done_tx, done_rx) = oneshot::channel();
362        let actor_session = self.tab.clone();
363        let actor_cancel = cancel.clone();
364        let actor_rules = self.rules;
365        let actor_patterns = self.patterns;
366        let actor_auth = self.auth;
367        tokio::spawn(async move {
368            run_actor(
369                actor_session,
370                actor_rules,
371                actor_patterns,
372                actor_auth,
373                actor_cancel,
374                done_tx,
375            )
376            .await;
377        });
378        InterceptHandle::new(cancel, done_rx)
379    }
380
381    /// Manual escape-hatch: subscribe to raw [`PausedRequest`] events.
382    ///
383    /// Enables `Fetch` interception with the declared patterns (defaulting
384    /// to a single match-all `"*"` pattern when none were added) and returns
385    /// a [`Stream`] that yields one [`PausedRequest`] per `Fetch.requestPaused`
386    /// CDP event. Callers must dispatch one of `PausedRequest`'s terminal
387    /// methods (`continue_` / `abort` / `respond` / `modify_and_continue`)
388    /// to release each pause — Chrome holds the request open otherwise.
389    ///
390    /// Rules registered via `block` / `redirect` / `respond` / `modify_request`
391    /// are ignored on this path: stream consumers drive every paused request
392    /// themselves. Use [`start`](Self::start) when you want the actor to
393    /// apply rules automatically.
394    ///
395    /// The returned stream owns the underlying CDP subscription. Dropping
396    /// the stream tears the subscription down — Chrome's interception stays
397    /// active until the session is closed, but no further pauses surface to
398    /// the caller.
399    #[must_use = "the returned stream is the only handle on the subscription"]
400    pub fn subscribe(mut self) -> impl Stream<Item = PausedRequest> + Send + use<> {
401        if self.patterns.is_empty() {
402            self.patterns.push(RequestPattern {
403                url_pattern: Some("*".into()),
404                ..RequestPattern::default()
405            });
406        }
407        // Same ordering as the actor: subscribe BEFORE the (fire-and-forget)
408        // enable so we don't drop events Chrome emits between the enable
409        // round-trip and the subscription registration.
410        let raw = self.tab.subscribe::<Value>("Fetch.requestPaused");
411        let session = self.tab.clone();
412        let enable_session = session.clone();
413        let enable_patterns: Vec<Value> = self.patterns.iter().map(serialize_pattern).collect();
414        tokio::spawn(async move {
415            if let Err(e) = enable_session
416                .call(
417                    "Fetch.enable",
418                    json!({
419                        "patterns": enable_patterns,
420                        "handleAuthRequests": false,
421                    }),
422                )
423                .await
424            {
425                warn!(error = %e, "interception: Fetch.enable failed; subscribe() stream will be empty");
426            }
427        });
428        raw.filter_map(move |ev_value| {
429            let session = session.clone();
430            async move {
431                let ev: RequestPausedEvent = match serde_json::from_value(ev_value) {
432                    Ok(ev) => ev,
433                    Err(e) => {
434                        warn!(error = %e, "interception: skipping malformed Fetch.requestPaused event");
435                        return None;
436                    }
437                };
438                let info = build_request_info(&ev);
439                let response = build_response_info(&ev);
440                Some(PausedRequest::new(ev.request_id, info, response, session))
441            }
442        })
443    }
444
445    /// Lazily push an empty pattern if none exists, so the stage/resource
446    /// setters always have a target. Mirrors CDP's "missing fields default to
447    /// match-all" semantics.
448    fn ensure_pattern(&mut self) -> &mut RequestPattern {
449        if self.patterns.is_empty() {
450            self.patterns.push(RequestPattern::default());
451        }
452        self.patterns
453            .last_mut()
454            .expect("ensure_pattern pushed if empty")
455    }
456
457    /// Test-only accessor: number of registered rules. Used by the Task 5
458    /// builder test (and future actor tests) without exposing the rule list
459    /// as public API.
460    #[cfg(test)]
461    pub(crate) fn rules_count(&self) -> usize {
462        self.rules.len()
463    }
464}
465
466#[cfg(test)]
467#[allow(clippy::panic, clippy::unwrap_used)]
468mod tests {
469    use super::*;
470    use std::time::Duration;
471    use zendriver_transport::testing::MockConnection;
472
473    /// Register three rules (block + redirect + respond) on a fresh builder
474    /// and assert the rule list grew to length 3. Verifies the chain wiring
475    /// without touching the actor (Task 6) or CDP dispatch (Task 7).
476    #[tokio::test]
477    async fn three_rules_register_and_count() {
478        let (_mock, conn) = MockConnection::pair();
479        let sess = SessionHandle::new(conn.clone(), "S1");
480
481        let builder = InterceptBuilder::new(&sess)
482            .block("*/ads/*")
483            .unwrap()
484            .redirect("*/old/*", "https://example.com/new/")
485            .unwrap()
486            .respond(
487                "*/api/health",
488                200,
489                vec![("content-type".into(), "application/json".into())],
490                br#"{"ok":true}"#.to_vec(),
491            )
492            .unwrap();
493
494        assert_eq!(builder.rules_count(), 3);
495        conn.shutdown();
496    }
497
498    /// End-to-end on the rule-driven `start()` path: register a Block rule,
499    /// spawn the actor via `start()`, observe `Fetch.enable`, emit a matching
500    /// `Fetch.requestPaused`, and assert `Fetch.failRequest` is dispatched.
501    ///
502    /// This is the actor test from T6 reframed through the `start()` entry
503    /// point — proves the builder properly forwards rules + patterns to
504    /// `run_actor` (and that `start()` actually spawns the task).
505    #[tokio::test]
506    async fn start_spawns_actor_with_rules() {
507        let (mut mock, conn) = MockConnection::pair();
508        let sess = SessionHandle::new(conn.clone(), "S1");
509
510        let handle = InterceptBuilder::new(&sess)
511            .block("*/blocked/*")
512            .unwrap()
513            .pattern("*")
514            .start();
515
516        // The actor's `Fetch.enable` side-task fires fire-and-forget; wait
517        // for it to land so the `Fetch.requestPaused` subscription is in
518        // place before we emit an event.
519        let enable_id =
520            tokio::time::timeout(Duration::from_secs(2), mock.expect_cmd("Fetch.enable"))
521                .await
522                .expect("actor did not send Fetch.enable within 2s");
523        let enable_params = mock.last_sent()["params"].clone();
524        assert_eq!(enable_params["handleAuthRequests"], false);
525        assert_eq!(enable_params["patterns"][0]["urlPattern"], "*");
526        mock.reply(enable_id, json!({})).await;
527
528        // Emit a paused-event whose URL matches the Block rule.
529        mock.emit_event_for_session(
530            "Fetch.requestPaused",
531            json!({
532                "requestId": "REQ-1",
533                "request": {
534                    "url": "https://example.test/blocked/banner.png",
535                    "method": "GET",
536                    "headers": {},
537                },
538                "resourceType": "Image",
539            }),
540            "S1",
541        )
542        .await;
543
544        // Actor should dispatch Fetch.failRequest with BlockedByClient.
545        let fail_id =
546            tokio::time::timeout(Duration::from_secs(2), mock.expect_cmd("Fetch.failRequest"))
547                .await
548                .expect("actor did not send Fetch.failRequest within 2s");
549        let fail_params = mock.last_sent()["params"].clone();
550        assert_eq!(fail_params["requestId"], "REQ-1");
551        assert_eq!(fail_params["errorReason"], "BlockedByClient");
552        mock.reply(fail_id, json!({})).await;
553
554        // Teardown via the handle: stop() cancels + awaits the oneshot the
555        // actor signals after Fetch.disable lands.
556        let stop_fut = tokio::spawn(handle.stop());
557        let disable_id =
558            tokio::time::timeout(Duration::from_secs(2), mock.expect_cmd("Fetch.disable"))
559                .await
560                .expect("actor did not send Fetch.disable on stop()");
561        mock.reply(disable_id, json!({})).await;
562        stop_fut
563            .await
564            .expect("stop() task panicked")
565            .expect("stop() returned Err");
566        conn.shutdown();
567    }
568
569    /// `start()` injects a match-all `"*"` pattern when the caller did not
570    /// add any via [`pattern`](InterceptBuilder::pattern) — otherwise
571    /// `Fetch.enable` would arrive with an empty patterns array and Chrome
572    /// would silently pause nothing.
573    #[tokio::test]
574    async fn start_defaults_to_match_all_pattern_when_none_registered() {
575        let (mut mock, conn) = MockConnection::pair();
576        let sess = SessionHandle::new(conn.clone(), "S1");
577
578        let handle = InterceptBuilder::new(&sess)
579            .block("*/blocked/*")
580            .unwrap()
581            .start();
582
583        let enable_id =
584            tokio::time::timeout(Duration::from_secs(2), mock.expect_cmd("Fetch.enable"))
585                .await
586                .expect("actor did not send Fetch.enable within 2s");
587        let patterns = mock.last_sent()["params"]["patterns"].clone();
588        let arr = patterns.as_array().expect("patterns must be a JSON array");
589        assert_eq!(arr.len(), 1);
590        assert_eq!(arr[0]["urlPattern"], "*");
591        mock.reply(enable_id, json!({})).await;
592
593        // Drop the handle to tear down; we don't need to observe the disable.
594        drop(handle);
595        conn.shutdown();
596    }
597
598    /// Regression guard for the "empty patterns with handleAuth" breakage: the
599    /// proxy / HTTP-auth-ONLY path (`handle_auth`, no rules, no explicit
600    /// pattern) must still send a NON-empty `patterns` on `Fetch.enable`.
601    /// Modern Chrome (~M132+) rejects `{ patterns: [], handleAuthRequests:
602    /// true }` with CDP -32602 ("Can't specify empty patterns with handleAuth
603    /// set"), which silently disables proxy-auth handling and 407s every
604    /// navigation. `start()` must inject a match-all `"*"` and set
605    /// `handleAuthRequests`.
606    #[tokio::test]
607    async fn start_auth_only_sends_match_all_pattern_with_handle_auth() {
608        let (mut mock, conn) = MockConnection::pair();
609        let sess = SessionHandle::new(conn.clone(), "S1");
610
611        let handle = InterceptBuilder::new(&sess)
612            .handle_auth("puser", "ppass")
613            .start();
614
615        let enable_id =
616            tokio::time::timeout(Duration::from_secs(2), mock.expect_cmd("Fetch.enable"))
617                .await
618                .expect("actor did not send Fetch.enable within 2s");
619        let params = mock.last_sent()["params"].clone();
620        assert_eq!(
621            params["handleAuthRequests"], true,
622            "auth-only path must enable auth handling"
623        );
624        let arr = params["patterns"]
625            .as_array()
626            .expect("patterns must be a JSON array");
627        assert_eq!(
628            arr.len(),
629            1,
630            "auth-only must send exactly one match-all pattern, never an empty array"
631        );
632        assert_eq!(arr[0]["urlPattern"], "*");
633        mock.reply(enable_id, json!({})).await;
634
635        drop(handle);
636        conn.shutdown();
637    }
638
639    #[tokio::test]
640    async fn start_auth_scoped_to_document_sends_single_document_pattern() {
641        let (mut mock, conn) = MockConnection::pair();
642        let sess = SessionHandle::new(conn.clone(), "S1");
643
644        let handle = InterceptBuilder::new(&sess)
645            .handle_auth_scoped_to_document("puser", "ppass")
646            .start();
647
648        let enable_id =
649            tokio::time::timeout(Duration::from_secs(2), mock.expect_cmd("Fetch.enable"))
650                .await
651                .expect("actor did not send Fetch.enable within 2s");
652        let params = mock.last_sent()["params"].clone();
653        assert_eq!(params["handleAuthRequests"], true);
654        let arr = params["patterns"]
655            .as_array()
656            .expect("patterns must be a JSON array");
657        assert_eq!(arr.len(), 1, "scoped preset must send exactly one pattern");
658        assert_eq!(arr[0]["urlPattern"], "*");
659        assert_eq!(
660            arr[0]["resourceType"], "Document",
661            "scoped preset must narrow to the Document navigation — the difference from plain handle_auth"
662        );
663        mock.reply(enable_id, json!({})).await;
664
665        drop(handle);
666        conn.shutdown();
667    }
668
669    #[tokio::test]
670    async fn handle_auth_scoped_to_document_composes_with_rules() {
671        let (mut mock, conn) = MockConnection::pair();
672        let sess = SessionHandle::new(conn.clone(), "S1");
673
674        let builder = InterceptBuilder::new(&sess)
675            .block("*/ads/*")
676            .unwrap()
677            .handle_auth_scoped_to_document("puser", "ppass");
678        assert_eq!(
679            builder.rules_count(),
680            1,
681            "the preset must not clobber a rule registered before it in the chain"
682        );
683        let handle = builder.start();
684
685        let enable_id =
686            tokio::time::timeout(Duration::from_secs(2), mock.expect_cmd("Fetch.enable"))
687                .await
688                .expect("actor did not send Fetch.enable within 2s");
689        let params = mock.last_sent()["params"].clone();
690        let arr = params["patterns"]
691            .as_array()
692            .expect("patterns must be a JSON array");
693        assert!(
694            arr.iter().any(|p| p["resourceType"] == "Document"),
695            "the Document-scoped auth pattern must survive alongside the block rule: {arr:?}"
696        );
697        mock.reply(enable_id, json!({})).await;
698
699        drop(handle);
700        conn.shutdown();
701    }
702
703    /// On the `subscribe()` path: each `Fetch.requestPaused` event becomes
704    /// a `PausedRequest` yielded from the stream, with the request payload
705    /// decoded into [`RequestInfo`].
706    #[tokio::test]
707    async fn subscribe_yields_paused_request_per_event() {
708        let (mut mock, conn) = MockConnection::pair();
709        let sess = SessionHandle::new(conn.clone(), "S1");
710
711        let mut stream = Box::pin(InterceptBuilder::new(&sess).subscribe());
712
713        // Wait for the side-task's Fetch.enable to land so the subscription
714        // is in place before we emit.
715        let enable_id =
716            tokio::time::timeout(Duration::from_secs(2), mock.expect_cmd("Fetch.enable"))
717                .await
718                .expect("subscribe() did not send Fetch.enable within 2s");
719        mock.reply(enable_id, json!({})).await;
720
721        mock.emit_event_for_session(
722            "Fetch.requestPaused",
723            json!({
724                "requestId": "REQ-1",
725                "request": {
726                    "url": "https://example.test/widget.json",
727                    "method": "GET",
728                    "headers": {"accept": "application/json"},
729                },
730                "resourceType": "XHR",
731            }),
732            "S1",
733        )
734        .await;
735
736        let paused = tokio::time::timeout(Duration::from_secs(2), stream.next())
737            .await
738            .expect("subscribe() stream did not yield within 2s")
739            .expect("subscribe() stream closed before yielding");
740        assert_eq!(paused.request_id, "REQ-1");
741        assert_eq!(paused.request.url, "https://example.test/widget.json");
742        assert_eq!(paused.request.method, "GET");
743        assert_eq!(
744            paused
745                .request
746                .headers
747                .iter()
748                .find(|(k, _)| k == "accept")
749                .map(|(_, v)| v.as_str()),
750            Some("application/json"),
751        );
752        assert!(
753            paused.response.is_none(),
754            "request-stage event has no response"
755        );
756
757        drop(stream);
758        conn.shutdown();
759    }
760}