use std::error::Error; use std::fs::File; use std::io::{Write}; use headless_chrome::{ protocol::browser::Bounds, protocol::page::ScreenshotFormat, Browser, LaunchOptionsBuilder, }; use md5; use super::url_utils; use colored::*; use std::time::Duration; use std::thread::sleep; fn calculate_render_sleep(px_in_capture: &u32) -> Duration { // Very large pages need more time to render. // This seems like a reasonable default scale // // a 2000 px by 20000 px page would be 40 million px, // and this function would calculate a delay of 4 seconds. // rounding occurs, so don't use from_seconds or too much precision is lost // 30 ms delay is an arbitrary amount to account for device/network slowness // TODO: Make these values configurable to users Duration::from_micros((30_000 + px_in_capture / 10).into()) } pub fn make_browser(config: &crate::config::Config) -> Result<Browser, Box<dyn Error>> { let browser_options = LaunchOptionsBuilder::default() .headless(config.headless) .window_size(Some((1600, 1000))) .idle_browser_timeout(Duration::new(40, 0)) .build()?; let browser = Browser::new(browser_options)?; let _tab = browser.wait_for_initial_tab()?; Ok(browser) } pub fn capture_snapshots( config: &crate::config::Config, slug: &String, ) -> Result<bool, Box<dyn Error>> { let trusted_domain = &config.trusted; let testing_domain = &config.testing; // We make a new browser per test, because a given browser stops responding // after about 20-23 tests. // Maybe there's a cleanup bug and tabs are not properly closed? // It would be more efficient to reuse the same browser for the whole suite. let browser = make_browser(config)?; let pic_name = url_utils::get_name_from_slug(&slug); let filepath_trusted = format!("{}/{}_trusted.png", config.screenshots, pic_name); let filepath_testing = format!("{}/{}_testing.png", config.screenshots, pic_name); println!("{}", "trusted url...".blue().dimmed()); let tab = browser.new_tab()?; tab.set_default_timeout(Duration::from_secs(40)); tab.navigate_to(&(trusted_domain.clone() + &slug))? .wait_until_navigated()?; tab.set_bounds(Bounds::Normal { left: Some(0), top: Some(0), width: Some(1600), height: None, })?; let body = tab.wait_for_element("body")?; body.call_js_fn("function() { this.style.overflowY = \"scroll\"; }", false)?; body.move_mouse_over()?; let content_size = tab.wait_for_element("html")? .get_box_model()?; let viewport = content_size.margin_viewport(); tab.set_bounds(Bounds::Normal { left: None, top: None, width: None, height: Some(content_size.height + 1) })?; // TODO: Clean up code that insures equal scroll bar presence/ page widths. let estimated_render_time = calculate_render_sleep(&(content_size.width * content_size.height)); println!("wait {:?} for render...", estimated_render_time); sleep(estimated_render_time); println!("viewport: w: {:?} h: {:?}", viewport.width, viewport.height); println!("capturing image..."); let pic_trusted = tab.capture_screenshot(ScreenshotFormat::PNG, Some(viewport ), true)?; let pic_trusted_len = pic_trusted.len(); println!("pic length: {:?}", pic_trusted_len); let mut out_trusted = File::create(filepath_trusted)?; out_trusted.write(&pic_trusted)?; println!("{}", "testing url...".blue().dimmed()); tab.navigate_to(&(testing_domain.clone() + &slug))? .wait_until_navigated()?; let body = tab.wait_for_element("body")?; body.call_js_fn("function() { this.style.overflowY = \"scroll\"; }", false)?; // move mouse to similar place on both of them, // so that the trusted tab is forced to have a mouse hover, // so that testing doesn't get focused elements that trusted url doesn't get body.move_mouse_over()?; println!("setting bounds"); tab.set_bounds(Bounds::Normal { left: None, top: Some(0), width: Some(1600), height: None, })?; println!("getting html box model"); let content_size = tab.wait_for_element("html")? .get_box_model()?; let viewport = content_size.margin_viewport(); println!("viewport: w: {:?} h: {:?}", viewport.width, viewport.height); // To really be sure and get true full snapshots this should probably set_bounds again here. // but the getWindowForTarget can return negative top when you grow the top a lot, and if you do // that the CurrentBounds type asplodes in parsing. So don't do that... // e.g. // method_call MethodCall { method_name: "Browser.getWindowForTarget", id: 12, params: GetWindowForTarget { target_id: "D27B318B1E2A43F837C7D05370C72ACD" } } // message text for browser: // "{\"method\":\"Browser.getWindowForTarget\",\"id\":12,\"params\":{\"targetId\":\"D27B318B1E2A43F837C7D05370C72ACD\"}}" // thing_to_parse: Object({"bounds": Object({"height": Number(5201), "left": Number(0), "top": Number(-4001), "width": Number(1600), "windowState": String("normal")}), "windowId": Number(1)}) println!("waiting {:?} for render...", estimated_render_time); sleep(estimated_render_time); println!("capturing image..."); let pic_testing = tab.capture_screenshot(ScreenshotFormat::PNG, Some(viewport), true)?; let pic_testing_len = pic_testing.len(); println!("pic length: {:?}", pic_testing_len); let mut out_testing = File::create(filepath_testing)?; out_testing.write(&pic_testing)?; let images_are_same = pic_trusted_len == pic_testing_len && md5::compute(pic_trusted) == md5::compute(pic_testing); Ok(images_are_same) } #[cfg(test)] mod tests { use super::*; #[test] fn sleeps_one_second_per_ten_million_px() { assert_eq!(calculate_render_sleep(&10_000_000), Duration::from_secs(1)); } }