Skip to main content

rightkit_qa/
page.rs

1//! Page-side QA primitives that run through `Control::eval` (or any evaluator):
2//! frame-scoped selectors, painted-not-acknowledged timing, and active-pane
3//! geometry. These port the `frames`, `paint`, and `panes` modules of the TS
4//! package; the scripts are plain function bodies (`return ...`).
5use crate::control::Control;
6use crate::util::{err, sleep_ms, Result};
7use serde_json::{json, Value};
8use std::time::{Duration, Instant};
9
10/// Anything that can evaluate a JS function body and return its JSON value.
11pub trait Eval {
12    fn eval_js(&self, js: &str) -> Result<Value>;
13}
14impl Eval for Control {
15    fn eval_js(&self, js: &str) -> Result<Value> {
16        self.eval(js)
17    }
18}
19
20fn q(s: &str) -> String {
21    serde_json::to_string(s).unwrap_or_else(|_| "\"\"".into())
22}
23
24// ----------------------------------------------------------------- frames
25
26/// Index path from the top document to a nested frame (`[]` is the top document).
27pub type FramePath = Vec<usize>;
28
29#[derive(Debug, Clone, PartialEq)]
30pub struct FrameMatch {
31    pub path: FramePath,
32    pub count: usize,
33}
34
35pub fn describe_frame(path: &[usize]) -> String {
36    if path.is_empty() {
37        "top document".into()
38    } else {
39        format!(
40            "frame {}",
41            path.iter()
42                .map(|i| i.to_string())
43                .collect::<Vec<_>>()
44                .join(".")
45        )
46    }
47}
48
49/// Script that reports every same-origin document (top and nested) matching `selector`.
50pub fn find_across_frames_script(selector: &str, max_depth: usize) -> String {
51    format!(
52        "var out=[];function visit(w,path,d){{try{{var n=w.document.querySelectorAll({sel}).length;if(n>0)out.push({{path:path,count:n}});}}catch(e){{}}\
53         if(d>={max_depth})return;var len=0;try{{len=w.frames.length}}catch(e){{}}\
54         for(var i=0;i<len;i++){{var c;try{{c=w.frames[i]}}catch(e){{continue}}visit(c,path.concat([i]),d+1)}}}}\
55         visit(window,[],0);return out;",
56        sel = q(selector)
57    )
58}
59
60pub fn parse_frame_matches(v: &Value) -> Vec<FrameMatch> {
61    v.as_array()
62        .map(|a| {
63            a.iter()
64                .map(|m| FrameMatch {
65                    path: m["path"]
66                        .as_array()
67                        .map(|p| {
68                            p.iter()
69                                .filter_map(|i| i.as_u64().map(|i| i as usize))
70                                .collect()
71                        })
72                        .unwrap_or_default(),
73                    count: m["count"].as_u64().unwrap_or(0) as usize,
74                })
75                .collect()
76        })
77        .unwrap_or_default()
78}
79
80pub fn find_across_frames(
81    page: &dyn Eval,
82    selector: &str,
83    max_depth: usize,
84) -> Result<Vec<FrameMatch>> {
85    Ok(parse_frame_matches(&page.eval_js(
86        &find_across_frames_script(selector, max_depth),
87    )?))
88}
89
90/// The one document a selector may match; ambiguity is an error naming both places.
91pub fn resolve_unique_frame(
92    page: &dyn Eval,
93    selector: &str,
94    max_depth: usize,
95) -> Result<FramePath> {
96    let matches = find_across_frames(page, selector, max_depth)?;
97    match matches.len() {
98        0 => err(format!("selector matched no document: {selector}")),
99        1 => Ok(matches[0].path.clone()),
100        n => err(format!(
101            "selector {selector} matches in {n} documents: {}. Scope it with eval_in_frame() so the assertion names the document it means.",
102            matches.iter().map(|m| format!("{} ({})", describe_frame(&m.path), m.count)).collect::<Vec<_>>().join(", ")
103        )),
104    }
105}
106
107/// Run a function body inside the same-origin frame at `path` (entering and
108/// leaving is implicit: each call starts from the top window, so a failure can
109/// never leave later selectors scoped to an iframe).
110pub fn eval_in_frame_script(path: &[usize], body: &str) -> String {
111    format!(
112        "var w=window;var p={};for(var i=0;i<p.length;i++){{w=w.frames[p[i]];if(!w)throw new Error('no frame '+p.join('.'));}}\
113         return w.eval('(function(){{'+{}+'}})()');",
114        json!(path),
115        q(body)
116    )
117}
118
119pub fn eval_in_frame(page: &dyn Eval, path: &[usize], body: &str) -> Result<Value> {
120    page.eval_js(&eval_in_frame_script(path, body))
121}
122
123// ------------------------------------------------------------------ paint
124
125pub const DEFAULT_SETTLE_MS: u64 = 120;
126pub const DEFAULT_PAINT_TIMEOUT_MS: u64 = 10_000;
127
128#[derive(Debug, Clone)]
129pub struct PaintMetrics {
130    pub request_id: String,
131    pub duration_ms: f64,
132    pub settle_ms: f64,
133    pub mutations: u64,
134    /// False when the document was hidden and rAF is throttled: honest, not silent.
135    pub animation_frames: bool,
136}
137
138fn paint_begin_script(id: &str, selector: Option<&str>) -> String {
139    let root = selector
140        .map(|s| format!("document.querySelector({})", q(s)))
141        .unwrap_or_else(|| "document.documentElement".into());
142    format!(
143        "var L=window.__rkQaPaint=window.__rkQaPaint||{{marks:{{}},results:{{}}}};var root={root};\
144         if(!root)throw new Error('paint target not found');\
145         var m={{startedAt:performance.now(),mutations:0,last:performance.now()}};\
146         m.observer=new MutationObserver(function(r){{m.mutations+=r.length;m.last=performance.now();}});\
147         m.observer.observe(root,{{subtree:true,childList:true,characterData:true,attributes:true}});\
148         L.marks[{id}]=m;return true;",
149        id = q(id)
150    )
151}
152
153fn paint_arm_script(id: &str, settle_ms: u64, timeout_ms: u64) -> String {
154    format!(
155        "var L=window.__rkQaPaint;var id={id};var m=L&&L.marks[id];if(!m)throw new Error('no paint mark');\
156         var deadline=performance.now()+{timeout_ms};\
157         function finish(af){{m.observer.disconnect();delete L.marks[id];L.results[id]={{durationMs:performance.now()-m.startedAt,settleMs:m.last-m.startedAt,mutations:m.mutations,animationFrames:af}};}}\
158         (function poll(){{var quiet=performance.now()-m.last;\
159         if(quiet>={settle_ms}){{if(document.hidden){{finish(false);return;}}requestAnimationFrame(function(){{requestAnimationFrame(function(){{finish(true);}});}});return;}}\
160         if(performance.now()>=deadline){{m.observer.disconnect();delete L.marks[id];L.results[id]={{error:'paint did not settle within {timeout_ms}ms; '+m.mutations+' mutations'}};return;}}\
161         setTimeout(poll,16);}})();return true;",
162        id = q(id)
163    )
164}
165
166fn paint_result_script(id: &str) -> String {
167    format!(
168        "var L=window.__rkQaPaint;return (L&&L.results[{id}])||null;",
169        id = q(id)
170    )
171}
172
173/// Run `trigger`, then return when the resulting render has actually been
174/// painted (DOM quiet, then two animation frames). The mark is installed before
175/// the trigger so no mutation slips between them.
176pub fn measure_paint(
177    page: &dyn Eval,
178    trigger: &mut dyn FnMut() -> Result<()>,
179    request_id: &str,
180    selector: Option<&str>,
181    settle_ms: u64,
182    timeout_ms: u64,
183) -> Result<PaintMetrics> {
184    page.eval_js(&paint_begin_script(request_id, selector))?;
185    trigger()?;
186    page.eval_js(&paint_arm_script(request_id, settle_ms, timeout_ms))?;
187    let deadline = Instant::now() + Duration::from_millis(timeout_ms + 2_000);
188    loop {
189        let r = page.eval_js(&paint_result_script(request_id))?;
190        if !r.is_null() {
191            if let Some(e) = r.get("error").and_then(Value::as_str) {
192                return err(format!("paint measurement failed for {request_id}: {e}"));
193            }
194            return Ok(PaintMetrics {
195                request_id: request_id.into(),
196                duration_ms: r["durationMs"].as_f64().unwrap_or(0.0),
197                settle_ms: r["settleMs"].as_f64().unwrap_or(0.0),
198                mutations: r["mutations"].as_u64().unwrap_or(0),
199                animation_frames: r["animationFrames"].as_bool().unwrap_or(false),
200            });
201        }
202        if Instant::now() >= deadline {
203            return err(format!("paint measurement for {request_id} never reported"));
204        }
205        sleep_ms(25);
206    }
207}
208
209pub fn assert_paint_within(m: &PaintMetrics, budget_ms: f64, label: &str) -> Result<()> {
210    if !(budget_ms.is_finite() && budget_ms > 0.0) {
211        return err("paint budget must be a positive number of milliseconds");
212    }
213    if m.duration_ms > budget_ms {
214        return err(format!(
215            "{label} painted in {}ms, over its {budget_ms}ms budget ({} DOM mutations, settled after {}ms, requestId {})",
216            m.duration_ms.round(),
217            m.mutations,
218            m.settle_ms.round(),
219            m.request_id
220        ));
221    }
222    Ok(())
223}
224
225// ------------------------------------------------------------------ panes
226
227#[derive(Debug, Clone)]
228pub struct PaneGeometry {
229    pub selector: String,
230    pub x: f64,
231    pub y: f64,
232    pub width: f64,
233    pub height: f64,
234    pub visible: bool,
235    pub active: bool,
236    pub matches: usize,
237}
238
239fn pane_script(selector: &str) -> String {
240    format!(
241        "var nodes=document.querySelectorAll({sel});if(nodes.length===0)return null;var chosen=null;\
242         function act(n){{return n.getAttribute('aria-selected')==='true'||n.getAttribute('aria-current')==='true'||n.dataset.active==='true'||n.classList.contains('active');}}\
243         for(var i=0;i<nodes.length;i++){{if(act(nodes[i])){{chosen=nodes[i];break;}}}}if(!chosen)chosen=nodes[0];\
244         var r=chosen.getBoundingClientRect();var s=getComputedStyle(chosen);\
245         var hs=s.display==='none'||s.visibility==='hidden'||Number(s.opacity)===0;\
246         var ha=chosen.hasAttribute('hidden')||chosen.getAttribute('aria-hidden')==='true';\
247         return {{x:r.x,y:r.y,width:r.width,height:r.height,visible:!hs&&!ha&&r.width>0&&r.height>0,\
248         active:act(chosen)||(nodes.length===1&&!hs&&!ha),matches:nodes.length}};",
249        sel = q(selector)
250    )
251}
252
253pub fn read_pane_geometry(page: &dyn Eval, selector: &str) -> Result<PaneGeometry> {
254    let v = page.eval_js(&pane_script(selector))?;
255    if v.is_null() {
256        return err(format!("pane selector matched no element: {selector}"));
257    }
258    Ok(PaneGeometry {
259        selector: selector.into(),
260        x: v["x"].as_f64().unwrap_or(0.0),
261        y: v["y"].as_f64().unwrap_or(0.0),
262        width: v["width"].as_f64().unwrap_or(0.0),
263        height: v["height"].as_f64().unwrap_or(0.0),
264        visible: v["visible"].as_bool().unwrap_or(false),
265        active: v["active"].as_bool().unwrap_or(false),
266        matches: v["matches"].as_u64().unwrap_or(0) as usize,
267    })
268}
269
270/// The pane under test must be visible and active; a background tab that happens
271/// to be laid out fails "active", a hidden pane fails "visible".
272pub fn assert_active_pane(g: &PaneGeometry) -> Result<()> {
273    if !g.visible {
274        return err(format!(
275            "{} is not visible ({}x{} at {},{}); the assertion would describe a pane the user cannot see",
276            g.selector, g.width, g.height, g.x, g.y
277        ));
278    }
279    if !g.active {
280        return err(format!(
281            "{} matched {} panes and the chosen one is not the active pane; scope the selector to the active tab before asserting",
282            g.selector, g.matches
283        ));
284    }
285    Ok(())
286}
287
288/// Wait until the ACTIVE pane's box has changed from `before` and then held still.
289pub fn wait_for_pane_resize(
290    page: &dyn Eval,
291    selector: &str,
292    before: (f64, f64),
293    timeout: Duration,
294    stable_reads: u32,
295) -> Result<PaneGeometry> {
296    let deadline = Instant::now() + timeout;
297    let mut stable = 0;
298    let mut previous: Option<(f64, f64)> = None;
299    let mut latest: Option<PaneGeometry> = None;
300    while Instant::now() < deadline {
301        let g = read_pane_geometry(page, selector)?;
302        assert_active_pane(&g)?;
303        let changed = g.width != before.0 || g.height != before.1;
304        let held = previous == Some((g.width, g.height));
305        if changed && held {
306            stable += 1;
307            if stable >= stable_reads {
308                return Ok(g);
309            }
310        } else if !held {
311            stable = 0;
312        }
313        previous = Some((g.width, g.height));
314        latest = Some(g);
315        sleep_ms(50);
316    }
317    err(format!(
318        "{selector} did not settle at a new size within {}ms: before {}x{}, last {}",
319        timeout.as_millis(),
320        before.0,
321        before.1,
322        latest
323            .map(|g| format!("{}x{}", g.width, g.height))
324            .unwrap_or_else(|| "unreadable".into())
325    ))
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    #[test]
333    fn frame_scripts_quote_selectors() {
334        let s = find_across_frames_script("a[href=\"x\"]", 3);
335        assert!(s.contains("a[href=\\\"x\\\"]") && s.contains("d>=3"));
336        assert!(eval_in_frame_script(&[0, 2], "return 1;").contains("[0,2]"));
337    }
338
339    #[test]
340    fn describes_frames_and_matches() {
341        assert_eq!(describe_frame(&[]), "top document");
342        assert_eq!(describe_frame(&[1, 0]), "frame 1.0");
343        let m = parse_frame_matches(&json!([{"path": [], "count": 1}, {"path": [0], "count": 2}]));
344        assert_eq!(m.len(), 2);
345        assert_eq!(
346            m[1],
347            FrameMatch {
348                path: vec![0],
349                count: 2
350            }
351        );
352    }
353
354    struct Fake(Value);
355    impl Eval for Fake {
356        fn eval_js(&self, _: &str) -> Result<Value> {
357            Ok(self.0.clone())
358        }
359    }
360
361    #[test]
362    fn ambiguous_selector_names_both_documents() {
363        let e = resolve_unique_frame(
364            &Fake(json!([{"path": [], "count": 1}, {"path": [0], "count": 1}])),
365            ".t",
366            4,
367        )
368        .unwrap_err();
369        assert!(e.0.contains("top document (1), frame 0 (1)"), "{e}");
370        assert!(resolve_unique_frame(&Fake(json!([])), ".t", 4).is_err());
371        assert_eq!(
372            resolve_unique_frame(&Fake(json!([{"path": [2], "count": 1}])), ".t", 4).unwrap(),
373            vec![2]
374        );
375    }
376
377    #[test]
378    fn pane_assertions() {
379        let mut g = PaneGeometry {
380            selector: ".p".into(),
381            x: 0.0,
382            y: 0.0,
383            width: 10.0,
384            height: 10.0,
385            visible: true,
386            active: true,
387            matches: 1,
388        };
389        assert!(assert_active_pane(&g).is_ok());
390        g.active = false;
391        assert!(assert_active_pane(&g)
392            .unwrap_err()
393            .0
394            .contains("not the active pane"));
395        g.visible = false;
396        assert!(assert_active_pane(&g)
397            .unwrap_err()
398            .0
399            .contains("not visible"));
400    }
401
402    #[test]
403    fn paint_budget() {
404        let m = PaintMetrics {
405            request_id: "r".into(),
406            duration_ms: 300.0,
407            settle_ms: 100.0,
408            mutations: 4,
409            animation_frames: true,
410        };
411        assert!(assert_paint_within(&m, 500.0, "open").is_ok());
412        assert!(assert_paint_within(&m, 100.0, "open")
413            .unwrap_err()
414            .0
415            .contains("over its 100ms budget"));
416    }
417}