amont_runtime/hooks/run_tests.rs
1//! pre-push-run-tests-js — run each touched JS package's gate before pushing.
2//!
3//! git feeds pre-push one line per ref on stdin. That list is parsed ONCE by
4//! the dispatcher (see `pushrefs`) and lent here, because stdin can only be
5//! consumed once and more than one check needs it:
6//! <local ref> <local oid> <remote ref> <remote oid>
7//! An all-zero local oid is a deletion; an all-zero remote oid means the branch
8//! is new, so everything it carries is in range.
9
10use super::common::program;
11use crate::check::Outcome;
12use crate::git;
13use std::process::{Command, Stdio};
14
15/// Whichever of these the package defines, cheapest first, stopping at the
16/// first failure — a type error costs seconds, not a full suite.
17///
18/// `lint` is deliberately absent: pre-commit-lint-js already lints staged files
19/// with the repo's pinned eslint, so repeating it here costs time and catches
20/// nothing new.
21///
22/// That argument is not special to `lint`. Any entry here can be moved earlier
23/// by a repository — see [`gated_at_commit`] — and once it runs on every commit,
24/// running it again on push is the same pure repetition. `typecheck` is the one
25/// people move: push is too late to hear about a type error you introduced an
26/// hour ago.
27const GATE: [&str; 3] = ["typecheck", "test:unit", "test"];
28
29/// GATE entries this repository already runs at COMMIT time, and so must not
30/// run again here.
31///
32/// A repository moves one earlier by declaring it in `amont.conf` under the
33/// name of the script:
34///
35/// ```text
36/// pre-commit typecheck *.ts,*.tsx block npm run typecheck
37/// ```
38///
39/// The name is the contract, deliberately — matching on the command would have
40/// to guess at `npm` vs `pnpm` vs `yarn`, at `--silent`, at a wrapper script,
41/// and would answer "no" to things that plainly are the same check.
42///
43/// **Only a declaration that would actually run, and actually block, counts**:
44///
45/// * `Kind::Runnable` excludes an unusable line and — through
46/// [`crate::manifest::gate`] — an UNTRUSTED manifest. A repository could
47/// otherwise declare `pre-commit typecheck`, never be trusted, and silently
48/// have types checked at neither end.
49/// * `hook.skip` is honoured for the same reason, one layer up: a declaration
50/// the author has switched off is not a check.
51/// * The EFFECTIVE severity must be `block`, overrides included. A `warn`
52/// declaration lets the commit through on failure, so treating it as
53/// commit-time coverage would replace a blocking push check with a
54/// non-blocking commit one.
55///
56/// The declaration's scope rides along rather than being judged here: whether
57/// it covered a given push depends on which files that push changed, which is
58/// a per-ref question `run` answers with [`crate::check::Scope::covers_all`].
59///
60/// Get any of these wrong and the result is the failure this project is
61/// arranged against — a push that reports a green gate having run nothing.
62/// A declaration passing this filter is still only a PROMISE: whether the
63/// check executed for the commits actually being pushed is what
64/// [`crate::gate_stamp`]'s per-commit stamps answer, in `run`.
65pub(crate) fn gated_at_commit(declared: &[crate::manifest::External]) -> Vec<GateDecl> {
66 // The fast path: pre-commit calls this on every commit to know what to
67 // stamp, and most repositories declare nothing — no GATE name declared
68 // means no git spawns for skips and severity overrides.
69 if gate_names_declared(declared).is_empty() {
70 return Vec::new();
71 }
72 let skips = crate::configured_skips();
73 let severities = crate::registry::Overrides::read();
74 GATE.iter()
75 .filter_map(|script| {
76 declared.iter().find_map(|ext| {
77 let crate::manifest::Kind::Runnable { scope, .. } = &ext.kind else {
78 return None;
79 };
80 (ext.stage == crate::check::Stage::PreCommit
81 && ext.short_name == *script
82 && severities.of(ext) == crate::check::Severity::Block
83 && !skips.iter().any(|s| crate::skip_suppresses(&ext.id, s)))
84 .then(|| GateDecl {
85 script,
86 id: ext.id.clone(),
87 scope: *scope,
88 })
89 })
90 })
91 .collect()
92}
93
94/// GATE names this manifest declares at pre-commit, before any config is
95/// read — the zero-spawn question "could this repository have a commit-time
96/// gate at all". [`gated_at_commit`] and the bypass ledger's fast path both
97/// start here, from the same predicate, so they cannot drift.
98pub(crate) fn gate_names_declared(declared: &[crate::manifest::External]) -> Vec<&'static str> {
99 GATE.iter()
100 .filter(|script| {
101 declared.iter().any(|ext| {
102 ext.stage == crate::check::Stage::PreCommit && ext.short_name == **script
103 })
104 })
105 .copied()
106 .collect()
107}
108
109/// One GATE entry a commit-time declaration stands in for.
110pub(crate) struct GateDecl {
111 /// The script name, as GATE spells it.
112 pub script: &'static str,
113 /// `<stage>-<name>` — what dispatch outcomes and `hook.skip` key on.
114 /// Carried so pre-commit can match "this declaration" to "that outcome"
115 /// without re-deriving the id and drifting.
116 pub id: String,
117 /// The declaration's scope, judged per ref by
118 /// [`crate::check::Scope::covers_all`].
119 pub scope: crate::check::Scope,
120}
121
122/// The extensions this check treats as "JS worth testing". Exported so
123/// `registry.rs` declares the scope from the same constant — see
124/// `lint_json_yaml::EXTS` for the drift this prevents.
125pub const JS_EXTS: &[&str] = &[".js", ".jsx", ".ts", ".tsx", ".vue"];
126
127fn is_js(file: &str) -> bool {
128 JS_EXTS.iter().any(|e| file.ends_with(e))
129}
130
131fn parent_of(path: &str) -> &str {
132 match path.rfind('/') {
133 Some(i) => &path[..i],
134 None => "",
135 }
136}
137
138/// Does `package.json` define `script` under "scripts"?
139///
140/// A brace-matched scan rather than a JSON parser: the only question is whether
141/// one key exists in one object, and a dependency-free binary is the point of
142/// this migration. Restricted to the TOP-LEVEL "scripts" object so a
143/// same-named key elsewhere (a dependency called `test`, say) cannot answer
144/// for it.
145pub fn defines_script(pkg_json: &str, script: &str) -> bool {
146 let Some(body) = scripts_object(pkg_json) else {
147 return false;
148 };
149 let needle = format!("\"{script}\"");
150 let mut from = 0;
151 while let Some(i) = body[from..].find(&needle) {
152 let at = from + i;
153 let after = &body[at + needle.len()..];
154 if after.trim_start().starts_with(':') {
155 return true;
156 }
157 from = at + needle.len();
158 }
159 false
160}
161
162/// The text of the top-level `"scripts"` object, braces included.
163///
164/// A depth-tracking scan, not `find("\"scripts\"")`: anchoring on the FIRST
165/// occurrence of the substring anywhere meant `{"files":["scripts"],…}`
166/// handed the brace-matcher whichever object came next — so a dependency
167/// named `test` answered as a script while the real scripts object was never
168/// read, which is exactly the false positive `defines_script`'s doc promises
169/// away. A string only counts as a key when it sits at depth 1 (inside the
170/// document object, outside any array) AND its next non-space byte is `:` —
171/// `{"name": "scripts"}` is a value, not the key.
172fn scripts_object(pkg_json: &str) -> Option<&str> {
173 let bytes = pkg_json.as_bytes();
174 let mut depth = 0usize;
175 let mut i = 0usize;
176 while i < bytes.len() {
177 match bytes[i] {
178 b'"' => {
179 let start = i + 1;
180 let close = string_end(bytes, start)?;
181 let content = &pkg_json[start..close];
182 i = close + 1;
183 if depth != 1 {
184 continue;
185 }
186 let mut j = i;
187 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
188 j += 1;
189 }
190 if j >= bytes.len() || bytes[j] != b':' {
191 continue; // a value, not a key
192 }
193 if content != "scripts" {
194 continue;
195 }
196 let mut k = j + 1;
197 while k < bytes.len() && bytes[k].is_ascii_whitespace() {
198 k += 1;
199 }
200 // The one top-level "scripts" key exists but is not an
201 // object: there are no scripts, and no later impostor may
202 // answer instead.
203 if k >= bytes.len() || bytes[k] != b'{' {
204 return None;
205 }
206 let end = object_end(bytes, k)?;
207 return Some(&pkg_json[k..=end]);
208 }
209 b'{' | b'[' => {
210 depth += 1;
211 i += 1;
212 }
213 b'}' | b']' => {
214 depth = depth.saturating_sub(1);
215 i += 1;
216 }
217 _ => i += 1,
218 }
219 }
220 None
221}
222
223/// Index of the closing quote of the string whose content starts at `from`.
224fn string_end(bytes: &[u8], from: usize) -> Option<usize> {
225 let mut escaped = false;
226 for (i, &c) in bytes.iter().enumerate().skip(from) {
227 if escaped {
228 escaped = false;
229 } else if c == b'\\' {
230 escaped = true;
231 } else if c == b'"' {
232 return Some(i);
233 }
234 }
235 None
236}
237
238/// Index of the `}` matching the `{` at `open`, string-aware.
239fn object_end(bytes: &[u8], open: usize) -> Option<usize> {
240 let mut depth = 0usize;
241 let mut i = open;
242 while i < bytes.len() {
243 match bytes[i] {
244 b'"' => i = string_end(bytes, i + 1)?,
245 b'{' => depth += 1,
246 b'}' => {
247 depth -= 1;
248 if depth == 0 {
249 return Some(i);
250 }
251 }
252 _ => {}
253 }
254 i += 1;
255 }
256 None
257}
258
259/// Tracked `package.json` paths, as directories relative to the repo root.
260///
261/// `git ls-files` instead of the shell version's `fd package.json`: it drops
262/// the `fd` dependency (undeclared, and one of two binaries the hooks silently
263/// required), and it is the more correct set anyway — only TRACKED packages can
264/// be part of a push, and node_modules is excluded by construction rather than
265/// by fd happening to honour .gitignore.
266pub fn package_dirs(ls_files: &[String]) -> Vec<String> {
267 let mut dirs: Vec<String> = ls_files
268 .iter()
269 .map(String::as_str)
270 .filter(|f| *f == "package.json" || f.ends_with("/package.json"))
271 .map(|f| parent_of(f).to_string())
272 .collect();
273 dirs.sort();
274 dirs.dedup();
275 dirs
276}
277
278/// Packages that actually contain one of the changed files.
279///
280/// `.iter().any()`, not the shell version's original `.filter()` — an empty
281/// array is truthy in JS, so that selected EVERY package regardless of what
282/// changed. Invisible in a single-package repo, quadratic noise in a monorepo.
283pub fn packages_to_test(pkg_dirs: &[String], changed_dirs: &[String]) -> Vec<String> {
284 pkg_dirs
285 .iter()
286 .filter(|pkg| {
287 changed_dirs.iter().any(|dir| {
288 if pkg.is_empty() {
289 true
290 } else {
291 dir == *pkg || dir.starts_with(&format!("{pkg}/"))
292 }
293 })
294 })
295 .cloned()
296 .collect()
297}
298
299/// The GATE, minus what a `pre-commit` declaration already covers.
300///
301/// Split from `run_gate` so the rule can be tested without a package on disk
302/// and without spawning npm — `run_gate` reaches for both.
303pub fn gate_for(pkg_json: &str, already: &[&str]) -> Vec<&'static str> {
304 GATE.iter()
305 .copied()
306 .filter(|s| defines_script(pkg_json, s) && !already.contains(s))
307 .collect()
308}
309
310/// True when the gate passed (or there was none to run).
311fn run_gate(root: &str, folder: &str, already: &[&str]) -> bool {
312 let dir = if folder.is_empty() {
313 root.to_string()
314 } else {
315 format!("{root}/{folder}")
316 };
317 let Ok(pkg) = std::fs::read_to_string(format!("{dir}/package.json")) else {
318 return true;
319 };
320 for script in gate_for(&pkg, already) {
321 // Same hazard as cargo test: git exports GIT_DIR to hooks, and a JS
322 // test that shells out to git would then operate on this repo rather
323 // than its own fixture.
324 let mut cmd = Command::new(program("npm"));
325 cmd.args(["run", script])
326 .current_dir(&dir)
327 .stdin(Stdio::null());
328 super::common::strip_git_env(&mut cmd);
329 match super::common::status_streamed(&mut cmd) {
330 Ok(super::common::Ran::Status(status)) if status.success() => {}
331 Ok(super::common::Ran::TimedOut(budget)) => {
332 super::common::say_timed_out(script, budget);
333 return false;
334 }
335 // The gate's own exit code is not propagated: git only
336 // distinguishes zero from non-zero, and npm's codes said nothing
337 // the message above has not already said.
338 Ok(_) | Err(_) => return false,
339 }
340 }
341 true
342}
343
344pub fn run(refs: &[crate::pushrefs::PushRef], declared: &[crate::manifest::External]) -> Outcome {
345 // An all-zero oid, of whatever length this repo's hash is (sha1 or sha256).
346 let zero = git::stdout(&["hash-object", "--stdin"])
347 .map(|h| "0".repeat(h.len()))
348 .unwrap_or_else(|| "0".repeat(40));
349
350 // `Unavailable`, never `Passed`, when git itself will not answer: a push
351 // gate that reports green having asked nothing is the exact conflation
352 // `Outcome::Unavailable` exists to prevent (see its doc in check.rs), and
353 // the same split `install::repo_hooks` was rewritten for. The dispatcher
354 // says "could not run" and does not block — loud, and honest.
355 let Some(root) = git::stdout(&["rev-parse", "--show-toplevel"]) else {
356 super::common::warn("run-tests-js: git would not answer — the gate did NOT run");
357 return Outcome::Unavailable;
358 };
359 let Some(tracked) = git::stdout_paths(&["ls-files"]) else {
360 super::common::warn("run-tests-js: git would not answer — the gate did NOT run");
361 return Outcome::Unavailable;
362 };
363 let pkg_dirs = package_dirs(&tracked);
364
365 // The DECLARATIONS are a property of the repository, resolved once; whether
366 // one covers a given push is a property of that ref's changed files and of
367 // its commits' stamps, judged inside the loop.
368 let declared_gate = gated_at_commit(declared);
369 let mut announced: Vec<&'static str> = Vec::new();
370 let mut warned: Vec<&'static str> = Vec::new();
371
372 for r in refs {
373 let local_oid = r.local_oid.as_str();
374 if local_oid == zero {
375 continue; // deleting a ref pushes no code
376 }
377 // `pushrefs::changed_files_for` exists for exactly this question, and
378 // this check used to recompute it inline with all three bugs that
379 // function's doc comment records fixing:
380 //
381 // - a brand-new branch (`remote_oid == zero`) diffed only its TIP,
382 // so on a multi-commit push an earlier commit's `.ts` change was
383 // invisible and the suite never ran;
384 // - a two-dot range handed to `diff-tree` is a two-TREE compare, not
385 // a commit walk, so a file changed and reverted later in the same
386 // push netted to nothing;
387 // - merge commits show NOTHING without `-m`, so a file touched only
388 // to resolve a conflict selected no package.
389 //
390 // Every one of those let a push proceed GREEN with the suite never
391 // having run. `rust_tools::test` was already the model.
392 let changed = crate::pushrefs::changed_files_for(r, &zero);
393 let js_changed: Vec<String> = changed.iter().filter(|f| is_js(f)).cloned().collect();
394 let changed_dirs: Vec<String> = js_changed
395 .iter()
396 .map(|f| parent_of(f).to_string())
397 .collect();
398 if changed_dirs.is_empty() {
399 continue;
400 }
401
402 // A declaration only stands in for this push if its scope saw every JS
403 // file the push changes — and only for the ROOT package, because that
404 // is where the declared command runs. A sub-package's gate is a
405 // different command in a different directory, and no root declaration
406 // has judged it.
407 //
408 // And the declaration must have EXECUTED, which is the stamps'
409 // question: every commit in this push the declaration would have
410 // fired on must carry a `gate_stamp` note naming the script. A commit
411 // made with `--no-verify`, from a client that runs no hooks, on a
412 // machine without amont, or with a rewritten hash has no stamp — and
413 // an unstamped commit is one the check never judged, so the gate runs
414 // rather than trusting the declaration's word for it.
415 let candidates: Vec<&GateDecl> = declared_gate
416 .iter()
417 .filter(|d| d.scope.covers_all(&js_changed))
418 .collect();
419 let mut already: Vec<&'static str> = Vec::new();
420 if !candidates.is_empty() {
421 let per_commit = crate::pushrefs::commits_and_files_for(r, &zero);
422 let ids: Vec<String> = per_commit.iter().map(|(c, _)| c.clone()).collect();
423 let stamps = crate::gate_stamp::stamps_for(&ids);
424 for d in candidates {
425 let unstamped = per_commit
426 .iter()
427 .filter(|(_, files)| d.scope.matches(files))
428 .filter(|(commit, _)| {
429 !stamps
430 .get(commit)
431 .is_some_and(|s| s.iter().any(|n| n == d.script))
432 })
433 .count();
434 if unstamped == 0 {
435 already.push(d.script);
436 } else if !warned.contains(&d.script) {
437 crate::say!(
438 "{} {} is declared at commit time, but {unstamped} pushed \
439 commit{} carr{} no record of it — running it here",
440 crate::ui::warning_sign(),
441 d.script,
442 if unstamped == 1 { "" } else { "s" },
443 if unstamped == 1 { "ies" } else { "y" },
444 );
445 warned.push(d.script);
446 }
447 }
448 }
449
450 // Same question as cargo-test: the suite should be answering about
451 // the commits being pushed, not about whatever is open in the
452 // editor — and about THIS ref's commits, not some other ref in the
453 // same push. A single worktree shared across every ref would run a
454 // second ref's tests against a first ref's tree.
455 let (run_in, _guard) = crate::pushed_tree::where_to_run(local_oid, &root);
456 let where_ = run_in.to_string_lossy().into_owned();
457 let folders = packages_to_test(&pkg_dirs, &changed_dirs);
458
459 // Said out loud, and not folded into the pass line. A check that stops
460 // running is exactly what this project refuses to let happen quietly —
461 // the reader has to be able to see that `typecheck` moved rather than
462 // discover later that nothing ran it. Announced only when the skip is
463 // actually applied to this ref: claiming suppression that severity,
464 // scope or a missing root package disqualified would be the same lie
465 // in the other direction.
466 if folders.iter().any(|f| f.is_empty()) {
467 let newly: Vec<&'static str> = already
468 .iter()
469 .copied()
470 .filter(|s| !announced.contains(s))
471 .collect();
472 if !newly.is_empty() {
473 crate::say!(
474 "{} {} gated at commit instead — not repeating {} here",
475 crate::ui::valid_sign(),
476 newly.join(", "),
477 if newly.len() == 1 { "it" } else { "them" },
478 );
479 announced.extend(newly);
480 }
481 }
482
483 for folder in folders {
484 // Root only: the declared command runs at the repo root.
485 let already_here: &[&str] = if folder.is_empty() { &already } else { &[] };
486 if !run_gate(&where_, &folder, already_here) {
487 return Outcome::Failed;
488 }
489 }
490 }
491 Outcome::Passed
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497
498 /// The filter itself, without a package on disk or an npm to spawn.
499 ///
500 /// Order is part of the contract and is asserted: GATE is cheapest-first so
501 /// a type error costs seconds rather than a full suite, and removing an
502 /// entry must not disturb what is left.
503 #[test]
504 fn the_gate_drops_only_what_commit_already_covers() {
505 let pkg = r#"{"scripts":{"typecheck":"tsc","test":"vitest run"}}"#;
506 assert_eq!(gate_for(pkg, &[]), vec!["typecheck", "test"]);
507 assert_eq!(gate_for(pkg, &["typecheck"]), vec!["test"]);
508 assert_eq!(gate_for(pkg, &["test"]), vec!["typecheck"]);
509 assert!(gate_for(pkg, &["typecheck", "test"]).is_empty());
510 // A name the package does not define is not a script to skip, and
511 // naming one must not disturb the rest.
512 assert_eq!(gate_for(pkg, &["test:unit"]), vec!["typecheck", "test"]);
513 }
514
515 /// Coverage is all-match: one changed file outside the declaration's scope
516 /// means commits this push carries were never judged by it.
517 #[test]
518 fn a_scope_covers_only_when_every_changed_file_matches() {
519 use crate::check::Scope;
520 const TS_ONLY: Scope = Scope::files(&[".ts", ".tsx"]);
521 let all_ts: Vec<String> = vec!["src/a.ts".into(), "src/b.tsx".into()];
522 let mixed: Vec<String> = vec!["src/a.ts".into(), "src/legacy.js".into()];
523 let js_only: Vec<String> = vec!["src/legacy.js".into()];
524 assert!(TS_ONLY.covers_all(&all_ts));
525 assert!(!TS_ONLY.covers_all(&mixed));
526 assert!(!TS_ONLY.covers_all(&js_only));
527 // An empty scope is `*` — it saw everything.
528 assert!(Scope::ALWAYS.covers_all(&mixed));
529 // Nothing relevant changed: nothing was missed.
530 assert!(TS_ONLY.covers_all(&[]));
531
532 // A bare-filename scope covers by BASENAME, never by suffix.
533 const BY_NAME: Scope = Scope {
534 files: &[],
535 names: &["package.json"],
536 opt_in: &[],
537 not_during: &[],
538 };
539 assert!(BY_NAME.covers_all(&["apps/web/package.json".into()]));
540 assert!(!BY_NAME.covers_all(&["not-package.json".into()]));
541 }
542
543 #[test]
544 fn finds_scripts_only_inside_the_scripts_object() {
545 let pkg = r#"{"name":"x","scripts":{"test":"vitest","typecheck":"tsc"},"devDependencies":{"lint":"1"}}"#;
546 assert!(defines_script(pkg, "test"));
547 assert!(defines_script(pkg, "typecheck"));
548 assert!(!defines_script(pkg, "test:unit"));
549 // present, but as a DEPENDENCY — must not answer for the scripts object
550 assert!(!defines_script(pkg, "lint"));
551 }
552
553 #[test]
554 fn survives_nested_objects_and_escaped_quotes() {
555 let pkg = r#"{"scripts":{"test":"echo \"hi\"","build":"x"},"other":{"test":"no"}}"#;
556 assert!(defines_script(pkg, "test"));
557 assert!(defines_script(pkg, "build"));
558 assert!(!defines_script(pkg, "other"));
559 }
560
561 #[test]
562 fn no_scripts_object_means_no_scripts() {
563 assert!(!defines_script(r#"{"name":"x"}"#, "test"));
564 }
565
566 /// The misanchor this scan replaced: `find("\"scripts\"")` hit the string
567 /// inside the `files` ARRAY, brace-matched the dependencies object that
568 /// followed, and a dependency named `test` answered as a script — while
569 /// the real scripts object was never read.
570 #[test]
571 fn a_scripts_string_elsewhere_cannot_anchor_the_scan() {
572 let pkg = r#"{"files":["scripts"],"dependencies":{"test":"1.0"},"scripts":{"build":"x"}}"#;
573 assert!(
574 !defines_script(pkg, "test"),
575 "a dependency answered as a script"
576 );
577 assert!(
578 defines_script(pkg, "build"),
579 "the real scripts object was skipped"
580 );
581
582 // The same shape via a string VALUE rather than an array element.
583 let pkg =
584 r#"{"description":"scripts","dependencies":{"test":"1.0"},"scripts":{"build":"x"}}"#;
585 assert!(!defines_script(pkg, "test"));
586 assert!(defines_script(pkg, "build"));
587
588 // A NESTED "scripts" key (inside another object) is not the top-level
589 // one.
590 let pkg = r#"{"config":{"scripts":{"test":"inner"}},"scripts":{"build":"x"}}"#;
591 assert!(!defines_script(pkg, "test"));
592 assert!(defines_script(pkg, "build"));
593 }
594
595 /// `"scripts"` as a top-level key whose value is not an object defines
596 /// nothing — and no later impostor may answer instead.
597 #[test]
598 fn a_non_object_scripts_value_defines_nothing() {
599 assert!(!defines_script(r#"{"scripts":"echo hi"}"#, "test"));
600 assert!(!defines_script(
601 r#"{"scripts":"x","other":{"test":"y"}}"#,
602 "test"
603 ));
604 }
605
606 #[test]
607 fn collects_package_directories() {
608 let ls: Vec<String> = [
609 "package.json",
610 "apps/web/package.json",
611 "apps/web/src/a.ts",
612 "README.md",
613 ]
614 .into_iter()
615 .map(String::from)
616 .collect();
617 assert_eq!(
618 package_dirs(&ls),
619 vec!["".to_string(), "apps/web".to_string()]
620 );
621 }
622
623 /// The JS bug: `.filter()` returns an array, `[]` is truthy, so every
624 /// package was selected whatever changed.
625 #[test]
626 fn selects_only_packages_containing_a_change() {
627 let pkgs = vec!["apps/web".to_string(), "apps/api".to_string()];
628 let changed = vec!["apps/web/src".to_string()];
629 assert_eq!(
630 packages_to_test(&pkgs, &changed),
631 vec!["apps/web".to_string()]
632 );
633 }
634
635 #[test]
636 fn the_repo_root_package_matches_any_change() {
637 let pkgs = vec!["".to_string()];
638 assert_eq!(
639 packages_to_test(&pkgs, &["src".to_string()]),
640 vec!["".to_string()]
641 );
642 }
643}