Skip to main content

playwright_cdp/
frame.rs

1//! `Frame` — a frame within a page (the main document, or an `<iframe>`).
2
3use crate::element_handle::ElementHandle;
4use crate::error::{Error, Result};
5use crate::options::{
6    CheckOptions, ClickOptions, DragToOptions, FillOptions, GotoOptions, HoverOptions, PressOptions,
7    PressSequentiallyOptions, SelectOption, SelectOptions, WaitForFunctionOptions, WaitForOptions, WaitUntil,
8};
9use crate::page::Page;
10use crate::response::Response;
11use crate::types::AriaRole;
12use serde_json::{json, Value};
13use std::time::Duration;
14use tokio::sync::broadcast;
15
16/// Options for [`Frame::add_script_tag`].
17///
18/// Either `url` (load a remote script) or `source` (inline script text) must
19/// be supplied. `type` sets the `<script type=...>` attribute (e.g.
20/// `"module"` / `"text/javascript"`).
21#[derive(Debug, Clone, Default)]
22#[non_exhaustive]
23pub struct AddScriptTagOptions {
24    /// The `src` URL of an external script to load.
25    pub url: Option<String>,
26    /// Inline script source text to inject as the element's text content.
27    pub source: Option<String>,
28    /// The `<script type=...>` attribute value (e.g. `"module"`).
29    pub r#type: Option<String>,
30}
31
32impl AddScriptTagOptions {
33    pub fn new() -> Self {
34        Self::default()
35    }
36    pub fn url(mut self, v: impl Into<String>) -> Self {
37        self.url = Some(v.into());
38        self
39    }
40    pub fn source(mut self, v: impl Into<String>) -> Self {
41        self.source = Some(v.into());
42        self
43    }
44    pub fn type_(mut self, v: impl Into<String>) -> Self {
45        self.r#type = Some(v.into());
46        self
47    }
48}
49
50/// Options for [`Frame::add_style_tag`].
51///
52/// Either `url` (load a remote stylesheet) or `source` (inline CSS text) must
53/// be supplied.
54#[derive(Debug, Clone, Default)]
55#[non_exhaustive]
56pub struct AddStyleTagOptions {
57    /// The `href` URL of an external stylesheet to load.
58    pub url: Option<String>,
59    /// Inline CSS source text to inject as the element's text content.
60    pub source: Option<String>,
61}
62
63impl AddStyleTagOptions {
64    pub fn new() -> Self {
65        Self::default()
66    }
67    pub fn url(mut self, v: impl Into<String>) -> Self {
68        self.url = Some(v.into());
69        self
70    }
71    pub fn source(mut self, v: impl Into<String>) -> Self {
72        self.source = Some(v.into());
73        self
74    }
75}
76
77/// Options for [`Frame::wait_for_url`].
78///
79/// Mirrors the subset of Playwright's `FrameWaitForUrlOptions` we support: a
80/// navigation-style timeout plus an optional lifecycle state to settle on.
81#[derive(Debug, Clone, Default)]
82#[non_exhaustive]
83pub struct FrameWaitForUrlOptions {
84    pub timeout: Option<Duration>,
85    pub wait_until: Option<WaitUntil>,
86}
87
88impl FrameWaitForUrlOptions {
89    pub fn new() -> Self {
90        Self::default()
91    }
92    pub fn timeout(mut self, v: Duration) -> Self {
93        self.timeout = Some(v);
94        self
95    }
96    pub fn wait_until(mut self, v: WaitUntil) -> Self {
97        self.wait_until = Some(v);
98        self
99    }
100}
101
102/// A frame in a page. The main frame wraps the page's top-level document.
103#[derive(Clone)]
104pub struct Frame {
105    page: Page,
106    frame_id: String,
107}
108
109impl Frame {
110    pub(crate) fn new(page: Page, frame_id: String) -> Self {
111        Self { page, frame_id }
112    }
113
114    pub(crate) fn main(page: Page) -> Self {
115        let frame_id = page.main_frame_id().unwrap_or_default();
116        Self { page, frame_id }
117    }
118
119    /// The owning page.
120    pub fn page(&self) -> Page {
121        self.page.clone()
122    }
123
124    /// CDP frame id.
125    pub fn frame_id(&self) -> &str {
126        &self.frame_id
127    }
128
129    pub fn name(&self) -> String {
130        self.page
131            .frame_data(&self.frame_id)
132            .map(|d| d.name)
133            .unwrap_or_default()
134    }
135
136    pub fn url(&self) -> String {
137        self.page
138            .frame_data(&self.frame_id)
139            .map(|d| d.url)
140            .unwrap_or_default()
141    }
142
143    pub fn parent_frame(&self) -> Option<Frame> {
144        let parent = self.page.frame_data(&self.frame_id)?.parent_id.clone()?;
145        Some(Frame::new(self.page.clone(), parent))
146    }
147
148    pub fn child_frames(&self) -> Vec<Frame> {
149        self.page
150            .frame_ids_with_parent(&self.frame_id)
151            .into_iter()
152            .map(|id| Frame::new(self.page.clone(), id))
153            .collect()
154    }
155
156    pub fn is_detached(&self) -> bool {
157        self.page
158            .frame_data(&self.frame_id)
159            .map(|d| d.detached)
160            .unwrap_or(false)
161    }
162
163    /// Evaluate `expression` in this frame's main world.
164    ///
165    /// Runs in the frame's own execution context (looked up from the page's
166    /// per-frame context map), so for a child `<iframe>` it acts on the
167    /// iframe's document/window, not the top-level page's.
168    pub async fn evaluate<R: serde::de::DeserializeOwned>(&self, expression: &str) -> Result<R> {
169        let ctx = self.page.ctx_for_frame(&self.frame_id).await;
170        let function = format!("(arg) => {{ return ({expression}); }}");
171        let v = crate::selectors::eval_context(self.page.session(), ctx, &function, Value::Null)
172            .await?;
173        serde_json::from_value::<R>(v).map_err(Error::from)
174    }
175
176    /// Evaluate `expression` in this frame's main world, returning a
177    /// [`JSHandle`](crate::js_handle::JSHandle) to the resulting remote object.
178    ///
179    /// Mirrors [`Frame::evaluate`] but returns the object by reference rather
180    /// than by value. The expression runs in the frame's own execution context
181    /// (`Runtime.evaluate { contextId }`, `returnByValue: false`); the returned
182    /// `objectId` is wrapped in a [`JSHandle`](crate::js_handle::JSHandle)
183    /// sharing the page's session.
184    pub async fn evaluate_handle(&self, expression: &str) -> Result<crate::js_handle::JSHandle> {
185        let ctx = self.page.ctx_for_frame(&self.frame_id).await;
186        let function = format!("(arg) => {{ return ({expression}); }}");
187        let oid = crate::selectors::eval_context_handle(
188            self.page.session(),
189            ctx,
190            &function,
191            Value::Null,
192        )
193        .await?
194        .ok_or_else(|| {
195            Error::ProtocolError(
196                "evaluate_handle did not return a remote object (no objectId)".into(),
197            )
198        })?;
199        Ok(crate::js_handle::JSHandle::new(self.page.session_arc(), oid))
200    }
201
202    /// Navigate this frame to `url` (main frame only).
203    pub async fn goto(
204        &self,
205        url: &str,
206        opts: Option<GotoOptions>,
207    ) -> Result<Option<Response>> {
208        if self.frame_id != self.page.main_frame_id().unwrap_or_default() {
209            return Err(Error::InvalidArgument(
210                "Frame::goto on non-main frames is not supported".into(),
211            ));
212        }
213        self.page.goto(url, opts).await
214    }
215
216    pub async fn wait_for_load_state(&self, state: Option<WaitUntil>) -> Result<()> {
217        self.page.wait_for_load_state(state).await
218    }
219
220    /// Poll `expression` until truthy (delegates to the page). See
221    /// [`Page::wait_for_function`].
222    pub async fn wait_for_function<R: serde::de::DeserializeOwned>(
223        &self,
224        expression: &str,
225        arg: Option<serde_json::Value>,
226        options: Option<WaitForFunctionOptions>,
227    ) -> Result<R> {
228        self.page.wait_for_function(expression, arg, options).await
229    }
230
231    /// The serialized HTML of this frame's document (`documentElement.outerHTML`).
232    pub async fn content(&self) -> Result<String> {
233        self.evaluate::<String>("document.documentElement.outerHTML").await
234    }
235
236    /// Replace this frame's document content with `html`.
237    ///
238    /// Runs `document.open()/write()/close()` in the frame's own context, so
239    /// for a child iframe it rewrites the iframe's document.
240    pub async fn set_content(&self, html: &str) -> Result<()> {
241        let ctx = self.page.ctx_for_frame(&self.frame_id).await;
242        let _ = crate::selectors::eval_context(
243            self.page.session(),
244            ctx,
245            "(html) => { document.open(); document.write(html); document.close(); }",
246            json!(html),
247        )
248        .await?;
249        Ok(())
250    }
251
252    /// The title of this frame's document (`document.title`).
253    pub async fn title(&self) -> Result<String> {
254        self.evaluate::<String>("document.title").await
255    }
256
257    /// Build a locator scoped to this frame's execution context.
258    ///
259    /// The resolved locator runs its selector queries in the frame's own
260    /// context, so for a child iframe `document` is the iframe's document.
261    /// This is the same-origin scoping mechanism used by [`Frame::evaluate`].
262    pub fn locator(&self, selector: impl Into<String>) -> crate::locator::Locator {
263        let ctx = self.page.context_for_frame(&self.frame_id);
264        crate::locator::Locator::new_in_frame_ctx(
265            self.page.clone(),
266            ctx,
267            selector.into(),
268            true,
269            None,
270        )
271    }
272
273    pub fn get_by_text(&self, text: &str, exact: bool) -> crate::locator::Locator {
274        let sel = if exact {
275            format!("text=\"{text}\"")
276        } else {
277            format!("text={text}")
278        };
279        self.locator(sel)
280    }
281
282    pub fn get_by_label(&self, text: &str) -> crate::locator::Locator {
283        self.locator(format!("[aria-label=\"{}\"]", attr_escape(text)))
284    }
285
286    pub fn get_by_role(
287        &self,
288        role: AriaRole,
289        opts: Option<crate::options::GetByRoleOptions>,
290    ) -> crate::locator::Locator {
291        let opts = opts.unwrap_or_default();
292        let mut sel = format!("role={}", role.as_str());
293        if let Some(name) = &opts.name {
294            sel.push_str(&format!("[name=\"{name}\"]"));
295        }
296        if opts.exact == Some(true) {
297            sel.push_str("[exact=\"true\"]");
298        }
299        self.locator(sel)
300    }
301
302    pub fn get_by_placeholder(&self, text: &str) -> crate::locator::Locator {
303        self.locator(format!("[placeholder=\"{}\"]", attr_escape(text)))
304    }
305
306    pub fn get_by_alt_text(&self, text: &str) -> crate::locator::Locator {
307        self.locator(format!("[alt=\"{}\"]", attr_escape(text)))
308    }
309
310    pub fn get_by_title(&self, text: &str) -> crate::locator::Locator {
311        self.locator(format!("[title=\"{}\"]", attr_escape(text)))
312    }
313
314    pub fn get_by_test_id(&self, text: &str) -> crate::locator::Locator {
315        self.locator(format!("[data-testid=\"{}\"]", attr_escape(text)))
316    }
317
318    /// Inject a `<script>` tag into this frame.
319    ///
320    /// Either `url` (external script) or `source` (inline script text) must be
321    /// provided; `type` sets the `<script type=...>` attribute.
322    ///
323    /// Implemented via DOM injection in the frame's own execution context, so
324    /// `document.head` is this frame's `<head>` — for a child iframe the script
325    /// is injected into (and executes within) the iframe's document.
326    pub async fn add_script_tag(&self, opts: Option<AddScriptTagOptions>) -> Result<()> {
327        let opts = opts.unwrap_or_default();
328        if opts.url.is_none() && opts.source.is_none() {
329            return Err(Error::InvalidArgument(
330                "AddScriptTagOptions requires either `url` or `source`".into(),
331            ));
332        }
333        let arg = json!({ "url": opts.url, "source": opts.source, "type": opts.r#type });
334        let ctx = self.page.ctx_for_frame(&self.frame_id).await;
335        let _ = crate::selectors::eval_context(
336            self.page.session(),
337            ctx,
338            "(a) => { const s = document.createElement('script'); if (a.type) s.type = a.type; if (a.url) s.src = a.url; if (a.source) s.textContent = a.source; document.head.appendChild(s); }",
339            arg,
340        )
341        .await?;
342        Ok(())
343    }
344
345    /// Inject a `<link rel=stylesheet>`/`<style>` tag into this frame.
346    ///
347    /// Either `url` (external stylesheet) or `source` (inline CSS) must be
348    /// provided. Runs in the frame's own context, so it targets this frame's
349    /// `<head>`.
350    pub async fn add_style_tag(&self, opts: Option<AddStyleTagOptions>) -> Result<()> {
351        let opts = opts.unwrap_or_default();
352        if opts.url.is_none() && opts.source.is_none() {
353            return Err(Error::InvalidArgument(
354                "AddStyleTagOptions requires either `url` or `source`".into(),
355            ));
356        }
357        let arg = json!({ "url": opts.url, "source": opts.source });
358        let ctx = self.page.ctx_for_frame(&self.frame_id).await;
359        let _ = crate::selectors::eval_context(
360            self.page.session(),
361            ctx,
362            "(a) => { if (a.url) { const l = document.createElement('link'); l.rel = 'stylesheet'; l.href = a.url; document.head.appendChild(l); } else { const s = document.createElement('style'); s.textContent = a.source; document.head.appendChild(s); } }",
363            arg,
364        )
365        .await?;
366        Ok(())
367    }
368
369    /// Wait until this frame's URL matches `url` (exact, or `*` glob).
370    ///
371    /// Subscribes to the page session and watches `Page.frameNavigated` /
372    /// `Page.lifecycleEvent` events for this frame, falling back to the cached
373    /// frame tree URL. On match, optionally waits for a lifecycle state.
374    pub async fn wait_for_url(&self, url: &str, opts: Option<FrameWaitForUrlOptions>) -> Result<()> {
375        let opts = opts.unwrap_or_default();
376        let timeout = opts
377            .timeout
378            .unwrap_or_else(|| self.page.default_navigation_timeout());
379        let deadline = tokio::time::Instant::now() + timeout;
380
381        // Fast path: already matches.
382        if glob_matches(url, &self.url()) {
383            if let Some(state) = opts.wait_until {
384                self.wait_for_load_state(Some(state)).await?;
385            }
386            return Ok(());
387        }
388
389        // Subscribe before polling so navigation/lifecycle events for this
390        // frame drive the loop (the background frame tracker refreshes the
391        // cached URL from `Page.frameNavigated`).
392        let mut rx = self.page.session().subscribe();
393        loop {
394            // Re-check the cached URL (kept fresh by the frame tracker task).
395            if glob_matches(url, &self.url()) {
396                break;
397            }
398            if tokio::time::Instant::now() >= deadline {
399                return Err(Error::Timeout(format!(
400                    "wait_for_url '{url}' timed out after {}ms (last: '{}')",
401                    timeout.as_millis(),
402                    self.url()
403                )));
404            }
405            // Block until the next CDP event, bounded by the remaining budget.
406            let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
407            match tokio::time::timeout(remaining, rx.recv()).await {
408                Ok(Ok(ev))
409                    if frame_event_matches(&ev, &self.frame_id) =>
410                {
411                    continue;
412                }
413                Ok(Ok(_)) => continue,
414                Ok(Err(broadcast::error::RecvError::Closed)) => {
415                    return Err(Error::ChannelClosed);
416                }
417                Ok(Err(broadcast::error::RecvError::Lagged(_))) => continue,
418                Err(_) => {
419                    return Err(Error::Timeout(format!(
420                        "wait_for_url '{url}' timed out after {}ms (last: '{}')",
421                        timeout.as_millis(),
422                        self.url()
423                    )));
424                }
425            }
426        }
427        if let Some(state) = opts.wait_until {
428            self.wait_for_load_state(Some(state)).await?;
429        }
430        Ok(())
431    }
432
433    // --- Frame action methods (thin delegations to locators) ---
434    //
435    // Each resolves a locator for `selector` and forwards the call, mirroring
436    // Playwright's `frame.*` API shape (selector first, options last).
437
438    /// Click the first element matching `selector`.
439    pub async fn click(&self, selector: &str, opts: Option<ClickOptions>) -> Result<()> {
440        self.locator(selector).click(opts).await
441    }
442
443    /// Double-click the first element matching `selector`.
444    pub async fn dblclick(&self, selector: &str, opts: Option<ClickOptions>) -> Result<()> {
445        self.locator(selector).dblclick(opts).await
446    }
447
448    /// Set the value of the first matching `<input>`/`<textarea>` to `value`.
449    pub async fn fill(&self, selector: &str, value: &str, opts: Option<FillOptions>) -> Result<()> {
450        self.locator(selector).fill(value, opts).await
451    }
452
453    /// Focus the first element matching `selector`.
454    pub async fn focus(&self, selector: &str) -> Result<()> {
455        self.locator(selector).focus().await
456    }
457
458    /// Hover the first element matching `selector`.
459    pub async fn hover(&self, selector: &str, opts: Option<HoverOptions>) -> Result<()> {
460        self.locator(selector).hover(opts).await
461    }
462
463    /// Press `key` on the first element matching `selector`.
464    pub async fn press(&self, selector: &str, key: &str, opts: Option<PressOptions>) -> Result<()> {
465        self.locator(selector).press(key, opts).await
466    }
467
468    /// Select `<option>`(s) on the first matching `<select>` by value/label/index.
469    pub async fn select_option(
470        &self,
471        selector: &str,
472        value: impl Into<SelectOption>,
473        opts: Option<SelectOptions>,
474    ) -> Result<Vec<String>> {
475        self.locator(selector).select_option(value, opts).await
476    }
477
478    /// Set files on the first matching `<input type=file>` by server-side path(s).
479    pub async fn set_input_files(&self, selector: &str, files: &[&str]) -> Result<()> {
480        self.locator(selector).set_input_files(files).await
481    }
482
483    /// Tap (touch) the first element matching `selector`.
484    pub async fn tap(&self, selector: &str, opts: Option<ClickOptions>) -> Result<()> {
485        self.locator(selector).tap(opts).await
486    }
487
488    /// Check the first matching checkbox/radio.
489    pub async fn check(&self, selector: &str, opts: Option<CheckOptions>) -> Result<()> {
490        self.locator(selector).check(opts).await
491    }
492
493    /// Uncheck the first matching checkbox.
494    pub async fn uncheck(&self, selector: &str, opts: Option<CheckOptions>) -> Result<()> {
495        self.locator(selector).uncheck(opts).await
496    }
497
498    /// Set the checked state of the first matching checkbox/radio.
499    pub async fn set_checked(
500        &self,
501        selector: &str,
502        checked: bool,
503        opts: Option<CheckOptions>,
504    ) -> Result<()> {
505        self.locator(selector)
506            .set_checked(checked, opts)
507            .await
508    }
509
510    /// Type `text` into the first element matching `selector` (fill-based).
511    pub async fn type_(
512        &self,
513        selector: &str,
514        text: &str,
515        opts: Option<PressSequentiallyOptions>,
516    ) -> Result<()> {
517        self.locator(selector).type_(text, opts).await
518    }
519
520    /// The `textContent` of the first element matching `selector`, if any.
521    pub async fn text_content(&self, selector: &str) -> Result<Option<String>> {
522        self.locator(selector).text_content().await
523    }
524
525    /// The visible text of the first element matching `selector`.
526    pub async fn inner_text(&self, selector: &str) -> Result<String> {
527        self.locator(selector).inner_text().await
528    }
529
530    /// The serialized HTML of the first element matching `selector`.
531    pub async fn inner_html(&self, selector: &str) -> Result<String> {
532        self.locator(selector).inner_html().await
533    }
534
535    /// The value of attribute `name` on the first element matching `selector`.
536    pub async fn get_attribute(&self, selector: &str, name: &str) -> Result<Option<String>> {
537        self.locator(selector).get_attribute(name).await
538    }
539
540    /// Number of elements matching `selector` in this frame.
541    pub async fn count(&self, selector: &str) -> Result<usize> {
542        self.locator(selector).count().await
543    }
544
545    /// Wait until an element matching `selector` resolves in this frame.
546    pub async fn wait_for_selector(
547        &self,
548        selector: &str,
549        opts: Option<WaitForOptions>,
550    ) -> Result<()> {
551        self.locator(selector).wait_for(opts).await
552    }
553
554    /// Evaluate `expression` against the first element matching `selector` in
555    /// this frame.
556    pub async fn eval_on_selector<R: serde::de::DeserializeOwned>(
557        &self,
558        selector: &str,
559        expression: &str,
560    ) -> Result<R> {
561        let ctx = self.page.ctx_for_frame(&self.frame_id).await;
562        let object_id = crate::selectors::element_at(self.page.session(), ctx, selector, 0)
563            .await?
564            .ok_or_else(|| Error::ElementNotFound(selector.to_string()))?;
565        let function = format!("(el) => {{ return ({expression}); }}");
566        let v = crate::selectors::eval_object(self.page.session(), &object_id, &function, Value::Null)
567            .await?;
568        self.release_object(&object_id).await;
569        serde_json::from_value::<R>(v).map_err(Error::from)
570    }
571
572    /// Evaluate `expression` against all elements matching `selector` in this
573    /// frame.
574    pub async fn eval_on_selector_all<R: serde::de::DeserializeOwned>(
575        &self,
576        selector: &str,
577        expression: &str,
578    ) -> Result<Vec<R>> {
579        let ctx = self.page.ctx_for_frame(&self.frame_id).await;
580        let n = crate::selectors::count(self.page.session(), ctx, selector).await?;
581        let mut out = Vec::with_capacity(n);
582        for i in 0..n {
583            if let Some(oid) =
584                crate::selectors::element_at(self.page.session(), ctx, selector, i).await?
585            {
586                let function = format!("(el) => {{ return ({expression}); }}");
587                let v =
588                    crate::selectors::eval_object(self.page.session(), &oid, &function, Value::Null)
589                        .await?;
590                self.release_object(&oid).await;
591                out.push(serde_json::from_value::<R>(v).map_err(Error::from)?);
592            }
593        }
594        Ok(out)
595    }
596
597    /// Dispatch a synthetic DOM event on the first element matching `selector`.
598    pub async fn dispatch_event(
599        &self,
600        selector: &str,
601        type_: &str,
602        init: Option<Value>,
603    ) -> Result<()> {
604        self.locator(selector)
605            .dispatch_event(type_, init)
606            .await
607    }
608
609    /// Drag the element matching `source` onto the element matching `target`.
610    pub async fn drag_and_drop(
611        &self,
612        source: &str,
613        target: &str,
614        opts: Option<DragToOptions>,
615    ) -> Result<()> {
616        self.locator(source).drag_to(&self.locator(target), opts).await
617    }
618
619    /// The first element matching `selector` in this frame, if any.
620    pub async fn query_selector(&self, selector: &str) -> Result<Option<ElementHandle>> {
621        let ctx = self.page.ctx_for_frame(&self.frame_id).await;
622        let oid = crate::selectors::element_at(self.page.session(), ctx, selector, 0)
623            .await?
624            .map(|oid| ElementHandle::new(self.page.clone(), oid));
625        Ok(oid)
626    }
627
628    /// All elements matching `selector` in this frame.
629    pub async fn query_selector_all(&self, selector: &str) -> Result<Vec<ElementHandle>> {
630        let ctx = self.page.ctx_for_frame(&self.frame_id).await;
631        let n = crate::selectors::count(self.page.session(), ctx, selector).await?;
632        let mut out = Vec::with_capacity(n);
633        for i in 0..n {
634            if let Some(oid) =
635                crate::selectors::element_at(self.page.session(), ctx, selector, i).await?
636            {
637                out.push(ElementHandle::new(self.page.clone(), oid));
638            }
639        }
640        Ok(out)
641    }
642
643    async fn release_object(&self, object_id: &str) {
644        let _ = self
645            .page
646            .session()
647            .send("Runtime.releaseObject", json!({ "objectId": object_id }))
648            .await;
649    }
650
651    /// Keep the `Value` import referenced (used by callers wiring args).
652    #[allow(dead_code)]
653    fn _json_marker(&self) -> Value {
654        serde_json::json!({})
655    }
656}
657
658/// Escape a string for safe embedding in a CSS-attribute selector.
659fn attr_escape(s: &str) -> String {
660    s.replace('\\', "\\\\").replace('"', "\\\"")
661}
662
663/// Whether a CDP event is one that can change this frame's URL/state — i.e. a
664/// `Page.frameNavigated` or `Page.lifecycleEvent` whose `frameId`/`frame.id`
665/// matches `frame_id`.
666fn frame_event_matches(ev: &crate::cdp::CdpEvent, frame_id: &str) -> bool {
667    match ev.method.as_str() {
668        "Page.frameNavigated" => ev
669            .params
670            .get("frame")
671            .and_then(|f| f.get("id"))
672            .and_then(|v| v.as_str())
673            == Some(frame_id),
674        "Page.lifecycleEvent" => {
675            ev.params.get("frameId").and_then(|v| v.as_str()) == Some(frame_id)
676        }
677        _ => false,
678    }
679}
680
681/// Match `pattern` against `value`: exact, or a leading/trailing/both `*` glob.
682///
683/// Mirrors the helper in `src/page.rs` used by `Page::wait_for_url`.
684fn glob_matches(pattern: &str, value: &str) -> bool {
685    if pattern == value {
686        return true;
687    }
688    // `*middle*` → contains.
689    if pattern.len() >= 2 && pattern.starts_with('*') && pattern.ends_with('*') {
690        return value.contains(&pattern[1..pattern.len() - 1]);
691    }
692    if let Some(rest) = pattern.strip_prefix('*') {
693        if value.ends_with(rest) {
694            return true;
695        }
696    }
697    if let Some(rest) = pattern.strip_suffix('*') {
698        if value.starts_with(rest) {
699            return true;
700        }
701    }
702    false
703}