browser_tester 1.5.0

Deterministic lightweight browser runtime for Rust tests
Documentation
use browser_tester::{Error, Harness};
use proptest::collection::vec;
use proptest::prelude::*;
use proptest::test_runner::{FileFailurePersistence, TestCaseResult};

const PARSER_PROPTEST_REGRESSION_FILE: &str =
    "tests/proptest-regressions/parser_property_fuzz_test.txt";
const DEFAULT_PARSER_PROPTEST_CASES: u32 = 256;

fn env_proptest_cases(var_name: &str, default_cases: u32) -> u32 {
    std::env::var(var_name)
        .ok()
        .and_then(|raw| raw.parse::<u32>().ok())
        .filter(|value| *value > 0)
        .unwrap_or(default_cases)
}

fn parser_proptest_cases() -> u32 {
    env_proptest_cases(
        "BROWSER_TESTER_PROPTEST_CASES",
        DEFAULT_PARSER_PROPTEST_CASES,
    )
}

fn identifier_strategy() -> BoxedStrategy<String> {
    prop_oneof![
        Just("a"),
        Just("b"),
        Just("c"),
        Just("x"),
        Just("y"),
        Just("value"),
        Just("index"),
        Just("items"),
        Just("state"),
        Just("_tmp"),
    ]
    .prop_map(str::to_string)
    .boxed()
}

fn literal_strategy() -> BoxedStrategy<String> {
    prop_oneof![
        Just("undefined".to_string()),
        Just("null".to_string()),
        Just("true".to_string()),
        Just("false".to_string()),
        any::<i16>().prop_map(|v| v.to_string()),
        any::<u16>().prop_map(|v| v.to_string()),
        Just("'x'".to_string()),
        Just("'日本語'".to_string()),
        Just("\"double\"".to_string()),
        Just("`template`".to_string()),
    ]
    .boxed()
}

fn regex_literal_strategy() -> BoxedStrategy<String> {
    prop_oneof![
        Just("/a/".to_string()),
        Just("/\\d+/".to_string()),
        Just("/^\\w+$/".to_string()),
        Just("/foo(?=bar)/".to_string()),
        Just("/\\/(x|y)/".to_string()),
        Just("/[a-z]{1,3}/gi".to_string()),
    ]
    .boxed()
}

fn binary_operator_strategy() -> BoxedStrategy<&'static str> {
    prop_oneof![
        Just("+"),
        Just("-"),
        Just("*"),
        Just("/"),
        Just("%"),
        Just("&&"),
        Just("||"),
        Just("==="),
        Just("!=="),
        Just("<"),
        Just(">"),
        Just("<="),
        Just(">="),
    ]
    .boxed()
}

fn expression_strategy() -> BoxedStrategy<String> {
    let leaf = prop_oneof![
        identifier_strategy(),
        literal_strategy(),
        regex_literal_strategy(),
    ]
    .boxed();

    leaf.prop_recursive(4, 96, 8, |inner| {
        prop_oneof![
            inner.clone().prop_map(|expr| format!("({expr})")),
            inner.clone().prop_map(|expr| format!("!({expr})")),
            inner.clone().prop_map(|expr| format!("+({expr})")),
            inner.clone().prop_map(|expr| format!("-({expr})")),
            (inner.clone(), binary_operator_strategy(), inner.clone())
                .prop_map(|(lhs, op, rhs)| format!("({lhs} {op} {rhs})")),
            (inner.clone(), inner.clone(), inner.clone())
                .prop_map(|(cond, left, right)| format!("({cond} ? {left} : {right})")),
            vec(inner.clone(), 0..=3).prop_map(|items| format!("[{}]", items.join(", "))),
            (inner.clone(), inner.clone())
                .prop_map(|(left, right)| format!("{{ left: {left}, right: {right} }}")),
            (identifier_strategy(), vec(inner.clone(), 0..=3))
                .prop_map(|(name, args)| format!("{name}({})", args.join(", "))),
            (inner.clone(), inner.clone()).prop_map(|(target, index)| format!("{target}[{index}]")),
            (inner.clone(), inner.clone())
                .prop_map(|(target, key)| format!("{target}[String({key})]")),
        ]
    })
    .boxed()
}

fn simple_statement_strategy() -> BoxedStrategy<String> {
    let ident = identifier_strategy();
    let expr = expression_strategy();

    prop_oneof![
        (ident.clone(), expr.clone()).prop_map(|(name, value)| format!("let {name} = {value};")),
        (ident.clone(), expr.clone()).prop_map(|(name, value)| format!("const {name} = {value};")),
        (ident.clone(), expr.clone()).prop_map(|(name, value)| format!("{name} = {value};")),
        expr.clone().prop_map(|value| format!("{value};")),
        (expr.clone(), expr.clone()).prop_map(|(target, key)| format!(
            "Object.prototype.hasOwnProperty.call({target}, {key});"
        )),
    ]
    .boxed()
}

