Add scripts/import_wpt_reftests.py to sparse-clone the upstream WPT repo and bulk-import qualifying CSS reftests (no JS, no external resources) as known_fail entries. 23 tests already pass and are promoted. The import script is idempotent and exposed via `just import-wpt`. CI now prints the WPT summary (pass=36 known_fail=2877 skip=1) on every run. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
258 lines
8.0 KiB
Rust
258 lines
8.0 KiB
Rust
use crate::types::{WptCase, WptMode, WptStatus};
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
pub fn load_manifest(root: &Path) -> Result<Vec<WptCase>, String> {
|
|
let manifest_path = root.join("wpt_manifest.toml");
|
|
let content = fs::read_to_string(&manifest_path)
|
|
.map_err(|e| format!("failed to read {}: {e}", manifest_path.display()))?;
|
|
|
|
parse_manifest(&content)
|
|
.and_then(validate_cases)
|
|
.map(|cases| {
|
|
cases
|
|
.into_iter()
|
|
.map(|case| WptCase {
|
|
input: root.join(case.input),
|
|
reference: case.reference.map(|p| root.join(p)),
|
|
expected_layout: case.expected_layout.map(|p| root.join(p)),
|
|
expected_dl: case.expected_dl.map(|p| root.join(p)),
|
|
..case
|
|
})
|
|
.collect()
|
|
})
|
|
}
|
|
|
|
pub fn parse_manifest(input: &str) -> Result<Vec<WptCase>, String> {
|
|
let mut cases = Vec::new();
|
|
let mut current: Option<CaseBuilder> = None;
|
|
|
|
for (idx, raw_line) in input.lines().enumerate() {
|
|
let line_no = idx + 1;
|
|
let line = raw_line.trim();
|
|
|
|
if line.is_empty() || line.starts_with('#') {
|
|
continue;
|
|
}
|
|
|
|
if line == "[[case]]" {
|
|
if let Some(case) = current.take() {
|
|
cases.push(case.build()?);
|
|
}
|
|
current = Some(CaseBuilder::default());
|
|
continue;
|
|
}
|
|
|
|
let builder = current
|
|
.as_mut()
|
|
.ok_or_else(|| format!("line {line_no}: key/value outside [[case]] block"))?;
|
|
|
|
let (key, value) = parse_key_value(line)
|
|
.ok_or_else(|| format!("line {line_no}: invalid key/value syntax"))?;
|
|
|
|
match key {
|
|
"id" => builder.id = Some(parse_string(value, line_no)?),
|
|
"input" => builder.input = Some(PathBuf::from(parse_string(value, line_no)?)),
|
|
"mode" => {
|
|
let mode = parse_string(value, line_no)?;
|
|
builder.mode = Some(match mode.as_str() {
|
|
"single" => WptMode::Single,
|
|
"reftest" => WptMode::Reftest,
|
|
_ => return Err(format!("line {line_no}: unknown mode '{mode}'")),
|
|
});
|
|
}
|
|
"reference" => {
|
|
builder.reference = Some(PathBuf::from(parse_string(value, line_no)?));
|
|
}
|
|
"expected_layout" => {
|
|
builder.expected_layout = Some(PathBuf::from(parse_string(value, line_no)?));
|
|
}
|
|
"expected_dl" => {
|
|
builder.expected_dl = Some(PathBuf::from(parse_string(value, line_no)?));
|
|
}
|
|
"status" => {
|
|
let status = parse_string(value, line_no)?;
|
|
builder.status = Some(status);
|
|
}
|
|
"reason" => builder.reason = Some(parse_string(value, line_no)?),
|
|
"flags" => builder.flags = parse_string_array(value, line_no)?,
|
|
_ => return Err(format!("line {line_no}: unknown key '{key}'")),
|
|
}
|
|
}
|
|
|
|
if let Some(case) = current.take() {
|
|
cases.push(case.build()?);
|
|
}
|
|
|
|
if cases.is_empty() {
|
|
return Err("manifest is empty".to_string());
|
|
}
|
|
|
|
Ok(cases)
|
|
}
|
|
|
|
fn validate_cases(cases: Vec<WptCase>) -> Result<Vec<WptCase>, String> {
|
|
let mut ids = std::collections::BTreeSet::new();
|
|
|
|
for case in &cases {
|
|
if !ids.insert(case.id.clone()) {
|
|
return Err(format!(
|
|
"duplicate case id '{}'; ids must be unique",
|
|
case.id
|
|
));
|
|
}
|
|
|
|
if let WptStatus::KnownFail { reason } | WptStatus::Skip { reason } = &case.status {
|
|
if reason.trim().is_empty() {
|
|
return Err(format!("case '{}' has empty reason", case.id));
|
|
}
|
|
}
|
|
|
|
match case.mode {
|
|
WptMode::Single => {
|
|
if case.expected_layout.is_none() || case.expected_dl.is_none() {
|
|
return Err(format!(
|
|
"case '{}' in single mode must define expected_layout and expected_dl",
|
|
case.id
|
|
));
|
|
}
|
|
if case.reference.is_some() {
|
|
return Err(format!(
|
|
"case '{}' in single mode must not define reference",
|
|
case.id
|
|
));
|
|
}
|
|
}
|
|
WptMode::Reftest => {
|
|
if case.reference.is_none() {
|
|
return Err(format!(
|
|
"case '{}' in reftest mode must define reference",
|
|
case.id
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(cases)
|
|
}
|
|
|
|
fn parse_key_value(line: &str) -> Option<(&str, &str)> {
|
|
let mut parts = line.splitn(2, '=');
|
|
let key = parts.next()?.trim();
|
|
let value = parts.next()?.trim();
|
|
if key.is_empty() || value.is_empty() {
|
|
return None;
|
|
}
|
|
Some((key, value))
|
|
}
|
|
|
|
fn parse_string(value: &str, line_no: usize) -> Result<String, String> {
|
|
if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
|
|
return Ok(value[1..value.len() - 1].to_string());
|
|
}
|
|
Err(format!("line {line_no}: expected quoted string"))
|
|
}
|
|
|
|
fn parse_string_array(value: &str, line_no: usize) -> Result<Vec<String>, String> {
|
|
if !value.starts_with('[') || !value.ends_with(']') {
|
|
return Err(format!("line {line_no}: expected array syntax [..]"));
|
|
}
|
|
|
|
let inner = value[1..value.len() - 1].trim();
|
|
if inner.is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
inner
|
|
.split(',')
|
|
.map(|entry| parse_string(entry.trim(), line_no))
|
|
.collect()
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct CaseBuilder {
|
|
id: Option<String>,
|
|
input: Option<PathBuf>,
|
|
mode: Option<WptMode>,
|
|
reference: Option<PathBuf>,
|
|
expected_layout: Option<PathBuf>,
|
|
expected_dl: Option<PathBuf>,
|
|
status: Option<String>,
|
|
reason: Option<String>,
|
|
flags: Vec<String>,
|
|
}
|
|
|
|
impl CaseBuilder {
|
|
fn build(self) -> Result<WptCase, String> {
|
|
let id = self
|
|
.id
|
|
.ok_or_else(|| "missing required key: id".to_string())?;
|
|
let input = self
|
|
.input
|
|
.ok_or_else(|| format!("case '{id}' missing required key: input"))?;
|
|
let mode = self
|
|
.mode
|
|
.ok_or_else(|| format!("case '{id}' missing required key: mode"))?;
|
|
|
|
let status = match self.status.as_deref().unwrap_or("pass") {
|
|
"pass" => WptStatus::Pass,
|
|
"known_fail" => WptStatus::KnownFail {
|
|
reason: self.reason.unwrap_or_default(),
|
|
},
|
|
"skip" => WptStatus::Skip {
|
|
reason: self.reason.unwrap_or_default(),
|
|
},
|
|
other => {
|
|
return Err(format!(
|
|
"case '{id}' has invalid status '{other}' (expected pass|known_fail|skip)"
|
|
));
|
|
}
|
|
};
|
|
|
|
Ok(WptCase {
|
|
id,
|
|
input,
|
|
mode,
|
|
reference: self.reference,
|
|
expected_layout: self.expected_layout,
|
|
expected_dl: self.expected_dl,
|
|
status,
|
|
flags: self.flags,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::parse_manifest;
|
|
use crate::types::WptStatus;
|
|
|
|
#[test]
|
|
fn parses_minimal_case() {
|
|
let input = r#"
|
|
[[case]]
|
|
id = "basic"
|
|
input = "fixtures/basic.html"
|
|
mode = "single"
|
|
expected_layout = "expected/basic.layout.txt"
|
|
expected_dl = "expected/basic.dl.txt"
|
|
"#;
|
|
|
|
let cases = parse_manifest(input).expect("manifest parse should succeed");
|
|
assert_eq!(cases.len(), 1);
|
|
assert_eq!(cases[0].id, "basic");
|
|
assert!(matches!(cases[0].status, WptStatus::Pass));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_key_outside_case_block() {
|
|
let input = r#"
|
|
id = "bad"
|
|
"#;
|
|
|
|
let err = parse_manifest(input).expect_err("manifest should fail");
|
|
assert!(err.contains("outside [[case]]"));
|
|
}
|
|
}
|