Skip to main content

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}