foundry_test_utils/
ui_runner.rs

1use std::path::Path;
2use ui_test::spanned::Spanned;
3
4/// Test runner based on `ui_test`. Adapted from `https://github.com/paradigmxyz/solar/blob/main/tools/tester/src/lib.rs`.
5pub fn run_tests<'a>(cmd: &str, cmd_path: &'a Path, testdata: &'a Path) -> eyre::Result<()> {
6    ui_test::color_eyre::install()?;
7
8    let mut args = ui_test::Args::test()?;
9
10    // Fast path for `--list`, invoked by `cargo-nextest`.
11    {
12        let mut dummy_config = ui_test::Config::dummy();
13        dummy_config.with_args(&args);
14        if ui_test::nextest::emulate(&mut vec![dummy_config]) {
15            return Ok(());
16        }
17    }
18
19    // Condense output if not explicitly requested.
20    let requested_pretty = || std::env::args().any(|x| x.contains("--format"));
21    if matches!(args.format, ui_test::Format::Pretty) && !requested_pretty() {
22        args.format = ui_test::Format::Terse;
23    }
24
25    let config = config(cmd, cmd_path, &args, testdata);
26
27    let text_emitter = match args.format {
28        ui_test::Format::Terse => ui_test::status_emitter::Text::quiet(),
29        ui_test::Format::Pretty => ui_test::status_emitter::Text::verbose(),
30    };
31    let gha_emitter = ui_test::status_emitter::Gha::<true> { name: "Foundry Lint UI".to_string() };
32    let status_emitter = (text_emitter, gha_emitter);
33
34    // run tests on all .sol files
35    ui_test::run_tests_generic(
36        vec![config],
37        move |path, _config| Some(path.extension().is_some_and(|ext| ext == "sol")),
38        per_file_config,
39        status_emitter,
40    )?;
41
42    Ok(())
43}
44
45fn config<'a>(
46    cmd: &str,
47    cmd_path: &'a Path,
48    args: &ui_test::Args,
49    testdata: &'a Path,
50) -> ui_test::Config {
51    let root = testdata.parent().unwrap();
52    assert!(
53        testdata.exists(),
54        "testdata directory does not exist: {};\n\
55         you may need to initialize submodules: `git submodule update --init --checkout`",
56        testdata.display()
57    );
58
59    let mut config = ui_test::Config {
60        host: Some(get_host().to_string()),
61        target: None,
62        root_dir: testdata.into(),
63        program: ui_test::CommandBuilder {
64            program: cmd_path.into(),
65            args: {
66                let args = vec![cmd, "--json"];
67                args.into_iter().map(Into::into).collect()
68            },
69            out_dir_flag: None,
70            input_file_flag: None,
71            envs: vec![],
72            cfg_flag: None,
73        },
74        output_conflict_handling: ui_test::error_on_output_conflict,
75        bless_command: Some(format!("cargo nextest run {} -- --bless", module_path!())),
76        out_dir: root.join("target").join("ui"),
77        comment_start: "//",
78        diagnostic_extractor: ui_test::diagnostics::rustc::rustc_diagnostics_extractor,
79        ..ui_test::Config::dummy()
80    };
81
82    macro_rules! register_custom_flags {
83        ($($ty:ty),* $(,)?) => {
84            $(
85                config.custom_comments.insert(<$ty>::NAME, <$ty>::parse);
86                if let Some(default) = <$ty>::DEFAULT {
87                    config.comment_defaults.base().add_custom(<$ty>::NAME, default);
88                }
89            )*
90        };
91    }
92    register_custom_flags![];
93
94    config.comment_defaults.base().exit_status = None.into();
95    config.comment_defaults.base().require_annotations = Spanned::dummy(true).into();
96    config.comment_defaults.base().require_annotations_for_level =
97        Spanned::dummy(ui_test::diagnostics::Level::Warn).into();
98
99    let filters = [
100        (ui_test::Match::PathBackslash, b"/".to_vec()),
101        #[cfg(windows)]
102        (ui_test::Match::Exact(vec![b'\r']), b"".to_vec()),
103        #[cfg(windows)]
104        (ui_test::Match::Exact(br"\\?\".to_vec()), b"".to_vec()),
105        (root.into(), b"ROOT".to_vec()),
106    ];
107    config.comment_defaults.base().normalize_stderr.extend(filters.iter().cloned());
108    config.comment_defaults.base().normalize_stdout.extend(filters);
109
110    let filters: &[(&str, &str)] = &[
111        // Erase line and column info.
112        (r"\.(\w+):[0-9]+:[0-9]+(: [0-9]+:[0-9]+)?", ".$1:LL:CC"),
113    ];
114    for &(pattern, replacement) in filters {
115        config.filter(pattern, replacement);
116    }
117
118    let stdout_filters: &[(&str, &str)] =
119        &[(&env!("CARGO_PKG_VERSION").replace(".", r"\."), "VERSION")];
120    for &(pattern, replacement) in stdout_filters {
121        config.stdout_filter(pattern, replacement);
122    }
123    let stderr_filters: &[(&str, &str)] = &[];
124    for &(pattern, replacement) in stderr_filters {
125        config.stderr_filter(pattern, replacement);
126    }
127
128    config.with_args(args);
129    config
130}
131
132fn per_file_config(config: &mut ui_test::Config, file: &Spanned<Vec<u8>>) {
133    let Ok(src) = std::str::from_utf8(&file.content) else {
134        return;
135    };
136
137    assert_eq!(config.comment_start, "//");
138    let has_annotations = src.contains("//~");
139    config.comment_defaults.base().require_annotations = Spanned::dummy(has_annotations).into();
140    let code = if has_annotations && src.contains("ERROR:") { 1 } else { 0 };
141    config.comment_defaults.base().exit_status = Spanned::dummy(code).into();
142}
143
144fn get_host() -> &'static str {
145    static CACHE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
146    CACHE.get_or_init(|| {
147        let mut config = ui_test::Config::dummy();
148        config.program = ui_test::CommandBuilder::rustc();
149        config.fill_host_and_target().unwrap();
150        config.host.unwrap()
151    })
152}