browserslist/queries/
extends.rs1use 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}