1use crate::control::Control;
6use crate::util::{err, sleep_ms, Result};
7use serde_json::{json, Value};
8use std::time::{Duration, Instant};
9
10pub 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
24pub const ACCESSIBLE_LOCATOR_SCRIPT: &str = include_str!("page/locator.js");
29
30pub 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
55pub 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
96pub 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
113pub 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
129pub 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 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
179pub 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#[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
276pub 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
294pub 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}