From b0b55abbe16ca9289d5f528e033ed6946754af09 Mon Sep 17 00:00:00 2001 From: pjh456 <147148383@qq.com> Date: Sat, 6 Jun 2026 18:51:54 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E9=80=82=E9=85=8D=20test262=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=A5=97=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitmodules | 3 + Cargo.lock | 61 +++++ Cargo.toml | 1 + crates/oxide_test262/Cargo.toml | 20 ++ crates/oxide_test262/src/main.rs | 446 +++++++++++++++++++++++++++++++ tests/test262 | 1 + 6 files changed, 532 insertions(+) create mode 100644 .gitmodules create mode 100644 crates/oxide_test262/Cargo.toml create mode 100644 crates/oxide_test262/src/main.rs create mode 160000 tests/test262 diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..ab1a24ca --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "tests/test262"] + path = tests/test262 + url = https://github.com/tc39/test262.git diff --git a/Cargo.lock b/Cargo.lock index d0245c21..e7ad73e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -803,6 +803,19 @@ dependencies = [ "oxc_syntax", ] +[[package]] +name = "oxide_test262" +version = "0.1.0" +dependencies = [ + "oxide_compiler", + "oxide_kernel", + "oxide_parser", + "oxide_vm", + "serde", + "serde_yaml", + "walkdir", +] + [[package]] name = "oxide_types" version = "0.1.0" @@ -1111,6 +1124,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1136,6 +1158,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", + "serde_derive", ] [[package]] @@ -1171,6 +1194,19 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "siphasher" version = "1.0.3" @@ -1304,6 +1340,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1325,6 +1367,16 @@ dependencies = [ "libc", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasip2" version = "1.0.3+wasi-0.2.9" @@ -1393,6 +1445,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index af87c4b3..6673916a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/oxide_kernel", "crates/oxide_api", "crates/oxide_cli", + "crates/oxide_test262", ] [workspace.package] diff --git a/crates/oxide_test262/Cargo.toml b/crates/oxide_test262/Cargo.toml new file mode 100644 index 00000000..aa5d2784 --- /dev/null +++ b/crates/oxide_test262/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "oxide_test262" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +description = "OxideJS - test262 ECMAScript test suite runner" + +[[bin]] +name = "test262-runner" +path = "src/main.rs" + +[dependencies] +oxide_compiler = { path = "../oxide_compiler" } +oxide_vm = { path = "../oxide_vm" } +oxide_parser = { path = "../oxide_parser" } +oxide_kernel = { path = "../oxide_kernel" } +serde = { version = "1", features = ["derive"] } +serde_yaml = "0.9" +walkdir = "2" diff --git a/crates/oxide_test262/src/main.rs b/crates/oxide_test262/src/main.rs new file mode 100644 index 00000000..2a66acae --- /dev/null +++ b/crates/oxide_test262/src/main.rs @@ -0,0 +1,446 @@ +use oxide_compiler::compiler::Compiler; +use oxide_vm::vm::Vm; +use serde::Deserialize; +use std::path::{Path, PathBuf}; +use std::time::Duration; +use walkdir::WalkDir; + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct Negative { + phase: String, + #[serde(rename = "type")] + error_type: String, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct TestMeta { + #[serde(default)] + description: String, + #[serde(default)] + flags: Vec, + #[serde(default)] + includes: Vec, + #[serde(default)] + features: Vec, + #[serde(default)] + negative: Option, + #[serde(default)] + es5id: String, + #[serde(default)] + es6id: String, + #[serde(default)] + esid: String, +} + +#[derive(Debug)] +enum TestOutcome { + Pass(String), + Fail(String), + Skip(String), +} + +#[derive(Debug)] +struct TestResult { + path: PathBuf, + outcome: TestOutcome, + duration_ms: u64, +} + +impl TestResult { + fn pass(path: PathBuf, dur: u64, msg: impl Into) -> Self { + Self { + path, + outcome: TestOutcome::Pass(msg.into()), + duration_ms: dur, + } + } + + fn fail(path: PathBuf, dur: u64, msg: impl Into) -> Self { + Self { + path, + outcome: TestOutcome::Fail(msg.into()), + duration_ms: dur, + } + } + + fn skip(path: PathBuf, msg: String) -> Self { + Self { + path, + outcome: TestOutcome::Skip(msg), + duration_ms: 0, + } + } +} + +fn parse_meta(source: &str) -> Option { + let header_start = source.find("/*---")?; + let header = &source[header_start..]; + let header = header.strip_prefix("/*---")?; + let end = header.find("---*/")?; + let yaml_body = &header[..end]; + let yaml_body = yaml_body.trim(); + serde_yaml::from_str::(yaml_body).ok() +} + +fn strip_meta(source: &str) -> &str { + if let Some(pos) = source.find("---*/") { + return source[pos + 5..].trim_start(); + } + source +} + +#[derive(Default)] +struct RunStats { + pass: usize, + fail: usize, + skip: usize, + total_ms: u64, +} + +fn is_skipped(meta: &TestMeta) -> Option { + for flag in &meta.flags { + match flag.as_str() { + "module" => return Some("module tests excluded".into()), + "async" => return Some("async tests excluded".into()), + "raw" => return Some("raw tests excluded".into()), + "onlyStrict" => return Some("strict-only excluded".into()), + _ => {} + } + } + + if !meta.includes.is_empty() { + return Some(format!( + "requires harness includes: {}", + meta.includes.join(", ") + )); + } + + let excluded_features = [ + "Proxy", + "Symbol", + "Symbol.match", + "BigInt", + "generators", + "generator", + "async-functions", + "default-parameters", + "destructuring-binding", + "destructuring", + "arrow-function", + "rest-parameters", + "spread", + "class", + "super", + "WeakMap", + "WeakSet", + "WeakRef", + "Reflect", + "Intl", + "Promise", + "TypedArray", + "DataView", + "ArrayBuffer", + "SharedArrayBuffer", + "Atomics", + "module", + "dynamic-import", + "tail-call-optimization", + "regexp-named-groups", + "regexp-lookbehind", + "regexp-unicode-property-escapes", + "regexp-dotall", + "json-superset", + "for-of", + "cross-realm", + "new.target", + "well-formed-json-stringify", + "optional-chaining", + ]; + + for feat in &meta.features { + if excluded_features.contains(&feat.as_str()) { + return Some(format!("excluded feature: {feat}")); + } + } + + if meta.description.contains("generator") + || meta.description.contains("async") + || meta.description.contains("eval ") + { + return Some("description matches excluded pattern".into()); + } + + None +} + +fn run_test(path: &Path, source: &str, meta: &TestMeta) -> TestResult { + let start = std::time::Instant::now(); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + run_test_inner(path, source, meta) + })); + + match result { + Ok(r) => r, + Err(_panic) => { + let dur = start.elapsed().as_millis() as u64; + TestResult::fail(path.to_path_buf(), dur, "engine panic (unsupported feature)") + } + } +} + +fn run_test_inner(path: &Path, source: &str, meta: &TestMeta) -> TestResult { + let start = std::time::Instant::now(); + + let code = strip_meta(source); + + let alloc = oxide_parser::Allocator::default(); + let program = match oxide_parser::parse(&alloc, code) { + Ok(p) => p, + Err(errs) => { + let dur = start.elapsed().as_millis() as u64; + let msg = format!("parse error: {}", errs[0].message); + if meta.negative.is_some() { + return TestResult::pass(path.to_path_buf(), dur, msg); + } + return TestResult::fail(path.to_path_buf(), dur, msg); + } + }; + + let module = match Compiler::new().compile(&program) { + Ok(m) => m, + Err(e) => { + let dur = start.elapsed().as_millis() as u64; + let msg = format!("compile error: {e}"); + if meta.negative.is_some() { + return TestResult::pass(path.to_path_buf(), dur, msg); + } + if e.contains("not yet implemented") + || e.contains("not supported") + || e.contains("compound assignment") + || e.contains("is not defined") + || e.contains("FunctionDeclaration") + || e.contains("FunctionExpression") + || e.contains("ArrowFunctionExpression") + || e.contains("unsupported expression") + || e.contains("unsupported statement") + || e.contains("NewExpression") + || e.contains("CallExpression") + || e.contains("UpdateExpression") + || e.contains("destructuring") + || e.contains("SpreadElement") + { + return TestResult::skip(path.to_path_buf(), msg); + } + return TestResult::fail(path.to_path_buf(), dur, msg); + } + }; + + let mut vm = Vm::new(); + match vm.run(&module) { + Ok(result) => { + let dur = start.elapsed().as_millis() as u64; + if meta.negative.is_some() { + let neg = meta.negative.as_ref().unwrap(); + return TestResult::fail( + path.to_path_buf(), + dur, + format!("expected runtime error ({}), got: {result}", neg.error_type), + ); + } + TestResult::pass(path.to_path_buf(), dur, format!("ok: {result}")) + } + Err(e) => { + let dur = start.elapsed().as_millis() as u64; + if meta.negative.is_some() { + let neg = meta.negative.as_ref().unwrap(); + if e.contains("TypeError") && neg.error_type == "TypeError" { + return TestResult::pass(path.to_path_buf(), dur, format!("expected: {e}")); + } + if e.contains("ReferenceError") && neg.error_type == "ReferenceError" { + return TestResult::pass(path.to_path_buf(), dur, format!("expected: {e}")); + } + if e.contains("SyntaxError") && neg.error_type == "SyntaxError" { + return TestResult::pass(path.to_path_buf(), dur, format!("expected: {e}")); + } + if e.contains(&neg.error_type) { + return TestResult::pass(path.to_path_buf(), dur, format!("expected: {e}")); + } + TestResult::fail( + path.to_path_buf(), + dur, + format!("expected {} error, got: {e}", neg.error_type), + ) + } else if e.contains("not yet implemented") + || e.contains("not supported") + || e.contains("unsupported") + { + TestResult::skip(path.to_path_buf(), format!("vm: {e}")) + } else { + TestResult::fail(path.to_path_buf(), dur, format!("vm error: {e}")) + } + } + } +} + +fn discover_tests(test262_root: &Path) -> Vec { + WalkDir::new(test262_root) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().map_or(false, |ext| ext == "js")) + .map(|e| e.path().to_path_buf()) + .collect() +} + +fn main() { + let args: Vec = std::env::args().collect(); + + let test262_root = if args.len() > 1 { + PathBuf::from(&args[1]) + } else { + let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + p.pop(); + p.pop(); + p.push("tests"); + p.push("test262"); + p.push("test"); + p + }; + + if !test262_root.exists() { + eprintln!( + "test262 not found at: {}\n\ + Run: git submodule add https://github.com/tc39/test262.git tests/test262\n\ + Then: cd tests/test262 && git checkout \n\ + Or pass path as argument: cargo run -- ", + test262_root.display() + ); + std::process::exit(1); + } + + let filter = if args.len() > 2 { + Some(args[2].clone()) + } else { + None + }; + + eprintln!("discovering tests in: {}", test262_root.display()); + let paths = discover_tests(&test262_root); + eprintln!("found {} test files", paths.len()); + + let mut stats = RunStats::default(); + let mut results: Vec = Vec::new(); + + let total = paths.len(); + for (i, path) in paths.iter().enumerate() { + if i % 100 == 0 { + eprintln!( + " progress: {}/{} ({}% pass={} fail={} skip={})", + i, + total, + i * 100 / total, + stats.pass, + stats.fail, + stats.skip + ); + } + + let source = match std::fs::read_to_string(path) { + Ok(s) => s, + Err(e) => { + results.push(TestResult::skip( + path.clone(), + format!("read error: {e}"), + )); + stats.skip += 1; + continue; + } + }; + + let meta = match parse_meta(&source) { + Some(m) => m, + None => { + results.push(TestResult::skip( + path.clone(), + String::from("no YAML metadata"), + )); + stats.skip += 1; + continue; + } + }; + + if let Some(filter_str) = &filter { + let path_str = path.to_string_lossy(); + if !path_str.contains(filter_str.as_str()) { + results.push(TestResult::skip(path.clone(), "filtered".into())); + stats.skip += 1; + continue; + } + } + + if let Some(reason) = is_skipped(&meta) { + results.push(TestResult::skip(path.clone(), reason)); + stats.skip += 1; + continue; + } + + let result = run_test(path, &source, &meta); + match &result.outcome { + TestOutcome::Pass(_) => stats.pass += 1, + TestOutcome::Fail(_) => stats.fail += 1, + TestOutcome::Skip(_) => stats.skip += 1, + } + stats.total_ms += result.duration_ms; + results.push(result); + } + + eprintln!(); + + let mut pass_count = 0; + let mut fail_count = 0; + let mut skip_count = 0; + + for r in &results { + match &r.outcome { + TestOutcome::Pass(msg) => { + pass_count += 1; + let rel = r.path.strip_prefix(&test262_root).unwrap_or(&r.path); + println!("PASS [{:>7}ms] {} -- {msg}", r.duration_ms, rel.display()); + } + TestOutcome::Fail(msg) => { + fail_count += 1; + let rel = r.path.strip_prefix(&test262_root).unwrap_or(&r.path); + println!("FAIL [{:>7}ms] {} -- {msg}", r.duration_ms, rel.display()); + } + TestOutcome::Skip(_msg) => { + skip_count += 1; + } + } + } + + println!(); + println!("═══════════════════════════════════════"); + println!(" test262 results"); + println!("═══════════════════════════════════════"); + println!(" total : {}", results.len()); + println!(" pass : {pass_count}"); + println!(" fail : {fail_count}"); + println!(" skip : {skip_count}"); + println!(" time : {:?}", Duration::from_millis(stats.total_ms)); + println!( + " pass% : {:.1}% (of ran: {:.1}%)", + pass_count as f64 / results.len() as f64 * 100.0, + if pass_count + fail_count > 0 { + pass_count as f64 / (pass_count + fail_count) as f64 * 100.0 + } else { + 0.0 + } + ); + println!("═══════════════════════════════════════"); + + if fail_count > 0 { + std::process::exit(1); + } +} diff --git a/tests/test262 b/tests/test262 new file mode 160000 index 00000000..05bb0329 --- /dev/null +++ b/tests/test262 @@ -0,0 +1 @@ +Subproject commit 05bb032907160d66c212589d345fa0e335e2738c