use impeccable::capture_snapshot::{HtmlSnapshot, SnapshotSelection};
use std::{
fs,
path::PathBuf,
time::{SystemTime, UNIX_EPOCH},
};
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
let root = std::env::temp_dir().join(format!(
"capture-snapshot-{}-{}-{}",
std::process::id(),
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(root.join("assets")).unwrap();
fs::create_dir_all(root.join(".impeccable")).unwrap();
for (name, bytes) in [
("index.html", "
"),
("other.html", "other page"),
("assets/art.png", "asset bytes"),
(".impeccable/spec.json", "{}"),
(".impeccable/comp.png", "comp bytes"),
(".env", "private"),
] {
fs::write(root.join(name), bytes).unwrap();
}
Self(root)
}
fn selection(&self) -> SnapshotSelection {
SnapshotSelection {
root: self.0.clone(),
entry: "index.html".into(),
served: vec!["index.html".into(), "assets/art.png".into()],
bound: vec![
".impeccable/spec.json".into(),
".impeccable/comp.png".into(),
],
}
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn snapshot_freezes_bytes_and_detects_every_changed_input() {
let f = Fixture::new();
let original = HtmlSnapshot::freeze(f.selection()).unwrap();
let mut reordered = f.selection();
reordered.served.reverse();
reordered.bound.reverse();
assert_eq!(
original.digest(),
HtmlSnapshot::freeze(reordered).unwrap().digest()
);
for name in [
"index.html",
"assets/art.png",
".impeccable/spec.json",
".impeccable/comp.png",
] {
let before = fs::read(f.0.join(name)).unwrap();
fs::write(f.0.join(name), b"changed").unwrap();
assert!(original.verify_current().is_err(), "{name}");
assert_eq!(original.bytes(name).unwrap(), before);
fs::write(f.0.join(name), before).unwrap();
original.verify_current().unwrap();
}
}
#[test]
fn routes_are_explicit_and_never_expose_bound_private_files() {
let f = Fixture::new();
let s = HtmlSnapshot::freeze(f.selection()).unwrap();
assert_eq!(
s.serve_path("/index.html?cache=2"),
Some("index.html".into())
);
assert_eq!(
s.serve_path("/assets/art.png"),
Some("assets/art.png".into())
);
for route in [
"/",
"/other.html",
"/.env",
"/.impeccable/comp.png",
"/../index.html",
"/assets/../index.html",
"/%2e%2e/index.html",
"/assets%2fart.png",
"/assets\\art.png",
"http://example.com/index.html",
] {
assert!(s.serve_path(route).is_none(), "{route}");
}
let mut selection = f.selection();
selection.served.push(".env".into());
assert!(HtmlSnapshot::freeze(selection).is_err());
}
#[test]
fn snapshot_rejects_traversal_symlinks_and_non_html_entries() {
let f = Fixture::new();
for bad in [
"../index.html",
"/index.html",
"assets/../index.html",
"assets/art.png",
] {
let mut selection = f.selection();
selection.entry = bad.into();
assert!(HtmlSnapshot::freeze(selection).is_err(), "{bad}");
}
#[cfg(unix)]
{
std::os::unix::fs::symlink(f.0.join("assets"), f.0.join("linked")).unwrap();
let mut selection = f.selection();
selection.served.push("linked/art.png".into());
assert!(HtmlSnapshot::freeze(selection).is_err());
let s = HtmlSnapshot::freeze(f.selection()).unwrap();
fs::rename(f.0.join("assets"), f.0.join("original-assets")).unwrap();
std::os::unix::fs::symlink(f.0.join("original-assets"), f.0.join("assets")).unwrap();
assert!(s.verify_current().is_err());
}
}
#[test]
fn fresh_capture_ignores_saved_receipts_and_rejects_stale_or_wrong_documents() {
use impeccable_comp_verbs::asset_capture::{
AssetCapture, AssetCaptureRequest, AssetRenderer, capture_sha256,
};
use serde_json::json;
use std::sync::Arc;
let f = Fixture::new();
fs::write(f.0.join(".impeccable/spec.json"),serde_json::to_vec(&json!({"comp":".impeccable/comp.png","compSize":{"width":200,"height":200},"regions":[{"id":"art","plate":"assets/art.png","px":{"x":20,"y":20,"w":80,"h":80}}]})).unwrap()).unwrap();
struct Fake {
entry: Vec,
mutation: Option,
wrong: bool,
partial: bool,
calls: usize,
}
impl AssetRenderer for Fake {
fn capture(&mut self, r: &AssetCaptureRequest) -> Result {
self.calls += 1;
assert!(r.url.starts_with("http://127.0.0.1:"));
assert!(r.url.ends_with("/index.html"));
if let Some(path) = &self.mutation {
fs::write(path, b"changed").unwrap();
}
Ok(AssetCapture {
receipt: json!({"status":if self.partial {"unavailable"} else {"captured"},"stableCapture":true,"resolvedUrl":if self.wrong {"http://other/page"} else {&r.url},"documentResponseSha256":capture_sha256(&self.entry),"assetSha256":capture_sha256(&r.asset_bytes),"referenceSha256":capture_sha256(&r.reference_bytes),"viewport":{"width":200,"height":200,"dpr":1},"expectedBox":r.expected_box,"reducedMotion":true}),
images: vec![],
})
}
}
let s = Arc::new(HtmlSnapshot::freeze(f.selection()).unwrap());
let mut renderer = Fake {
entry: s.bytes("index.html").unwrap().to_vec(),
mutation: None,
wrong: false,
partial: false,
calls: 0,
};
fs::write(
f.0.join(".impeccable/receipt.json"),
br#"{"status":"captured","visible":true}"#,
)
.unwrap();
let capture = s
.capture_region(&mut renderer, ".impeccable/spec.json", "art", true)
.unwrap();
assert_eq!(capture.receipt["inputSnapshot"]["digest"], s.digest());
assert_eq!(renderer.calls, 1);
renderer.wrong = true;
assert!(
s.capture_region(&mut renderer, ".impeccable/spec.json", "art", true)
.is_err()
);
renderer.partial = true;
assert!(
s.capture_region(&mut renderer, ".impeccable/spec.json", "art", true)
.is_err(),
"partial stable evidence must also bind the document"
);
renderer.partial = false;
renderer.wrong = false;
renderer.entry = b"different entry".to_vec();
assert!(
s.capture_region(&mut renderer, ".impeccable/spec.json", "art", true)
.is_err()
);
renderer.entry = s.bytes("index.html").unwrap().to_vec();
renderer.mutation = Some(f.0.join("assets/art.png"));
assert!(
s.capture_region(&mut renderer, ".impeccable/spec.json", "art", true)
.is_err()
);
let calls = renderer.calls;
assert!(
s.capture_region(&mut renderer, ".impeccable/spec.json", "art", true)
.is_err()
);
assert_eq!(
renderer.calls, calls,
"stale input must fail before renderer launch"
);
}
#[test]
fn snapshot_server_serves_frozen_bytes_and_checks_host() {
use std::{
io::{Read, Write},
net::TcpStream,
sync::Arc,
};
let f = Fixture::new();
let snapshot = Arc::new(HtmlSnapshot::freeze(f.selection()).unwrap());
let server = snapshot.serve().unwrap();
let url = server.entry_url();
let host = url
.strip_prefix("http://")
.unwrap()
.split('/')
.next()
.unwrap();
let get = |path: &str, request_host: &str| {
let mut stream = TcpStream::connect(host).unwrap();
stream
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
.unwrap();
write!(
stream,
"GET {path} HTTP/1.1\r\nHost: {request_host}\r\nConnection: close\r\n\r\n"
)
.unwrap();
let mut response = Vec::new();
stream.read_to_end(&mut response).unwrap();
response
};
fs::write(f.0.join("assets/art.png"), b"new bytes").unwrap();
let actual = get("/assets/art.png", host);
assert!(actual.ends_with(b"asset bytes"));
assert!(
String::from_utf8_lossy(&actual)
.contains("Cache-Control: private, max-age=3600, immutable")
);
assert!(String::from_utf8_lossy(&actual).starts_with("HTTP/1.1 200 OK"));
for (path, h) in [
("/.env", host),
("/.impeccable/spec.json", host),
("/other.html", host),
("/index.html", "attacker.example"),
("/assets/../index.html", host),
] {
assert!(String::from_utf8_lossy(&get(path, h)).starts_with("HTTP/1.1 404"));
}
assert!(snapshot.verify_current().is_err());
}
#[test]
fn native_browser_capture_is_bound_to_frozen_entry_spec_and_asset() {
use impeccable::asset_capture::CdpAssetRenderer;
use impeccable_comp::{png_io, raster};
use std::sync::Arc;
let env = std::env::vars().collect();
if impeccable_browser::discovery::find_browser(&env).is_err() {
eprintln!("skip: browser unavailable");
return;
}
let f = Fixture::new();
fs::write(f.0.join("index.html"),"
").unwrap();
let image = png_io::encode_png(&raster::create_image(32, 32, [231, 60, 30, 255]), &[]).unwrap();
fs::write(f.0.join("assets/art.png"), &image).unwrap();
fs::write(f.0.join(".impeccable/comp.png"), &image).unwrap();
fs::write(f.0.join(".impeccable/spec.json"),br#"{"comp":".impeccable/comp.png","compSize":{"width":200,"height":200},"regions":[{"id":"art","plate":"assets/art.png","px":{"x":20,"y":20,"w":80,"h":80}}]}"#).unwrap();
let snapshot = Arc::new(HtmlSnapshot::freeze(f.selection()).unwrap());
let capture = snapshot
.capture_region(
&mut CdpAssetRenderer::from_process_env(),
".impeccable/spec.json",
"art",
true,
)
.unwrap();
assert_eq!(capture.receipt["status"], "captured", "{}", capture.receipt);
assert_eq!(
capture.receipt["combinedContribution"]["changedPixelsInRegion"],
6400
);
assert_eq!(
capture.receipt["inputSnapshot"]["digest"],
snapshot.digest()
);
}
#[test]
fn batch_uses_one_document_and_rejects_mixed_inputs() {
use impeccable::asset_capture::CdpAssetRenderer;
use impeccable_comp::{png_io, raster};
use impeccable_comp_verbs::asset_capture::{AssetCaptureRequest, AssetRenderer, CaptureBox};
use std::sync::Arc;
let env = std::env::vars().collect();
if impeccable_browser::discovery::find_browser(&env).is_err() {
eprintln!("skip: browser unavailable");
return;
}
let f = Fixture::new();
// A page-generated token must be identical for every measured region in the batch.
fs::write(f.0.join("index.html"),"
").unwrap();
let image = png_io::encode_png(&raster::create_image(32, 32, [231, 60, 30, 255]), &[]).unwrap();
fs::write(f.0.join("assets/art.png"), &image).unwrap();
fs::write(f.0.join(".impeccable/comp.png"), &image).unwrap();
fs::write(f.0.join(".impeccable/spec.json"),br#"{"comp":".impeccable/comp.png","compSize":{"width":200,"height":200},"regions":[{"id":"first","plate":"assets/art.png","px":{"x":20,"y":20,"w":80,"h":80}},{"id":"second","plate":"assets/art.png","px":{"x":110,"y":110,"w":80,"h":80}}]}"#).unwrap();
let snapshot = Arc::new(HtmlSnapshot::freeze(f.selection()).unwrap());
let server = snapshot.serve().unwrap();
let make = |x| AssetCaptureRequest {
url: server.entry_url(),
viewport: [200, 200],
reduced_motion: true,
reference_bytes: image.clone(),
asset_bytes: image.clone(),
expected_box: CaptureBox {
x,
y: x,
w: 80.,
h: 80.,
},
};
let mut renderer = CdpAssetRenderer::from_process_env();
let captures = snapshot
.capture_regions(
&mut renderer,
".impeccable/spec.json",
&["first", "second"],
true,
)
.unwrap();
assert_eq!(captures.len(), 2);
for capture in &captures {
assert_eq!(capture.receipt["status"], "captured", "{}", capture.receipt);
assert_eq!(
capture.receipt["combinedContribution"]["changedPixelsInRegion"],
6400
);
assert_eq!(
capture.receipt["domSha256"],
captures[0].receipt["domSha256"]
);
assert_eq!(
capture.receipt["screenshotSha256"],
captures[0].receipt["screenshotSha256"]
);
assert_eq!(
capture.receipt["captureDocument"],
captures[0].receipt["captureDocument"]
);
}
let mut wrong = make(110.);
wrong.url.push_str("?other-entry");
assert!(renderer.capture_batch(&[make(20.), wrong]).is_err());
}
#[test]
fn snapshot_server_delivers_large_binary_response_completely() {
use std::{
io::{Read, Write},
net::TcpStream,
sync::Arc,
};
let f = Fixture::new();
let payload: Vec = (0..3 * 1024 * 1024).map(|n| (n % 251) as u8).collect();
fs::write(f.0.join("assets/art.png"), &payload).unwrap();
let snapshot = Arc::new(HtmlSnapshot::freeze(f.selection()).unwrap());
let server = snapshot.serve().unwrap();
let url = server.entry_url();
let host = url
.strip_prefix("http://")
.unwrap()
.split('/')
.next()
.unwrap();
let mut stream = TcpStream::connect(host).unwrap();
stream
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
.unwrap();
write!(
stream,
"GET /assets/art.png HTTP/1.1\r\nHost: {host}\r\n\r\n"
)
.unwrap();
let mut response = Vec::new();
stream.read_to_end(&mut response).unwrap();
let body = response.windows(4).position(|b| b == b"\r\n\r\n").unwrap() + 4;
assert_eq!(
response.len() - body,
payload.len(),
"large response was truncated"
);
assert_eq!(&response[body..], payload);
}
#[test]
fn viewport_capture_does_not_switch_responsive_picture_sources() {
use impeccable::asset_capture::CdpAssetRenderer;
use impeccable_comp::{png_io, raster};
use std::sync::Arc;
if impeccable_browser::discovery::find_browser(&std::env::vars().collect()).is_err() {
return;
}
let f = Fixture::new();
fs::write(f.0.join("index.html"), "
").unwrap();
let image = png_io::encode_png(&raster::create_image(32, 32, [231, 60, 30, 255]), &[]).unwrap();
let mobile =
png_io::encode_png(&raster::create_image(32, 32, [30, 60, 231, 255]), &[]).unwrap();
fs::write(f.0.join("assets/art.png"), &image).unwrap();
fs::write(f.0.join("assets/mobile.png"), mobile).unwrap();
fs::write(f.0.join(".impeccable/comp.png"), &image).unwrap();
fs::write(f.0.join(".impeccable/spec.json"), br#"{"comp":".impeccable/comp.png","compSize":{"width":1440,"height":960},"regions":[{"id":"art","plate":"assets/art.png","px":{"x":20,"y":20,"w":80,"h":80}}]}"#).unwrap();
let mut selection = f.selection();
selection.served.push("assets/mobile.png".into());
let snapshot = Arc::new(HtmlSnapshot::freeze(selection).unwrap());
let captures = snapshot
.capture_regions(
&mut CdpAssetRenderer::from_process_env(),
".impeccable/spec.json",
&["art"],
true,
)
.unwrap();
for capture in &captures {
assert_eq!(capture.receipt["status"], "captured", "{}", capture.receipt);
assert_eq!(
capture.receipt["combinedContribution"]["changedPixelsInRegion"],
6400
);
assert_eq!(capture.receipt["batchStabilityVerified"], true);
let responses = capture.receipt["settlingResponses"].as_array().unwrap();
assert!(
!responses
.iter()
.any(|r| r["url"].as_str().unwrap_or("").ends_with("mobile.png"))
);
}
}