Author-origin declarations now always beat UA-origin declarations
regardless of specificity, fixing issues like `.btn-primary { color: white }`
failing to override UA `a:link { color: #0000EE }`. The cascade now checks
origin+importance weight before specificity: UA normal (0) < Author normal (1)
< Author !important (2) < UA !important (3).
The UA stylesheet is applied internally by compute_styles rather than being
prepended by callers, making origin structurally determined. Promotes 3 WPT
tests from known_fail to pass.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
494 lines
16 KiB
Rust
494 lines
16 KiB
Rust
use crate::types::{WptCase, WptMode, WptOutcome, WptStatus};
|
|
use display_list::build_display_list;
|
|
use html::HtmlParser;
|
|
use layout::LayoutEngine;
|
|
use render::CpuRasterizer;
|
|
use std::collections::HashMap;
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
use style::StyleContext;
|
|
|
|
/// Default per-channel tolerance for pixel comparison.
|
|
/// Allows small rounding differences from anti-aliasing.
|
|
const PIXEL_CHANNEL_TOLERANCE: u8 = 2;
|
|
|
|
/// Default maximum fraction of pixels that may differ.
|
|
/// 0.0 = exact match, 0.01 = 1% of pixels.
|
|
const PIXEL_MAX_DIFF_FRACTION: f64 = 0.0;
|
|
|
|
/// Rasterization viewport dimensions for pixel comparison.
|
|
const RASTER_WIDTH: u32 = 800;
|
|
const RASTER_HEIGHT: u32 = 600;
|
|
|
|
pub fn run_case(case: &WptCase, artifacts_root: &Path) -> WptOutcome {
|
|
if let WptStatus::Skip { reason } = &case.status {
|
|
return WptOutcome::Skipped {
|
|
reason: reason.clone(),
|
|
};
|
|
}
|
|
|
|
let html = match fs::read_to_string(&case.input) {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
return WptOutcome::Fail {
|
|
reason: format!("failed to read input {}: {e}", case.input.display()),
|
|
};
|
|
}
|
|
};
|
|
|
|
match case.mode {
|
|
WptMode::Single => run_single_case(case, &html, artifacts_root),
|
|
WptMode::Reftest => run_reftest_case(case, &html, artifacts_root),
|
|
}
|
|
}
|
|
|
|
fn run_single_case(case: &WptCase, html: &str, artifacts_root: &Path) -> WptOutcome {
|
|
let (actual_layout, actual_dl) = run_pipeline(html);
|
|
let actual_layout = normalize_dump(&actual_layout);
|
|
let actual_dl = normalize_dump(&actual_dl);
|
|
|
|
let expected_layout_path = case
|
|
.expected_layout
|
|
.as_ref()
|
|
.expect("single mode must define expected_layout");
|
|
let expected_dl_path = case
|
|
.expected_dl
|
|
.as_ref()
|
|
.expect("single mode must define expected_dl");
|
|
|
|
let expected_layout = match fs::read_to_string(expected_layout_path) {
|
|
Ok(v) => normalize_dump(&v),
|
|
Err(e) => {
|
|
return WptOutcome::Fail {
|
|
reason: format!(
|
|
"failed to read expected layout {}: {e}",
|
|
expected_layout_path.display()
|
|
),
|
|
};
|
|
}
|
|
};
|
|
let expected_dl = match fs::read_to_string(expected_dl_path) {
|
|
Ok(v) => normalize_dump(&v),
|
|
Err(e) => {
|
|
return WptOutcome::Fail {
|
|
reason: format!(
|
|
"failed to read expected display list {}: {e}",
|
|
expected_dl_path.display()
|
|
),
|
|
};
|
|
}
|
|
};
|
|
|
|
if actual_layout == expected_layout && actual_dl == expected_dl {
|
|
WptOutcome::Pass
|
|
} else {
|
|
let artifact_paths = write_text_artifacts(
|
|
artifacts_root,
|
|
&case.id,
|
|
&actual_layout,
|
|
&actual_dl,
|
|
Some(&expected_layout),
|
|
Some(&expected_dl),
|
|
);
|
|
WptOutcome::Fail {
|
|
reason: format!(
|
|
"single-case mismatch for '{}'; artifacts: {}",
|
|
case.id,
|
|
artifact_paths.join(", ")
|
|
),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn run_reftest_case(case: &WptCase, html: &str, artifacts_root: &Path) -> WptOutcome {
|
|
let reference_path = case
|
|
.reference
|
|
.as_ref()
|
|
.expect("reftest mode must define reference");
|
|
let reference_html = match fs::read_to_string(reference_path) {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
return WptOutcome::Fail {
|
|
reason: format!("failed to read reference {}: {e}", reference_path.display()),
|
|
};
|
|
}
|
|
};
|
|
|
|
// Run both pipelines to get text dumps for fast comparison
|
|
let (actual_layout, actual_dl) = run_pipeline(html);
|
|
let (reference_layout, reference_dl) = run_pipeline(&reference_html);
|
|
|
|
let actual_layout = normalize_dump(&actual_layout);
|
|
let actual_dl = normalize_dump(&actual_dl);
|
|
let reference_layout_norm = normalize_node_ids(&normalize_dump(&reference_layout));
|
|
let reference_dl_norm = normalize_node_ids(&normalize_dump(&reference_dl));
|
|
let actual_layout_norm = normalize_node_ids(&actual_layout);
|
|
let actual_dl_norm = normalize_node_ids(&actual_dl);
|
|
|
|
// Fast path: text comparison
|
|
if actual_layout_norm == reference_layout_norm && actual_dl_norm == reference_dl_norm {
|
|
return WptOutcome::Pass;
|
|
}
|
|
|
|
// Slow path: pixel comparison (rasterize both sides)
|
|
let actual_pixels = rasterize_html(html);
|
|
let reference_pixels = rasterize_html(&reference_html);
|
|
|
|
match compare_pixels(
|
|
&actual_pixels,
|
|
&reference_pixels,
|
|
PIXEL_CHANNEL_TOLERANCE,
|
|
PIXEL_MAX_DIFF_FRACTION,
|
|
) {
|
|
PixelCompareResult::Match => WptOutcome::Pass,
|
|
PixelCompareResult::Mismatch {
|
|
diff_count,
|
|
total_pixels,
|
|
max_channel_diff,
|
|
} => {
|
|
// Skip all artifact writing for known_fail tests to avoid
|
|
// thousands of file writes that slow down the suite
|
|
let is_known_fail = matches!(case.status, WptStatus::KnownFail { .. });
|
|
|
|
let _artifact_paths = if is_known_fail {
|
|
Vec::new()
|
|
} else {
|
|
let mut paths = write_text_artifacts(
|
|
artifacts_root,
|
|
&case.id,
|
|
&actual_layout,
|
|
&actual_dl,
|
|
Some(&normalize_dump(&reference_layout)),
|
|
Some(&normalize_dump(&reference_dl)),
|
|
);
|
|
paths.extend(write_png_artifacts(
|
|
artifacts_root,
|
|
&case.id,
|
|
&actual_pixels,
|
|
&reference_pixels,
|
|
));
|
|
paths
|
|
};
|
|
|
|
let pct = if total_pixels > 0 {
|
|
(diff_count as f64 / total_pixels as f64) * 100.0
|
|
} else {
|
|
0.0
|
|
};
|
|
WptOutcome::Fail {
|
|
reason: format!(
|
|
"reftest pixel mismatch for '{}': {diff_count}/{total_pixels} pixels differ ({pct:.2}%), max_channel_diff={max_channel_diff}",
|
|
case.id,
|
|
),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn enforce_case_policy(case: &WptCase, outcome: WptOutcome) -> Result<(), String> {
|
|
match (&case.status, outcome) {
|
|
(WptStatus::Pass, WptOutcome::Pass) => Ok(()),
|
|
(WptStatus::Pass, WptOutcome::Fail { reason }) => Err(reason),
|
|
(WptStatus::Pass, WptOutcome::Skipped { reason }) => {
|
|
Err(format!("case '{}' unexpectedly skipped: {reason}", case.id))
|
|
}
|
|
(WptStatus::KnownFail { .. }, WptOutcome::Fail { .. }) => Ok(()),
|
|
(WptStatus::KnownFail { .. }, WptOutcome::Pass) => Err(format!(
|
|
"case '{}' is marked known_fail but now passes; promote it to pass in manifest",
|
|
case.id
|
|
)),
|
|
(WptStatus::KnownFail { .. }, WptOutcome::Skipped { reason }) => Err(format!(
|
|
"case '{}' marked known_fail unexpectedly skipped: {reason}",
|
|
case.id
|
|
)),
|
|
(WptStatus::Skip { .. }, WptOutcome::Skipped { .. }) => Ok(()),
|
|
(WptStatus::Skip { .. }, WptOutcome::Pass) => Err(format!(
|
|
"case '{}' marked skip but unexpectedly passed",
|
|
case.id
|
|
)),
|
|
(WptStatus::Skip { .. }, WptOutcome::Fail { reason }) => Err(format!(
|
|
"case '{}' marked skip but unexpectedly executed and failed: {reason}",
|
|
case.id
|
|
)),
|
|
}
|
|
}
|
|
|
|
pub fn regenerate_expected_outputs(cases: &[WptCase]) -> Result<usize, String> {
|
|
let mut rewritten = 0usize;
|
|
|
|
for case in cases {
|
|
if case.status != WptStatus::Pass || case.mode != WptMode::Single {
|
|
continue;
|
|
}
|
|
|
|
let html = fs::read_to_string(&case.input)
|
|
.map_err(|e| format!("failed to read {}: {e}", case.input.display()))?;
|
|
let (layout, dl) = run_pipeline(&html);
|
|
|
|
let expected_layout = case
|
|
.expected_layout
|
|
.as_ref()
|
|
.expect("single pass case must define expected_layout");
|
|
let expected_dl = case
|
|
.expected_dl
|
|
.as_ref()
|
|
.expect("single pass case must define expected_dl");
|
|
|
|
if let Some(parent) = expected_layout.parent() {
|
|
fs::create_dir_all(parent)
|
|
.map_err(|e| format!("failed to create {}: {e}", parent.display()))?;
|
|
}
|
|
|
|
fs::write(expected_layout, normalize_dump(&layout))
|
|
.map_err(|e| format!("failed to write {}: {e}", expected_layout.display()))?;
|
|
fs::write(expected_dl, normalize_dump(&dl))
|
|
.map_err(|e| format!("failed to write {}: {e}", expected_dl.display()))?;
|
|
|
|
rewritten += 1;
|
|
}
|
|
|
|
Ok(rewritten)
|
|
}
|
|
|
|
pub fn normalize_dump(input: &str) -> String {
|
|
let mut out = String::new();
|
|
for line in input.replace("\r\n", "\n").lines() {
|
|
out.push_str(line.trim_end());
|
|
out.push('\n');
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Replace each `node=#N` with `node=#0`, `node=#1`, etc. based on order of
|
|
/// first appearance. This makes reftest comparisons insensitive to differing
|
|
/// DOM structures (e.g. different `<head>` content) that shift node IDs.
|
|
fn normalize_node_ids(dump: &str) -> String {
|
|
let mut map: HashMap<&str, usize> = HashMap::new();
|
|
let mut next_id = 0usize;
|
|
let mut result = String::with_capacity(dump.len());
|
|
let mut rest = dump;
|
|
|
|
while let Some(idx) = rest.find("node=#") {
|
|
result.push_str(&rest[..idx]);
|
|
let after_prefix = &rest[idx + 6..]; // skip "node=#"
|
|
let end = after_prefix
|
|
.find(|c: char| !c.is_ascii_digit())
|
|
.unwrap_or(after_prefix.len());
|
|
let original = &after_prefix[..end];
|
|
let sequential = *map.entry(original).or_insert_with(|| {
|
|
let id = next_id;
|
|
next_id += 1;
|
|
id
|
|
});
|
|
result.push_str(&format!("node=#{sequential}"));
|
|
rest = &after_prefix[end..];
|
|
}
|
|
result.push_str(rest);
|
|
result
|
|
}
|
|
|
|
fn run_pipeline(html: &str) -> (String, String) {
|
|
let html_parser = HtmlParser::new();
|
|
let style_context = StyleContext::new();
|
|
let layout_engine = LayoutEngine::new_proportional();
|
|
|
|
let document = html_parser.parse(html);
|
|
let stylesheets = style_context.extract_stylesheets(&document);
|
|
let inline_styles = style_context.extract_inline_styles(&document);
|
|
let computed_styles = style_context.compute_styles(
|
|
&document,
|
|
&stylesheets,
|
|
&inline_styles,
|
|
Some(&css::Viewport::new(800.0, 600.0)),
|
|
);
|
|
|
|
let layout_tree = layout_engine.layout(&document, &computed_styles, 800.0, 600.0);
|
|
let display_list = build_display_list(&layout_tree);
|
|
|
|
(layout_tree.dump(), display_list.dump())
|
|
}
|
|
|
|
/// Rasterize HTML to a pixel buffer for visual comparison.
|
|
fn rasterize_html(html: &str) -> render::PixelBuffer {
|
|
let html_parser = HtmlParser::new();
|
|
let style_context = StyleContext::new();
|
|
let layout_engine = LayoutEngine::new_proportional();
|
|
let rasterizer = CpuRasterizer::new();
|
|
|
|
let document = html_parser.parse(html);
|
|
let stylesheets = style_context.extract_stylesheets(&document);
|
|
let inline_styles = style_context.extract_inline_styles(&document);
|
|
let computed_styles = style_context.compute_styles(
|
|
&document,
|
|
&stylesheets,
|
|
&inline_styles,
|
|
Some(&css::Viewport::new(800.0, 600.0)),
|
|
);
|
|
|
|
let layout_tree = layout_engine.layout(&document, &computed_styles, 800.0, 600.0);
|
|
let display_list = build_display_list(&layout_tree);
|
|
|
|
rasterizer.rasterize(&display_list, RASTER_WIDTH, RASTER_HEIGHT)
|
|
}
|
|
|
|
// =============================================================================
|
|
// Pixel Comparison
|
|
// =============================================================================
|
|
|
|
enum PixelCompareResult {
|
|
Match,
|
|
Mismatch {
|
|
diff_count: usize,
|
|
total_pixels: usize,
|
|
max_channel_diff: u8,
|
|
},
|
|
}
|
|
|
|
/// Compare two pixel buffers with tolerance.
|
|
///
|
|
/// `channel_tolerance`: max allowed per-channel difference (0 = exact)
|
|
/// `max_diff_fraction`: max fraction of pixels that may differ (0.0 = none, 1.0 = all)
|
|
fn compare_pixels(
|
|
actual: &render::PixelBuffer,
|
|
expected: &render::PixelBuffer,
|
|
channel_tolerance: u8,
|
|
max_diff_fraction: f64,
|
|
) -> PixelCompareResult {
|
|
let a_data = actual.data();
|
|
let e_data = expected.data();
|
|
|
|
// If dimensions differ, compare the overlapping region
|
|
let width = actual.width().min(expected.width());
|
|
let height = actual.height().min(expected.height());
|
|
let total_pixels = (width as usize) * (height as usize);
|
|
|
|
if total_pixels == 0 {
|
|
if actual.width() == 0 && expected.width() == 0 {
|
|
return PixelCompareResult::Match;
|
|
}
|
|
return PixelCompareResult::Mismatch {
|
|
diff_count: 1,
|
|
total_pixels: 1,
|
|
max_channel_diff: 255,
|
|
};
|
|
}
|
|
|
|
let mut diff_count = 0usize;
|
|
let mut max_channel_diff: u8 = 0;
|
|
|
|
for y in 0..height {
|
|
for x in 0..width {
|
|
let a_idx = ((y * actual.width() + x) * 4) as usize;
|
|
let e_idx = ((y * expected.width() + x) * 4) as usize;
|
|
|
|
let mut pixel_differs = false;
|
|
for c in 0..4 {
|
|
let diff =
|
|
(a_data[a_idx + c] as i16 - e_data[e_idx + c] as i16).unsigned_abs() as u8;
|
|
if diff > channel_tolerance {
|
|
pixel_differs = true;
|
|
}
|
|
if diff > max_channel_diff {
|
|
max_channel_diff = diff;
|
|
}
|
|
}
|
|
if pixel_differs {
|
|
diff_count += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Also check pixels outside the overlapping region (if dimensions differ)
|
|
if actual.width() != expected.width() || actual.height() != expected.height() {
|
|
let max_w = actual.width().max(expected.width()) as usize;
|
|
let max_h = actual.height().max(expected.height()) as usize;
|
|
let extra = max_w * max_h - total_pixels;
|
|
diff_count += extra;
|
|
max_channel_diff = 255;
|
|
}
|
|
|
|
let total = (actual.width().max(expected.width()) as usize)
|
|
* (actual.height().max(expected.height()) as usize);
|
|
let max_allowed = (total as f64 * max_diff_fraction) as usize;
|
|
|
|
if diff_count <= max_allowed {
|
|
PixelCompareResult::Match
|
|
} else {
|
|
PixelCompareResult::Mismatch {
|
|
diff_count,
|
|
total_pixels: total,
|
|
max_channel_diff,
|
|
}
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Artifact Writing
|
|
// =============================================================================
|
|
|
|
fn write_text_artifacts(
|
|
artifacts_root: &Path,
|
|
case_id: &str,
|
|
actual_layout: &str,
|
|
actual_dl: &str,
|
|
expected_layout: Option<&str>,
|
|
expected_dl: Option<&str>,
|
|
) -> Vec<String> {
|
|
let mut paths = Vec::new();
|
|
|
|
if fs::create_dir_all(artifacts_root).is_err() {
|
|
return paths;
|
|
}
|
|
|
|
let actual_layout_path = artifacts_root.join(format!("{case_id}.actual.layout.txt"));
|
|
let actual_dl_path = artifacts_root.join(format!("{case_id}.actual.dl.txt"));
|
|
if fs::write(&actual_layout_path, actual_layout).is_ok() {
|
|
paths.push(actual_layout_path.display().to_string());
|
|
}
|
|
if fs::write(&actual_dl_path, actual_dl).is_ok() {
|
|
paths.push(actual_dl_path.display().to_string());
|
|
}
|
|
|
|
if let Some(layout) = expected_layout {
|
|
let expected_layout_path = artifacts_root.join(format!("{case_id}.expected.layout.txt"));
|
|
if fs::write(&expected_layout_path, layout).is_ok() {
|
|
paths.push(expected_layout_path.display().to_string());
|
|
}
|
|
}
|
|
if let Some(dl) = expected_dl {
|
|
let expected_dl_path = artifacts_root.join(format!("{case_id}.expected.dl.txt"));
|
|
if fs::write(&expected_dl_path, dl).is_ok() {
|
|
paths.push(expected_dl_path.display().to_string());
|
|
}
|
|
}
|
|
|
|
paths
|
|
}
|
|
|
|
fn write_png_artifacts(
|
|
artifacts_root: &Path,
|
|
case_id: &str,
|
|
actual: &render::PixelBuffer,
|
|
reference: &render::PixelBuffer,
|
|
) -> Vec<String> {
|
|
let mut paths = Vec::new();
|
|
|
|
let actual_path = artifacts_root.join(format!("{case_id}.actual.png"));
|
|
if actual.write_png(&actual_path).is_ok() {
|
|
paths.push(actual_path.display().to_string());
|
|
}
|
|
|
|
let ref_path = artifacts_root.join(format!("{case_id}.reference.png"));
|
|
if reference.write_png(&ref_path).is_ok() {
|
|
paths.push(ref_path.display().to_string());
|
|
}
|
|
|
|
paths
|
|
}
|
|
|
|
pub fn default_artifacts_dir() -> PathBuf {
|
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
.join("target")
|
|
.join("wpt_artifacts")
|
|
}
|