use std::path::Path;
use anyhow::{Context, Result, bail};
use crate::dom::{Dom, Mutation};
use crate::script::Script;
pub enum Step {
Click(String),
Type(String),
Press(String),
Scroll(String, f64, f64),
At(f64),
}
impl Step {
fn parse(line: &str) -> Option<Self> {
let (verb, rest) = line.split_once(char::is_whitespace)?;
let rest = rest.trim();
match verb {
"click" => Some(Self::Click(rest.to_string())),
"type" => Some(Self::Type(rest.to_string())),
"press" => Some(Self::Press(rest.to_string())),
"at" => rest.parse().ok().map(Self::At),
"scroll" => {
let mut parts = rest.split(',');
let selector = parts.next()?.trim().to_string();
let dx = parts.next()?.trim().parse().ok()?;
let dy = parts.next()?.trim().parse().ok()?;
Some(Self::Scroll(selector, dx, dy))
}
_ => None,
}
}
fn render(&self) -> String {
match self {
Self::Click(selector) => format!("click {selector}"),
Self::Type(text) => format!("type {text}"),
Self::Press(key) => format!("press {key}"),
Self::At(seconds) => format!("at {seconds}"),
Self::Scroll(selector, dx, dy) => format!("scroll {selector},{dx},{dy}"),
}
}
}
pub fn parse(source: &str) -> Vec<Step> {
source
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.filter_map(Step::parse)
.collect()
}
pub fn render(steps: &[Step]) -> String {
let mut out = String::from("# kiln trace\n");
for step in steps {
out.push_str(&step.render());
out.push('\n');
}
out
}
fn apply(step: &Step, dom: &Dom, script: &Script) -> Result<()> {
match step {
Step::Click(selector) => {
let node = dom
.query_selector(selector)
.with_context(|| format!("no element matches {selector}"))?;
let (x, y) = dom
.center_of(node)
.with_context(|| format!("{selector} has no layout box"))?;
for event in [
crate::events::pointer_button(
x,
y,
winit::event::MouseButton::Left,
winit::event::ElementState::Pressed,
),
crate::events::pointer_button(
x,
y,
winit::event::MouseButton::Left,
winit::event::ElementState::Released,
),
] {
for dispatch in dom.drive(event) {
script.dispatch(&dispatch)?;
}
}
}
Step::Type(text) => {
for ch in text.chars() {
let ch = ch.to_string();
for pressed in [true, false] {
for dispatch in dom.drive(crate::events::text_key(&ch, pressed)) {
script.dispatch(&dispatch)?;
}
}
}
}
Step::Press(key) => {
for pressed in [true, false] {
let event = crate::events::named(key, pressed)
.with_context(|| format!("unknown key {key}"))?;
for dispatch in dom.drive(event) {
script.dispatch(&dispatch)?;
}
}
}
Step::Scroll(selector, dx, dy) => {
let node = dom
.query_selector(selector)
.with_context(|| format!("no element matches {selector}"))?;
let (x, y) = dom
.center_of(node)
.with_context(|| format!("{selector} has no layout box"))?;
for dispatch in dom.drive(crate::events::pointer_move(x, y)) {
script.dispatch(&dispatch)?;
}
for dispatch in dom.drive(crate::events::wheel_pixels(x, y, *dx, *dy)) {
script.dispatch(&dispatch)?;
}
let anchor = dom.hover_node();
for dispatch in dom.scroll(anchor, -dx, -dy) {
script.dispatch(&dispatch)?;
}
}
Step::At(seconds) => dom.set_time(*seconds),
}
dom.settle(script);
Ok(())
}
fn fingerprint(dom: &Dom) -> String {
use std::fmt::Write;
let journal = dom.journal().borrow();
let mut out = String::new();
for record in journal.since(0) {
match &record.mutation {
Mutation::ChildList {
parent,
added,
removed,
..
} => {
let _ = writeln!(
out,
"{} childList {parent} +{} -{}",
record.seq,
added.len(),
removed.len()
);
}
Mutation::Attribute { target, name, .. } => {
let _ = writeln!(out, "{} attribute {target} {name}", record.seq);
}
Mutation::CharacterData { target, .. } => {
let _ = writeln!(out, "{} characterData {target}", record.seq);
}
}
}
out
}
fn digest(text: &str) -> String {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in text.bytes() {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x1_0000_01b3);
}
format!("{hash:016x}")
}
pub struct Outcome {
pub steps: usize,
pub mutations: usize,
pub fingerprint: String,
}
pub fn run(input: &str, steps: &[Step]) -> Result<Outcome> {
let (dom, script, _native) = crate::load(input)?;
let _consumer = dom.journal().borrow_mut().register();
dom.settle(&script);
for step in steps {
apply(step, &dom, &script)?;
}
let snapshot = dom.snapshot();
let mut fingerprint = fingerprint(&dom);
let mutations = fingerprint.lines().count();
fingerprint.push_str(&format!("= tree {}\n", digest(&snapshot)));
Ok(Outcome {
steps: steps.len(),
mutations,
fingerprint,
})
}
pub fn record(input: &str, steps: &[Step], out: &Path) -> Result<Outcome> {
let outcome = run(input, steps)?;
let mut trace = render(steps);
trace.push_str("\n# fingerprint\n");
for line in outcome.fingerprint.lines() {
trace.push_str("# ");
trace.push_str(line);
trace.push('\n');
}
std::fs::write(out, trace).with_context(|| format!("write {}", out.display()))?;
Ok(outcome)
}
pub fn replay(input: &str, trace: &Path) -> Result<Outcome> {
let source =
std::fs::read_to_string(trace).with_context(|| format!("read {}", trace.display()))?;
let expected: String = source
.lines()
.filter_map(|line| line.strip_prefix("# "))
.filter(|line| {
line.starts_with('=') || line.chars().next().is_some_and(|c| c.is_ascii_digit())
})
.map(|line| format!("{line}\n"))
.collect();
let outcome = run(input, &parse(&source))?;
if !expected.is_empty() && expected != outcome.fingerprint {
let expected_lines = expected.lines().count();
let actual_lines = outcome.fingerprint.lines().count();
let first = expected
.lines()
.zip(outcome.fingerprint.lines())
.find(|(a, b)| a != b)
.map(|(a, b)| format!("\n recorded: {a}\n replayed: {b}"))
.unwrap_or_default();
bail!(
"replay diverged: {expected_lines} recorded mutations, {actual_lines} on replay{first}"
);
}
Ok(outcome)
}