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 /// Push a new pattern entry with the given URL pattern string.
151 ///
152 /// Subsequent [`at_request`](Self::at_request) /
153 /// [`at_response`](Self::at_response) / [`resource`](Self::resource) calls
154 /// mutate this newest entry, so a chain like
155 /// `.pattern("*").at_response().resource(ResourceType::XHR)` produces one
156 /// `RequestPattern` with all three fields populated.
157 #[must_use]
158 pub fn pattern(mut self, pattern: impl Into<String>) -> Self {
159 self.patterns.push(RequestPattern {
160 url_pattern: Some(pattern.into()),
161 ..RequestPattern::default()
162 });
163 self
164 }
165
166 /// Pause matching requests at the `Request` stage on the most-recently
167 /// pushed pattern.
168 ///
169 /// If no pattern has been pushed yet, this creates an empty one (matches
170 /// every URL by CDP default) and sets the stage on it.
171 #[must_use]
172 pub fn at_request(mut self) -> Self {
173 self.ensure_pattern().request_stage = Some(RequestStage::Request);
174 self
175 }
176
177 /// Pause matching requests at the `Response` stage on the most-recently
178 /// pushed pattern.
179 #[must_use]
180 pub fn at_response(mut self) -> Self {
181 self.ensure_pattern().request_stage = Some(RequestStage::Response);
182 self
183 }
184
185 /// Restrict the most-recently pushed pattern to a single resource type.
186 #[must_use]
187 pub fn resource(mut self, kind: ResourceType) -> Self {
188 self.ensure_pattern().resource_type = Some(kind);
189 self
190 }
191
192 /// Register a [`Rule::Block`] for `pattern`.
193 ///
194 /// Compiles `pattern` eagerly; an invalid pattern fails the builder chain
195 /// with [`InterceptionError::InvalidPattern`] returned as `Err(Self)` via
196 /// the `Result` wrapper.
197 pub fn block(mut self, pattern: impl Into<String>) -> Result<Self, InterceptionError> {
198 self.rules.push(Rule::Block {
199 pattern: UrlPattern::new(pattern)?,
200 });
201 Ok(self)
202 }
203
204 /// Register a [`Rule::BlockHosts`] backed by `matcher`.
205 ///
206 /// Every request whose host is in `matcher` (exact, or a parent domain on
207 /// a dot boundary) is failed with `BlockedByClient`. Composes with other
208 /// rules in registration order. `zendriver` core's tracker-blocklist
209 /// wiring uses this; most callers reach it via `BrowserBuilder::block_trackers`.
210 #[must_use]
211 pub fn block_hosts(mut self, matcher: Arc<HostMatcher>) -> Self {
212 self.rules.push(Rule::BlockHosts { matcher });
213 self
214 }
215
216 /// Register a [`Rule::Redirect`] that rewrites `from` → `to`.
217 pub fn redirect(
218 mut self,
219 from: impl Into<String>,
220 to: impl Into<String>,
221 ) -> Result<Self, InterceptionError> {
222 self.rules.push(Rule::Redirect {
223 from: UrlPattern::new(from)?,
224 to: to.into(),
225 });
226 Ok(self)
227 }
228
229 /// Register a [`Rule::Respond`] serving a synthesized response.
230 pub fn respond(
231 mut self,
232 pattern: impl Into<String>,
233 status: u16,
234 headers: Vec<(String, String)>,
235 body: Vec<u8>,
236 ) -> Result<Self, InterceptionError> {
237 self.rules.push(Rule::Respond {
238 pattern: UrlPattern::new(pattern)?,
239 status,
240 headers,
241 body,
242 });
243 Ok(self)
244 }
245
246 /// Register a [`Rule::Modify`] driven by a user closure.
247 ///
248 /// The closure runs on the actor task per matching request — it must be
249 /// `Send + Sync` and `'static`. Wrap shared state in `Arc` if needed.
250 pub fn modify_request<F>(
251 mut self,
252 pattern: impl Into<String>,
253 modify: F,
254 ) -> Result<Self, InterceptionError>
255 where
256 F: Fn(&RequestInfo) -> RequestOverrides + Send + Sync + 'static,
257 {
258 self.rules.push(Rule::Modify {
259 pattern: UrlPattern::new(pattern)?,
260 modify: Arc::new(modify),
261 });
262 Ok(self)
263 }
264
265 /// Register a [`Rule::ModifyResponse`] driven by a user closure.
266 ///
267 /// The closure rewrites an upstream response's status/headers (keeping
268 /// Chrome's body) and only fires at the `Response` stage — pair this with
269 /// [`at_response`](Self::at_response) so Chrome actually pauses there.
270 /// Header overrides are *replacement*, not merge (CDP semantics): return
271 /// every header you want forwarded.
272 ///
273 /// Like [`modify_request`](Self::modify_request), the closure runs on the
274 /// actor task per matching response, so it must be `Send + Sync` and
275 /// `'static`. Wrap shared state in `Arc` if needed.
276 pub fn modify_response<F>(
277 mut self,
278 pattern: impl Into<String>,
279 modify: F,
280 ) -> Result<Self, InterceptionError>
281 where
282 F: Fn(&ResponseInfo) -> ResponseOverrides + Send + Sync + 'static,
283 {
284 self.rules.push(Rule::ModifyResponse {
285 pattern: UrlPattern::new(pattern)?,
286 modify: Arc::new(modify),
287 });
288 Ok(self)
289 }
290
291 /// Activate the rule-based interception loop.
292 ///
293 /// Spawns the background actor task with the registered rules and CDP
294 /// `RequestPattern` list, and returns an [`InterceptHandle`] whose
295 /// [`Drop`] (or explicit [`stop`](InterceptHandle::stop)) tears the
296 /// actor down.
297 ///
298 /// If no [`pattern`](Self::pattern) entries were added, a single
299 /// match-all (`"*"`) pattern is sent so Chrome actually pauses requests
300 /// — without it, `Fetch.enable` would attach to nothing and the rule
301 /// list would never fire.
302 #[must_use = "interception stops when the handle is dropped — bind the returned InterceptHandle to keep it alive"]
303 pub fn start(mut self) -> InterceptHandle {
304 if self.patterns.is_empty() && (!self.rules.is_empty() || self.auth.is_some()) {
305 // A match-all pattern is required in two cases:
306 //
307 // 1. Rules need Chrome to actually pause requests — without a
308 // pattern `Fetch.enable` attaches to nothing and every rule is a
309 // silent no-op.
310 // 2. `handleAuthRequests: true` (the proxy / HTTP-auth path):
311 // Chrome (~M132+) REJECTS `Fetch.enable { patterns: [],
312 // handleAuthRequests: true }` with CDP error -32602 "Can't
313 // specify empty patterns with handleAuth set". Older Chrome
314 // tolerated empty patterns — auth challenges still surfaced while
315 // pausing zero requests — so we used to leave them empty on the
316 // auth-ONLY path to avoid distorting the page's XHR cadence
317 // (reese84 / Incapsula read it). Modern Chrome no longer permits
318 // that, so a pattern is mandatory whenever auth handling is on.
319 //
320 // On the auth-only path this pauses (and immediately auto-continues
321 // — see `handle_paused`'s no-match arm) every request. If that
322 // per-request CDP round-trip regresses a timing-sensitive bot
323 // sensor, a caller can register an explicit narrower pattern (e.g.
324 // `resource_type: Document`, so only the main navigation — which
325 // carries the first proxy 407 — is paused), at the risk of missing
326 // 407s on parallel proxy connections.
327 self.patterns.push(RequestPattern {
328 url_pattern: Some("*".into()),
329 ..RequestPattern::default()
330 });
331 }
332 let cancel = CancellationToken::new();
333 let (done_tx, done_rx) = oneshot::channel();
334 let actor_session = self.tab.clone();
335 let actor_cancel = cancel.clone();
336 let actor_rules = self.rules;
337 let actor_patterns = self.patterns;
338 let actor_auth = self.auth;
339 tokio::spawn(async move {
340 run_actor(
341 actor_session,
342 actor_rules,
343 actor_patterns,
344 actor_auth,
345 actor_cancel,
346 done_tx,
347 )
348 .await;
349 });
350 InterceptHandle::new(cancel, done_rx)
351 }
352
353 /// Manual escape-hatch: subscribe to raw [`PausedRequest`] events.
354 ///
355 /// Enables `Fetch` interception with the declared patterns (defaulting
356 /// to a single match-all `"*"` pattern when none were added) and returns
357 /// a [`Stream`] that yields one [`PausedRequest`] per `Fetch.requestPaused`
358 /// CDP event. Callers must dispatch one of `PausedRequest`'s terminal
359 /// methods (`continue_` / `abort` / `respond` / `modify_and_continue`)
360 /// to release each pause — Chrome holds the request open otherwise.
361 ///
362 /// Rules registered via `block` / `redirect` / `respond` / `modify_request`
363 /// are ignored on this path: stream consumers drive every paused request
364 /// themselves. Use [`start`](Self::start) when you want the actor to
365 /// apply rules automatically.
366 ///
367 /// The returned stream owns the underlying CDP subscription. Dropping
368 /// the stream tears the subscription down — Chrome's interception stays
369 /// active until the session is closed, but no further pauses surface to
370 /// the caller.
371 #[must_use = "the returned stream is the only handle on the subscription"]
372 pub fn subscribe(mut self) -> impl Stream<Item = PausedRequest> + Send + use<> {
373 if self.patterns.is_empty() {
374 self.patterns.push(RequestPattern {
375 url_pattern: Some("*".into()),
376 ..RequestPattern::default()
377 });
378 }
379 // Same ordering as the actor: subscribe BEFORE the (fire-and-forget)
380 // enable so we don't drop events Chrome emits between the enable
381 // round-trip and the subscription registration.
382 let raw = self.tab.subscribe::<Value>("Fetch.requestPaused");
383 let session = self.tab.clone();
384 let enable_session = session.clone();
385 let enable_patterns: Vec<Value> = self.patterns.iter().map(serialize_pattern).collect();
386 tokio::spawn(async move {
387 if let Err(e) = enable_session
388 .call(
389 "Fetch.enable",
390 json!({
391 "patterns": enable_patterns,
392 "handleAuthRequests": false,
393 }),
394 )
395 .await
396 {
397 warn!(error = %e, "interception: Fetch.enable failed; subscribe() stream will be empty");
398 }
399 });
400 raw.filter_map(move |ev_value| {
401 let session = session.clone();
402 async move {
403 let ev: RequestPausedEvent = match serde_json::from_value(ev_value) {
404 Ok(ev) => ev,
405 Err(e) => {
406 warn!(error = %e, "interception: skipping malformed Fetch.requestPaused event");
407 return None;
408 }
409 };
410 let info = build_request_info(&ev);
411 let response = build_response_info(&ev);
412 Some(PausedRequest::new(ev.request_id, info, response, session))
413 }
414 })
415 }
416
417 /// Lazily push an empty pattern if none exists, so the stage/resource
418 /// setters always have a target. Mirrors CDP's "missing fields default to
419 /// match-all" semantics.
420 fn ensure_pattern(&mut self) -> &mut RequestPattern {
421 if self.patterns.is_empty() {
422 self.patterns.push(RequestPattern::default());
423 }
424 self.patterns
425 .last_mut()
426 .expect("ensure_pattern pushed if empty")
427 }
428
429 /// Test-only accessor: number of registered rules. Used by the Task 5
430 /// builder test (and future actor tests) without exposing the rule list
431 /// as public API.
432 #[cfg(test)]
433 pub(crate) fn rules_count(&self) -> usize {
434 self.rules.len()
435 }
436}
437
438#[cfg(test)]
439#[allow(clippy::panic, clippy::unwrap_used)]
440mod tests {
441 use super::*;
442 use std::time::Duration;
443 use zendriver_transport::testing::MockConnection;
444
445 /// Register three rules (block + redirect + respond) on a fresh builder
446 /// and assert the rule list grew to length 3. Verifies the chain wiring
447 /// without touching the actor (Task 6) or CDP dispatch (Task 7).
448 #[tokio::test]
449 async fn three_rules_register_and_count() {
450 let (_mock, conn) = MockConnection::pair();
451 let sess = SessionHandle::new(conn.clone(), "S1");
452
453 let builder = InterceptBuilder::new(&sess)
454 .block("*/ads/*")
455 .unwrap()
456 .redirect("*/old/*", "https://example.com/new/")
457 .unwrap()
458 .respond(
459 "*/api/health",
460 200,
461 vec![("content-type".into(), "application/json".into())],
462 br#"{"ok":true}"#.to_vec(),
463 )
464 .unwrap();
465
466 assert_eq!(builder.rules_count(), 3);
467 conn.shutdown();
468 }
469
470 /// End-to-end on the rule-driven `start()` path: register a Block rule,
471 /// spawn the actor via `start()`, observe `Fetch.enable`, emit a matching
472 /// `Fetch.requestPaused`, and assert `Fetch.failRequest` is dispatched.
473 ///
474 /// This is the actor test from T6 reframed through the `start()` entry
475 /// point — proves the builder properly forwards rules + patterns to
476 /// `run_actor` (and that `start()` actually spawns the task).
477 #[tokio::test]
478 async fn start_spawns_actor_with_rules() {
479 let (mut mock, conn) = MockConnection::pair();
480 let sess = SessionHandle::new(conn.clone(), "S1");
481
482 let handle = InterceptBuilder::new(&sess)
483 .block("*/blocked/*")
484 .unwrap()
485 .pattern("*")
486 .start();
487
488 // The actor's `Fetch.enable` side-task fires fire-and-forget; wait
489 // for it to land so the `Fetch.requestPaused` subscription is in
490 // place before we emit an event.
491 let enable_id =
492 tokio::time::timeout(Duration::from_secs(2), mock.expect_cmd("Fetch.enable"))
493 .await
494 .expect("actor did not send Fetch.enable within 2s");
495 let enable_params = mock.last_sent()["params"].clone();
496 assert_eq!(enable_params["handleAuthRequests"], false);
497 assert_eq!(enable_params["patterns"][0]["urlPattern"], "*");
498 mock.reply(enable_id, json!({})).await;
499
500 // Emit a paused-event whose URL matches the Block rule.
501 mock.emit_event_for_session(
502 "Fetch.requestPaused",
503 json!({
504 "requestId": "REQ-1",
505 "request": {
506 "url": "https://example.test/blocked/banner.png",
507 "method": "GET",
508 "headers": {},
509 },
510 "resourceType": "Image",
511 }),
512 "S1",
513 )
514 .await;
515
516 // Actor should dispatch Fetch.failRequest with BlockedByClient.
517 let fail_id =
518 tokio::time::timeout(Duration::from_secs(2), mock.expect_cmd("Fetch.failRequest"))
519 .await
520 .expect("actor did not send Fetch.failRequest within 2s");
521 let fail_params = mock.last_sent()["params"].clone();
522 assert_eq!(fail_params["requestId"], "REQ-1");
523 assert_eq!(fail_params["errorReason"], "BlockedByClient");
524 mock.reply(fail_id, json!({})).await;
525
526 // Teardown via the handle: stop() cancels + awaits the oneshot the
527 // actor signals after Fetch.disable lands.
528 let stop_fut = tokio::spawn(handle.stop());
529 let disable_id =
530 tokio::time::timeout(Duration::from_secs(2), mock.expect_cmd("Fetch.disable"))
531 .await
532 .expect("actor did not send Fetch.disable on stop()");
533 mock.reply(disable_id, json!({})).await;
534 stop_fut
535 .await
536 .expect("stop() task panicked")
537 .expect("stop() returned Err");
538 conn.shutdown();
539 }
540
541 /// `start()` injects a match-all `"*"` pattern when the caller did not
542 /// add any via [`pattern`](InterceptBuilder::pattern) — otherwise
543 /// `Fetch.enable` would arrive with an empty patterns array and Chrome
544 /// would silently pause nothing.
545 #[tokio::test]
546 async fn start_defaults_to_match_all_pattern_when_none_registered() {
547 let (mut mock, conn) = MockConnection::pair();
548 let sess = SessionHandle::new(conn.clone(), "S1");
549
550 let handle = InterceptBuilder::new(&sess)
551 .block("*/blocked/*")
552 .unwrap()
553 .start();
554
555 let enable_id =
556 tokio::time::timeout(Duration::from_secs(2), mock.expect_cmd("Fetch.enable"))
557 .await
558 .expect("actor did not send Fetch.enable within 2s");
559 let patterns = mock.last_sent()["params"]["patterns"].clone();
560 let arr = patterns.as_array().expect("patterns must be a JSON array");
561 assert_eq!(arr.len(), 1);
562 assert_eq!(arr[0]["urlPattern"], "*");
563 mock.reply(enable_id, json!({})).await;
564
565 // Drop the handle to tear down; we don't need to observe the disable.
566 drop(handle);
567 conn.shutdown();
568 }
569
570 /// Regression guard for the "empty patterns with handleAuth" breakage: the
571 /// proxy / HTTP-auth-ONLY path (`handle_auth`, no rules, no explicit
572 /// pattern) must still send a NON-empty `patterns` on `Fetch.enable`.
573 /// Modern Chrome (~M132+) rejects `{ patterns: [], handleAuthRequests:
574 /// true }` with CDP -32602 ("Can't specify empty patterns with handleAuth
575 /// set"), which silently disables proxy-auth handling and 407s every
576 /// navigation. `start()` must inject a match-all `"*"` and set
577 /// `handleAuthRequests`.
578 #[tokio::test]
579 async fn start_auth_only_sends_match_all_pattern_with_handle_auth() {
580 let (mut mock, conn) = MockConnection::pair();
581 let sess = SessionHandle::new(conn.clone(), "S1");
582
583 let handle = InterceptBuilder::new(&sess)
584 .handle_auth("puser", "ppass")
585 .start();
586
587 let enable_id =
588 tokio::time::timeout(Duration::from_secs(2), mock.expect_cmd("Fetch.enable"))
589 .await
590 .expect("actor did not send Fetch.enable within 2s");
591 let params = mock.last_sent()["params"].clone();
592 assert_eq!(
593 params["handleAuthRequests"], true,
594 "auth-only path must enable auth handling"
595 );
596 let arr = params["patterns"]
597 .as_array()
598 .expect("patterns must be a JSON array");
599 assert_eq!(
600 arr.len(),
601 1,
602 "auth-only must send exactly one match-all pattern, never an empty array"
603 );
604 assert_eq!(arr[0]["urlPattern"], "*");
605 mock.reply(enable_id, json!({})).await;
606
607 drop(handle);
608 conn.shutdown();
609 }
610
611 /// On the `subscribe()` path: each `Fetch.requestPaused` event becomes
612 /// a `PausedRequest` yielded from the stream, with the request payload
613 /// decoded into [`RequestInfo`].
614 #[tokio::test]
615 async fn subscribe_yields_paused_request_per_event() {
616 let (mut mock, conn) = MockConnection::pair();
617 let sess = SessionHandle::new(conn.clone(), "S1");
618
619 let mut stream = Box::pin(InterceptBuilder::new(&sess).subscribe());
620
621 // Wait for the side-task's Fetch.enable to land so the subscription
622 // is in place before we emit.
623 let enable_id =
624 tokio::time::timeout(Duration::from_secs(2), mock.expect_cmd("Fetch.enable"))
625 .await
626 .expect("subscribe() did not send Fetch.enable within 2s");
627 mock.reply(enable_id, json!({})).await;
628
629 mock.emit_event_for_session(
630 "Fetch.requestPaused",
631 json!({
632 "requestId": "REQ-1",
633 "request": {
634 "url": "https://example.test/widget.json",
635 "method": "GET",
636 "headers": {"accept": "application/json"},
637 },
638 "resourceType": "XHR",
639 }),
640 "S1",
641 )
642 .await;
643
644 let paused = tokio::time::timeout(Duration::from_secs(2), stream.next())
645 .await
646 .expect("subscribe() stream did not yield within 2s")
647 .expect("subscribe() stream closed before yielding");
648 assert_eq!(paused.request_id, "REQ-1");
649 assert_eq!(paused.request.url, "https://example.test/widget.json");
650 assert_eq!(paused.request.method, "GET");
651 assert_eq!(
652 paused
653 .request
654 .headers
655 .iter()
656 .find(|(k, _)| k == "accept")
657 .map(|(_, v)| v.as_str()),
658 Some("application/json"),
659 );
660 assert!(
661 paused.response.is_none(),
662 "request-stage event has no response"
663 );
664
665 drop(stream);
666 conn.shutdown();
667 }
668}