amont_runtime/hooks/
run_tests.rs1use super::common::program;
11use crate::check::Outcome;
12use crate::git;
13use std::process::{Command, Stdio};
14
15const GATE: [&str; 3] = ["typecheck", "test:unit", "test"];
22
23pub const JS_EXTS: &[&str] = &[".js", ".jsx", ".ts", ".tsx", ".vue"];
27
28fn is_js(file: &str) -> bool {
29 JS_EXTS.iter().any(|e| file.ends_with(e))
30}
31
32fn parent_of(path: &str) -> &str {
33 match path.rfind('/') {
34 Some(i) => &path[..i],
35 None => "",
36 }
37}
38
39pub fn defines_script(pkg_json: &str, script: &str) -> bool {
46 let Some(k) = pkg_json.find("\"scripts\"") else {
47 return false;
48 };
49 let Some(open_rel) = pkg_json[k..].find('{') else {
50 return false;
51 };
52 let open = k + open_rel;
53 let bytes = pkg_json.as_bytes();
54 let mut depth = 0usize;
55 let mut end = open;
56 let mut in_str = false;
57 let mut escaped = false;
58 for (i, &c) in bytes.iter().enumerate().skip(open) {
59 if in_str {
60 if escaped {
61 escaped = false;
62 } else if c == b'\\' {
63 escaped = true;
64 } else if c == b'"' {
65 in_str = false;
66 }
67 continue;
68 }
69 match c {
70 b'"' => in_str = true,
71 b'{' => depth += 1,
72 b'}' => {
73 depth -= 1;
74 if depth == 0 {
75 end = i;
76 break;
77 }
78 }
79 _ => {}
80 }
81 }
82 if end <= open {
83 return false;
84 }
85 let body = &pkg_json[open..=end];
86 let needle = format!("\"{script}\"");
87 let mut from = 0;
88 while let Some(i) = body[from..].find(&needle) {
89 let at = from + i;
90 let after = &body[at + needle.len()..];
91 if after.trim_start().starts_with(':') {
92 return true;
93 }
94 from = at + needle.len();
95 }
96 false
97}
98
99pub fn package_dirs(ls_files: &[String]) -> Vec<String> {
107 let mut dirs: Vec<String> = ls_files
108 .iter()
109 .map(String::as_str)
110 .filter(|f| *f == "package.json" || f.ends_with("/package.json"))
111 .map(|f| parent_of(f).to_string())
112 .collect();
113 dirs.sort();
114 dirs.dedup();
115 dirs
116}
117
118pub fn packages_to_test(pkg_dirs: &[String], changed_dirs: &[String]) -> Vec<String> {
124 pkg_dirs
125 .iter()
126 .filter(|pkg| {
127 changed_dirs.iter().any(|dir| {
128 if pkg.is_empty() {
129 true
130 } else {
131 dir == *pkg || dir.starts_with(&format!("{pkg}/"))
132 }
133 })
134 })
135 .cloned()
136 .collect()
137}
138
139fn run_gate(root: &str, folder: &str) -> bool {
141 let dir = if folder.is_empty() {
142 root.to_string()
143 } else {
144 format!("{root}/{folder}")
145 };
146 let Ok(pkg) = std::fs::read_to_string(format!("{dir}/package.json")) else {
147 return true;
148 };
149 for script in GATE.iter().filter(|s| defines_script(&pkg, s)) {
150 let mut cmd = Command::new(program("npm"));
154 cmd.args(["run", script])
155 .current_dir(&dir)
156 .stdin(Stdio::null());
157 super::common::strip_git_env(&mut cmd);
158 let status = cmd.status();
159 match status {
160 Ok(status) if status.success() => {}
161 Ok(_) | Err(_) => return false,
165 }
166 }
167 true
168}
169
170pub fn run(refs: &[crate::pushrefs::PushRef]) -> Outcome {
171 let zero = git::stdout(&["hash-object", "--stdin"])
173 .map(|h| "0".repeat(h.len()))
174 .unwrap_or_else(|| "0".repeat(40));
175
176 let Some(root) = git::stdout(&["rev-parse", "--show-toplevel"]) else {
177 return Outcome::Passed;
178 };
179 let pkg_dirs = git::stdout_paths(&["ls-files"])
180 .map(|f| package_dirs(&f))
181 .unwrap_or_default();
182
183 for r in refs {
184 let local_oid = r.local_oid.as_str();
185 if local_oid == zero {
186 continue; }
188 let changed = crate::pushrefs::changed_files_for(r, &zero);
204 let changed_dirs: Vec<String> = changed
205 .iter()
206 .map(String::as_str)
207 .filter(|f| is_js(f))
208 .map(|f| parent_of(f).to_string())
209 .collect();
210 if changed_dirs.is_empty() {
211 continue;
212 }
213
214 let (run_in, _guard) = crate::pushed_tree::where_to_run(local_oid, &root);
220 let where_ = run_in.to_string_lossy().into_owned();
221 for folder in packages_to_test(&pkg_dirs, &changed_dirs) {
222 if !run_gate(&where_, &folder) {
223 return Outcome::Failed;
224 }
225 }
226 }
227 Outcome::Passed
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233
234 #[test]
235 fn finds_scripts_only_inside_the_scripts_object() {
236 let pkg = r#"{"name":"x","scripts":{"test":"vitest","typecheck":"tsc"},"devDependencies":{"lint":"1"}}"#;
237 assert!(defines_script(pkg, "test"));
238 assert!(defines_script(pkg, "typecheck"));
239 assert!(!defines_script(pkg, "test:unit"));
240 assert!(!defines_script(pkg, "lint"));
242 }
243
244 #[test]
245 fn survives_nested_objects_and_escaped_quotes() {
246 let pkg = r#"{"scripts":{"test":"echo \"hi\"","build":"x"},"other":{"test":"no"}}"#;
247 assert!(defines_script(pkg, "test"));
248 assert!(defines_script(pkg, "build"));
249 assert!(!defines_script(pkg, "other"));
250 }
251
252 #[test]
253 fn no_scripts_object_means_no_scripts() {
254 assert!(!defines_script(r#"{"name":"x"}"#, "test"));
255 }
256
257 #[test]
258 fn collects_package_directories() {
259 let ls: Vec<String> = [
260 "package.json",
261 "apps/web/package.json",
262 "apps/web/src/a.ts",
263 "README.md",
264 ]
265 .into_iter()
266 .map(String::from)
267 .collect();
268 assert_eq!(
269 package_dirs(&ls),
270 vec!["".to_string(), "apps/web".to_string()]
271 );
272 }
273
274 #[test]
277 fn selects_only_packages_containing_a_change() {
278 let pkgs = vec!["apps/web".to_string(), "apps/api".to_string()];
279 let changed = vec!["apps/web/src".to_string()];
280 assert_eq!(
281 packages_to_test(&pkgs, &changed),
282 vec!["apps/web".to_string()]
283 );
284 }
285
286 #[test]
287 fn the_repo_root_package_matches_any_change() {
288 let pkgs = vec!["".to_string()];
289 assert_eq!(
290 packages_to_test(&pkgs, &["src".to_string()]),
291 vec!["".to_string()]
292 );
293 }
294}