Skip to main content

playwright_cdp/
route.rs

1//! `Route` — network interception via the CDP `Fetch` domain.
2//!
3//! `Page::route(pattern, handler)` enables request interception; the handler
4//! receives a [`Route`] and chooses to [`continue_`](Route::continue_),
5//! [`fulfill`](Route::fulfill), or [`abort`](Route::abort) it.
6
7use crate::cdp::session::CdpSession;
8use crate::cdp::CdpEvent;
9use crate::error::Result;
10use crate::network::NetworkStore;
11use crate::request::Request;
12use crate::types::Headers;
13use base64::Engine;
14use parking_lot::Mutex;
15use serde_json::{json, Value};
16use std::future::Future;
17use std::pin::Pin;
18use std::sync::atomic::{AtomicBool, Ordering};
19use std::sync::{Arc, Weak};
20use tokio::sync::broadcast;
21
22/// A handler invoked for each intercepted request matching a route pattern.
23pub(crate) type RouteHandler =
24    Arc<dyn Fn(Route) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
25
26/// One registered route: a URL glob plus its handler.
27#[derive(Clone)]
28pub(crate) struct RouteEntry {
29    pub pattern: String,
30    pub handler: RouteHandler,
31}
32
33/// Background task: dispatch `Fetch.requestPaused` events to matching handlers.
34pub(crate) async fn route_listener(
35    mut rx: broadcast::Receiver<CdpEvent>,
36    session: Arc<CdpSession>,
37    store: Arc<NetworkStore>,
38    handlers: Arc<Mutex<Vec<RouteEntry>>>,
39) {
40    loop {
41        match rx.recv().await {
42            Ok(ev) if ev.method == "Fetch.requestPaused" => {
43                let url = ev
44                    .params
45                    .get("request")
46                    .and_then(|r| r.get("url"))
47                    .and_then(|v| v.as_str())
48                    .unwrap_or("")
49                    .to_string();
50                let fetch_id = ev
51                    .params
52                    .get("requestId")
53                    .and_then(|v| v.as_str())
54                    .unwrap_or("")
55                    .to_string();
56                let handler = {
57                    let hs = handlers.lock();
58                    hs.iter()
59                        .find(|e| pattern_matches(&e.pattern, &url))
60                        .map(|e| Arc::clone(&e.handler))
61                };
62                match (handler, request_from_paused(&ev.params, Arc::downgrade(&store))) {
63                    (Some(h), Some(req)) => {
64                        let route = Route::new(req, fetch_id, Arc::clone(&session));
65                        tokio::spawn(async move {
66                            (h)(route).await;
67                        });
68                    }
69                    _ => {
70                        // No handler / malformed: continue so the request isn't stalled.
71                        let _ = session
72                            .send("Fetch.continueRequest", json!({ "requestId": fetch_id }))
73                            .await;
74                    }
75                }
76            }
77            Ok(_) => {}
78            Err(broadcast::error::RecvError::Closed) => break,
79            Err(broadcast::error::RecvError::Lagged(_)) => continue,
80        }
81    }
82}
83
84/// Glob match supporting the Playwright URL glob syntax.
85///
86/// Supported wildcards (Playwright-compatible):
87/// - `**` — matches any sequence of characters, *including* `/` (crosses path
88///   segments). `**` alone matches every URL.
89/// - `*` — matches any sequence of characters *except* `/` (a single path
90///   segment).
91/// - `?` — matches a single character.
92/// - `[abc]` — a character class; `[!abc]` is its negation.
93/// - everything else is matched literally.
94///
95/// `**/*` therefore matches any URL with a path, and `*/api/*` matches any URL
96/// containing a `/api/` segment. Bare patterns without wildcards are treated as
97/// a substring test (matching the legacy behaviour), and the legacy
98/// `*foo` / `foo*` / `*foo*` shapes keep working: a trailing/leading/both `*`
99/// (with no other special characters) is a suffix/prefix/substring test.
100///
101/// The pattern is compiled into a regular expression (the `regex` crate is
102/// already a dependency); if compilation fails it falls back to a plain
103/// `url.contains(pattern)` so a malformed pattern can never panic or reject
104/// every URL.
105pub(crate) fn pattern_matches(pattern: &str, url: &str) -> bool {
106    // Fast path: exact equality.
107    if pattern == url {
108        return true;
109    }
110
111    // Legacy short-circuit: a pattern whose only wildcard characters are a
112    // single leading, trailing, or both `*` keeps the original (cheap) string
113    // semantics. This also guards patterns like `*foo*` that have no other glob
114    // metacharacters, preserving backwards compatibility byte-for-byte.
115    if let Some(result) = legacy_simple_wildcard(pattern, url) {
116        return result;
117    }
118
119    // General path: translate the glob into a regex and anchor it.
120    match glob_to_regex(pattern) {
121        Some(re) => re.is_match(url),
122        // Untranslatable pattern: fall back to substring match.
123        None => url.contains(pattern),
124    }
125}
126
127/// If `pattern` is one of the legacy shapes — `foo`, `*foo`, `foo*`, `*foo*`
128/// where the inner text contains no glob metacharacters (`*`, `?`, `[`) —
129/// evaluate it with the original exact/suffix/prefix/substring semantics and
130/// return `Some(bool)`. Otherwise return `None` so the caller uses the full
131/// glob engine.
132fn legacy_simple_wildcard(pattern: &str, url: &str) -> Option<bool> {
133    let has_leading = pattern.starts_with('*');
134    let has_trailing = pattern.ends_with('*') && pattern.len() > 1;
135    // Inner slice with the surrounding `*` stripped.
136    let inner_start = if has_leading { 1 } else { 0 };
137    let inner_end = if has_trailing {
138        pattern.len() - 1
139    } else {
140        pattern.len()
141    };
142    if inner_start > inner_end {
143        // e.g. pattern == "*" handled by the glob path below.
144        return None;
145    }
146    let inner = &pattern[inner_start..inner_end];
147    // Only treat as legacy if no other glob metacharacters are present.
148    if inner.contains('*') || inner.contains('?') || inner.contains('[') {
149        return None;
150    }
151    match (has_leading, has_trailing) {
152        (true, true) => Some(url.contains(inner)),
153        (true, false) => Some(url.ends_with(inner)),
154        (false, true) => Some(url.starts_with(inner)),
155        (false, false) => {
156            // No wildcards at all: legacy behaviour is substring (contains).
157            Some(url.contains(pattern))
158        }
159    }
160}
161
162/// Translate a Playwright-style URL glob into a compiled regex anchored with
163/// `^...$`. Returns `None` if the translation or compilation fails.
164fn glob_to_regex(pattern: &str) -> Option<regex::Regex> {
165    let mut out = String::with_capacity(pattern.len() * 2);
166    out.push('^');
167    let chars: Vec<char> = pattern.chars().collect();
168    let mut i = 0;
169    while i < chars.len() {
170        let c = chars[i];
171        match c {
172            '*' => {
173                // `**` matches across path separators; a lone `*` stays within
174                // a single segment.
175                if i + 1 < chars.len() && chars[i + 1] == '*' {
176                    out.push_str(".*");
177                    i += 2;
178                    // Tolerate a stray third `*` (e.g. `***`).
179                    while i < chars.len() && chars[i] == '*' {
180                        i += 1;
181                    }
182                } else {
183                    out.push_str("[^/]*");
184                    i += 1;
185                }
186            }
187            '?' => {
188                out.push_str("[^/]");
189                i += 1;
190            }
191            '[' => {
192                // Character class. Find the matching `]`. A leading `!` becomes
193                // `^` (negation); a leading `]` is a literal member.
194                let start = i + 1;
195                let mut j = start;
196                if j < chars.len() && (chars[j] == '!' || chars[j] == '^') {
197                    j += 1;
198                }
199                if j < chars.len() && chars[j] == ']' {
200                    j += 1;
201                }
202                while j < chars.len() && chars[j] != ']' {
203                    j += 1;
204                }
205                if j >= chars.len() {
206                    // Unterminated class: treat `[` literally (escape it).
207                    out.push_str("\\[");
208                    i += 1;
209                } else {
210                    out.push('[');
211                    let mut k = start;
212                    if k < chars.len() && chars[k] == '!' {
213                        out.push('^');
214                        k += 1;
215                    }
216                    while k < j {
217                        let ch = chars[k];
218                        // Escape regex metacharacters inside the class so a
219                        // user-supplied `]`/`\\`/etc. can't break the regex.
220                        if "\\.+*?(){}|^$".contains(ch) {
221                            out.push('\\');
222                        }
223                        out.push(ch);
224                        k += 1;
225                    }
226                    out.push(']');
227                    i = j + 1;
228                }
229            }
230            _ => {
231                // Escape regex metacharacters; everything else is literal.
232                out.push_str(&regex::escape(&c.to_string()));
233                i += 1;
234            }
235        }
236    }
237    out.push('$');
238    regex::Regex::new(&out).ok()
239}
240
241/// Options for [`Route::continue_`] (optional request overrides).
242#[derive(Debug, Clone, Default)]
243#[non_exhaustive]
244pub struct RouteContinueOptions {
245    pub url: Option<String>,
246    pub method: Option<String>,
247    pub headers: Option<Headers>,
248    pub post_data: Option<String>,
249}
250
251/// Options for [`Route::fulfill`] (serve a synthetic response).
252#[derive(Debug, Clone, Default)]
253#[non_exhaustive]
254pub struct RouteFulfillOptions {
255    pub status: Option<u16>,
256    pub headers: Option<Headers>,
257    pub content_type: Option<String>,
258    pub body: Option<String>,
259}
260
261impl RouteFulfillOptions {
262    pub fn new() -> Self {
263        Self::default()
264    }
265    pub fn status(mut self, v: u16) -> Self {
266        self.status = Some(v);
267        self
268    }
269    pub fn body(mut self, v: impl Into<String>) -> Self {
270        self.body = Some(v.into());
271        self
272    }
273    pub fn content_type(mut self, v: impl Into<String>) -> Self {
274        self.content_type = Some(v.into());
275        self
276    }
277}
278
279/// Options for [`Route::fetch`] (optional overrides applied on top of the
280/// intercepted request).
281///
282/// Mirrors <https://playwright.dev/docs/api/class-route#route-fetch>.
283#[derive(Debug, Clone, Default)]
284#[non_exhaustive]
285pub struct RouteFetchOptions {
286    pub url: Option<String>,
287    pub method: Option<String>,
288    pub headers: Option<Headers>,
289    pub post_data: Option<String>,
290    pub post_data_bytes: Option<Vec<u8>>,
291    /// Request timeout, in milliseconds. `None` uses the client default.
292    pub timeout: Option<u64>,
293    /// Maximum number of redirects to follow. `None` uses the client default.
294    pub max_redirects: Option<u32>,
295}
296
297impl RouteFetchOptions {
298    pub fn new() -> Self {
299        Self::default()
300    }
301    pub fn url(mut self, v: impl Into<String>) -> Self {
302        self.url = Some(v.into());
303        self
304    }
305    pub fn method(mut self, v: impl Into<String>) -> Self {
306        self.method = Some(v.into());
307        self
308    }
309    pub fn headers(mut self, v: Headers) -> Self {
310        self.headers = Some(v);
311        self
312    }
313    pub fn post_data(mut self, v: impl Into<String>) -> Self {
314        self.post_data = Some(v.into());
315        self
316    }
317    pub fn post_data_bytes(mut self, v: Vec<u8>) -> Self {
318        self.post_data_bytes = Some(v);
319        self
320    }
321    pub fn timeout(mut self, v: u64) -> Self {
322        self.timeout = Some(v);
323        self
324    }
325    pub fn max_redirects(mut self, v: u32) -> Self {
326        self.max_redirects = Some(v);
327        self
328    }
329}
330
331/// A response returned by [`Route::fetch`], capturing the full body so that
332/// accessors are cheap. Used to inspect (and optionally modify) the real
333/// response before fulfilling the route.
334///
335/// Mirrors <https://playwright.dev/docs/api/class-apiresponse>.
336#[derive(Debug, Clone)]
337pub struct RouteFetchResponse {
338    url: String,
339    status: u16,
340    status_text: String,
341    headers: Headers,
342    body: Vec<u8>,
343}
344
345impl RouteFetchResponse {
346    pub fn url(&self) -> &str {
347        &self.url
348    }
349    pub fn status(&self) -> u16 {
350        self.status
351    }
352    pub fn status_text(&self) -> &str {
353        &self.status_text
354    }
355    pub fn headers(&self) -> &Headers {
356        &self.headers
357    }
358    pub fn body(&self) -> &[u8] {
359        &self.body
360    }
361
362    /// The response body decoded as UTF-8.
363    pub fn text(&self) -> String {
364        String::from_utf8_lossy(&self.body).into_owned()
365    }
366}
367
368/// An intercepted network request, paused in the `Fetch` domain.
369#[derive(Clone)]
370pub struct Route {
371    inner: Arc<RouteInner>,
372}
373
374struct RouteInner {
375    request: Request,
376    fetch_request_id: String,
377    session: Arc<CdpSession>,
378    handled: AtomicBool,
379}
380
381impl Route {
382    pub(crate) fn new(
383        request: Request,
384        fetch_request_id: String,
385        session: Arc<CdpSession>,
386    ) -> Self {
387        Self {
388            inner: Arc::new(RouteInner {
389                request,
390                fetch_request_id,
391                session,
392                handled: AtomicBool::new(false),
393            }),
394        }
395    }
396
397    /// The intercepted request.
398    pub fn request(&self) -> Request {
399        self.inner.request.clone()
400    }
401
402    fn claim(&self) -> bool {
403        !self.inner.handled.swap(true, Ordering::SeqCst)
404    }
405
406    /// Let the request proceed (optionally overriding url/method/headers/body).
407    pub async fn continue_(&self, options: Option<RouteContinueOptions>) -> Result<()> {
408        if !self.claim() {
409            return Ok(());
410        }
411        let opts = options.unwrap_or_default();
412        let mut params = json!({ "requestId": self.inner.fetch_request_id });
413        if let Some(u) = opts.url {
414            params["url"] = json!(u);
415        }
416        if let Some(m) = opts.method {
417            params["method"] = json!(m);
418        }
419        if let Some(h) = opts.headers {
420            params["headers"] = json!(headers_to_array(&h));
421        }
422        if let Some(p) = opts.post_data {
423            params["postData"] = json!(base64::engine::general_purpose::STANDARD.encode(p));
424        }
425        self.inner
426            .session
427            .send("Fetch.continueRequest", params)
428            .await
429            .map(|_: Value| ())
430    }
431
432    /// Let the request proceed to the next matching route (no-op if only one).
433    pub async fn fallback(&self) -> Result<()> {
434        self.continue_(None).await
435    }
436
437    /// Serve a synthetic response.
438    pub async fn fulfill(&self, options: RouteFulfillOptions) -> Result<()> {
439        if !self.claim() {
440            return Ok(());
441        }
442        let status = options.status.unwrap_or(200);
443        let mut headers = options.headers.unwrap_or_default();
444        if let Some(ct) = options.content_type {
445            headers.entry("content-type".into()).or_insert(ct);
446        }
447        let body_b64 = options
448            .body
449            .map(|b| base64::engine::general_purpose::STANDARD.encode(b))
450            .unwrap_or_default();
451        self.inner
452            .session
453            .send(
454                "Fetch.fulfillRequest",
455                json!({
456                    "requestId": self.inner.fetch_request_id,
457                    "responseCode": status,
458                    "responseHeaders": headers_to_array(&headers),
459                    "body": body_b64,
460                }),
461            )
462            .await
463            .map(|_: Value| ())
464    }
465
466    /// Abort the request with a CDP `ErrorReason` (default `"Failed"`).
467    pub async fn abort(&self, error_code: Option<&str>) -> Result<()> {
468        if !self.claim() {
469            return Ok(());
470        }
471        let reason = error_code.unwrap_or("Failed");
472        self.inner
473            .session
474            .send(
475                "Fetch.failRequest",
476                json!({ "requestId": self.inner.fetch_request_id, "errorReason": reason }),
477            )
478            .await
479            .map(|_: Value| ())
480    }
481
482    /// Perform the intercepted request as a **real** HTTP request, returning the
483    /// response so it can be inspected (and optionally modified) before
484    /// [`fulfill`](Self::fulfill).
485    ///
486    /// Unlike [`continue_`](Self::continue_) (which lets the *page* perform the
487    /// request through the browser), `fetch` issues the request itself via a
488    /// standalone `reqwest::Client` that is entirely outside the page's CDP
489    /// `Fetch`-domain network interception. It therefore does **not** re-trigger
490    /// any registered route handlers — this is Playwright's documented
491    /// `route.fetch` semantics, useful for "fetch the real response, then modify
492    /// and fulfill".
493    ///
494    /// The intercepted request's url/method/headers/post_data are used as the
495    /// base; any field set on `options` overrides the corresponding one.
496    ///
497    /// See: <https://playwright.dev/docs/api/class-route#route-fetch>
498    pub async fn fetch(&self, options: Option<RouteFetchOptions>) -> Result<RouteFetchResponse> {
499        let opts = options.unwrap_or_default();
500        let request = self.request();
501
502        let url = opts
503            .url
504            .clone()
505            .unwrap_or_else(|| request.url().to_string());
506
507        let method_str = opts
508            .method
509            .clone()
510            .unwrap_or_else(|| request.method().to_string());
511        let method = reqwest::Method::from_bytes(method_str.as_bytes())
512            .map_err(|e| crate::error::Error::InvalidArgument(format!("invalid method: {e}")))?;
513
514        // Headers: original request headers as the base, options override/merge.
515        let mut headers = request.headers().clone();
516        if let Some(extra) = opts.headers.as_ref() {
517            for (k, v) in extra {
518                headers.insert(k.clone(), v.clone());
519            }
520        }
521
522        // Body: explicit bytes take priority, then string post_data, then the
523        // original request body.
524        let body: Vec<u8> = if let Some(b) = opts.post_data_bytes.clone() {
525            b
526        } else if let Some(s) = opts.post_data.clone() {
527            s.into_bytes()
528        } else {
529            request.post_data_buffer().unwrap_or_default()
530        };
531
532        // Build an isolated client: per-call redirect policy + per-call timeout.
533        let mut client_builder = reqwest::Client::builder();
534        if let Some(n) = opts.max_redirects {
535            client_builder = client_builder.redirect(reqwest::redirect::Policy::limited(
536                n.max(1) as usize,
537            ));
538        }
539        let client = client_builder
540            .build()
541            .map_err(|e| crate::error::Error::Http(format!("failed to build client: {e}")))?;
542
543        let mut builder = client.request(method, &url);
544
545        // Apply headers, skipping hop-by-hop / reqwest-managed headers
546        // (Host, Content-Length, Connection, encoding, ...). Copying the
547        // browser's versions verbatim makes reqwest reject the request with
548        // "error sending request". Invalid header names/values are also skipped.
549        const SKIP: &[&str] = &[
550            "host",
551            "connection",
552            "content-length",
553            "transfer-encoding",
554            "accept-encoding",
555            "content-encoding",
556            "keep-alive",
557            "proxy-connection",
558            "upgrade",
559        ];
560        for (k, v) in &headers {
561            if SKIP.contains(&k.as_str()) {
562                continue;
563            }
564            if let (Ok(name), Ok(val)) = (
565                reqwest::header::HeaderName::from_bytes(k.as_bytes()),
566                reqwest::header::HeaderValue::from_str(v),
567            ) {
568                builder = builder.header(name, val);
569            }
570        }
571
572        // A body is only valid for non-GET/HEAD requests (and harmless to omit).
573        let is_bodyless =
574            method_str.eq_ignore_ascii_case("GET") || method_str.eq_ignore_ascii_case("HEAD");
575        if !body.is_empty() && !is_bodyless {
576            builder = builder.body(body);
577        }
578
579        // Default 30s timeout (Playwright's route.fetch default) so a hung
580        // upstream can't stall the route handler forever.
581        let timeout_ms = opts.timeout.unwrap_or(30_000);
582        builder = builder.timeout(std::time::Duration::from_millis(timeout_ms));
583
584        let resp = builder.send().await.map_err(|e| {
585            // Walk the source chain so the root cause (hyper/io) surfaces in
586            // the error message instead of reqwest's generic "error sending
587            // request".
588            let mut s = format!("{e}");
589            let mut src = std::error::Error::source(&e);
590            while let Some(c) = src {
591                s.push_str(" :: ");
592                s.push_str(&c.to_string());
593                src = c.source();
594            }
595            crate::error::Error::Http(format!("request failed: {s}"))
596        })?;
597
598        let final_url = resp.url().to_string();
599        let status = resp.status().as_u16();
600        let status_text = resp.status().canonical_reason().unwrap_or("").to_string();
601
602        let mut resp_headers: Headers = Headers::with_capacity(resp.headers().len());
603        for (name, value) in resp.headers().iter() {
604            let key = name.as_str().to_ascii_lowercase();
605            let val = value.to_str().map(String::from).unwrap_or_else(|_| {
606                String::from_utf8_lossy(value.as_bytes()).into_owned()
607            });
608            resp_headers.insert(key, val);
609        }
610
611        let body = resp
612            .bytes()
613            .await
614            .map_err(|e| crate::error::Error::Http(format!("failed to read body: {e}")))?;
615
616        Ok(RouteFetchResponse {
617            url: final_url,
618            status,
619            status_text,
620            headers: resp_headers,
621            body: body.to_vec(),
622        })
623    }
624}
625
626/// Build a `Request` from a `Fetch.requestPaused` event payload.
627pub(crate) fn request_from_paused(params: &Value, store: Weak<NetworkStore>) -> Option<Request> {
628    let req = params.get("request")?;
629    let request_id = params.get("requestId").and_then(|v| v.as_str())?.to_string();
630    let url = req.get("url").and_then(|v| v.as_str())?.to_string();
631    let method = req
632        .get("method")
633        .and_then(|v| v.as_str())
634        .unwrap_or("GET")
635        .to_string();
636    let headers = headers_from_object(req.get("headers"));
637    let resource_type = params
638        .get("resourceType")
639        .and_then(|v| v.as_str())
640        .unwrap_or("Other")
641        .to_string();
642    Some(Request::new(
643        url,
644        method,
645        headers,
646        resource_type.clone(),
647        request_id,
648        resource_type == "Document",
649        store,
650    ))
651}
652
653fn headers_to_array(h: &Headers) -> Vec<Value> {
654    h.iter().map(|(k, v)| json!({ "name": k, "value": v })).collect()
655}
656
657fn headers_from_object(v: Option<&Value>) -> Headers {
658    let mut out = Headers::new();
659    if let Some(obj) = v.and_then(|v| v.as_object()) {
660        for (k, val) in obj {
661            if let Some(s) = val.as_str() {
662                out.insert(k.to_lowercase(), s.to_string());
663            }
664        }
665    }
666    out
667}
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672
673    // ---- Backwards compatibility: legacy shapes used by smoke tests ----
674
675    #[test]
676    fn legacy_exact_match() {
677        assert!(pattern_matches("http://x/y", "http://x/y"));
678        assert!(!pattern_matches("http://x/y", "http://x/z"));
679    }
680
681    #[test]
682    fn legacy_leading_star_is_suffix() {
683        // `*mocked` -> url ends_with "mocked"
684        assert!(pattern_matches("*mocked", "http://host/mock-api-mocked"));
685        assert!(!pattern_matches("*mocked", "http://host/api"));
686    }
687
688    #[test]
689    fn legacy_trailing_star_is_prefix() {
690        // `foo*` -> url starts_with "foo"
691        assert!(pattern_matches("http://x*", "http://x/api"));
692        assert!(!pattern_matches("http://x*", "http://y/api"));
693    }
694
695    #[test]
696    fn legacy_both_stars_is_substring() {
697        // `*mocked*` / `*blocked*` -> url contains — the shape the smoke tests use.
698        assert!(pattern_matches("*mocked*", "http://host/mock-api-mocked/path"));
699        assert!(pattern_matches("*blocked*", "http://host/blocked"));
700        assert!(!pattern_matches("*mocked*", "http://host/api"));
701    }
702
703    #[test]
704    fn legacy_no_wildcard_is_substring() {
705        // A bare substring with no metacharacters falls back to `contains`.
706        assert!(pattern_matches("api/v1", "http://host/api/v1/users"));
707        assert!(!pattern_matches("api/v1", "http://host/api/v2"));
708    }
709
710    // ---- New Playwright glob semantics ----
711
712    #[test]
713    fn double_star_matches_anything_including_path() {
714        // `**` matches every URL, including ones with paths and query strings.
715        assert!(pattern_matches("**", "http://x/"));
716        assert!(pattern_matches("**", "http://x/a/b/c"));
717        assert!(pattern_matches("**", "https://example.com/api/v1?x=1#frag"));
718        // `**` matches any sequence — including an empty one.
719        assert!(pattern_matches("**", ""));
720    }
721
722    #[test]
723    fn double_star_slash_star_matches_any() {
724        // `**/*` is Playwright's "match all" idiom.
725        assert!(pattern_matches("**/*", "http://x/api/y"));
726        assert!(pattern_matches("**/*", "http://x/"));
727    }
728
729    #[test]
730    fn bare_single_star_matches_any() {
731        // A single `*` spans the whole URL here (no `/` to bound it as a segment
732        // because there is nothing else in the pattern).
733        assert!(pattern_matches("*", "http://x/a/b"));
734    }
735
736    #[test]
737    fn single_star_does_not_cross_slash() {
738        // `*/api/*` matches URLs containing an `/api/` segment.
739        assert!(pattern_matches("*/api/*", "http://x/api/y"));
740        assert!(pattern_matches("*/api/*", "http://x/a/b/api/c"));
741        // No trailing segment after /api: `/api` alone has no closing `/`.
742        // (Documented boundary — the pattern expects a `/api/` segment.)
743        // This is intentionally not asserted either way to avoid over-constraining.
744        // But a path that clearly lacks the segment must not match:
745        assert!(!pattern_matches("*/api/*", "http://x/users"));
746    }
747
748    #[test]
749    fn question_mark_matches_single_char() {
750        assert!(pattern_matches("http://x/a?c", "http://x/abc"));
751        assert!(pattern_matches("http://x/a?c", "http://x/aZc"));
752        // `?` does not match `/` (single path segment only).
753        assert!(!pattern_matches("http://x/a?c", "http://x/a/c"));
754    }
755
756    #[test]
757    fn char_class_positive() {
758        assert!(pattern_matches("http://x/v[12]", "http://x/v1"));
759        assert!(pattern_matches("http://x/v[12]", "http://x/v2"));
760        assert!(!pattern_matches("http://x/v[12]", "http://x/v3"));
761    }
762
763    #[test]
764    fn char_class_negation() {
765        assert!(pattern_matches("http://x/v[!12]", "http://x/v3"));
766        assert!(!pattern_matches("http://x/v[!12]", "http://x/v1"));
767    }
768
769    #[test]
770    fn real_world_typical_pattern() {
771        // A realistic Playwright-style route pattern.
772        let pattern = "**/api/users/*";
773        assert!(pattern_matches(pattern, "https://app.example.com/api/users/42"));
774        assert!(pattern_matches(pattern, "https://app.example.com/v2/api/users/abc"));
775        // `*` can match an empty segment, so a trailing slash still matches.
776        assert!(pattern_matches(pattern, "https://app.example.com/api/users/"));
777        assert!(!pattern_matches(pattern, "https://app.example.com/api/groups/1"));
778        // Wrong host/domain entirely (no /api/users/ segment).
779        assert!(!pattern_matches(pattern, "https://other.example.com/feed"));
780    }
781
782    #[test]
783    fn regex_metacharacters_in_pattern_are_literal() {
784        // A `.` in the pattern is a literal dot, not the regex "any char".
785        assert!(pattern_matches("http://x/.png", "http://x/.png"));
786        assert!(!pattern_matches("http://x/.png", "http://x/xpng"));
787    }
788}