browserslist/queries/
extends.rs

1use super::QueryResult;
2use crate::{error::Error, opts::Opts};
3
4#[cfg(test)]
5fn base_test_dir() -> &'static std::path::Path {
6    use std::{env::temp_dir, path::PathBuf, sync::OnceLock};
7    static BASE_TEST_DIR: OnceLock<PathBuf> = OnceLock::new();
8    BASE_TEST_DIR.get_or_init(|| temp_dir().join("browserslist-test-pkgs"))
9}
10
11#[cfg(target_arch = "wasm32")]
12pub(super) fn extends(pkg: &str, opts: &Opts) -> QueryResult {
13    if opts.dangerous_extend {
14        Err(Error::UnsupportedExtends)
15    } else {
16        check_extend_name(pkg).map(|_| Default::default())
17    }
18}
19
20#[cfg(not(target_arch = "wasm32"))]
21pub(super) fn extends(pkg: &str, opts: &Opts) -> QueryResult {
22    use std::{env, process};
23
24    use crate::{config, resolve};
25
26    let dangerous_extend =
27        opts.dangerous_extend || env::var("BROWSERSLIST_DANGEROUS_EXTEND").is_ok();
28    if !dangerous_extend {
29        check_extend_name(pkg)?;
30    }
31
32    let mut command = process::Command::new("node");
33    command.args(["-p", &format!("JSON.stringify(require('{pkg}'))")]);
34    #[cfg(test)]
35    command.current_dir(base_test_dir());
36    let output = command.output().map_err(|_| Error::UnsupportedExtends)?.stdout;
37    let config = serde_json::from_str(&String::from_utf8_lossy(&output))
38        .map_err(|_| Error::FailedToResolveExtend(pkg.to_string()))?;
39
40    resolve(&config::load_with_config(config, opts)?, opts)
41}
42
43fn check_extend_name(pkg: &str) -> Result<(), Error> {
44    let unscoped =
45        pkg.strip_prefix('@').and_then(|s| s.find('/').and_then(|i| s.get(i + 1..))).unwrap_or(pkg);
46    if !(unscoped.starts_with("browserslist-config-")
47        || pkg.starts_with('@') && unscoped == "browserslist-config")
48    {
49        return Err(Error::InvalidExtendName(
50            "Browserslist config needs `browserslist-config-` prefix.",
51        ));
52    }
53    if unscoped.contains('.') {
54        return Err(Error::InvalidExtendName("`.` not allowed in Browserslist config name."));
55    }
56    if pkg.contains("node_modules") {
57        return Err(Error::InvalidExtendName("`node_modules` not allowed in Browserslist config."));
58    }
59
60    Ok(())
61}
62
63#[cfg(all(test, not(miri)))]
64mod tests {
65    use std::fs;
66
67    use serde_json::json;
68    use test_case::test_case;
69
70    use super::*;
71    use crate::{
72        opts::Opts,
73        test::{run_compare, should_failed},
74    };
75
76    fn mock(name: &str, value: serde_json::Value) {
77        let dir = base_test_dir().join("node_modules").join(name);
78        fs::create_dir_all(&dir).unwrap();
79        fs::write(
80            dir.join("index.js"),
81            format!("module.exports = {}", serde_json::to_string(&value).unwrap()),
82        )
83        .unwrap();
84    }
85
86    fn clean(name: &str) {
87        let _ = fs::remove_dir_all(base_test_dir().join("node_modules").join(name));
88    }
89
90    #[test_case("browserslist-config-test", json!(["ie 11"]), "extends browserslist-config-test"; "package")]
91    #[test_case("browserslist-config-test-file/ie", json!(["ie 11"]), "extends browserslist-config-test-file/ie"; "file in package")]
92    #[test_case("@scope/browserslist-config-test", json!(["ie 11"]), "extends @scope/browserslist-config-test"; "scoped package")]
93    #[test_case("@example.com/browserslist-config-test", json!(["ie 11"]), "extends @example.com/browserslist-config-test"; "scoped package with dot in name")]
94    #[test_case("@scope/browserslist-config-test-file/ie", json!(["ie 11"]), "extends @scope/browserslist-config-test-file/ie"; "file in scoped package")]
95    #[test_case("@scope/browserslist-config", json!(["ie 11"]), "extends @scope/browserslist-config"; "file-less scoped package")]
96    #[test_case("browserslist-config-rel", json!(["ie 9-10"]), "extends browserslist-config-rel and not ie 9"; "with override")]
97    #[test_case("browserslist-config-with-env-a", json!({ "someEnv": ["ie 10"] }), "extends browserslist-config-with-env-a"; "no default env")]
98    #[test_case("browserslist-config-with-defaults", json!({ "defaults": ["ie 10"] }), "extends browserslist-config-with-defaults"; "default env")]
99    fn valid(pkg: &str, value: serde_json::Value, query: &str) {
100        mock(pkg, value);
101        run_compare(query, &Default::default(), Some(base_test_dir()));
102        clean(pkg);
103    }
104
105    #[test]
106    fn dangerous_extend() {
107        mock("pkg", json!(["ie 11"]));
108        run_compare(
109            "extends pkg",
110            &Opts { dangerous_extend: true, ..Default::default() },
111            Some(base_test_dir()),
112        );
113        clean("pkg");
114    }
115
116    #[test]
117    fn recursively_import() {
118        mock("browserslist-config-a", json!(["extends browserslist-config-b", "ie 9"]));
119        mock("browserslist-config-b", json!(["ie 10"]));
120        run_compare("extends browserslist-config-a", &Default::default(), Some(base_test_dir()));
121        clean("browserslist-config-a");
122        clean("browserslist-config-b");
123    }
124
125    #[test]
126    fn specific_env() {
127        mock("browserslist-config-with-env-b", json!(["ie 11"]));
128        run_compare(
129            "extends browserslist-config-with-env-b",
130            &Opts { env: Some("someEnv".into()), ..Default::default() },
131            Some(base_test_dir()),
132        );
133        clean("browserslist-config-with-env-b");
134    }
135
136    #[test_case("browserslist-config-wrong", json!(null), "extends browserslist-config-wrong"; "empty export")]
137    fn invalid(pkg: &str, value: serde_json::Value, query: &str) {
138        mock(pkg, value);
139        assert!(matches!(
140            should_failed(query, &Default::default()),
141            Error::FailedToResolveExtend(..)
142        ));
143        clean(pkg);
144    }
145
146    #[test_case("extends thing-without-prefix"; "without prefix")]
147    #[test_case("extends browserslist-config-package/../something"; "has dot")]
148    #[test_case("extends browserslist-config-test/node_modules/a"; "has node_modules")]
149    fn invalid_name(query: &str) {
150        assert!(matches!(should_failed(query, &Default::default()), Error::InvalidExtendName(..)));
151    }
152}