Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/justfile
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ core:
@just gha::_step examples
@just gha::_step test-translate
@just gha::_step conformance
@just gha::_step test-wasmtime
@just gha::_step test-wasmtime-guests
@just gha::_step sched-seeds

# Page and worker realms share per-engine expectations; WebKit is best-effort.
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ jobs:
submodules: true # third_party/component-model: testgen + conformance corpus
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown,wasm32-wasip2
# wasm32-wasip1 builds the upstream Wasmtime async guests
# (tools/wasmtime-guests/build.ts); the corpus itself is not
# cargo-built, only converted (docs/architecture.md §11).
targets: wasm32-unknown-unknown,wasm32-wasip1,wasm32-wasip2
- uses: Swatinem/rust-cache@v2
- uses: taiki-e/install-action@v2
with:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# @polyengine/translator packaged asset (copied by `just shim`)
translator/translator_shim.wasm
harness/generated/
harness/generated-wasmtime/
examples/guests/build/
node_modules/
dist/
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/testgen/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ path = "src/main.rs"
anyhow = "1"
json-from-wast = "0.258.0"
serde_json = { version = "1", features = ["preserve_order"] }
serde = { version = "1", features = ["derive"] }
wast = "258.0.0"
238 changes: 208 additions & 30 deletions crates/testgen/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
//! as libraries. See harness/README.md for the pipeline and schema documentation.
//!
//! Usage:
//! testgen [--test-dir DIR] [--out-dir DIR] [SUBDIR...]
//! testgen [--test-dir DIR] [--out-dir DIR] [--source-prefix PREFIX] [SUBDIR...]
//!
//! Defaults (resolved relative to the repository root, so this works from
//! any working directory):
Expand All @@ -18,6 +18,13 @@ use anyhow::{bail, Context, Result};
use std::path::{Path, PathBuf};
use std::process::ExitCode;

#[derive(serde::Serialize)]
struct SupplementaryFile {
path: String,
source: String,
directives: std::collections::BTreeMap<String, bool>,
}

