use crate::agent::CliError;
use clap::{Args, Subcommand};
use serde::Deserialize;
use serde_json::json;
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
#[derive(Args)]
pub(crate) struct IngestArgs {
#[command(subcommand)]
operation: IngestOperation,
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod containment_tests {
use super::*;
#[test]
fn manifest_paths_reject_parent_absolute_and_symlink_escape() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("run");
std::fs::create_dir(&root).unwrap();
let outside = temp.path().join("outside.png");
std::fs::write(&outside, b"image").unwrap();
assert!(contained_source(&root, Path::new("../outside.png")).is_err());
assert!(contained_source(&root, &outside).is_err());
#[cfg(unix)]
{
std::os::unix::fs::symlink(&outside, root.join("link.png")).unwrap();
assert!(contained_source(&root, Path::new("link.png")).is_err());
}
}
}
#[derive(Subcommand)]
enum IngestOperation {
Blender {
root: PathBuf,
#[arg(long)]
out: PathBuf,
#[arg(long)]
json: bool,
},
Bevy {
reference: PathBuf,
capture: PathBuf,
#[arg(long)]
out: PathBuf,
#[arg(long)]
json: bool,
},
Unity {
assets: PathBuf,
#[arg(long)]
out: PathBuf,
#[arg(long)]
json: bool,
},
Unreal {
results: PathBuf,
#[arg(long)]
out: PathBuf,
#[arg(long)]
json: bool,
},
Playwright {
manifest: PathBuf,
#[arg(long)]
out: PathBuf,
#[arg(long)]
json: bool,
#[arg(long)]
threshold: Option<f64>,
},
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Manifest {
schema: String,
entries: Vec<Snapshot>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Snapshot {
test_id: String,
project: String,
browser: String,
viewport: Option<[u32; 2]>,
expected: String,
actual: String,
diff: Option<String>,
}
pub(crate) fn contained_source(root: &Path, relative: &Path) -> Result<PathBuf, CliError> {
if relative.is_absolute() {
return Err(CliError::usage(format!(
"ingest path must stay under {}: {}",
root.display(),
relative.display()
)));
}
let root = std::fs::canonicalize(root).map_err(|e| CliError::io(e.to_string()))?;
let path =
std::fs::canonicalize(root.join(relative)).map_err(|e| CliError::io(e.to_string()))?;
if !path.starts_with(&root) {
return Err(CliError::usage(format!(
"ingest path escapes root {}: {}",
root.display(),
relative.display()
)));
}
Ok(path)
}
fn source(manifest: &Path, relative: &str) -> Result<PathBuf, CliError> {
if relative.is_empty() {
return Err(CliError::usage("Playwright attachment path is empty"));
}
let path = contained_source(
manifest.parent().unwrap_or(Path::new(".")),
Path::new(relative),
)?;
if !path.is_file() {
return Err(CliError::usage(
"Playwright attachment is not a regular file",
));
}
Ok(path)
}
fn copy_image(from: &Path, to: &Path) -> Result<(), CliError> {
image::image_dimensions(from)
.map_err(|e| CliError::usage(format!("invalid Playwright screenshot: {e}")))?;
std::fs::copy(from, to).map_err(|e| CliError::io(e.to_string()))?;
Ok(())
}
pub(crate) fn run(args: IngestArgs, absolute: bool) -> Result<u8, CliError> {
match args.operation {
IngestOperation::Blender { root, out, json } => crate::engine_ingest::run(
crate::engine_ingest::Format::Blender(root),
&out,
json,
absolute,
),
IngestOperation::Bevy {
reference,
capture,
out,
json,
} => crate::engine_ingest::run(
crate::engine_ingest::Format::Bevy(reference, capture),
&out,
json,
absolute,
),
IngestOperation::Unity { assets, out, json } => crate::engine_ingest::run(
crate::engine_ingest::Format::Unity(assets),
&out,
json,
absolute,
),
IngestOperation::Unreal { results, out, json } => crate::engine_ingest::run(
crate::engine_ingest::Format::Unreal(results),
&out,
json,
absolute,
),
IngestOperation::Playwright {
manifest,
out,
json,
threshold,
} => {
if std::fs::symlink_metadata(&out).is_ok_and(|m| m.file_type().is_symlink()) {
return Err(CliError::usage("ingest --out must not be a symlink"));
}
let raw = std::fs::read(&manifest).map_err(|e| CliError::io(e.to_string()))?;
let source_manifest: Manifest = serde_json::from_slice(&raw)?;
if source_manifest.schema != "saccade-playwright.v1"
|| source_manifest.entries.is_empty()
{
return Err(CliError::usage(
"expected nonempty saccade-playwright.v1 manifest",
));
}
if out.exists()
&& std::fs::read_dir(&out)
.map_err(|e| CliError::io(e.to_string()))?
.next()
.is_some()
{
return Err(CliError::usage("ingest --out must be empty or absent"));
}
let mut seen = BTreeSet::new();
let mut validated = Vec::new();
for entry in &source_manifest.entries {
if entry.test_id.is_empty()
|| !seen.insert((entry.test_id.clone(), entry.project.clone()))
{
return Err(CliError::usage(
"Playwright test IDs must be nonempty and unique per project",
));
}
validated.push((
source(&manifest, &entry.expected)?,
source(&manifest, &entry.actual)?,
entry
.diff
.as_deref()
.map(|d| source(&manifest, d))
.transpose()?,
));
}
let baseline = out.join("baseline");
let capture = out.join("capture");
let diffs = out.join("playwright-diffs");
for dir in [&baseline, &capture, &diffs] {
std::fs::create_dir_all(dir).map_err(|e| CliError::io(e.to_string()))?;
}
let mut mapping = Vec::new();
for (index, (entry, (expected, actual, diff))) in source_manifest
.entries
.iter()
.zip(validated.iter())
.enumerate()
{
let name = format!("{index:04}.png");
copy_image(expected, &baseline.join(&name))?;
copy_image(actual, &capture.join(&name))?;
if let Some(diff) = diff {
copy_image(diff, &diffs.join(&name))?;
}
let sidecar = json!({
"playwright_test_id":entry.test_id,
"playwright_project":entry.project,
"playwright_browser":entry.browser,
"playwright_viewport_width":entry.viewport.map(|v|v[0]),
"playwright_viewport_height":entry.viewport.map(|v|v[1])
});
let sidecar_name = format!("{index:04}.saccade-meta.json");
for dir in [&baseline, &capture] {
crate::local_cmd::write_value(&dir.join(&sidecar_name), &sidecar)?;
}
mapping.push(json!({"entry":name,"test_id":entry.test_id,"project":entry.project,"browser":entry.browser,"viewport":entry.viewport,"diff":diff.as_ref().map(|_|format!("playwright-diffs/{index:04}.png"))}));
}
crate::local_cmd::write_value(
&out.join("playwright-mapping.json"),
&json!({"schema":"saccade-playwright-mapping.v1","entries":mapping}),
)?;
let mut config = saccade_core::config::RunConfig {
record_absolute_paths: absolute,
..Default::default()
};
if let Some(threshold) = threshold {
config.default_threshold = threshold;
}
let report_dir = out.join("report");
let report = saccade_core::run::run(&baseline, &capture, &report_dir, &config)?;
crate::emit_run(&report, &report_dir, json, absolute)?;
Ok(u8::from(report.is_regression()))
}
}
}