From bb43dc5bebfa9587c027644cc29a0542edad0b8c Mon Sep 17 00:00:00 2001 From: Dunqing <29533304+Dunqing@users.noreply.github.com> Date: Sun, 12 Oct 2025 15:55:38 +0000 Subject: [PATCH] test(formatter): add snapshot-based test infrastructure (#14400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement a flexible snapshot-based testing framework for oxc_formatter using `insta` and build-time test generation. ## Features - **Auto-discovery**: Tests are automatically discovered from `tests/fixtures/` directory - **Individual test functions**: Each fixture file gets its own test function for easy identification - **Hierarchical options**: Support for `options.json` files at any directory level - **Multiple option sets**: Test the same input with multiple formatting configurations - **Co-located snapshots**: Snapshot files are stored next to test files (e.g., `foo.js.snap`) - **Comprehensive README**: Detailed documentation for adding and running tests ## Implementation - **build.rs**: Scans `tests/fixtures/` and generates test functions at build time - **tests/fixtures/mod.rs**: Core test infrastructure with option resolution and snapshot generation - **tests/README.md**: Complete guide for using the test framework - **Sample tests**: Example tests demonstrating JS, JSX, TS, and nested configurations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- crates/oxc_formatter/Cargo.toml | 4 + crates/oxc_formatter/build.rs | 246 +++++++++++++++++++++ crates/oxc_formatter/tests/README.md | 163 ++++++++++++++ crates/oxc_formatter/tests/fixtures/mod.rs | 220 ++++++++++++++++++ crates/oxc_formatter/tests/mod.rs | 1 + 5 files changed, 634 insertions(+) create mode 100644 crates/oxc_formatter/build.rs create mode 100644 crates/oxc_formatter/tests/README.md create mode 100644 crates/oxc_formatter/tests/fixtures/mod.rs diff --git a/crates/oxc_formatter/Cargo.toml b/crates/oxc_formatter/Cargo.toml index 4485414b906ee..c806b57b5acd4 100644 --- a/crates/oxc_formatter/Cargo.toml +++ b/crates/oxc_formatter/Cargo.toml @@ -39,3 +39,7 @@ insta = { workspace = true } oxc_parser = { workspace = true } pico-args = { workspace = true } project-root = { workspace = true } +serde_json = { workspace = true } + +[build-dependencies] +oxc_span = { workspace = true } diff --git a/crates/oxc_formatter/build.rs b/crates/oxc_formatter/build.rs new file mode 100644 index 0000000000000..b27a9db9c9778 --- /dev/null +++ b/crates/oxc_formatter/build.rs @@ -0,0 +1,246 @@ +use std::{ + collections::BTreeMap, + env, + fs::{self, File}, + io::Write, + path::{Path, PathBuf}, +}; + +use oxc_span::SourceType; + +fn main() { + let out_dir = env::var("OUT_DIR").unwrap(); + let dest_path = Path::new(&out_dir).join("generated_tests.rs"); + let mut f = File::create(&dest_path).unwrap(); + + let fixtures_dir = Path::new("tests/fixtures"); + + if !fixtures_dir.exists() { + // If no fixtures directory exists, create an empty file + writeln!(f, "// No test fixtures found").unwrap(); + return; + } + + writeln!(f, "// Auto-generated test modules and functions").unwrap(); + writeln!(f).unwrap(); + + // Collect all test files organized by directory + let mut dir_structure = DirStructure::new(); + collect_tests(fixtures_dir, fixtures_dir, &mut dir_structure).unwrap(); + + // Generate nested modules + generate_modules(&mut f, &dir_structure, 0).unwrap(); + + println!("cargo:rerun-if-changed=tests/fixtures"); +} + +#[derive(Default)] +struct DirStructure { + /// Test files in this directory (relative paths from fixtures root) + test_files: Vec, + /// Subdirectories + subdirs: BTreeMap, +} + +impl DirStructure { + fn new() -> Self { + Self::default() + } +} + +/// Collect all test files and organize them by directory +fn collect_tests(dir: &Path, base_dir: &Path, structure: &mut DirStructure) -> std::io::Result<()> { + let entries = fs::read_dir(dir)?; + + for entry in entries { + let entry = entry?; + let path = entry.path(); + + if path.is_dir() { + let dir_name = path.file_name().unwrap().to_string_lossy().to_string(); + let subdir = structure.subdirs.entry(dir_name).or_default(); + collect_tests(&path, base_dir, subdir)?; + } else if is_test_file(&path) { + let relative_path = path.strip_prefix(base_dir).unwrap().to_path_buf(); + structure.test_files.push(relative_path); + } + } + + Ok(()) +} + +/// Generate nested modules for the directory structure +fn generate_modules( + f: &mut File, + structure: &DirStructure, + indent_level: usize, +) -> std::io::Result<()> { + let indent = " ".repeat(indent_level); + + // Generate test functions for files in this directory + for test_file in &structure.test_files { + generate_test_function(f, test_file, indent_level)?; + } + + // Generate submodules + for (dir_name, subdir) in &structure.subdirs { + let module_name = sanitize_module_name(dir_name); + + writeln!(f, "{indent}#[cfg(test)]")?; + writeln!(f, "{indent}mod {module_name} {{")?; + writeln!(f, "{indent} use super::test_file;")?; + writeln!(f)?; + + generate_modules(f, subdir, indent_level + 1)?; + + writeln!(f, "{indent}}}")?; + writeln!(f)?; + } + + Ok(()) +} + +fn is_test_file(path: &Path) -> bool { + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + SourceType::from_extension(ext).is_ok() + } else { + false + } +} + +fn generate_test_function( + f: &mut File, + relative_path: &Path, + indent_level: usize, +) -> std::io::Result<()> { + let indent = " ".repeat(indent_level); + // Use only the filename for the test name (directories are handled by modules) + let test_name = file_to_test_name(relative_path.file_name().unwrap().to_str().unwrap()); + + writeln!(f, "{indent}#[test]")?; + writeln!(f, "{indent}fn {test_name}() {{")?; + writeln!( + f, + "{} let path = std::path::Path::new(\"tests/fixtures/{}\");", + indent, + relative_path.display() + )?; + writeln!(f, "{indent} test_file(path);")?; + writeln!(f, "{indent}}}")?; + writeln!(f)?; + + Ok(()) +} + +/// Convert filename to a valid Rust test function name +fn file_to_test_name(filename: &str) -> String { + let mut name = String::new(); + + // Replace non-alphanumeric characters with underscores + for c in filename.chars() { + if c.is_alphanumeric() { + name.push(c.to_ascii_lowercase()); + } else { + name.push('_'); + } + } + + // Remove file extension + if let Some(pos) = name.rfind('_') { + let after_underscore = &name[pos + 1..]; + if SourceType::from_extension(after_underscore).is_ok() { + name.truncate(pos); + } + } + + sanitize_identifier(name, "test") +} + +/// Sanitize directory name to be a valid Rust module name +fn sanitize_module_name(name: &str) -> String { + let mut result = String::new(); + + for c in name.chars() { + if c.is_alphanumeric() { + result.push(c.to_ascii_lowercase()); + } else { + result.push('_'); + } + } + + sanitize_identifier(result, "") +} + +/// Sanitize a string to be a valid Rust identifier +/// - prefix: prefix to add if identifier is empty or starts with digit (e.g., "test" or "") +fn sanitize_identifier(mut name: String, prefix: &str) -> String { + // Ensure it starts with a letter or underscore + if name.is_empty() || name.chars().next().unwrap().is_numeric() { + name = if prefix.is_empty() { format!("_{name}") } else { format!("{prefix}_{name}") }; + } + + // Handle reserved keywords + if is_reserved_keyword(&name) { + return format!("r#{name}"); + } + + name +} + +/// Check if a string is a Rust reserved keyword +fn is_reserved_keyword(s: &str) -> bool { + matches!( + s, + "mod" + | "fn" + | "let" + | "mut" + | "const" + | "static" + | "type" + | "use" + | "as" + | "async" + | "await" + | "break" + | "continue" + | "crate" + | "dyn" + | "else" + | "enum" + | "extern" + | "false" + | "for" + | "if" + | "impl" + | "in" + | "loop" + | "match" + | "move" + | "pub" + | "ref" + | "return" + | "self" + | "Self" + | "struct" + | "super" + | "trait" + | "true" + | "unsafe" + | "where" + | "while" + | "abstract" + | "become" + | "box" + | "do" + | "final" + | "macro" + | "override" + | "priv" + | "typeof" + | "unsized" + | "virtual" + | "yield" + | "try" + ) +} diff --git a/crates/oxc_formatter/tests/README.md b/crates/oxc_formatter/tests/README.md new file mode 100644 index 0000000000000..f5696f6e6e597 --- /dev/null +++ b/crates/oxc_formatter/tests/README.md @@ -0,0 +1,163 @@ +# Oxc Formatter Tests + +This directory contains snapshot-based tests for the oxc_formatter crate. + +## Overview + +The test infrastructure is designed to be simple and flexible: + +- **File-based testing**: Just create `.js`, `.jsx`, `.ts`, or `.tsx` files in the `fixtures/` directory +- **Hierarchical options**: Configure format options via `options.json` files at any directory level +- **Automatic discovery**: Tests are automatically discovered via build script - no manual registration needed +- **Nested modules**: Directory structure becomes module hierarchy, enabling targeted test execution +- **Individual test functions**: Each file gets its own test function, visible in `cargo test` output +- **Snapshot testing**: Uses `insta` for snapshot testing with easy review workflow +- **Co-located snapshots**: Snapshots are stored next to test files for easy navigation + +## Directory Structure + +``` +tests/ +├── mod.rs # Main test runner +└── fixtures/ # Test input files + ├── js/ # JavaScript/JSX tests + │ ├── options.json # Shared options for all js tests + │ ├── arrow-functions.js + │ ├── arrow-functions.js.snap # Snapshot file (includes extension) + │ ├── simple.jsx + │ ├── simple.jsx.snap + │ └── nested/ + │ ├── options.json # Overrides parent options + │ ├── example.js + │ └── example.js.snap + └── ts/ # TypeScript/TSX tests + ├── generics.ts + └── generics.ts.snap +``` + +## Adding New Tests + +### Simple Test (using default options) + +Just create a new file in `fixtures/js/` or `fixtures/ts/`: + +```bash +# Create a new test file +echo "const foo = bar;" > tests/fixtures/js/my-test.js + +# The test is automatically discovered - just run tests +cargo test -p oxc_formatter --test mod +``` + +The test function is automatically generated by the build script in the module `fixtures::js::my_test`. The snapshot will be created as `tests/fixtures/js/my-test.js.snap` next to your test file. + +### Test with Custom Options + +Create an `options.json` file in the same directory (or parent directory): + +```json +[ + { + "semi": true, + "singleQuote": false, + "arrowParens": "always" + }, + { + "semi": false, + "singleQuote": true, + "arrowParens": "avoid", + "printWidth": 120 + } +] +``` + +**Note**: Each object in the array is a separate option set. The options are displayed in the snapshot output. + +### Nested/Organized Tests + +Create subdirectories to organize related tests: + +```bash +mkdir -p tests/fixtures/js/classes +echo "class Foo {}" > tests/fixtures/js/classes/simple.js +``` + +The test will inherit options from the nearest `options.json` file in the directory tree. + +## Supported Options + +The following format options are supported in `options.json`: + +- `semi`: `true` | `false` - Semicolons (maps to `Semicolons::Always` / `Semicolons::AsNeeded`) +- `singleQuote`: `true` | `false` - Quote style +- `jsxSingleQuote`: `true` | `false` - JSX quote style +- `arrowParens`: `"always"` | `"avoid"` - Arrow function parentheses +- `trailingComma`: `"none"` | `"es5"` | `"all"` - Trailing commas +- `printWidth`: number - Line width +- `tabWidth`: number - Indentation width +- `useTabs`: `true` | `false` - Use tabs for indentation +- `bracketSpacing`: `true` | `false` - Object literal spacing +- `bracketSameLine`: `true` | `false` - JSX bracket on same line +- `jsxBracketSameLine`: `true` | `false` - (alias for bracketSameLine) + +## Running Tests + +### Run all tests +```bash +cargo test -p oxc_formatter --test mod +``` + +### Run tests in a specific directory +```bash +# Run all tests in the js/comments directory +cargo test -p oxc_formatter fixtures::js::comments + +# Run all tests in the js directory (including subdirectories) +cargo test -p oxc_formatter fixtures::js + +# Run a specific test file +cargo test -p oxc_formatter fixtures::js::comments::test +``` + +### Accept new/changed snapshots +```bash +cargo insta test --accept -p oxc_formatter --test mod +``` + +### Accept snapshots for specific tests +```bash +FILTER="arrow" cargo insta test --accept -p oxc_formatter --test mod +``` + +### Review snapshots interactively +```bash +cargo insta review -p oxc_formatter +``` + +## How It Works + +1. **Build-time Discovery**: The build script (`build.rs`) scans `tests/fixtures/` for all `.{js,jsx,ts,tsx}` files +2. **Module Generation**: Directories become nested modules matching the file system structure +3. **Code Generation**: For each file, a test function is generated in the appropriate module + - Example: `fixtures/js/comments/test.js` → `fixtures::js::comments::test()` +4. **Test Execution**: Each generated test function calls the shared `test_file()` helper +5. **Filtering**: If `FILTER` env var is set, only matching paths are tested +6. **Option Resolution**: For each file, walk up the directory tree to find `options.json` +7. **Formatting**: Format the file with each option set +8. **Snapshot**: Generate a snapshot showing input + all outputs (stored next to test file) +9. **Comparison**: Compare with existing snapshot (if any) + +## Tips + +- **Auto-discovery**: Just add a `.js/.jsx/.ts/.tsx` file to `fixtures/` - no need to register it anywhere +- **Nested Modules**: Directories become modules, enabling targeted test execution: + - `fixtures/js/comments/test.js` → `cargo test fixtures::js::comments::test` + - `fixtures/js/comments/` → `cargo test fixtures::js::comments` (all tests in directory) + - `fixtures/js/` → `cargo test fixtures::js` (all tests including subdirectories) +- **Organization**: Use subdirectories to group related tests (e.g., `fixtures/js/arrows/`, `fixtures/js/classes/`) +- **Shared Options**: Put `options.json` at the directory level to apply to all files in that directory +- **Override Options**: Create `options.json` in a subdirectory to override parent options +- **Default Options**: If no `options.json` is found, `FormatOptions::default()` is used +- **File Extensions**: The source type is detected automatically from the file extension +- **Co-located Snapshots**: Snapshots are stored next to test files for easy navigation and review +- **Rebuilds**: The build script automatically rebuilds when files in `tests/fixtures/` change diff --git a/crates/oxc_formatter/tests/fixtures/mod.rs b/crates/oxc_formatter/tests/fixtures/mod.rs new file mode 100644 index 0000000000000..967bdf4b38e7c --- /dev/null +++ b/crates/oxc_formatter/tests/fixtures/mod.rs @@ -0,0 +1,220 @@ +use std::{env::current_dir, fs, path::Path, str::FromStr}; + +use oxc_allocator::Allocator; +use oxc_formatter::{ + ArrowParentheses, BracketSameLine, BracketSpacing, FormatOptions, Formatter, IndentStyle, + IndentWidth, LineWidth, QuoteStyle, Semicolons, TrailingCommas, +}; +use oxc_parser::{ParseOptions, Parser}; +use oxc_span::SourceType; + +type OptionSet = serde_json::Map; + +/// Resolve format options for a test file by walking up the directory tree +fn resolve_options(test_file: &Path) -> Vec { + let mut current_dir = test_file.parent(); + + // Walk up the directory tree looking for options.json + while let Some(dir) = current_dir { + let options_file = dir.join("options.json"); + if options_file.exists() { + if let Ok(content) = fs::read_to_string(&options_file) + && let Ok(option_sets) = serde_json::from_str::>(&content) + { + return option_sets; + } + break; + } + + // Stop at fixtures directory + if dir.ends_with("fixtures") { + break; + } + + current_dir = dir.parent(); + } + + // Default: single option set with default options (empty map) + vec![serde_json::Map::new()] +} + +/// Parse JSON options into FormatOptions +fn parse_format_options(json: &OptionSet) -> FormatOptions { + let mut options = FormatOptions::default(); + + for (key, value) in json { + match key.as_str() { + "semi" => { + if let Some(b) = value.as_bool() { + options.semicolons = if b { Semicolons::Always } else { Semicolons::AsNeeded }; + } + } + "singleQuote" => { + if let Some(b) = value.as_bool() { + options.quote_style = if b { QuoteStyle::Single } else { QuoteStyle::Double }; + } + } + "jsxSingleQuote" => { + if let Some(b) = value.as_bool() { + options.jsx_quote_style = + if b { QuoteStyle::Single } else { QuoteStyle::Double }; + } + } + "arrowParens" => { + if let Some(s) = value.as_str() { + options.arrow_parentheses = match s { + "always" => ArrowParentheses::Always, + "avoid" => ArrowParentheses::AsNeeded, + _ => options.arrow_parentheses, + }; + } + } + "trailingComma" => { + if let Some(s) = value.as_str() { + options.trailing_commas = match s { + "none" => TrailingCommas::None, + "es5" => TrailingCommas::Es5, + "all" => TrailingCommas::All, + _ => options.trailing_commas, + }; + } + } + "printWidth" => { + if let Some(n) = value.as_str() + && let Ok(width) = LineWidth::from_str(n) + { + options.line_width = width; + } + } + "tabWidth" => { + if let Some(n) = value.as_str() + && let Ok(width) = IndentWidth::from_str(n) + { + options.indent_width = width; + } + } + "useTabs" => { + if let Some(b) = value.as_bool() { + options.indent_style = if b { IndentStyle::Tab } else { IndentStyle::Space }; + } + } + "bracketSpacing" => { + if let Some(b) = value.as_bool() { + options.bracket_spacing = BracketSpacing::from(b); + } + } + "bracketSameLine" | "jsxBracketSameLine" => { + if let Some(b) = value.as_bool() { + options.bracket_same_line = BracketSameLine::from(b); + } + } + _ => {} + } + } + + options +} + +/// Format options to a readable string for snapshot display +fn format_options_display(json: &OptionSet) -> String { + if json.is_empty() { + return String::new(); + } + + let mut parts: Vec = json + .iter() + .map(|(k, v)| { + let value_str = match v { + serde_json::Value::Bool(b) => b.to_string(), + serde_json::Value::Number(n) => n.to_string(), + serde_json::Value::String(s) => format!("\"{s}\""), + _ => v.to_string(), + }; + format!("{k}: {value_str}") + }) + .collect(); + + parts.sort(); + parts.join(", ") +} + +/// Format a source file with given options +fn format_source(source_text: &str, source_type: SourceType, options: FormatOptions) -> String { + let allocator = Allocator::default(); + let ret = Parser::new(&allocator, source_text, source_type) + .with_options(ParseOptions { + parse_regular_expression: false, + allow_v8_intrinsics: true, + allow_return_outside_function: true, + preserve_parens: false, + }) + .parse(); + + let formatter = Formatter::new(&allocator, options); + formatter.build(&ret.program) +} + +/// Generate snapshot for a test file +fn generate_snapshot(path: &Path, source_text: &str) -> String { + let source_type = SourceType::from_path(path).unwrap(); + let option_sets = resolve_options(path); + + let mut snapshot = String::new(); + snapshot.push_str("==================== Input ====================\n"); + snapshot.push_str(source_text); + snapshot.push('\n'); + + if !option_sets.is_empty() { + snapshot.push_str("==================== Output ====================\n"); + } + + for option_json in option_sets { + let options_display = format_options_display(&option_json); + let options_line = if options_display.is_empty() { + String::default() + } else { + format!("{{ {options_display} }}") + }; + let separator = "-".repeat(options_line.len()); + + if !options_line.is_empty() { + snapshot.push_str(&separator); + snapshot.push('\n'); + snapshot.push_str(&options_line); + snapshot.push('\n'); + snapshot.push_str(&separator); + snapshot.push('\n'); + } + + let options = parse_format_options(&option_json); + let formatted = format_source(source_text, source_type, options); + snapshot.push_str(&formatted); + snapshot.push('\n'); + } + + snapshot.push_str("===================== End =====================\n"); + + snapshot +} + +/// Helper function to run a test for a single file +#[expect(unused)] +fn test_file(path: &Path) { + let source_text = fs::read_to_string(path).unwrap(); + let snapshot = generate_snapshot(path, &source_text); + let snapshot_path = current_dir().unwrap().join(path.parent().unwrap()); + let snapshot_name = path.file_name().unwrap().to_str().unwrap(); + + insta::with_settings!({ + snapshot_path => snapshot_path, + prepend_module_to_snapshot => false, + snapshot_suffix => "", + omit_expression => true, + + }, { + insta::assert_snapshot!(snapshot_name, snapshot); + }); +} + +// Include auto-generated test functions from build.rs +include!(concat!(env!("OUT_DIR"), "/generated_tests.rs")); diff --git a/crates/oxc_formatter/tests/mod.rs b/crates/oxc_formatter/tests/mod.rs index 4580a75c89ad3..171d8496c29f1 100644 --- a/crates/oxc_formatter/tests/mod.rs +++ b/crates/oxc_formatter/tests/mod.rs @@ -1 +1,2 @@ +mod fixtures; mod ir_transform;