use crate::extensions::util::truncate_chars_with_postfix;
use serde_json::Value;
const SNAPSHOT_JS: &str = r#"(() => {
const MAX = __MAX_ELEMENTS__;
const SELECTOR = 'a[href],button,input,select,textarea,summary,[role=button],[role=link],[role=checkbox],[role=radio],[role=switch],[role=tab],[role=menuitem],[role=option],[contenteditable=true],[onclick]';
const label = (el) => {
const raw = el.getAttribute('aria-label') || el.getAttribute('placeholder') || el.getAttribute('alt') || el.getAttribute('title') || el.innerText || el.value || '';
const text = String(raw).replace(/\s+/g, ' ').trim();
return text.length > 80 ? text.slice(0, 77) + '...' : text;
};
const visible = (el) => {
const rect = el.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return false;
const style = getComputedStyle(el);
return style.visibility !== 'hidden' && style.display !== 'none' && style.opacity !== '0';
};
const interactable = Array.from(document.querySelectorAll(SELECTOR)).filter(visible);
const refs = {};
const lines = interactable.slice(0, MAX).map((el, index) => {
const ref = 'e' + (index + 1);
refs[ref] = el;
const name = el.tagName.toLowerCase();
const type = el.getAttribute('type');
const kind = type ? '<' + name + '[' + type + ']>' : '<' + name + '>';
const value = (typeof el.value === 'string' && el.value !== '') ? ' value=' + JSON.stringify(el.value.slice(0, 60)) : '';
const href = el.getAttribute('href') ? ' href=' + JSON.stringify(el.getAttribute('href').slice(0, 100)) : '';
const disabled = el.disabled ? ' disabled' : '';
return ref + ' ' + kind + ' ' + JSON.stringify(label(el)) + value + href + disabled;
});
window.__pruxRefs = refs;
const body = document.body;
const text = (body ? (body.innerText || body.textContent || '') : '').replace(/\n{3,}/g, '\n\n').trim();
return { title: document.title, url: location.href, refs: lines, total: interactable.length, text: text };
})()"#;
pub(crate) const TEXT_JS: &str = "(() => { const body = document.body; return body ? (body.innerText || body.textContent || '') : ''; })()";
pub(crate) const HTML_JS: &str =
"(() => document.documentElement ? document.documentElement.outerHTML : '')()";
pub(crate) const READY_STATE_JS: &str = "(() => document.readyState)()";
pub(crate) const CLEAR_FOCUSED_JS: &str = r#"(() => {
const el = document.activeElement;
if (!el || !('value' in el)) return { error: 'no focused input to clear; pass ref or selector' };
const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), 'value');
if (descriptor && descriptor.set) descriptor.set.call(el, ''); else el.value = '';
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
return { ok: true };
})()"#;
const TRUNCATED_SUFFIX: &str = "\n… [truncated]";
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct SnapshotData {
pub title: String,
pub url: String,
pub refs: Vec<String>,
pub total: usize,
pub text: String,
}
impl SnapshotData {
pub fn refs_truncated(&self) -> bool {
self.total > self.refs.len()
}
}
pub(crate) fn snapshot_expression(max_elements: usize) -> String {
SNAPSHOT_JS.replace("__MAX_ELEMENTS__", &max_elements.to_string())
}
pub(crate) fn parse(value: &Value) -> Result<SnapshotData, String> {
let Some(object) = value.as_object() else {
return Err("browser: snapshot returned no page data".to_string());
};
let refs: Vec<String> = object
.get("refs")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.map(|item| item.as_str().unwrap_or_default().to_string())
.collect()
})
.unwrap_or_default();
Ok(SnapshotData {
title: string_field(object.get("title")),
url: string_field(object.get("url")),
total: object
.get("total")
.and_then(Value::as_u64)
.unwrap_or(refs.len() as u64) as usize,
refs,
text: string_field(object.get("text")),
})
}
pub(crate) fn format(data: &SnapshotData, max_chars: usize) -> String {
let mut out = String::new();
out.push_str(&format!("Page: {}\n", data.title));
out.push_str(&format!("URL: {}\n", data.url));
let header = if data.refs_truncated() {
format!(
"Interactables ({} shown of {}):",
data.refs.len(),
data.total
)
} else {
format!("Interactables ({}):", data.refs.len())
};
out.push_str(&header);
out.push('\n');
for line in &data.refs {
out.push_str(" ");
out.push_str(line);
out.push('\n');
}
if data.refs.is_empty() {
out.push_str(" (none)\n");
}
out.push_str("Visible text:\n");
out.push_str(&data.text);
truncate_chars_with_postfix(&out, max_chars, TRUNCATED_SUFFIX)
}
pub(crate) fn element_expression(
ref_id: Option<&str>,
selector: Option<&str>,
) -> Result<String, String> {
if let Some(ref_id) = ref_id {
let digits = ref_id.trim().strip_prefix('e').unwrap_or("");
if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
return Err(format!(
"browser: `ref` must look like `e1` (a reference from snapshot), got `{ref_id}`"
));
}
return Ok(format!(
"(window.__pruxRefs && window.__pruxRefs[{}]) || null",
json_literal(ref_id.trim())
));
}
if let Some(selector) = selector {
return Ok(format!(
"document.querySelector({})",
json_literal(selector)
));
}
Err("browser: pass `ref` (from snapshot) or `selector`".to_string())
}
pub(crate) fn element_rect_expression(element: &str) -> String {
format!(
r#"(() => {{
const el = {element};
if (!el) return {{ error: 'element not found; references go stale after a navigation - call snapshot again' }};
if (el.scrollIntoView) el.scrollIntoView({{ block: 'center', inline: 'center' }});
const rect = el.getBoundingClientRect();
return {{ x: rect.left, y: rect.top, width: rect.width, height: rect.height }};
}})()"#
)
}
pub(crate) fn focus_expression(element: &str, clear: bool) -> String {
format!(
r#"(() => {{
const el = {element};
if (!el) return {{ error: 'element not found; references go stale after a navigation - call snapshot again' }};
if (el.focus) el.focus();
if ({clear} && 'value' in el) {{
const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), 'value');
if (descriptor && descriptor.set) descriptor.set.call(el, ''); else el.value = '';
el.dispatchEvent(new Event('input', {{ bubbles: true }}));
el.dispatchEvent(new Event('change', {{ bubbles: true }}));
}}
return {{ ok: true }};
}})()"#
)
}
pub(crate) fn select_expression(element: &str, value: &str) -> String {
let literal = json_literal(value);
format!(
r#"(() => {{
const el = {element};
if (!el) return {{ error: 'element not found; references go stale after a navigation - call snapshot again' }};
if (el.tagName !== 'SELECT') return {{ error: 'element is <' + el.tagName.toLowerCase() + '>, not a <select>' }};
el.value = {literal};
if (el.value !== {literal}) return {{ error: 'no <option> with value ' + {literal} }};
el.dispatchEvent(new Event('input', {{ bubbles: true }}));
el.dispatchEvent(new Event('change', {{ bubbles: true }}));
return {{ ok: true, value: el.value }};
}})()"#
)
}
pub(crate) fn scroll_expression(
element: Option<&str>,
to: Option<&str>,
dx: f64,
dy: f64,
) -> String {
if let Some(element) = element {
return format!(
"(() => {{ const el = {element}; if (!el) return {{ error: 'element not found; references go stale after a navigation - call snapshot again' }}; el.scrollIntoView({{ block: 'center', inline: 'center' }}); return {{ ok: true, y: window.scrollY }}; }})()"
);
}
if let Some(to) = to {
let y = if to == "bottom" {
"Math.max(document.body ? document.body.scrollHeight : 0, document.documentElement ? document.documentElement.scrollHeight : 0)".to_string()
} else {
"0".to_string()
};
return format!(
"(() => {{ window.scrollTo(0, {y}); return {{ ok: true, y: window.scrollY }}; }})()"
);
}
format!(
"(() => {{ window.scrollBy({dx}, {dy}); return {{ ok: true, x: window.scrollX, y: window.scrollY }}; }})()"
)
}
pub(crate) fn error_of(value: &Value) -> Option<String> {
value
.get("error")
.and_then(Value::as_str)
.map(str::to_string)
}
pub(crate) fn json_literal(value: &str) -> String {
Value::String(value.to_string()).to_string()
}
fn string_field(value: Option<&Value>) -> String {
value.and_then(Value::as_str).unwrap_or("").to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn format_includes_structure_and_marks_truncation() {
let data = SnapshotData {
title: "Example".to_string(),
url: "https://example.test/".to_string(),
refs: vec!["e1 <button> \"Submit\"".to_string()],
total: 3,
text: "hello".to_string(),
};
let text = format(&data, 10_000);
assert!(text.contains("Page: Example"));
assert!(text.contains("URL: https://example.test/"));
assert!(text.contains("Interactables (1 shown of 3):"));
assert!(text.contains(" e1 <button> \"Submit\""));
assert!(text.ends_with("Visible text:\nhello"));
}
#[test]
fn format_marks_an_empty_element_list() {
let data = SnapshotData {
title: "t".to_string(),
url: "u".to_string(),
refs: Vec::new(),
total: 0,
text: String::new(),
};
let text = format(&data, 10_000);
assert!(text.contains("Interactables (0):\n (none)"));
assert!(!data.refs_truncated());
}
#[test]
fn format_truncates_by_characters() {
let data = SnapshotData {
title: "标题".to_string(),
url: "u".to_string(),
refs: Vec::new(),
total: 0,
text: "正文".repeat(100),
};
let text = format(&data, 20);
assert!(text.contains("[truncated]"));
assert!(text.chars().count() <= 20 + TRUNCATED_SUFFIX.chars().count());
}
#[test]
fn parse_reads_snapshot_fields() {
let value =
json!({ "title": "t", "url": "u", "refs": ["e1 <a>"], "total": 5, "text": "body" });
let data = parse(&value).unwrap();
assert_eq!(data.title, "t");
assert_eq!(data.refs, vec!["e1 <a>".to_string()]);
assert_eq!(data.total, 5);
assert!(data.refs_truncated());
assert!(parse(&json!("nope")).is_err());
}
#[test]
fn parse_defaults_total_to_element_count() {
let value = json!({ "refs": ["e1 <a>", "e2 <a>"] });
let data = parse(&value).unwrap();
assert_eq!(data.total, 2);
assert!(!data.refs_truncated());
}
#[test]
fn element_expression_rejects_bad_refs() {
let error = element_expression(Some("e1); alert(1"), None).unwrap_err();
assert!(error.contains("must look like `e1`"), "{error}");
assert_eq!(
element_expression(Some("e12"), None).unwrap(),
"(window.__pruxRefs && window.__pruxRefs[\"e12\"]) || null"
);
}
#[test]
fn element_expression_prefers_ref_then_selector() {
assert_eq!(
element_expression(None, Some("#submit")).unwrap(),
"document.querySelector(\"#submit\")"
);
assert!(element_expression(None, None).is_err());
}
#[test]
fn element_expression_escapes_selectors() {
let expression = element_expression(None, Some("a[href=\"x\"]")).unwrap();
assert_eq!(expression, "document.querySelector(\"a[href=\\\"x\\\"]\")");
}
#[test]
fn error_of_reads_only_the_error_field() {
assert_eq!(error_of(&json!({ "error": "boom" })), Some("boom".into()));
assert_eq!(error_of(&json!({ "ok": true })), None);
assert_eq!(error_of(&json!(null)), None);
}
#[test]
fn snapshot_expression_substitutes_the_element_limit() {
let expression = snapshot_expression(42);
assert!(expression.contains("const MAX = 42;"));
assert!(!expression.contains("__MAX_ELEMENTS__"));
}
}