fn main() -> ExitCode {
match run() {
Ok(failures) if failures == 0 => ExitCode::SUCCESS,
Expand Down Expand Up @@ -46,14 +53,24 @@ fn run() -> Result<usize> {
let mut test_dir = root.join("third_party/component-model/test");
let mut out_dir = root.join("harness/generated");
let mut subdirs: Vec<String> = Vec::new();
let mut source_prefix = "third_party/component-model/test".to_string();
let mut source_revision: Option<String> = None;

let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--test-dir" => test_dir = PathBuf::from(args.next().context("--test-dir needs a value")?),
"--test-dir" => {
test_dir = PathBuf::from(args.next().context("--test-dir needs a value")?)
}
"--out-dir" => out_dir = PathBuf::from(args.next().context("--out-dir needs a value")?),
"--source-prefix" => {
source_prefix = args.next().context("--source-prefix needs a value")?
}
"--source-revision" => {
source_revision = Some(args.next().context("--source-revision needs a value")?)
}
"--help" | "-h" => {
println!("usage: testgen [--test-dir DIR] [--out-dir DIR] [SUBDIR...]");
println!("usage: testgen [--test-dir DIR] [--out-dir DIR] [--source-prefix PREFIX] [SUBDIR...]");
return Ok(0);
}
s if s.starts_with('-') => bail!("unknown flag: {s}"),
Expand All @@ -64,6 +81,23 @@ fn run() -> Result<usize> {
if !test_dir.is_dir() {
bail!("test dir not found: {}", test_dir.display());
}
let canonical_input = test_dir.canonicalize()?;
let canonical_output = if out_dir.exists() {
// Resolve the complete existing path: its final component may itself
// be a symlink back into the input tree.
out_dir.canonicalize()?
} else {
out_dir.parent().unwrap_or(&out_dir).canonicalize()?.join(
out_dir
.file_name()
.context("output directory has no final component")?,
)
};
if canonical_input.starts_with(&canonical_output)
|| canonical_output.starts_with(&canonical_input)
{
bail!("input and output directories must not overlap");
}

// Deterministic subdir set: sorted, filtered to requested names.
let mut found: Vec<String> = std::fs::read_dir(&test_dir)
Expand All @@ -72,11 +106,25 @@ fn run() -> Result<usize> {
.filter(|e| e.path().is_dir())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
if std::fs::read_dir(&test_dir)?
.filter_map(|e| e.ok())
.any(|e| e.path().extension().is_some_and(|ext| ext == "wast"))
{
found.push(String::new());
}
found.sort();
if !subdirs.is_empty() {
for want in &subdirs {
if !found.contains(want) {
bail!("no such test subdirectory: {want} (available: {})", found.join(", "));
bail!(
"no such test subdirectory: {want} (available: {})",
found
.iter()
.filter(|s| !s.is_empty())
.cloned()
.collect::<Vec<_>>()
.join(", ")
);
}
}
found.retain(|d| subdirs.contains(d));
Expand All @@ -85,25 +133,36 @@ fn run() -> Result<usize> {
let mut converted = 0usize;
let mut total_commands = 0usize;
let mut failures: Vec<(PathBuf, anyhow::Error)> = Vec::new();
let mut generated_files = Vec::new();
let mut supplementary_files = Vec::new();

if subdirs.is_empty() && out_dir.exists() {
std::fs::remove_dir_all(&out_dir)
.with_context(|| format!("cleaning {}", out_dir.display()))?;
}
std::fs::create_dir_all(&out_dir)?;

for sub in &found {
let in_sub = test_dir.join(sub);
let out_sub = out_dir.join(sub);
// Regenerate from scratch so deleted/renamed wast files leave no
// stale outputs behind.
if out_sub.exists() {
if !sub.is_empty() && out_sub.exists() {
std::fs::remove_dir_all(&out_sub)
.with_context(|| format!("cleaning {}", out_sub.display()))?;
}
std::fs::create_dir_all(&out_sub)
.with_context(|| format!("creating {}", out_sub.display()))?;

let mut wast_files: Vec<PathBuf> = std::fs::read_dir(&in_sub)
.with_context(|| format!("reading {}", in_sub.display()))?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "wast"))
.collect();
let mut wast_files = if sub.is_empty() {
std::fs::read_dir(&test_dir)?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "wast"))
.collect()
} else {
recursive_wast_files(&in_sub)?
};
wast_files.sort();

for wast_path in wast_files {
Expand All @@ -113,10 +172,18 @@ fn run() -> Result<usize> {
.to_string_lossy()
.into_owned();
// Stable, machine-independent source reference.
let relative = wast_path
.strip_prefix(&test_dir)
.expect("discovered below test dir");
let source_rel = format!(
"third_party/component-model/test/{sub}/{}",
wast_path.file_name().unwrap().to_string_lossy()
"{}/{}",
source_prefix.trim_end_matches('/'),
relative.to_string_lossy().replace('\\', "/")
);
let relative_parent = relative.parent().unwrap_or(Path::new(""));
let artifact_dir = out_dir.join(relative_parent);
std::fs::create_dir_all(&artifact_dir)
.with_context(|| format!("creating {}", artifact_dir.display()))?;
let text = std::fs::read_to_string(&wast_path)
.with_context(|| format!("reading {}", wast_path.display()))?;

Expand All @@ -140,13 +207,25 @@ fn run() -> Result<usize> {
Ok(wast) => {
let n_artifacts = wast.wasms.len();
for (filename, bytes) in &wast.wasms {
std::fs::write(out_sub.join(filename), bytes)
.with_context(|| format!("writing {sub}/{filename}"))?;
std::fs::write(artifact_dir.join(filename), bytes).with_context(|| {
format!("writing {}/{filename}", relative_parent.display())
})?;
}
let mut json = serde_json::to_string_pretty(&wast)?;
json.push('\n');
std::fs::write(out_sub.join(format!("{stem}.json")), json)
.with_context(|| format!("writing {sub}/{stem}.json"))?;
std::fs::write(artifact_dir.join(format!("{stem}.json")), json).with_context(
|| format!("writing {}/{stem}.json", relative_parent.display()),
)?;
let generated = relative
.with_extension("json")
.to_string_lossy()
.replace('\\', "/");
generated_files.push(generated.clone());
supplementary_files.push(SupplementaryFile {
path: generated,
source: source_rel.clone(),
directives: parse_directives(&text)?,
});
println!(
"converted {source_rel}: {} commands, {} artifacts",
wast.commands.len(),
Expand All @@ -162,22 +241,19 @@ fn run() -> Result<usize> {

// Manifest: lets consumers (e.g. a browser runner without directory
// listings) discover the generated JSON files. Sorted, deterministic.
let mut json_files: Vec<String> = Vec::new();
for sub in &found {
let out_sub = out_dir.join(sub);
let mut files: Vec<String> = std::fs::read_dir(&out_sub)?
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|f| f.ends_with(".json"))
.map(|f| format!("{sub}/{f}"))
.collect();
files.sort();
json_files.extend(files);
}
let manifest = serde_json::json!({ "files": json_files });
generated_files.sort();
supplementary_files.sort_by(|a, b| a.path.cmp(&b.path));
let manifest = serde_json::json!({ "files": generated_files });
let mut manifest_str = serde_json::to_string_pretty(&manifest)?;
manifest_str.push('\n');
std::fs::write(out_dir.join("manifest.json"), manifest_str)?;
let metadata = serde_json::json!({
"source_revision": source_revision,
"files": supplementary_files,
});
let mut metadata_str = serde_json::to_string_pretty(&metadata)?;
metadata_str.push('\n');
std::fs::write(out_dir.join("supplementary-metadata.json"), metadata_str)?;

println!(
"testgen: converted {converted} wast file(s), {total_commands} commands, {} failure(s)",
Expand All @@ -189,6 +265,108 @@ fn run() -> Result<usize> {
Ok(failures.len())
}

fn parse_directives(text: &str) -> Result<std::collections::BTreeMap<String, bool>> {
let mut directives = std::collections::BTreeMap::new();
for line in text
.lines()
.take_while(|line| line.trim().is_empty() || line.starts_with(";;!"))
{
let Some(setting) = line.strip_prefix(";;!") else {
continue;
};
let (key, value) = setting.split_once('=').context("invalid ;;! directive")?;
const KNOWN: &[&str] = &[
"bulk_memory",
"component_model_async",
"component_model_async_stackful",
"component_model_error_context",
"component_model_fixed_length_lists",
"component_model_gc",
"component_model_implements",
"component_model_map",
"component_model_memory64",
"component_model_more_async_builtins",
"component_model_threading",
"exceptions",
"function_references",
"gc",
"gc_types",
"hogs_memory",
"memory64",
"multi_memory",
"reference_types",
];
if !KNOWN.contains(&key.trim()) {
bail!("unknown ;;! directive {}", key.trim());
}
let value = match value.trim() {
"true" => true,
"false" => false,
other => bail!("unknown ;;! value {other:?} for {}", key.trim()),
};
if directives.insert(key.trim().to_string(), value).is_some() {
bail!("duplicate ;;! directive {}", key.trim());
}
}
Ok(directives)
}

fn recursive_wast_files(root: &Path) -> Result<Vec<PathBuf>> {
recursive_files_with_extension(root, "wast")
}

fn recursive_files_with_extension(root: &Path, extension: &str) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
let mut dirs = vec![root.to_path_buf()];
while let Some(dir) = dirs.pop() {
for entry in
std::fs::read_dir(&dir).with_context(|| format!("reading {}", dir.display()))?
{
let path = entry?.path();
if path.is_dir() {
dirs.push(path);
} else if path.extension().is_some_and(|e| e == extension) {
files.push(path);
}
}
}
files.sort();
Ok(files)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn recursive_discovery_preserves_distinct_parent_paths() {
let root = std::env::temp_dir().join(format!("polyengine-testgen-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("a/nested")).unwrap();
std::fs::create_dir_all(root.join("b")).unwrap();
std::fs::write(root.join("a/nested/same.wast"), "").unwrap();
std::fs::write(root.join("b/same.wast"), "").unwrap();
let got = recursive_wast_files(&root).unwrap();
assert_eq!(
got,
vec![root.join("a/nested/same.wast"), root.join("b/same.wast")]
);
std::fs::remove_dir_all(root).unwrap();
}

#[test]
fn directives_are_strict() {
let got = parse_directives(
";;! component_model_async = true\n;;! component_model_implements = false\n(component)",
)
.unwrap();
assert_eq!(got["component_model_async"], true);
assert_eq!(got["component_model_implements"], false);
assert!(parse_directives(";;! gc = maybe").is_err());
assert!(parse_directives(";;! imaginary = true").is_err());
}
}

fn pretty(mut e: wast::Error, path: &str, text: &str) -> anyhow::Error {
e.set_path(std::path::Path::new(path));
e.set_text(text);
Expand Down
Loading
Loading