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