use std::collections::HashMap;
use std::io;
use std::sync::{Arc, Mutex};
use rich_embed::{PageState, ScreencastFrame};
use serde_json::Value;
use crate::engine::{
Engine, EngineEvent, Format, Frame, HistoryStep, MediaOp, Metrics, PageAction, PageSnapshot,
Playback, Rect, TabId,
};
#[derive(Default)]
pub struct FakeState {
pub pages: HashMap<String, PageSnapshot>,
pub current: Option<String>,
pub history: Vec<String>,
pub at: usize,
pub actions: Vec<PageAction>,
pub opened: Vec<String>,
pub events: Vec<EngineEvent>,
pub media: HashMap<i64, Playback>,
pub scroll_y: f64,
pub viewport_height: f64,
pub not_loaded: std::collections::HashSet<i64>,
pub screencast: bool,
pub screencast_scale: f64,
pub frames: Vec<ScreencastFrame>,
pub codecs: HashMap<String, String>,
pub resources: HashMap<String, Vec<u8>>,
pub resource_root: Option<std::path::PathBuf>,
pub recorded: Option<std::path::PathBuf>,
recorded_frames: HashMap<String, Vec<image::RgbImage>>,
playing_since: HashMap<i64, std::time::Instant>,
pub viewport_width: f64,
}
impl FakeState {
pub fn resource(&self, url: &str) -> Option<Vec<u8>> {
if let Some(bytes) = self.resources.get(url) {
return Some(bytes.clone());
}
let root = self.resource_root.as_ref()?;
let path = url.split(['?', '#']).next().unwrap_or(url);
let relative = match path.rfind("/fixtures/") {
Some(at) => &path[at + "/fixtures/".len()..],
None => path.rsplit('/').next().unwrap_or(path),
};
if relative
.split('/')
.any(|part| part == ".." || part.is_empty())
{
return None;
}
std::fs::read(root.join(relative)).ok()
}
}
impl FakeState {
pub fn content_height(&self) -> f64 {
self.current
.as_ref()
.and_then(|name| self.pages.get(name))
.and_then(|s| s.dom["documents"][0]["contentHeight"].as_f64())
.unwrap_or(0.0)
}
}
pub fn feed_snapshot(url: &str, posts: usize) -> PageSnapshot {
feed_snapshot_of(url, 1, posts as i64)
}
pub fn feed_snapshot_of(url: &str, first: i64, last: i64) -> PageSnapshot {
let posts = (last - first + 1).max(0) as usize;
use serde_json::json;
let mut strings: Vec<String> = [
url,
"Feed",
"#document",
"HTML",
"BODY",
"H1",
"P",
"#text",
"block",
"visible",
"rgb(0, 0, 0)",
"rgba(0, 0, 0, 0)",
"400",
"normal",
"none",
"1",
"700",
"inline",
]
.iter()
.map(|s| s.to_string())
.collect();
let block = json!([8, 9, 10, 11, 12, 13, 14, 13, 15]);
let bold = json!([8, 9, 10, 11, 16, 13, 14, 13, 15]);
let inline = json!([17, 9, 10, 11, 12, 13, 14, 13, 15]);
let (mut parent, mut kind, mut name, mut value, mut backend) = (
vec![-1, 0, 1],
vec![9, 1, 1],
vec![2, 3, 4],
vec![-1, -1, -1],
vec![1, 2, 3],
);
let height = 60.0 + 36.0 * posts as f64;
let mut layout_node = vec![0, 1, 2];
let mut styles = vec![json!([]), block.clone(), block.clone()];
let mut bounds = vec![
json!([0, 0, 800, height]),
json!([0, 0, 800, height]),
json!([8, 8, 784, height - 16.0]),
];
let mut text = vec![-1i64, -1, -1];
let mut push = |tag: i64, id: i64, words: String, y: f64, style: &serde_json::Value| {
let element = parent.len() as i64;
parent.push(2);
kind.push(1);
name.push(tag);
value.push(-1);
backend.push(id);
let at = strings.len() as i64;
strings.push(words);
parent.push(element);
kind.push(3);
name.push(7);
value.push(at);
backend.push(id + 1);
layout_node.push(element);
styles.push(style.clone());
bounds.push(json!([8, y, 784, 18]));
text.push(-1);
layout_node.push(element + 1);
styles.push(inline.clone());
bounds.push(json!([8, y, 60, 18]));
text.push(at);
};
push(5, 10, "Feed".into(), 8.0, &bold);
for (k, post) in (first..=last).enumerate() {
let id = 100_000 + 2 * post;
push(
6,
id,
format!("Post {post}"),
60.0 + 36.0 * k as f64,
&block,
);
}
let n = parent.len();
let empty = || json!({"index": [], "value": []});
let dom = json!({
"documents": [{
"documentURL": 0, "title": 1, "baseURL": 0,
"contentWidth": 800, "contentHeight": height,
"scrollOffsetX": 0, "scrollOffsetY": 0,
"nodes": {
"parentIndex": parent, "nodeType": kind, "nodeName": name,
"nodeValue": value, "backendNodeId": backend,
"attributes": vec![json!([]); n],
"textValue": empty(), "inputValue": empty(),
"inputChecked": {"index": []}, "optionSelected": {"index": []},
"currentSourceURL": empty(),
},
"layout": {
"nodeIndex": layout_node, "styles": styles, "bounds": bounds, "text": text,
},
}],
"strings": strings,
});
PageSnapshot {
dom,
ax: Value::Null,
}
}
pub struct PageBuilder {
url: String,
title: String,
width: f64,
height: f64,
parent: Vec<i64>,
kind: Vec<i64>,
name: Vec<String>,
value: Vec<Option<String>>,
attrs: Vec<Vec<(String, String)>>,
layout: Vec<Option<(Vec<String>, Rect)>>,
current_source: Vec<(usize, String)>,
}
const INHERITED: &[&str] = &[
"visibility",
"color",
"font-weight",
"font-style",
"white-space",
];
fn initial(name: &str) -> &'static str {
match name {
"display" => "block",
"visibility" => "visible",
"color" => "rgb(0, 0, 0)",
"background-color" => "rgba(0, 0, 0, 0)",
"font-weight" => "400",
"font-style" | "white-space" => "normal",
"text-decoration-line" | "background-image" => "none",
"opacity" => "1",
"overflow-y" | "overflow-x" => "visible",
"flex-wrap" => "nowrap",
"clip" => "auto",
"clip-path" => "none",
"position" => "static",
_ => "",
}
}
impl PageBuilder {
pub fn new(url: &str, title: &str, width: f64, height: f64) -> PageBuilder {
let mut page = PageBuilder {
url: url.to_string(),
title: title.to_string(),
width,
height,
parent: Vec::new(),
kind: Vec::new(),
name: Vec::new(),
value: Vec::new(),
attrs: Vec::new(),
layout: Vec::new(),
current_source: Vec::new(),
};
let all = Rect::new(0.0, 0.0, width, height);
page.push(-1, 9, "#document", None, &[], Some(all));
page.element(0, "HTML", &[], &[], Some(all));
page.element(1, "BODY", &[], &[], Some(all));
page
}
pub fn body(&self) -> usize {
2
}
fn push(
&mut self,
parent: i64,
kind: i64,
name: &str,
value: Option<&str>,
styles: &[(&str, &str)],
rect: Option<Rect>,
) -> usize {
let at = self.parent.len();
let from = usize::try_from(parent)
.ok()
.and_then(|p| self.layout.get(p).cloned().flatten())
.map(|(s, _)| s);
let computed = crate::cdp::SNAPSHOT_STYLES
.iter()
.enumerate()
.map(|(k, &style)| {
if let Some((_, v)) = styles.iter().find(|(n, _)| *n == style) {
return v.to_string();
}
match &from {
Some(from) if kind == 3 || INHERITED.contains(&style) => from[k].clone(),
_ => initial(style).to_string(),
}
})
.collect();
self.parent.push(parent);
self.kind.push(kind);
self.name.push(name.to_string());
self.value.push(value.map(str::to_string));
self.attrs.push(Vec::new());
self.layout.push(rect.map(|r| (computed, r)));
at
}
pub fn element(
&mut self,
parent: usize,
tag: &str,
attrs: &[(&str, &str)],
styles: &[(&str, &str)],
rect: Option<Rect>,
) -> usize {
let rect = rect.filter(|_| self.layout[parent].is_some());
let at = self.push(parent as i64, 1, tag, None, styles, rect);
self.attrs[at] = attrs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
at
}
pub fn set_style(&mut self, node: usize, style: &str, value: &str) {
let at = crate::cdp::SNAPSHOT_STYLES.iter().position(|s| *s == style);
if let (Some(at), Some((styles, _))) = (at, self.layout[node].as_mut()) {
styles[at] = value.to_string();
}
}
pub fn text(&mut self, parent: usize, text: &str, rect: Rect) -> usize {
let rect = self.layout[parent].is_some().then_some(rect);
self.push(parent as i64, 3, "#text", Some(text), &[], rect)
}
pub fn image(&mut self, parent: usize, src: &str, alt: &str, rect: Rect) -> usize {
let at = self.element(
parent,
"IMG",
&[("src", src), ("alt", alt)],
&[("display", "inline")],
Some(rect),
);
self.current_source.push((at, src.to_string()));
at
}
pub fn snapshot(&self) -> PageSnapshot {
use serde_json::json;
let mut strings: Vec<String> = Vec::new();
let mut index: HashMap<String, i64> = HashMap::new();
let mut s = |text: &str| -> i64 {
if let Some(&i) = index.get(text) {
return i;
}
strings.push(text.to_string());
index.insert(text.to_string(), strings.len() as i64 - 1);
strings.len() as i64 - 1
};
let url = s(&self.url);
let title = s(&self.title);
let names: Vec<i64> = self.name.iter().map(|n| s(n)).collect();
let values: Vec<i64> = self
.value
.iter()
.map(|v| v.as_deref().map_or(-1, &mut s))
.collect();
let attrs: Vec<Vec<i64>> = self
.attrs
.iter()
.map(|list| list.iter().flat_map(|(k, v)| [s(k), s(v)]).collect())
.collect();
let (mut node_index, mut styles, mut bounds, mut text) =
(Vec::new(), Vec::new(), Vec::new(), Vec::new());
for (i, layout) in self.layout.iter().enumerate() {
let Some((computed, rect)) = layout else {
continue;
};
node_index.push(i as i64);
styles.push(computed.iter().map(|v| s(v)).collect::<Vec<_>>());
bounds.push(json!([rect.x, rect.y, rect.width, rect.height]));
text.push(if self.kind[i] == 3 { values[i] } else { -1 });
}
let source = (
self.current_source
.iter()
.map(|(i, _)| *i as i64)
.collect::<Vec<_>>(),
self.current_source
.iter()
.map(|(_, u)| s(u))
.collect::<Vec<_>>(),
);
let n = self.parent.len();
let empty = || json!({"index": [], "value": []});
let dom = json!({
"documents": [{
"documentURL": url, "title": title, "baseURL": url,
"contentWidth": self.width, "contentHeight": self.height,
"scrollOffsetX": 0, "scrollOffsetY": 0,
"nodes": {
"parentIndex": self.parent, "nodeType": self.kind,
"nodeName": names, "nodeValue": values,
"backendNodeId": (1..=n as i64).collect::<Vec<_>>(),
"attributes": attrs,
"textValue": empty(), "inputValue": empty(),
"inputChecked": {"index": []}, "optionSelected": {"index": []},
"currentSourceURL": {"index": source.0, "value": source.1},
},
"layout": {
"nodeIndex": node_index, "styles": styles, "bounds": bounds, "text": text,
},
}],
"strings": strings,
});
PageSnapshot {
dom,
ax: Value::Null,
}
}
}
pub type OnAct = Box<dyn FnMut(&PageAction, &mut FakeState) + Send>;
pub struct FakeEngine {
pub state: Arc<Mutex<FakeState>>,
on_act: Option<OnAct>,
}
pub fn snapshot_file(path: &std::path::Path) -> io::Result<PageSnapshot> {
let all: Value = serde_json::from_str(&std::fs::read_to_string(path)?)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
Ok(PageSnapshot {
dom: all["dom"].clone(),
ax: all["ax"].clone(),
})
}
impl FakeEngine {
pub fn from_dir(dir: &std::path::Path) -> io::Result<FakeEngine> {
let mut state = FakeState::default();
let fixtures = dir.join("..").join("fixtures");
if fixtures.is_dir() {
state.resource_root = Some(fixtures);
}
for entry in std::fs::read_dir(dir)? {
let path = entry?.path();
if path.extension().is_some_and(|e| e == "json") {
let name = path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
state.pages.insert(name, snapshot_file(&path)?);
}
}
Ok(FakeEngine {
state: Arc::new(Mutex::new(state)),
on_act: None,
})
}
#[must_use]
pub fn replaying(self, dir: &std::path::Path) -> FakeEngine {
self.lock().recorded = Some(dir.to_path_buf());
self
}
#[must_use]
pub fn on_act(
mut self,
hook: impl FnMut(&PageAction, &mut FakeState) + Send + 'static,
) -> FakeEngine {
self.on_act = Some(Box::new(hook));
self
}
fn lock(&self) -> std::sync::MutexGuard<'_, FakeState> {
self.state.lock().unwrap_or_else(|e| e.into_inner())
}
}
impl FakeState {
pub fn go(&mut self, url: &str) {
let name = url
.rsplit('/')
.next()
.unwrap_or(url)
.split(['.', '?', '#'])
.next()
.unwrap_or_default()
.to_string();
self.history
.truncate(self.at + usize::from(!self.history.is_empty()));
self.history.push(name.clone());
self.at = self.history.len() - 1;
self.current = Some(name);
self.events.push(EngineEvent::Navigated(url.to_string()));
if self.recorded.is_some() {
self.ready_videos();
}
}
fn ready_videos(&mut self) {
let Some(page) = self.current.as_ref().and_then(|n| self.pages.get(n)) else {
return;
};
let model = crate::model::build(page, &mut crate::model::Revisions::new());
for video in model
.media
.iter()
.filter(|m| m.kind == crate::model::MediaKind::Video)
{
self.media.entry(video.node).or_insert(Playback {
paused: !video.autoplay,
muted: true,
volume: 1.0,
duration: Some(5.0),
ready: 4,
..Playback::default()
});
if video.autoplay {
self.playing_since
.insert(video.node, std::time::Instant::now());
}
}
}
fn length(&self, node: i64) -> f64 {
self.media
.get(&node)
.and_then(|p| p.duration)
.unwrap_or(5.0)
.max(0.1)
}
fn clock(&self, node: i64) -> Option<f64> {
let since = self.playing_since.get(&node)?;
Some(since.elapsed().as_secs_f64() % self.length(node))
}
fn recorded_frame(&mut self) -> Option<ScreencastFrame> {
let dir = self.recorded.clone()?;
let page = self.current.as_ref().and_then(|n| self.pages.get(n))?;
let model = crate::model::build(page, &mut crate::model::Revisions::new());
let playing: Vec<_> = model
.media
.iter()
.filter(|m| m.kind == crate::model::MediaKind::Video)
.filter(|m| self.media.get(&m.node).is_some_and(|p| !p.paused))
.cloned()
.collect();
let top = playing.iter().map(|m| m.rect.y).fold(f64::MAX, f64::min);
let bottom = playing.iter().map(|m| m.rect.bottom()).fold(0.0, f64::max);
if playing.is_empty() || bottom <= top {
return None;
}
let width = self.viewport_width.max(80.0) as u32;
let height = (bottom - top).ceil().max(1.0) as u32;
let mut canvas = image::RgbImage::new(width, height);
for video in &playing {
let stem = video
.src
.rsplit('/')
.next()
.and_then(|name| name.split('.').next())
.unwrap_or_default()
.to_string();
let at = self.clock(video.node).unwrap_or(0.0) / self.length(video.node);
let frames = self.recorded_frames.entry(stem.clone()).or_insert_with(|| {
(0..100)
.map_while(|k| image::open(dir.join(format!("{stem}-{k:02}.jpg"))).ok())
.map(|i| i.to_rgb8())
.collect()
});
if frames.is_empty() {
continue;
}
let frame = &frames[((at * frames.len() as f64) as usize).min(frames.len() - 1)];
let (w, h) = (video.rect.width.max(1.0), video.rect.height.max(1.0));
let scaled = image::imageops::resize(
frame,
w as u32,
h as u32,
image::imageops::FilterType::Triangle,
);
image::imageops::overlay(
&mut canvas,
&scaled,
video.rect.x as i64,
(video.rect.y - top) as i64,
);
}
let scroll_y = top;
Some(ScreencastFrame {
pixels: rich_embed::Pixels::new(width, height, canvas.into_raw())?,
meta: rich_embed::FrameMeta {
scroll_x: 0.0,
scroll_y,
offset_top: 0.0,
page_scale: 1.0,
device_width: width as f64,
device_height: height as f64,
timestamp: None,
},
})
}
}
pub fn follow(action: &PageAction, state: &mut FakeState) {
let PageAction::Click(node) = action else {
return;
};
let Some(page) = state
.current
.as_ref()
.and_then(|name| state.pages.get(name))
else {
return;
};
let model = crate::model::build(page, &mut crate::model::Revisions::new());
if let Some(href) = model.control(*node).and_then(|c| c.href.clone()) {
let target = href.rsplit('/').next().unwrap_or_default().to_string();
state.go(&target);
}
}
impl Engine for FakeEngine {
fn resize(&mut self, columns: u16, rows: u16) -> io::Result<()> {
let mut state = self.lock();
state.viewport_height = rows as f64 * crate::cdp::CELL.1 as f64;
state.viewport_width = columns as f64 * crate::cdp::CELL.0 as f64;
Ok(())
}
fn open(&mut self, _tab: TabId, url: &str) -> io::Result<()> {
let mut state = self.lock();
state.opened.push(url.to_string());
state.go(url);
Ok(())
}
fn history(&mut self, _tab: TabId, step: HistoryStep) -> io::Result<()> {
let mut state = self.lock();
match step {
HistoryStep::Back if state.at > 0 => state.at -= 1,
HistoryStep::Forward if state.at + 1 < state.history.len() => state.at += 1,
_ => {}
}
let at = state.at;
state.current = state.history.get(at).cloned();
state.events.push(EngineEvent::Loaded);
Ok(())
}
fn state(&self, _tab: TabId) -> PageState {
let state = self.lock();
let url = state
.current
.as_ref()
.and_then(|name| state.pages.get(name))
.map(|s| {
let strings = &s.dom["strings"];
s.dom["documents"][0]["documentURL"]
.as_u64()
.and_then(|i| strings[i as usize].as_str())
.unwrap_or_default()
.to_string()
})
.unwrap_or_default();
PageState {
url,
can_go_back: state.at > 0,
can_go_forward: state.at + 1 < state.history.len(),
..PageState::default()
}
}
fn snapshot(&mut self, _tab: TabId) -> io::Result<PageSnapshot> {
let state = self.lock();
state
.current
.as_ref()
.and_then(|name| state.pages.get(name))
.cloned()
.ok_or_else(|| io::Error::other("no page"))
}
fn capture(&mut self, _tab: TabId, region: Rect, format: Format) -> io::Result<Frame> {
let (w, h) = (region.width.max(1.0) as u32, region.height.max(1.0) as u32);
let image = image::RgbImage::from_pixel(w, h, image::Rgb([30, 90, 220]));
let mut bytes = Vec::new();
image
.write_to(
&mut std::io::Cursor::new(&mut bytes),
image::ImageFormat::Png,
)
.map_err(io::Error::other)?;
Ok(Frame { format, bytes })
}
fn act(&mut self, _tab: TabId, action: &PageAction) -> io::Result<()> {
let mut hook = self.on_act.take();
{
let mut state = self.lock();
state.actions.push(action.clone());
let bottom = (state.content_height() - state.viewport_height).max(0.0);
match action {
PageAction::ScrollTo(y) => state.scroll_y = y.clamp(0.0, bottom),
PageAction::Wheel(d) => state.scroll_y = (state.scroll_y + d).clamp(0.0, bottom),
_ => {}
}
if let PageAction::Media { node, op } = action {
let recorded = state.recorded.is_some();
if recorded {
if let Some(time) = state.clock(*node)
&& let Some(p) = state.media.get_mut(node)
{
p.time = time;
}
state.playing_since.remove(node);
}
if let Some(p) = state.media.get_mut(node) {
match op {
MediaOp::Play => p.paused = false,
MediaOp::Pause => p.paused = true,
MediaOp::Mute(on) => p.muted = *on,
MediaOp::Volume(v) => p.volume = *v,
MediaOp::Seek(d) => {
let end = p.duration.unwrap_or(p.time);
p.time = (p.time + d).clamp(0.0, end);
}
}
}
if let Some(p) = state.media.get(node).filter(|p| recorded && !p.paused) {
let start = std::time::Instant::now()
.checked_sub(std::time::Duration::from_secs_f64(p.time))
.unwrap_or_else(std::time::Instant::now);
state.playing_since.insert(*node, start);
}
state.events.push(EngineEvent::Changed);
}
match hook.as_mut() {
Some(hook) => hook(action, &mut state),
None => follow(action, &mut state),
}
}
self.on_act = hook;
Ok(())
}
fn playback(&mut self, _tab: TabId, node: i64) -> io::Result<Playback> {
let state = self.lock();
let mut playback = state
.media
.get(&node)
.copied()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no media"))?;
if let Some(time) = state.clock(node).filter(|_| !playback.paused) {
playback.time = time;
}
Ok(playback)
}
fn image_ready(&mut self, _tab: TabId, node: i64) -> io::Result<bool> {
Ok(!self.lock().not_loaded.contains(&node))
}
fn resource(&mut self, _tab: TabId, url: &str) -> io::Result<Vec<u8>> {
self.lock()
.resource(url)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no such resource"))
}
fn metrics(&mut self, _tab: TabId) -> io::Result<Metrics> {
let state = self.lock();
Ok(Metrics {
scroll_y: state.scroll_y,
content_height: state.content_height(),
viewport_height: state.viewport_height,
})
}
fn set_screencast(&mut self, _tab: TabId, on: bool) -> io::Result<()> {
self.lock().screencast = on;
Ok(())
}
fn set_screencast_scale(&mut self, _tab: TabId, scale: f64) -> io::Result<()> {
self.lock().screencast_scale = scale;
Ok(())
}
fn screencast_frame(&mut self, _tab: TabId) -> Option<ScreencastFrame> {
let mut state = self.lock();
if state.screencast && state.frames.is_empty() && state.recorded.is_some() {
return state.recorded_frame();
}
if !state.screencast || state.frames.is_empty() {
return None;
}
let last = state.frames.pop();
state.frames.clear();
last
}
fn can_play(&mut self, _tab: TabId, types: &[&str]) -> io::Result<Vec<String>> {
let state = self.lock();
Ok(types
.iter()
.map(|t| state.codecs.get(*t).cloned().unwrap_or_default())
.collect())
}
fn events(&mut self) -> Vec<EngineEvent> {
std::mem::take(&mut self.lock().events)
}
}