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 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
49pub 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
90pub 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
107pub 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
123pub 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 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
173pub 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#[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
270pub 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
288pub 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}