fn statement_strategy() -> BoxedStrategy<String> {
    let simple = simple_statement_strategy();

    simple
        .prop_recursive(4, 192, 8, |inner| {
            let expr = expression_strategy();
            let ident = identifier_strategy();

            prop_oneof![
                (
                    expr.clone(),
                    vec(inner.clone(), 1..=3),
                    vec(inner.clone(), 0..=2),
                )
                    .prop_map(|(cond, then_body, else_body)| {
                        if else_body.is_empty() {
                            format!("if ({cond}) {{ {} }}", then_body.join(" "))
                        } else {
                            format!(
                                "if ({cond}) {{ {} }} else {{ {} }}",
                                then_body.join(" "),
                                else_body.join(" ")
                            )
                        }
                    }),
                (ident.clone(), expr.clone(), expr.clone(), vec(inner.clone(), 1..=2))
                    .prop_map(|(name, start, end, body)| {
                        format!(
                            "for (let {name} = {start}; {name} < {end}; {name} = {name} + 1) {{ {} }}",
                            body.join(" ")
                        )
                    }),
                (expr.clone(), vec(inner.clone(), 1..=2)).prop_map(|(cond, body)| {
                    format!("while ({cond}) {{ {} break; }}", body.join(" "))
                }),
                (ident.clone(), vec(inner.clone(), 1..=3)).prop_map(|(name, body)| {
                    format!("function {name}(arg) {{ {} return arg; }}", body.join(" "))
                }),
                (vec(inner.clone(), 1..=2), vec(inner.clone(), 1..=2))
                    .prop_map(|(try_body, catch_body)| {
                        format!(
                            "try {{ {} }} catch (err) {{ {} }}",
                            try_body.join(" "),
                            catch_body.join(" ")
                        )
                    }),
            ]
        })
        .boxed()
}

fn callback_body_strategy() -> BoxedStrategy<String> {
    vec(statement_strategy(), 1..=10)
        .prop_map(|mut stmts| {
            stmts.push("return;".to_string());
            stmts.join("\n")
        })
        .boxed()
}

fn escaped_script_end_tag_strategy() -> BoxedStrategy<String> {
    prop_oneof![
        Just("<\\/script>".to_string()),
        Just("<\\/SCRIPT>".to_string()),
        Just("<\\/ScRiPt>".to_string()),
        Just("<\\x2Fscript>".to_string()),
    ]
    .boxed()
}

fn script_boundary_fragment_strategy() -> BoxedStrategy<String> {
    let marker = escaped_script_end_tag_strategy();
    prop_oneof![
        marker
            .clone()
            .prop_map(|value| format!("const marker = '{value}';")),
        marker
            .clone()
            .prop_map(|value| format!("const marker = \"{value}\";")),
        marker
            .clone()
            .prop_map(|value| format!("const marker = `{value}`;")),
        marker
            .clone()
            .prop_map(|value| format!("const marker = `x${{String('{value}')}}y`;")),
        marker
            .clone()
            .prop_map(|value| format!("const rx = /{value}/i;")),
        marker
            .clone()
            .prop_map(|value| format!("const rxHit = /{value}/i.test('{value}');")),
        marker
            .clone()
            .prop_map(|value| format!("const n = 1; // marker: {value}")),
        marker
            .clone()
            .prop_map(|value| format!("/* marker: {value} */ const block = 2;")),
    ]
    .boxed()
}

fn html_with_callback_body(callback_body: &str) -> String {
    format!(
        r#"
<button id="run">run</button>
<script>
document.getElementById("run").addEventListener("click", () => {{
{callback_body}
}});
</script>
"#
    )
}

fn assert_parser_path_never_panics(callback_body: &str) -> TestCaseResult {
    let html = html_with_callback_body(callback_body);
    let outcome = std::panic::catch_unwind(|| Harness::from_html(&html));
    prop_assert!(
        outcome.is_ok(),
        "Harness::from_html panicked for generated body:\n{callback_body}"
    );
    Ok(())
}

fn assert_script_boundary_path_never_reports_unclosed_script(
    fragments: &[String],
) -> TestCaseResult {
    let body = format!(
        "{}\ndocument.getElementById(\"run\").textContent = \"ok\";",
        fragments.join("\n")
    );
    let html = format!(
        r#"
<div id="run">seed</div>
<script>
{body}
</script>
"#
    );
    let outcome = std::panic::catch_unwind(|| Harness::from_html(&html));
    match outcome {
        Err(_) => {
            prop_assert!(
                false,
                "Harness::from_html panicked for script boundary body:\n{body}"
            );
        }
        Ok(Ok(harness)) => {
            prop_assert!(
                harness.assert_text("#run", "ok").is_ok(),
                "generated script did not execute as expected:\n{body}"
            );
        }
        Ok(Err(Error::HtmlParse(message))) => {
            prop_assert!(
                !message.contains("unclosed <script>"),
                "unexpected unclosed <script> for generated script boundary body:\n{body}\nerror: {message}"
            );
        }
        Ok(Err(_)) => {}
    }
    Ok(())
}

proptest! {
    #![proptest_config(ProptestConfig {
        cases: parser_proptest_cases(),
        failure_persistence: Some(Box::new(
            FileFailurePersistence::Direct(PARSER_PROPTEST_REGRESSION_FILE),
        )),
        .. ProptestConfig::default()
    })]

    #[test]
    fn parser_generated_statement_blocks_do_not_panic(body in callback_body_strategy()) {
        assert_parser_path_never_panics(&body)?;
    }

    #[test]
    fn parser_generated_expression_combinations_do_not_panic(expr in expression_strategy()) {
        let body = format!(
            r#"
const seed = {expr};
const wrapped = [seed, {expr}];
const first = wrapped[0];
const fallback = first ? first : wrapped[1];
Object.prototype.hasOwnProperty.call({{}}, String(fallback));
return;
"#
        );
        assert_parser_path_never_panics(body.as_str())?;
    }

    #[test]
    fn parser_script_boundary_combinations_do_not_report_unclosed_script(
        fragments in vec(script_boundary_fragment_strategy(), 1..=8)
    ) {
        assert_script_boundary_path_never_reports_unclosed_script(&fragments)?;
    }
}