Files
Zachary D. Rowitsch dbadb659bc Add CSS cascade origin ordering per CSS Cascading Level 4
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>
2026-02-17 16:35:44 -05:00

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")
}