1use std::collections::HashMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7pub struct TestClass<'a> {
9 pub name: &'a str,
10 pub test_case: bool,
11}
12
13pub struct TestSpec<'a> {
17 pub language: &'a str,
18 pub kind: &'a str,
19 pub path: &'a str,
20 pub line: usize,
21 pub name: &'a str,
22 pub classes: &'a [TestClass<'a>],
23}
24
25pub struct CommandFinder {
29 root: PathBuf,
30 folders: HashMap<String, Folder>,
31 sources: HashMap<String, Vec<String>>,
32}
33
34#[derive(Clone, Copy)]
35enum Runner {
36 Vitest,
37 Jest,
38 Mocha,
39 NodeTest,
40}
41
42#[derive(Clone, Copy)]
43enum Build {
44 Maven,
45 Gradle,
46}
47
48#[derive(Clone)]
49struct Cargo {
50 package: Option<String>,
51 workspace: bool,
52}
53
54#[derive(Clone)]
55struct PackageJson {
56 runner: Option<Runner>,
57}
58
59struct Folder {
60 cargo: Option<Cargo>,
61 go_mod: bool,
62 package_json: Option<PackageJson>,
63 build: Option<Build>,
64 gradlew: bool,
65}
66
67impl Folder {
68 fn read(dir: &Path) -> Self {
69 let text = |name: &str| {
70 fs::read(dir.join(name))
71 .ok()
72 .map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
73 };
74 let has = |name: &str| dir.join(name).is_file();
75 Folder {
76 cargo: text("Cargo.toml").map(|toml| Cargo {
77 package: package_name(&toml),
78 workspace: has_workspace(&toml),
79 }),
80 go_mod: has("go.mod"),
81 package_json: text("package.json").map(|json| PackageJson {
82 runner: runner(&json),
83 }),
84 build: if has("pom.xml") {
85 Some(Build::Maven)
86 } else if has("build.gradle") || has("build.gradle.kts") {
87 Some(Build::Gradle)
88 } else {
89 None
90 },
91 gradlew: has("gradlew"),
92 }
93 }
94}
95
96pub(crate) fn package_name(toml: &str) -> Option<String> {
98 let mut in_package = false;
99 for line in toml.lines().map(str::trim) {
100 if line.starts_with('[') {
101 in_package = line == "[package]";
102 } else if in_package
103 && let Some((key, value)) = line.split_once('=')
104 && key.trim() == "name"
105 {
106 return value.split(['"', '\'']).nth(1).map(str::to_string);
107 }
108 }
109 None
110}
111
112fn has_workspace(toml: &str) -> bool {
113 toml.lines()
114 .map(str::trim)
115 .any(|line| line == "[workspace]" || line.starts_with("[workspace."))
116}
117
118fn runner(package_json: &str) -> Option<Runner> {
119 let json: serde_json::Value = serde_json::from_str(package_json).ok()?;
120 let script = json["scripts"]["test"].as_str().unwrap_or_default();
121 let uses = |name: &str| {
122 ["devDependencies", "dependencies"]
123 .iter()
124 .any(|section| json[*section].get(name).is_some())
125 || script.split_whitespace().any(|word| word == name)
126 };
127 if uses("vitest") {
128 Some(Runner::Vitest)
129 } else if uses("jest") {
130 Some(Runner::Jest)
131 } else if uses("mocha") {
132 Some(Runner::Mocha)
133 } else if script.contains("node --test") {
134 Some(Runner::NodeTest)
135 } else {
136 None
137 }
138}
139
140fn parent_dir(path: &str) -> &str {
141 path.rsplit_once('/').map_or("", |(parent, _)| parent)
142}
143
144fn relative<'a>(dir: &str, path: &'a str) -> &'a str {
145 if dir.is_empty() {
146 path
147 } else {
148 path.strip_prefix(dir)
149 .and_then(|rest| rest.strip_prefix('/'))
150 .unwrap_or(path)
151 }
152}
153
154fn backslashed(name: &str, special: impl Fn(char) -> bool) -> String {
155 let mut out = String::with_capacity(name.len());
156 for c in name.chars() {
157 if special(c) {
158 out.push('\\');
159 }
160 out.push(c);
161 }
162 out
163}
164
165fn double_quoted(name: &str) -> String {
167 backslashed(name, |c| matches!(c, '"' | '\\' | '$' | '`'))
168}
169
170fn single_quoted(text: &str) -> String {
171 format!("'{}'", text.replace('\'', "'\\''"))
172}
173
174fn shell_word(text: &str) -> String {
177 let plain = !text.is_empty()
178 && text
179 .chars()
180 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '/' | '-'));
181 if plain {
182 text.to_string()
183 } else {
184 single_quoted(text)
185 }
186}
187
188fn in_folder(dir: &str, command: String) -> String {
190 if dir.is_empty() {
191 command
192 } else {
193 format!("cd {} && {command}", shell_word(dir))
194 }
195}
196
197const UNREPRESENTABLE: char = '\0';
199
200fn js_unescaped(name: &str) -> String {
205 let mut out = String::with_capacity(name.len());
206 let mut rest = name;
207 while let Some(c) = rest.chars().next() {
208 rest = &rest[c.len_utf8()..];
209 if c != '\\' {
210 out.push(c);
211 continue;
212 }
213 let Some(escaped) = rest.chars().next() else {
214 out.push('\\');
215 break;
216 };
217 if let Some(len) = line_break_len(rest) {
218 rest = &rest[len..];
219 continue;
220 }
221 let (decoded, len) = decoded_escape(rest).unwrap_or((escaped, escaped.len_utf8()));
222 out.push(decoded);
223 rest = &rest[len..];
224 }
225 out
226}
227
228fn line_break_len(text: &str) -> Option<usize> {
230 if text.starts_with("\r\n") {
231 return Some(2);
232 }
233 text.chars()
234 .next()
235 .filter(|c| matches!(c, '\n' | '\r' | '\u{2028}' | '\u{2029}'))
236 .map(char::len_utf8)
237}
238
239fn decoded_escape(text: &str) -> Option<(char, usize)> {
242 match text.as_bytes().first()? {
243 b'n' => Some(('\n', 1)),
244 b't' => Some(('\t', 1)),
245 b'r' => Some(('\r', 1)),
246 b'b' => Some(('\u{8}', 1)),
247 b'f' => Some(('\u{c}', 1)),
248 b'v' => Some(('\u{b}', 1)),
249 b'0'..=b'3' => octal_escape(text, 3),
250 b'4'..=b'7' => octal_escape(text, 2),
251 b'x' => Some((char::from_u32(hex_value(text.get(1..3)?)?)?, 3)),
252 b'u' => unicode_escape(text),
253 _ => None,
254 }
255}
256
257fn octal_escape(text: &str, max_digits: usize) -> Option<(char, usize)> {
258 let len = text
259 .bytes()
260 .take(max_digits)
261 .take_while(|byte| matches!(byte, b'0'..=b'7'))
262 .count();
263 Some((
264 char::from_u32(u32::from_str_radix(&text[..len], 8).ok()?)?,
265 len,
266 ))
267}
268
269fn hex_value(digits: &str) -> Option<u32> {
270 if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_hexdigit()) {
271 return None;
272 }
273 u32::from_str_radix(digits, 16).ok()
274}
275
276fn unicode_escape(text: &str) -> Option<(char, usize)> {
278 if let Some(braced) = text.strip_prefix("u{") {
279 let digits = &braced[..braced.find('}')?];
280 let code = hex_value(digits).filter(|code| *code <= 0x10FFFF)?;
281 let character = char::from_u32(code).unwrap_or(UNREPRESENTABLE);
282 return Some((character, digits.len() + 3));
283 }
284 let high = hex_value(text.get(1..5)?)?;
285 let low = text
286 .get(5..)
287 .and_then(|after| after.strip_prefix("\\u"))
288 .and_then(|after| hex_value(after.get(..4)?))
289 .filter(|low| (0xDC00..=0xDFFF).contains(low));
290 match (high, low) {
291 (0xD800..=0xDBFF, Some(low)) => {
292 let code = 0x10000 + ((high - 0xD800) << 10) + (low - 0xDC00);
293 Some((char::from_u32(code)?, 11))
294 }
295 _ => Some((char::from_u32(high).unwrap_or(UNREPRESENTABLE), 5)),
296 }
297}
298
299fn hole_len(text: &str) -> Option<usize> {
302 let code = text.strip_prefix("${")?;
303 Some(2 + closing_brace(code.as_bytes())? + 1)
304}
305
306fn closing_brace(code: &[u8]) -> Option<usize> {
308 let mut depth = 0;
309 let mut at = 0;
310 while at < code.len() {
311 match code[at] {
312 b'{' => depth += 1,
313 b'}' if depth == 0 => return Some(at),
314 b'}' => depth -= 1,
315 b'\'' | b'"' | b'`' => {
316 at = after_literal(code, at)?;
317 continue;
318 }
319 _ => {}
320 }
321 at += 1;
322 }
323 None
324}
325
326fn after_literal(code: &[u8], start: usize) -> Option<usize> {
329 let quote = code[start];
330 let mut at = start + 1;
331 while at < code.len() {
332 match code[at] {
333 b'\\' => at += 1,
334 b'$' if quote == b'`' && code.get(at + 1) == Some(&b'{') => {
335 at += 2 + closing_brace(&code[at + 2..])?;
336 }
337 byte if byte == quote => return Some(at + 1),
338 _ => {}
339 }
340 at += 1;
341 }
342 None
343}
344
345fn literal_parts(name: &str) -> Vec<String> {
348 let bytes = name.as_bytes();
349 let mut raw_parts = Vec::new();
350 let (mut start, mut at) = (0, 0);
351 while at < bytes.len() {
352 if bytes[at] == b'\\' {
353 at += 2;
354 } else if bytes[at] == b'$'
355 && let Some(len) = hole_len(&name[at..])
356 {
357 raw_parts.push(&name[start..at]);
358 at += len;
359 start = at;
360 } else {
361 at += 1;
362 }
363 }
364 raw_parts.push(&name[start..]);
365 raw_parts
366 .into_iter()
367 .flat_map(|raw| {
368 js_unescaped(raw)
369 .split(UNREPRESENTABLE)
370 .map(str::to_string)
371 .collect::<Vec<_>>()
372 })
373 .collect()
374}
375
376fn push_regex_escaped(out: &mut String, c: char) {
377 match c {
378 '\n' => out.push_str("\\n"),
379 '\t' => out.push_str("\\t"),
380 '\r' => out.push_str("\\r"),
381 '\u{2028}' | '\u{2029}' => out.push_str(&format!("\\u{:04x}", u32::from(c))),
382 c if c.is_control() => out.push_str(&format!("\\x{:02x}", u32::from(c))),
383 '\\' | '^' | '$' | '.' | '*' | '+' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' => {
384 out.push('\\');
385 out.push(c);
386 }
387 _ => out.push(c),
388 }
389}
390
391fn is_word_byte(byte: &u8) -> bool {
392 byte.is_ascii_alphanumeric() || *byte == b'_'
393}
394
395fn placeholder_len(text: &str) -> Option<usize> {
398 let bytes = text.as_bytes();
399 match bytes {
400 [
401 b'%',
402 b's' | b'd' | b'i' | b'f' | b'j' | b'o' | b'O' | b'p' | b'#',
403 ..,
404 ] => Some(2),
405 [b'$', first, ..] if first.is_ascii_alphabetic() || *first == b'_' => {
406 let mut len = 1;
407 loop {
408 len += bytes[len..].iter().take_while(|b| is_word_byte(b)).count();
409 match &bytes[len..] {
410 [b'.', next, ..] if is_word_byte(next) => len += 1,
411 _ => return Some(len),
412 }
413 }
414 }
415 _ => None,
416 }
417}
418
419fn each_pattern(name: &str) -> String {
423 let mut out = String::with_capacity(name.len());
424 let mut rest = name;
425 while let Some(c) = rest.chars().next() {
426 if rest.starts_with("%%") {
427 out.push_str("%%?");
428 rest = &rest[2..];
429 } else if let Some(len) = placeholder_len(rest) {
430 out.push_str(".*");
431 rest = &rest[len..];
432 } else {
433 push_regex_escaped(&mut out, c);
434 rest = &rest[c.len_utf8()..];
435 }
436 }
437 out
438}
439
440fn name_pattern(parts: &[String]) -> String {
443 parts
444 .iter()
445 .map(|part| each_pattern(part))
446 .collect::<Vec<_>>()
447 .join(".*")
448}
449
450fn splits_a_line(c: char) -> bool {
453 (c.is_control() && c != '\t') || matches!(c, '\u{2028}' | '\u{2029}')
454}
455
456fn longest_literal(parts: &[String]) -> Option<&str> {
459 parts
460 .iter()
461 .flat_map(|part| part.split(splits_a_line))
462 .filter(|piece| !piece.trim().is_empty())
463 .rev()
464 .max_by_key(|piece| piece.len())
465}
466
467fn depth(dir: &str) -> usize {
468 if dir.is_empty() {
469 0
470 } else {
471 dir.split('/').count()
472 }
473}
474
475fn wrapper_path(dir: &str, wrapper_dir: &str) -> String {
477 match depth(dir) - depth(wrapper_dir) {
478 0 => "./gradlew".to_string(),
479 up => format!("{}gradlew", "../".repeat(up)),
480 }
481}
482
483fn names_go_test(name: &str, prefix: &str) -> bool {
486 name.strip_prefix(prefix)
487 .is_some_and(|rest| !rest.chars().next().is_some_and(char::is_lowercase))
488}
489
490fn pytest_collects(classes: &[TestClass]) -> bool {
494 let named = classes
495 .iter()
496 .all(|class| class.test_case || class.name.starts_with("Test"));
497 let inside_test_case = classes
498 .split_last()
499 .is_some_and(|(_, outer)| outer.iter().any(|class| class.test_case));
500 named && !inside_test_case
501}
502
503fn python(test: &TestSpec) -> Option<String> {
506 if !pytest_collects(test.classes) {
507 return None;
508 }
509 let breaks_a_node_id = |text: &str| text.contains('[') || text.contains("::");
510 let (dir, file) = test.path.rsplit_once('/').unwrap_or(("", test.path));
511 if breaks_a_node_id(file) {
512 return None;
513 }
514 let (dir, file) = if breaks_a_node_id(dir) {
515 (dir, file)
516 } else {
517 ("", test.path)
518 };
519 let classes: String = test
520 .classes
521 .iter()
522 .map(|class| format!("{}::", class.name))
523 .collect();
524 Some(in_folder(
525 dir,
526 format!("pytest {}::{classes}{}", shell_word(file), test.name),
527 ))
528}
529
530impl CommandFinder {
531 pub fn new(root: &Path) -> Self {
533 Self {
534 root: root.to_path_buf(),
535 folders: HashMap::new(),
536 sources: HashMap::new(),
537 }
538 }
539
540 pub fn command(&mut self, test: &TestSpec) -> Option<String> {
542 match test.language {
543 "python" => python(test),
544 "rust" => Some(self.rust(test)),
545 "go" => self.go(test),
546 "javascript" | "typescript" | "tsx" | "jsx" => self.script(test),
547 "java" => self.java(test),
548 "csharp" => test.classes.last().map(|class| {
549 let filter = double_quoted(&format!("{}.{}", class.name, test.name));
550 format!("dotnet test --filter \"FullyQualifiedName~{filter}\"")
551 }),
552 _ => None,
553 }
554 }
555
556 fn folder(&mut self, dir: &str) -> &Folder {
557 let root = &self.root;
558 self.folders
559 .entry(dir.to_string())
560 .or_insert_with(|| Folder::read(&root.join(dir)))
561 }
562
563 fn nearest<T>(
566 &mut self,
567 dir: &str,
568 pick: impl Fn(&Folder) -> Option<T>,
569 ) -> Option<(String, T)> {
570 let mut dir = dir;
571 loop {
572 if let Some(found) = pick(self.folder(dir)) {
573 return Some((dir.to_string(), found));
574 }
575 if dir.is_empty() {
576 return None;
577 }
578 dir = parent_dir(dir);
579 }
580 }
581
582 fn source(&mut self, path: &str) -> &[String] {
584 let root = &self.root;
585 self.sources.entry(path.to_string()).or_insert_with(|| {
586 fs::read_to_string(root.join(path))
587 .map(|text| text.lines().map(str::to_string).collect())
588 .unwrap_or_default()
589 })
590 }
591
592 fn rust(&mut self, test: &TestSpec) -> String {
593 let ignored = if self.has_ignore_attribute(test) {
594 " -- --ignored"
595 } else {
596 ""
597 };
598 let name = format!("{}{ignored}", test.name);
599 let Some((dir, cargo)) = self.nearest(parent_dir(test.path), |folder| folder.cargo.clone())
600 else {
601 return format!("cargo test {name}");
602 };
603 let command = match cargo.package {
604 None => format!("cargo test {name}"),
605 Some(package) => match relative(&dir, test.path)
606 .strip_prefix("tests/")
607 .and_then(|rest| rest.strip_suffix(".rs"))
608 .filter(|stem| !stem.contains('/'))
609 {
610 Some(stem) => {
611 format!("cargo test -p {package} --test {} {name}", shell_word(stem))
612 }
613 None => format!("cargo test -p {package} {name}"),
614 },
615 };
616 if self
617 .folder("")
618 .cargo
619 .as_ref()
620 .is_some_and(|root| root.workspace)
621 {
622 command
623 } else {
624 in_folder(&dir, command)
625 }
626 }
627
628 fn has_ignore_attribute(&mut self, test: &TestSpec) -> bool {
630 let above = test.line.saturating_sub(1);
631 self.source(test.path)
632 .iter()
633 .take(above)
634 .rev()
635 .map(|line| line.trim())
636 .take_while(|line| line.is_empty() || line.starts_with('#') || line.starts_with("//"))
637 .filter(|line| line.starts_with('#'))
638 .any(|line| {
639 line.match_indices("#[ignore").any(|(at, found)| {
640 matches!(
641 line[at + found.len()..].trim_start().chars().next(),
642 Some(']' | '=')
643 )
644 })
645 })
646 }
647
648 fn go(&mut self, test: &TestSpec) -> Option<String> {
652 let name = test.name;
653 if test.kind != "function" || !test.path.ends_with("_test.go") {
654 return None;
655 }
656 let selector = if names_go_test(name, "Benchmark") {
657 format!("-bench '^{name}$' -run '^$'")
658 } else if (names_go_test(name, "Test") && name != "TestMain")
659 || names_go_test(name, "Fuzz")
660 || (names_go_test(name, "Example") && self.go_example_prints_output(test))
661 {
662 format!("-run '^{name}$'")
663 } else {
664 return None;
665 };
666 let module = self
667 .nearest(parent_dir(test.path), |folder| folder.go_mod.then_some(()))
668 .map_or_else(String::new, |(dir, _)| dir);
669 let dir = match parent_dir(relative(&module, test.path)) {
670 "" => ".",
671 dir => dir,
672 };
673 Some(in_folder(
674 &module,
675 format!("go test {} {selector}", shell_word(&format!("./{dir}"))),
676 ))
677 }
678
679 fn go_example_prints_output(&mut self, test: &TestSpec) -> bool {
683 let mut body = self
684 .source(test.path)
685 .iter()
686 .skip(test.line.saturating_sub(1));
687 if body
688 .next()
689 .is_none_or(|first| first.trim_end().ends_with('}'))
690 {
691 return false;
692 }
693 body.take_while(|line| line.as_str() != "}").any(|line| {
694 let comment = line.trim().strip_prefix("//").unwrap_or("").trim_start();
695 let comment = comment.to_ascii_lowercase();
696 comment.starts_with("output:") || comment.starts_with("unordered output:")
697 })
698 }
699
700 fn script(&mut self, test: &TestSpec) -> Option<String> {
701 let (dir, package) =
702 self.nearest(parent_dir(test.path), |folder| folder.package_json.clone())?;
703 let path = shell_word(relative(&dir, test.path));
704 let parts = literal_parts(test.name);
705 let pattern = || double_quoted(&name_pattern(&parts));
706 let command = match package.runner? {
707 Runner::Vitest => format!("npx vitest run {path} -t \"{}\"", pattern()),
708 Runner::Jest => format!("npx jest --runTestsByPath {path} -t \"{}\"", pattern()),
709 Runner::Mocha => match longest_literal(&parts) {
710 Some(part) => format!("npx mocha {path} --fgrep \"{}\"", double_quoted(part)),
711 None => format!("npx mocha {path}"),
712 },
713 Runner::NodeTest => format!(
714 "node --test-name-pattern \"{}\" {path}",
715 double_quoted(&format!("^{}$", name_pattern(&parts)))
716 ),
717 };
718 Some(in_folder(&dir, command))
719 }
720
721 fn java(&mut self, test: &TestSpec) -> Option<String> {
722 if test.classes.is_empty() {
723 return None;
724 }
725 let class = test
726 .classes
727 .iter()
728 .map(|class| class.name)
729 .collect::<Vec<_>>()
730 .join("$");
731 let (dir, build) = self.nearest(parent_dir(test.path), |folder| folder.build)?;
732 let command = match build {
733 Build::Maven => format!(
734 "mvn test -Dtest={}#{}",
735 shell_word(&class),
736 shell_word(test.name)
737 ),
738 Build::Gradle => {
739 let tool = match self.nearest(&dir, |folder| folder.gradlew.then_some(())) {
740 Some((wrapper_dir, _)) => wrapper_path(&dir, &wrapper_dir),
741 None => "gradle".to_string(),
742 };
743 let filter = single_quoted(&format!("{class}.{}", test.name));
744 format!("{tool} test --tests {filter}")
745 }
746 };
747 Some(in_folder(&dir, command))
748 }
749}
750
751#[cfg(test)]
752mod tests {
753 use super::*;
754 use crate::safe_tempdir;
755 use std::fs;
756
757 fn project(files: &[(&str, &str)]) -> tempfile::TempDir {
758 let temp = safe_tempdir();
759 for (path, content) in files {
760 let full = temp.path().join(path);
761 fs::create_dir_all(full.parent().unwrap()).unwrap();
762 fs::write(full, content).unwrap();
763 }
764 temp
765 }
766
767 fn command(
768 project: &tempfile::TempDir,
769 language: &str,
770 path: &str,
771 name: &str,
772 class: Option<&str>,
773 ) -> Option<String> {
774 nested(project, language, path, name, class.as_slice())
775 }
776
777 fn script(files: &[(&str, &str)], path: &str, name: &str) -> Option<String> {
778 command(&project(files), "typescript", path, name, None)
779 }
780
781 fn nested(
782 project: &tempfile::TempDir,
783 language: &str,
784 path: &str,
785 name: &str,
786 classes: &[&str],
787 ) -> Option<String> {
788 let classes: Vec<TestClass> = classes
789 .iter()
790 .map(|name| TestClass {
791 name,
792 test_case: false,
793 })
794 .collect();
795 CommandFinder::new(project.path()).command(&TestSpec {
796 language,
797 kind: "function",
798 path,
799 line: 1,
800 name,
801 classes: &classes,
802 })
803 }
804
805 #[test]
806 fn python_tests_run_through_pytest_with_the_class_only_for_a_member_of_one() {
807 let empty = project(&[]);
808
809 assert_eq!(
810 command(&empty, "python", "tests/test_a.py", "test_one", None).as_deref(),
811 Some("pytest tests/test_a.py::test_one")
812 );
813 assert_eq!(
814 command(
815 &empty,
816 "python",
817 "tests/test_a.py",
818 "test_one",
819 Some("TestA")
820 )
821 .as_deref(),
822 Some("pytest tests/test_a.py::TestA::test_one")
823 );
824 }
825
826 #[test]
827 fn a_rust_test_names_the_package_of_the_nearest_cargo_toml() {
828 let crate_dir = project(&[
829 (
830 "Cargo.toml",
831 "[workspace]\nmembers = [\"crates/a\"]\n\n[package]\nname = \"outer\"\n",
832 ),
833 (
834 "crates/a/Cargo.toml",
835 "[dependencies]\nname = \"not-this\"\n\n[package]\nversion = \"0.1.0\"\nname = \"inner-crate\" # the crate\n\n[lib]\nname = \"also_not\"\n",
836 ),
837 ]);
838
839 let run = |path: &str| command(&crate_dir, "rust", path, "case", None);
840
841 assert_eq!(
842 run("crates/a/src/lib.rs").as_deref(),
843 Some("cargo test -p inner-crate case")
844 );
845 assert_eq!(
846 run("crates/a/tests/it.rs").as_deref(),
847 Some("cargo test -p inner-crate --test it case")
848 );
849 assert_eq!(
850 run("crates/a/tests/sub/it.rs").as_deref(),
851 Some("cargo test -p inner-crate case")
852 );
853 assert_eq!(
854 run("src/main.rs").as_deref(),
855 Some("cargo test -p outer case")
856 );
857 }
858
859 #[test]
860 fn a_rust_test_without_a_package_name_runs_by_name_alone() {
861 let none = project(&[]);
862 let workspace_only = project(&[("Cargo.toml", "[workspace]\nmembers = []\n")]);
863
864 assert_eq!(
865 command(&none, "rust", "tests/it.rs", "case", None).as_deref(),
866 Some("cargo test case")
867 );
868 assert_eq!(
869 command(&workspace_only, "rust", "src/lib.rs", "case", None).as_deref(),
870 Some("cargo test case")
871 );
872 }
873
874 #[test]
875 fn a_go_test_names_its_folder_relative_to_the_nearest_go_mod() {
876 let module = project(&[
877 ("go.mod", "module example.com/m\n"),
878 ("sub/go.mod", "module example.com/m/sub\n"),
879 ]);
880
881 let run = |path: &str| command(&module, "go", path, "TestRun", None);
882
883 assert_eq!(
884 run("pkg/run_test.go").as_deref(),
885 Some("go test ./pkg -run '^TestRun$'")
886 );
887 assert_eq!(
888 run("run_test.go").as_deref(),
889 Some("go test ./. -run '^TestRun$'")
890 );
891 assert_eq!(
892 run("sub/deep/pkg/run_test.go").as_deref(),
893 Some("cd sub && go test ./deep/pkg -run '^TestRun$'")
894 );
895 assert_eq!(
896 run("sub/run_test.go").as_deref(),
897 Some("cd sub && go test ./. -run '^TestRun$'")
898 );
899 }
900
901 #[test]
902 fn a_script_test_picks_the_runner_the_nearest_package_json_names() {
903 let run = |body: &str| {
904 script(
905 &[("package.json", body)],
906 "src/a.test.ts",
907 "adds \"two\" numbers",
908 )
909 };
910
911 assert_eq!(
912 run(r#"{"devDependencies":{"vitest":"^1"}}"#).as_deref(),
913 Some(r#"npx vitest run src/a.test.ts -t "adds \"two\" numbers""#)
914 );
915 assert_eq!(
916 run(r#"{"dependencies":{"jest":"^29"}}"#).as_deref(),
917 Some(r#"npx jest --runTestsByPath src/a.test.ts -t "adds \"two\" numbers""#)
918 );
919 assert_eq!(
920 run(r#"{"devDependencies":{"mocha":"^10"}}"#).as_deref(),
921 Some(r#"npx mocha src/a.test.ts --fgrep "adds \"two\" numbers""#)
922 );
923 assert_eq!(
924 run(r#"{"scripts":{"test":"node --test"}}"#).as_deref(),
925 Some(r#"node --test-name-pattern "^adds \"two\" numbers\$" src/a.test.ts"#)
926 );
927 assert_eq!(
928 run(r#"{"scripts":{"test":"cross-env CI=1 jest --runInBand"}}"#).as_deref(),
929 Some(r#"npx jest --runTestsByPath src/a.test.ts -t "adds \"two\" numbers""#)
930 );
931 assert_eq!(run(r#"{"scripts":{"test":"tap"}}"#), None);
932 assert_eq!(run("not json"), None);
933 }
934
935 #[test]
936 fn a_script_test_uses_the_nearest_package_json_only() {
937 let files = [
938 ("package.json", r#"{"devDependencies":{"vitest":"^1"}}"#),
939 ("pkg/package.json", r#"{"type":"module"}"#),
940 ];
941
942 assert_eq!(script(&files, "pkg/a.test.ts", "case"), None);
943 assert_eq!(
944 script(&files, "other/a.test.ts", "case").as_deref(),
945 Some(r#"npx vitest run other/a.test.ts -t "case""#)
946 );
947 assert_eq!(script(&[], "a.test.ts", "case"), None);
948 }
949
950 #[test]
951 fn a_script_name_escapes_the_characters_a_double_quoted_shell_word_expands() {
952 let files = [("package.json", r#"{"devDependencies":{"mocha":"^10"}}"#)];
953
954 assert_eq!(
955 script(&files, "a.test.ts", r#"costs $5 `now` a\\b"#).as_deref(),
956 Some(r#"npx mocha a.test.ts --fgrep "costs \$5 \`now\` a\\b""#)
957 );
958 }
959
960 #[test]
961 fn a_vitest_or_jest_name_escapes_the_characters_of_a_regular_expression() {
962 let name = "should not render evil javascript links in anchor href (prevent XSS)";
963 let vitest = [("package.json", r#"{"devDependencies":{"vitest":"^1"}}"#)];
964 let jest = [("package.json", r#"{"devDependencies":{"jest":"^29"}}"#)];
965
966 assert_eq!(
967 script(&vitest, "a.test.ts", name).as_deref(),
968 Some(
969 r#"npx vitest run a.test.ts -t "should not render evil javascript links in anchor href \\(prevent XSS\\)""#
970 )
971 );
972 assert_eq!(
973 script(&jest, "a.test.ts", "parses v1.2 (beta)").as_deref(),
974 Some(r#"npx jest --runTestsByPath a.test.ts -t "parses v1\\.2 \\(beta\\)""#)
975 );
976 }
977
978 #[test]
979 fn a_vitest_name_escapes_every_regular_expression_character_before_the_shell_quoting() {
980 let files = [("package.json", r#"{"devDependencies":{"vitest":"^1"}}"#)];
981
982 assert_eq!(
983 script(&files, "a.test.ts", r#"\\ ^ $ . * + ? ( ) [ ] { } | "x""#).as_deref(),
984 Some(
985 r#"npx vitest run a.test.ts -t "\\\\ \\^ \\\$ \\. \\* \\+ \\? \\( \\) \\[ \\] \\{ \\} \\| \"x\"""#
986 )
987 );
988 }
989
990 #[test]
991 fn a_mocha_name_is_a_plain_substring_so_it_keeps_its_regular_expression_characters() {
992 let files = [("package.json", r#"{"devDependencies":{"mocha":"^10"}}"#)];
993
994 assert_eq!(
995 script(&files, "a.test.ts", "parses v1.2 (beta)").as_deref(),
996 Some(r#"npx mocha a.test.ts --fgrep "parses v1.2 (beta)""#)
997 );
998 }
999
1000 #[test]
1001 fn a_script_name_decodes_its_source_escapes_before_any_escaping() {
1002 let run = |dependency: &str, name: &str| {
1003 script(
1004 &[(
1005 "package.json",
1006 &format!(r#"{{"devDependencies":{{"{dependency}":"^1"}}}}"#),
1007 )],
1008 "a.test.js",
1009 name,
1010 )
1011 };
1012
1013 assert_eq!(
1014 run("vitest", r"doesn\'t throw on empty input").as_deref(),
1015 Some(r#"npx vitest run a.test.js -t "doesn't throw on empty input""#)
1016 );
1017 assert_eq!(
1018 run("jest", r#"says \"hi\" politely"#).as_deref(),
1019 Some(r#"npx jest --runTestsByPath a.test.js -t "says \"hi\" politely""#)
1020 );
1021 assert_eq!(
1022 run("vitest", r"tick \` and back\\slash").as_deref(),
1023 Some(r#"npx vitest run a.test.js -t "tick \` and back\\\\slash""#)
1024 );
1025 assert_eq!(
1026 run("vitest", r"two\nlines\tand\rmore").as_deref(),
1027 Some(r#"npx vitest run a.test.js -t "two\\nlines\\tand\\rmore""#)
1028 );
1029 assert_eq!(
1030 run("mocha", r#"doesn\'t say \"hi\" or back\\slash"#).as_deref(),
1031 Some(r#"npx mocha a.test.js --fgrep "doesn't say \"hi\" or back\\slash""#)
1032 );
1033 }
1034
1035 #[test]
1036 fn a_vitest_or_jest_name_turns_each_format_placeholder_into_a_wildcard() {
1037 let vitest = [("package.json", r#"{"devDependencies":{"vitest":"^1"}}"#)];
1038 let jest = [("package.json", r#"{"devDependencies":{"jest":"^29"}}"#)];
1039
1040 assert_eq!(
1041 script(
1042 &vitest,
1043 "a.test.ts",
1044 "adds %i + %d = %s, %f %j %o %O %p #%#"
1045 )
1046 .as_deref(),
1047 Some(r#"npx vitest run a.test.ts -t "adds .* \\+ .* = .*, .* .* .* .* .* #.*""#)
1048 );
1049 assert_eq!(
1050 script(
1051 &jest,
1052 "a.test.ts",
1053 "$a is $b.c.d. costs $5 or 100% and 50%x"
1054 )
1055 .as_deref(),
1056 Some(
1057 r#"npx jest --runTestsByPath a.test.ts -t ".* is .*\\. costs \\\$5 or 100% and 50%x""#
1058 )
1059 );
1060 }
1061
1062 #[test]
1063 fn a_mocha_name_keeps_its_format_placeholders() {
1064 let files = [("package.json", r#"{"devDependencies":{"mocha":"^10"}}"#)];
1065
1066 assert_eq!(
1067 script(&files, "a.test.ts", "adds %i + %s for $name").as_deref(),
1068 Some(r#"npx mocha a.test.ts --fgrep "adds %i + %s for \$name""#)
1069 );
1070 }
1071
1072 #[test]
1073 fn a_mocha_name_keeps_a_double_percent_as_written() {
1074 let files = [("package.json", r#"{"devDependencies":{"mocha":"^10"}}"#)];
1075
1076 assert_eq!(
1077 script(&files, "a.test.ts", "pct 100%% sure").as_deref(),
1078 Some(r#"npx mocha a.test.ts --fgrep "pct 100%% sure""#)
1079 );
1080 }
1081
1082 #[test]
1083 fn a_vitest_or_jest_name_matches_a_double_percent_as_one_or_two_percent_signs() {
1084 let vitest = [("package.json", r#"{"devDependencies":{"vitest":"^1"}}"#)];
1085 let jest = [("package.json", r#"{"devDependencies":{"jest":"^29"}}"#)];
1086
1087 assert_eq!(
1088 script(&vitest, "a.test.ts", "pct 100%% sure %s").as_deref(),
1089 Some(r#"npx vitest run a.test.ts -t "pct 100%%? sure .*""#)
1090 );
1091 assert_eq!(
1092 script(&jest, "a.test.ts", "%%%s%%").as_deref(),
1093 Some(r#"npx jest --runTestsByPath a.test.ts -t "%%?.*%%?""#)
1094 );
1095 }
1096
1097 #[test]
1098 fn a_vitest_or_jest_name_turns_each_template_hole_into_a_wildcard() {
1099 let vitest = [("package.json", r#"{"devDependencies":{"vitest":"^1"}}"#)];
1100 let jest = [("package.json", r#"{"devDependencies":{"jest":"^29"}}"#)];
1101
1102 assert_eq!(
1103 script(&vitest, "a.test.ts", "handles ${kind} kind").as_deref(),
1104 Some(r#"npx vitest run a.test.ts -t "handles .* kind""#)
1105 );
1106 assert_eq!(
1107 script(&jest, "a.test.ts", "${a} and ${b.c}! (${ {x: 1}.x })").as_deref(),
1108 Some(r#"npx jest --runTestsByPath a.test.ts -t ".* and .*! \\(.*\\)""#)
1109 );
1110 assert_eq!(
1111 script(&vitest, "a.test.ts", "costs ${5").as_deref(),
1112 Some(r#"npx vitest run a.test.ts -t "costs \\\$\\{5""#)
1113 );
1114 assert_eq!(
1115 script(&vitest, "a.test.ts", r"costs \${5} total").as_deref(),
1116 Some(r#"npx vitest run a.test.ts -t "costs \\\$\\{5\\} total""#)
1117 );
1118 assert_eq!(
1119 script(&vitest, "a.test.ts", "$a and ${b}$c").as_deref(),
1120 Some(r#"npx vitest run a.test.ts -t ".* and .*.*""#)
1121 );
1122 }
1123
1124 #[test]
1125 fn a_mocha_name_with_a_template_hole_uses_its_longest_literal_part() {
1126 let files = [("package.json", r#"{"devDependencies":{"mocha":"^10"}}"#)];
1127 let run = |name: &str| script(&files, "a.test.ts", name);
1128
1129 assert_eq!(
1130 run("handles ${kind} kind").as_deref(),
1131 Some(r#"npx mocha a.test.ts --fgrep "handles ""#)
1132 );
1133 assert_eq!(
1134 run("a ${x} and a longer ${ {y: 1}.y } tail").as_deref(),
1135 Some(r#"npx mocha a.test.ts --fgrep " and a longer ""#)
1136 );
1137 assert_eq!(
1138 run("ab${x}cd").as_deref(),
1139 Some(r#"npx mocha a.test.ts --fgrep "ab""#)
1140 );
1141 assert_eq!(
1142 run(r"costs \${5} total").as_deref(),
1143 Some(r#"npx mocha a.test.ts --fgrep "costs \${5} total""#)
1144 );
1145 }
1146
1147 #[test]
1148 fn a_mocha_name_with_no_literal_part_has_no_name_filter() {
1149 let files = [("package.json", r#"{"devDependencies":{"mocha":"^10"}}"#)];
1150 let run = |name: &str| script(&files, "a.test.ts", name);
1151
1152 assert_eq!(run("${kind}").as_deref(), Some("npx mocha a.test.ts"));
1153 assert_eq!(run("${a} ${b}").as_deref(), Some("npx mocha a.test.ts"));
1154 }
1155
1156 #[test]
1157 fn a_script_name_decodes_hex_unicode_and_octal_escapes() {
1158 let vitest = [("package.json", r#"{"devDependencies":{"vitest":"^1"}}"#)];
1159 let mocha = [("package.json", r#"{"devDependencies":{"mocha":"^10"}}"#)];
1160
1161 assert_eq!(
1162 script(&vitest, "a.test.js", r"caf\xe9 é \u{1F600} 😀").as_deref(),
1163 Some("npx vitest run a.test.js -t \"café é 😀 😀\"")
1164 );
1165 assert_eq!(
1166 script(&mocha, "a.test.js", r"\101\60\8 \x41B \\x41").as_deref(),
1167 Some(r#"npx mocha a.test.js --fgrep "A08 AB \\x41""#)
1168 );
1169 assert_eq!(
1170 script(&vitest, "a.test.js", r"a\bb\fc\vd").as_deref(),
1171 Some(r#"npx vitest run a.test.js -t "a\\x08b\\x0cc\\x0bd""#)
1172 );
1173 assert_eq!(
1174 script(&vitest, "a.test.js", r"bad \xZ1 \u12 \u{110000} \u{}").as_deref(),
1175 Some(r#"npx vitest run a.test.js -t "bad xZ1 u12 u\\{110000\\} u\\{\\}""#)
1176 );
1177 }
1178
1179 #[test]
1180 fn a_character_a_command_cannot_carry_splits_a_script_name_like_a_template_hole() {
1181 let vitest = [("package.json", r#"{"devDependencies":{"vitest":"^1"}}"#)];
1182 let mocha = [("package.json", r#"{"devDependencies":{"mocha":"^10"}}"#)];
1183
1184 assert_eq!(
1185 script(&vitest, "a.test.js", r"a\0b\x00c\ud83dd").as_deref(),
1186 Some(r#"npx vitest run a.test.js -t "a.*b.*c.*d""#)
1187 );
1188 assert_eq!(
1189 script(&mocha, "a.test.js", r"a\0bcd\ud83de").as_deref(),
1190 Some(r#"npx mocha a.test.js --fgrep "bcd""#)
1191 );
1192 }
1193
1194 #[test]
1195 fn a_backslash_before_a_multibyte_character_or_at_the_end_of_a_name_keeps_the_hole_scan_in_step()
1196 {
1197 let vitest = [("package.json", r#"{"devDependencies":{"vitest":"^1"}}"#)];
1198
1199 assert_eq!(
1200 script(&vitest, "a.test.js", r"caf\é ${x} é${y}").as_deref(),
1201 Some(r#"npx vitest run a.test.js -t "café .* é.*""#)
1202 );
1203 assert_eq!(
1204 script(&vitest, "a.test.js", r"end\").as_deref(),
1205 Some(r#"npx vitest run a.test.js -t "end\\\\""#)
1206 );
1207 }
1208
1209 #[test]
1210 fn a_path_with_a_space_or_shell_character_is_single_quoted_in_every_command() {
1211 let scripts =
1212 |dependency: &str| format!(r#"{{"devDependencies":{{"{dependency}":"^1"}}}}"#);
1213 let tricky = "it's $x/a b.test.ts";
1214 let quoted = r#"'it'\''s $x/a b.test.ts'"#;
1215 let run =
1216 |dependency: &str| script(&[("package.json", &scripts(dependency))], tricky, "case");
1217
1218 assert_eq!(
1219 run("vitest").as_deref(),
1220 Some(format!(r#"npx vitest run {quoted} -t "case""#).as_str())
1221 );
1222 assert_eq!(
1223 run("jest").as_deref(),
1224 Some(format!(r#"npx jest --runTestsByPath {quoted} -t "case""#).as_str())
1225 );
1226 assert_eq!(
1227 run("mocha").as_deref(),
1228 Some(format!(r#"npx mocha {quoted} --fgrep "case""#).as_str())
1229 );
1230 assert_eq!(
1231 script(
1232 &[("package.json", r#"{"scripts":{"test":"node --test"}}"#)],
1233 tricky,
1234 "case"
1235 )
1236 .as_deref(),
1237 Some(format!(r#"node --test-name-pattern "^case\$" {quoted}"#).as_str())
1238 );
1239 }
1240
1241 #[test]
1242 fn a_path_with_a_space_is_single_quoted_for_pytest_cargo_and_go() {
1243 let empty = project(&[]);
1244 let cargo = project(&[("Cargo.toml", "[package]\nname = \"p\"\n")]);
1245 let module = project(&[("go.mod", "module m\n")]);
1246
1247 assert_eq!(
1248 command(&empty, "python", "tests/a b.py", "test_one", Some("TestA")).as_deref(),
1249 Some("pytest 'tests/a b.py'::TestA::test_one")
1250 );
1251 assert_eq!(
1252 command(&empty, "python", "tests/a_b.py", "test_one", None).as_deref(),
1253 Some("pytest tests/a_b.py::test_one")
1254 );
1255 assert_eq!(
1256 command(&cargo, "rust", "tests/my test.rs", "case", None).as_deref(),
1257 Some("cargo test -p p --test 'my test' case")
1258 );
1259 assert_eq!(
1260 command(&module, "go", "my pkg/run_test.go", "TestRun", None).as_deref(),
1261 Some("go test './my pkg' -run '^TestRun$'")
1262 );
1263 }
1264
1265 #[test]
1266 fn a_jest_command_names_its_file_by_path_so_no_character_is_a_regular_expression() {
1267 let jest = [("package.json", r#"{"devDependencies":{"jest":"^29"}}"#)];
1268
1269 for (path, word) in [
1270 ("test/(grp)/c.test.js", "'test/(grp)/c.test.js'"),
1271 ("test/[id]/c.test.js", "'test/[id]/c.test.js'"),
1272 ("test/a+b/c.test.js", "'test/a+b/c.test.js'"),
1273 ("test/d$x/c.test.js", "'test/d$x/c.test.js'"),
1274 ("test/a.test.js", "test/a.test.js"),
1275 ] {
1276 assert_eq!(
1277 script(&jest, path, "case").as_deref(),
1278 Some(format!(r#"npx jest --runTestsByPath {word} -t "case""#).as_str()),
1279 "{path}"
1280 );
1281 }
1282 }
1283
1284 #[test]
1285 fn a_node_test_command_runs_the_file_itself_so_no_character_is_a_glob() {
1286 let files = [("package.json", r#"{"scripts":{"test":"node --test"}}"#)];
1287
1288 for (path, word) in [
1289 ("test/[id]/a.test.js", "'test/[id]/a.test.js'"),
1290 ("test/+(x)/a.test.js", "'test/+(x)/a.test.js'"),
1291 ("test/!(x)/a.test.js", "'test/!(x)/a.test.js'"),
1292 ("test/{a,b}/a.test.js", "'test/{a,b}/a.test.js'"),
1293 ("test/a.test.js", "test/a.test.js"),
1294 ] {
1295 assert_eq!(
1296 script(&files, path, "case").as_deref(),
1297 Some(format!(r#"node --test-name-pattern "^case\$" {word}"#).as_str()),
1298 "{path}"
1299 );
1300 }
1301 }
1302
1303 #[test]
1304 fn a_python_test_in_a_nested_class_names_every_enclosing_class() {
1305 let empty = project(&[]);
1306
1307 assert_eq!(
1308 nested(
1309 &empty,
1310 "python",
1311 "tests/test_n.py",
1312 "test_deep",
1313 &["TestOuter", "TestInner"]
1314 )
1315 .as_deref(),
1316 Some("pytest tests/test_n.py::TestOuter::TestInner::test_deep")
1317 );
1318 assert_eq!(
1319 nested(
1320 &empty,
1321 "python",
1322 "tests/test_n.py",
1323 "test_top",
1324 &["TestOuter"]
1325 )
1326 .as_deref(),
1327 Some("pytest tests/test_n.py::TestOuter::test_top")
1328 );
1329 }
1330
1331 #[test]
1332 fn a_java_test_in_a_nested_class_names_the_binary_class_name_as_one_shell_word() {
1333 let maven = project(&[("pom.xml", "<project/>")]);
1334 let gradle = project(&[("gradlew", ""), ("build.gradle", "")]);
1335 let path = "src/test/java/OuterTest.java";
1336 let classes = ["OuterTest", "InnerTest", "DeepTest"];
1337
1338 assert_eq!(
1339 nested(&maven, "java", path, "checks", &classes).as_deref(),
1340 Some(r#"mvn test -Dtest='OuterTest$InnerTest$DeepTest'#checks"#)
1341 );
1342 assert_eq!(
1343 nested(&gradle, "java", path, "checks", &classes).as_deref(),
1344 Some(r#"./gradlew test --tests 'OuterTest$InnerTest$DeepTest.checks'"#)
1345 );
1346 assert_eq!(
1347 nested(&maven, "java", path, "checks", &["OuterTest"]).as_deref(),
1348 Some("mvn test -Dtest=OuterTest#checks")
1349 );
1350 }
1351
1352 #[test]
1353 fn a_java_or_csharp_name_with_a_shell_character_stays_one_shell_word() {
1354 let maven = project(&[("pom.xml", "<project/>")]);
1355 let gradle = project(&[("build.gradle", "")]);
1356 let path = "src/test/java/FooTest.java";
1357
1358 assert_eq!(
1359 nested(&maven, "java", path, "a$b `c` it's", &["Foo$1"]).as_deref(),
1360 Some(r#"mvn test -Dtest='Foo$1'#'a$b `c` it'\''s'"#)
1361 );
1362 assert_eq!(
1363 nested(&gradle, "java", path, "a$b it's", &["Foo$1"]).as_deref(),
1364 Some(r#"gradle test --tests 'Foo$1.a$b it'\''s'"#)
1365 );
1366 assert_eq!(
1367 nested(&maven, "csharp", "T/FooTests.cs", "C\"D$`e`\\f", &["A$B"]).as_deref(),
1368 Some(r#"dotnet test --filter "FullyQualifiedName~A\$B.C\"D\$\`e\`\\f""#)
1369 );
1370 }
1371
1372 #[test]
1373 fn a_csharp_test_in_a_nested_class_names_the_innermost_class() {
1374 let empty = project(&[]);
1375
1376 assert_eq!(
1377 nested(
1378 &empty,
1379 "csharp",
1380 "T/FooTests.cs",
1381 "Checks",
1382 &["FooTests", "Inner"]
1383 )
1384 .as_deref(),
1385 Some("dotnet test --filter \"FullyQualifiedName~Inner.Checks\"")
1386 );
1387 }
1388
1389 #[test]
1390 fn a_backslash_before_a_line_break_in_a_script_name_drops_both() {
1391 let vitest = [("package.json", r#"{"devDependencies":{"vitest":"^1"}}"#)];
1392 let mocha = [("package.json", r#"{"devDependencies":{"mocha":"^10"}}"#)];
1393
1394 for name in [
1395 "part one \\\npart two",
1396 "part one \\\r\npart two",
1397 "part one \\\rpart two",
1398 "part one \\\u{2028}part two",
1399 "part one \\\u{2029}part two",
1400 ] {
1401 assert_eq!(
1402 script(&vitest, "a.test.js", name).as_deref(),
1403 Some(r#"npx vitest run a.test.js -t "part one part two""#),
1404 "{name:?}"
1405 );
1406 assert_eq!(
1407 script(&mocha, "a.test.js", name).as_deref(),
1408 Some(r#"npx mocha a.test.js --fgrep "part one part two""#),
1409 "{name:?}"
1410 );
1411 }
1412 assert_eq!(
1413 script(&vitest, "a.test.js", "a\\\\\nb").as_deref(),
1414 Some(r#"npx vitest run a.test.js -t "a\\\\\\nb""#)
1415 );
1416 }
1417
1418 #[test]
1419 fn a_string_or_template_inside_a_template_hole_does_not_end_the_hole() {
1420 let vitest = [("package.json", r#"{"devDependencies":{"vitest":"^1"}}"#)];
1421 let run = |name: &str| script(&vitest, "a.test.js", name);
1422
1423 for name in [
1424 r#"literal ${"}"} inside hole"#,
1425 r"literal ${'}'} inside hole",
1426 "literal ${`}`} inside hole",
1427 r#"literal ${"\"}"} inside hole"#,
1428 r#"literal ${`x ${"}"} y`} inside hole"#,
1429 r"literal ${f('}', {a: 1}.a)} inside hole",
1430 ] {
1431 assert_eq!(
1432 run(name).as_deref(),
1433 Some(r#"npx vitest run a.test.js -t "literal .* inside hole""#),
1434 "{name}"
1435 );
1436 }
1437 assert_eq!(
1438 run(r#"a ${"} b"#).as_deref(),
1439 Some(r#"npx vitest run a.test.js -t "a \\\$\\{\"\\} b""#)
1440 );
1441 }
1442
1443 #[test]
1444 fn a_mocha_name_cuts_at_a_character_that_would_split_the_command_line() {
1445 let mocha = [("package.json", r#"{"devDependencies":{"mocha":"^10"}}"#)];
1446 let run = |name: &str| script(&mocha, "a.test.js", name);
1447
1448 assert_eq!(
1449 run(r"multi\nline name").as_deref(),
1450 Some(r#"npx mocha a.test.js --fgrep "line name""#)
1451 );
1452 assert_eq!(
1453 run(r"carriage\rreturn").as_deref(),
1454 Some(r#"npx mocha a.test.js --fgrep "carriage""#)
1455 );
1456 assert_eq!(
1457 run(r"backspace \b and formfeed \f").as_deref(),
1458 Some(r#"npx mocha a.test.js --fgrep " and formfeed ""#)
1459 );
1460 assert_eq!(
1461 run("real\nnewline").as_deref(),
1462 Some(r#"npx mocha a.test.js --fgrep "newline""#)
1463 );
1464 assert_eq!(
1465 run("ab\u{2028}cdef\u{85}gh").as_deref(),
1466 Some(r#"npx mocha a.test.js --fgrep "cdef""#)
1467 );
1468 assert_eq!(
1469 run(r"a\nb\fc").as_deref(),
1470 Some(r#"npx mocha a.test.js --fgrep "a""#)
1471 );
1472 assert_eq!(run(r"\n\f").as_deref(), Some("npx mocha a.test.js"));
1473 assert_eq!(
1474 run(r"tab\there").as_deref(),
1475 Some("npx mocha a.test.js --fgrep \"tab\there\"")
1476 );
1477 }
1478
1479 #[test]
1480 fn a_vitest_or_jest_name_writes_a_unicode_line_separator_as_an_escape() {
1481 let vitest = [("package.json", r#"{"devDependencies":{"vitest":"^1"}}"#)];
1482
1483 assert_eq!(
1484 script(&vitest, "a.test.js", "a\u{2028}b\u{2029}c").as_deref(),
1485 Some(r#"npx vitest run a.test.js -t "a\\u2028b\\u2029c""#)
1486 );
1487 }
1488
1489 #[test]
1490 fn a_go_test_below_the_root_changes_into_the_folder_of_its_go_mod() {
1491 let module = project(&[
1492 ("svc/go.mod", "module example.com/svc\n"),
1493 ("my svc/go.mod", "module example.com/mine\n"),
1494 ("a/b/go.mod", "module example.com/ab\n"),
1495 ]);
1496
1497 let run = |path: &str| command(&module, "go", path, "TestAdd", None);
1498
1499 assert_eq!(
1500 run("svc/pkg/m_test.go").as_deref(),
1501 Some("cd svc && go test ./pkg -run '^TestAdd$'")
1502 );
1503 assert_eq!(
1504 run("svc/m_test.go").as_deref(),
1505 Some("cd svc && go test ./. -run '^TestAdd$'")
1506 );
1507 assert_eq!(
1508 run("my svc/pkg/m_test.go").as_deref(),
1509 Some("cd 'my svc' && go test ./pkg -run '^TestAdd$'")
1510 );
1511 assert_eq!(
1512 run("a/b/c/m_test.go").as_deref(),
1513 Some("cd a/b && go test ./c -run '^TestAdd$'")
1514 );
1515 }
1516
1517 #[test]
1518 fn a_script_test_below_the_root_changes_into_the_folder_of_its_package_json() {
1519 let run = |manifest: &str, dir: &str| {
1520 script(
1521 &[(&format!("{dir}/package.json"), manifest)],
1522 &format!("{dir}/test/x.test.js"),
1523 "case",
1524 )
1525 };
1526
1527 assert_eq!(
1528 run(r#"{"devDependencies":{"vitest":"^1"}}"#, "packages/a").as_deref(),
1529 Some(r#"cd packages/a && npx vitest run test/x.test.js -t "case""#)
1530 );
1531 assert_eq!(
1532 run(r#"{"devDependencies":{"jest":"^29"}}"#, "packages/a").as_deref(),
1533 Some(r#"cd packages/a && npx jest --runTestsByPath test/x.test.js -t "case""#)
1534 );
1535 assert_eq!(
1536 run(r#"{"devDependencies":{"mocha":"^10"}}"#, "web").as_deref(),
1537 Some(r#"cd web && npx mocha test/x.test.js --fgrep "case""#)
1538 );
1539 assert_eq!(
1540 run(r#"{"scripts":{"test":"node --test"}}"#, "my pkg").as_deref(),
1541 Some(r#"cd 'my pkg' && node --test-name-pattern "^case\$" test/x.test.js"#)
1542 );
1543 }
1544
1545 #[test]
1546 fn a_rust_test_changes_into_its_crate_unless_the_root_manifest_is_a_workspace() {
1547 let member = "[package]\nname = \"inner\"\n";
1548 let no_root = project(&[("crates/n/Cargo.toml", member)]);
1549 let plain_root = project(&[
1550 ("Cargo.toml", "[package]\nname = \"outer\"\n"),
1551 ("crates/n/Cargo.toml", member),
1552 ]);
1553 let workspace_root = project(&[
1554 ("Cargo.toml", "[workspace]\nmembers = [\"crates/n\"]\n"),
1555 ("crates/n/Cargo.toml", member),
1556 ]);
1557 let unnamed = project(&[("crates/n/Cargo.toml", "[workspace]\nmembers = []\n")]);
1558
1559 let run =
1560 |project: &tempfile::TempDir, path: &str| command(project, "rust", path, "case", None);
1561
1562 assert_eq!(
1563 run(&no_root, "crates/n/src/lib.rs").as_deref(),
1564 Some("cd crates/n && cargo test -p inner case")
1565 );
1566 assert_eq!(
1567 run(&no_root, "crates/n/tests/it.rs").as_deref(),
1568 Some("cd crates/n && cargo test -p inner --test it case")
1569 );
1570 assert_eq!(
1571 run(&plain_root, "crates/n/src/lib.rs").as_deref(),
1572 Some("cd crates/n && cargo test -p inner case")
1573 );
1574 assert_eq!(
1575 run(&plain_root, "src/lib.rs").as_deref(),
1576 Some("cargo test -p outer case")
1577 );
1578 assert_eq!(
1579 run(&workspace_root, "crates/n/src/lib.rs").as_deref(),
1580 Some("cargo test -p inner case")
1581 );
1582 assert_eq!(
1583 run(&unnamed, "crates/n/src/lib.rs").as_deref(),
1584 Some("cd crates/n && cargo test case")
1585 );
1586 }
1587
1588 #[test]
1589 fn a_python_test_below_the_root_runs_from_the_root_by_its_path() {
1590 let nested_project = project(&[("pkg/pyproject.toml", "[project]\nname = \"p\"\n")]);
1591
1592 assert_eq!(
1593 command(
1594 &nested_project,
1595 "python",
1596 "pkg/tests/test_a.py",
1597 "test_one",
1598 None
1599 )
1600 .as_deref(),
1601 Some("pytest pkg/tests/test_a.py::test_one")
1602 );
1603 }
1604
1605 #[test]
1606 fn java_tests_run_through_the_nearest_maven_or_gradle_build() {
1607 let maven = project(&[("app/pom.xml", "<project/>")]);
1608 let wrapped = project(&[("gradlew", ""), ("app/build.gradle", "")]);
1609 let kotlin_dsl = project(&[("gradlew", ""), ("app/build.gradle.kts", "")]);
1610 let bare = project(&[("app/build.gradle", "")]);
1611 let both = project(&[("pom.xml", "<project/>"), ("build.gradle", "")]);
1612 let none = project(&[]);
1613 let path = "app/src/test/java/FooTest.java";
1614
1615 let run =
1616 |project: &tempfile::TempDir| command(project, "java", path, "checks", Some("FooTest"));
1617
1618 assert_eq!(
1619 run(&maven).as_deref(),
1620 Some("cd app && mvn test -Dtest=FooTest#checks")
1621 );
1622 assert_eq!(
1623 run(&wrapped).as_deref(),
1624 Some("cd app && ../gradlew test --tests 'FooTest.checks'")
1625 );
1626 assert_eq!(
1627 run(&kotlin_dsl).as_deref(),
1628 Some("cd app && ../gradlew test --tests 'FooTest.checks'")
1629 );
1630 assert_eq!(
1631 run(&bare).as_deref(),
1632 Some("cd app && gradle test --tests 'FooTest.checks'")
1633 );
1634 assert_eq!(
1635 command(&both, "java", path, "checks", Some("FooTest")).as_deref(),
1636 Some("mvn test -Dtest=FooTest#checks")
1637 );
1638 assert_eq!(run(&none), None);
1639 assert_eq!(command(&maven, "java", path, "checks", None), None);
1640 }
1641
1642 #[test]
1643 fn csharp_tests_run_through_dotnet_test_with_a_class_filter() {
1644 let empty = project(&[]);
1645
1646 assert_eq!(
1647 command(
1648 &empty,
1649 "csharp",
1650 "T/FooTests.cs",
1651 "Checks",
1652 Some("FooTests")
1653 )
1654 .as_deref(),
1655 Some("dotnet test --filter \"FullyQualifiedName~FooTests.Checks\"")
1656 );
1657 assert_eq!(
1658 command(&empty, "csharp", "T/FooTests.cs", "Checks", None),
1659 None
1660 );
1661 }
1662
1663 #[test]
1664 fn other_languages_have_no_command() {
1665 let empty = project(&[]);
1666
1667 for language in ["ruby", "php", "kotlin", "swift", "cpp", "vue"] {
1668 assert_eq!(
1669 command(&empty, language, "tests/a_test.x", "case", Some("A")),
1670 None,
1671 "{language}"
1672 );
1673 }
1674 }
1675
1676 #[test]
1677 fn a_marker_file_is_read_once_per_folder_for_the_life_of_the_finder() {
1678 let project = project(&[("package.json", r#"{"devDependencies":{"vitest":"^1"}}"#)]);
1679 let mut finder = CommandFinder::new(project.path());
1680 let spec = |path| TestSpec {
1681 language: "typescript",
1682 kind: "function",
1683 path,
1684 line: 1,
1685 name: "case",
1686 classes: &[],
1687 };
1688 let first = finder.command(&spec("a.test.ts"));
1689
1690 fs::write(
1691 project.path().join("package.json"),
1692 r#"{"devDependencies":{"jest":"^29"}}"#,
1693 )
1694 .unwrap();
1695 let second = finder.command(&spec("b.test.ts"));
1696
1697 assert_eq!(
1698 first.as_deref(),
1699 Some(r#"npx vitest run a.test.ts -t "case""#)
1700 );
1701 assert_eq!(
1702 second.as_deref(),
1703 Some(r#"npx vitest run b.test.ts -t "case""#)
1704 );
1705 }
1706
1707 fn at(
1708 project: &tempfile::TempDir,
1709 language: &str,
1710 kind: &str,
1711 path: &str,
1712 name: &str,
1713 ) -> Option<String> {
1714 let text = fs::read_to_string(project.path().join(path)).unwrap();
1715 let line = text
1716 .lines()
1717 .position(|line| line.contains(&format!("{name}(")))
1718 .unwrap()
1719 + 1;
1720 CommandFinder::new(project.path()).command(&TestSpec {
1721 language,
1722 kind,
1723 path,
1724 line,
1725 name,
1726 classes: &[],
1727 })
1728 }
1729
1730 fn pytest(project: &tempfile::TempDir, path: &str, classes: &[(&str, bool)]) -> Option<String> {
1731 let classes: Vec<TestClass> = classes
1732 .iter()
1733 .map(|(name, test_case)| TestClass {
1734 name,
1735 test_case: *test_case,
1736 })
1737 .collect();
1738 CommandFinder::new(project.path()).command(&TestSpec {
1739 language: "python",
1740 kind: "method",
1741 path,
1742 line: 1,
1743 name: "test_m",
1744 classes: &classes,
1745 })
1746 }
1747
1748 #[test]
1749 fn a_go_benchmark_runs_with_the_bench_flag_and_no_test() {
1750 let module = project(&[("go.mod", "module m\n")]);
1751
1752 assert_eq!(
1753 command(&module, "go", "pkg/run_test.go", "BenchmarkRun", None).as_deref(),
1754 Some("go test ./pkg -bench '^BenchmarkRun$' -run '^$'")
1755 );
1756 assert_eq!(
1757 command(&module, "go", "run_test.go", "Benchmark_snake", None).as_deref(),
1758 Some("go test ./. -bench '^Benchmark_snake$' -run '^$'")
1759 );
1760 }
1761
1762 #[test]
1763 fn a_go_fuzz_target_and_a_test_run_by_name_when_the_go_tool_reads_the_name_as_one() {
1764 let module = project(&[("go.mod", "module m\n")]);
1765
1766 for name in [
1767 "TestRun",
1768 "Test_snake",
1769 "Test",
1770 "FuzzRun",
1771 "Fuzz",
1772 "TestÜber",
1773 ] {
1774 assert_eq!(
1775 command(&module, "go", "pkg/run_test.go", name, None).as_deref(),
1776 Some(format!("go test ./pkg -run '^{name}$'").as_str()),
1777 "{name}"
1778 );
1779 }
1780 }
1781
1782 #[test]
1783 fn a_go_function_the_go_tool_does_not_run_by_name_has_no_command() {
1784 let module = project(&[("go.mod", "module m\n")]);
1785
1786 for name in [
1787 "helperNotATest",
1788 "Testlower",
1789 "Benchmarkx",
1790 "TestMain",
1791 "setupTest",
1792 ] {
1793 assert_eq!(
1794 command(&module, "go", "pkg/run_test.go", name, None),
1795 None,
1796 "{name}"
1797 );
1798 }
1799 assert_eq!(command(&module, "go", "pkg/run.go", "TestRun", None), None);
1800 let method = CommandFinder::new(module.path()).command(&TestSpec {
1801 language: "go",
1802 kind: "method",
1803 path: "pkg/run_test.go",
1804 line: 1,
1805 name: "TestSuiteMethod",
1806 classes: &[],
1807 });
1808 assert_eq!(method, None);
1809 }
1810
1811 #[test]
1812 fn a_go_example_runs_only_when_its_body_ends_in_an_output_comment() {
1813 let module = project(&[
1814 ("go.mod", "module m\n"),
1815 (
1816 "pkg/ex_test.go",
1817 "package pkg\n\nfunc ExampleOne() {\n\tfmt.Println(1)\n\t// Output: 1\n}\n\nfunc ExampleTwo() {\n\tfmt.Println(2)\n\t// Unordered output: 2\n}\n\nfunc ExampleThree() {\n\tfmt.Println(3)\n}\n\nfunc ExampleFour() {\n\tfmt.Println(4)\n\t// output: 4\n}\n\nfunc ExampleFive() { fmt.Println(5) }\n\nfunc ExampleSix() {\n\t// shows 6\n}\n",
1818 ),
1819 ]);
1820 let run = |name: &str| at(&module, "go", "function", "pkg/ex_test.go", name);
1821
1822 assert_eq!(
1823 run("ExampleOne").as_deref(),
1824 Some("go test ./pkg -run '^ExampleOne$'")
1825 );
1826 assert_eq!(
1827 run("ExampleTwo").as_deref(),
1828 Some("go test ./pkg -run '^ExampleTwo$'")
1829 );
1830 assert_eq!(
1831 run("ExampleFour").as_deref(),
1832 Some("go test ./pkg -run '^ExampleFour$'")
1833 );
1834 for name in ["ExampleThree", "ExampleFive", "ExampleSix"] {
1835 assert_eq!(run(name), None, "{name}");
1836 }
1837 }
1838
1839 #[test]
1840 fn a_maven_or_gradle_test_below_the_root_changes_into_the_folder_of_its_build() {
1841 let maven = project(&[
1842 ("mvn/pom.xml", "<project/>"),
1843 ("my app/pom.xml", "<project/>"),
1844 ]);
1845 let local_wrapper = project(&[("gr/gradlew", ""), ("gr/build.gradle", "")]);
1846 let far_wrapper = project(&[("gradlew", ""), ("a/b/build.gradle.kts", "")]);
1847 let mid_wrapper = project(&[("a/gradlew", ""), ("a/b/build.gradle", "")]);
1848 let root_build = project(&[("gradlew", ""), ("build.gradle", "")]);
1849
1850 let run = |project: &tempfile::TempDir, dir: &str| {
1851 command(
1852 project,
1853 "java",
1854 &format!("{dir}/src/test/java/FooTest.java"),
1855 "checks",
1856 Some("FooTest"),
1857 )
1858 };
1859
1860 assert_eq!(
1861 run(&maven, "mvn").as_deref(),
1862 Some("cd mvn && mvn test -Dtest=FooTest#checks")
1863 );
1864 assert_eq!(
1865 run(&maven, "my app").as_deref(),
1866 Some("cd 'my app' && mvn test -Dtest=FooTest#checks")
1867 );
1868 assert_eq!(
1869 run(&local_wrapper, "gr").as_deref(),
1870 Some("cd gr && ./gradlew test --tests 'FooTest.checks'")
1871 );
1872 assert_eq!(
1873 run(&far_wrapper, "a/b").as_deref(),
1874 Some("cd a/b && ../../gradlew test --tests 'FooTest.checks'")
1875 );
1876 assert_eq!(
1877 run(&mid_wrapper, "a/b").as_deref(),
1878 Some("cd a/b && ../gradlew test --tests 'FooTest.checks'")
1879 );
1880 assert_eq!(
1881 command(
1882 &root_build,
1883 "java",
1884 "src/test/java/FooTest.java",
1885 "checks",
1886 Some("FooTest")
1887 )
1888 .as_deref(),
1889 Some("./gradlew test --tests 'FooTest.checks'")
1890 );
1891 }
1892
1893 #[test]
1894 fn a_python_test_in_a_folder_with_square_brackets_runs_from_that_folder() {
1895 let empty = project(&[]);
1896
1897 assert_eq!(
1898 command(
1899 &empty,
1900 "python",
1901 "tests/a[b] (c)/test_p.py",
1902 "test_m",
1903 Some("TestK")
1904 )
1905 .as_deref(),
1906 Some("cd 'tests/a[b] (c)' && pytest test_p.py::TestK::test_m")
1907 );
1908 assert_eq!(
1909 command(&empty, "python", "a[b]/test_p.py", "test_free", None).as_deref(),
1910 Some("cd 'a[b]' && pytest test_p.py::test_free")
1911 );
1912 assert_eq!(
1913 command(&empty, "python", "a::b/test_p.py", "test_free", None).as_deref(),
1914 Some("cd 'a::b' && pytest test_p.py::test_free")
1915 );
1916 assert_eq!(
1917 command(&empty, "python", "tests/a]b/test_p.py", "test_free", None).as_deref(),
1918 Some("pytest 'tests/a]b/test_p.py'::test_free")
1919 );
1920 }
1921
1922 #[test]
1923 fn a_python_test_in_a_file_whose_name_holds_square_brackets_has_no_command() {
1924 let empty = project(&[]);
1925
1926 assert_eq!(
1927 command(&empty, "python", "tests/test_[a].py", "test_m", None),
1928 None
1929 );
1930 assert_eq!(
1931 command(&empty, "python", "tests/test::a.py", "test_m", None),
1932 None
1933 );
1934 }
1935
1936 #[test]
1937 fn a_python_test_in_a_class_pytest_does_not_collect_has_no_command() {
1938 let empty = project(&[]);
1939 let path = "tests/test_c.py";
1940
1941 assert_eq!(pytest(&empty, path, &[("Helper", false)]), None);
1942 assert_eq!(
1943 pytest(&empty, path, &[("TestOuter", false), ("Helper", false)]),
1944 None
1945 );
1946 assert_eq!(
1947 pytest(
1948 &empty,
1949 path,
1950 &[
1951 ("TestOuter", false),
1952 ("Helper", false),
1953 ("TestInner", false)
1954 ]
1955 ),
1956 None
1957 );
1958 assert_eq!(
1959 pytest(&empty, path, &[("Testing", false)]).as_deref(),
1960 Some("pytest tests/test_c.py::Testing::test_m")
1961 );
1962 }
1963
1964 #[test]
1965 fn a_python_test_case_subclass_is_collected_under_any_name_but_its_nested_classes_are_not() {
1966 let empty = project(&[]);
1967 let path = "tests/test_c.py";
1968
1969 assert_eq!(
1970 pytest(&empty, path, &[("FooTests", true)]).as_deref(),
1971 Some("pytest tests/test_c.py::FooTests::test_m")
1972 );
1973 assert_eq!(
1974 pytest(&empty, path, &[("TestOuter", false), ("Inner", true)]).as_deref(),
1975 Some("pytest tests/test_c.py::TestOuter::Inner::test_m")
1976 );
1977 assert_eq!(
1978 pytest(&empty, path, &[("FooTests", true), ("TestInner", false)]),
1979 None
1980 );
1981 assert_eq!(
1982 pytest(&empty, path, &[("TestFoo", true), ("TestInner", false)]),
1983 None
1984 );
1985 }
1986
1987 #[test]
1988 fn a_rust_test_marked_ignore_runs_with_the_ignored_flag() {
1989 let cargo = project(&[
1990 ("Cargo.toml", "[package]\nname = \"p\"\n"),
1991 (
1992 "src/lib.rs",
1993 "#[cfg(test)]\nmod tests {\n #[test]\n #[ignore]\n fn slow() {}\n\n #[test]\n #[ignore = \"needs network\"]\n fn net() {}\n\n #[ignore] // later\n #[test]\n fn flipped() {}\n\n #[test]\n #[ignored_by_us]\n fn custom() {}\n\n /// #[ignore]\n #[test]\n fn documented() {}\n\n #[test]\n fn fast() {}\n\n #[test] #[ignore]\n fn one_line() {}\n}\n",
1994 ),
1995 (
1996 "tests/it.rs",
1997 "#[test]\n#[ignore]\nfn slow_it() {}\n\n#[test]\nfn ok_it() {}\n",
1998 ),
1999 ]);
2000 let run = |path: &str, name: &str| at(&cargo, "rust", "function", path, name);
2001
2002 for name in ["slow", "net", "flipped", "one_line"] {
2003 assert_eq!(
2004 run("src/lib.rs", name).as_deref(),
2005 Some(format!("cargo test -p p {name} -- --ignored").as_str()),
2006 "{name}"
2007 );
2008 }
2009 for name in ["custom", "documented", "fast"] {
2010 assert_eq!(
2011 run("src/lib.rs", name).as_deref(),
2012 Some(format!("cargo test -p p {name}").as_str()),
2013 "{name}"
2014 );
2015 }
2016 assert_eq!(
2017 run("tests/it.rs", "slow_it").as_deref(),
2018 Some("cargo test -p p --test it slow_it -- --ignored")
2019 );
2020 assert_eq!(
2021 run("tests/it.rs", "ok_it").as_deref(),
2022 Some("cargo test -p p --test it ok_it")
2023 );
2024 }
2025
2026 #[test]
2027 fn an_ignored_rust_test_below_the_root_or_without_a_package_keeps_the_ignored_flag() {
2028 let member = project(&[
2029 ("crates/n/Cargo.toml", "[package]\nname = \"inner\"\n"),
2030 ("crates/n/src/lib.rs", "#[test]\n#[ignore]\nfn slow() {}\n"),
2031 ]);
2032 let bare = project(&[("src/lib.rs", "#[test]\n#[ignore]\nfn slow() {}\n")]);
2033
2034 assert_eq!(
2035 at(&member, "rust", "function", "crates/n/src/lib.rs", "slow").as_deref(),
2036 Some("cd crates/n && cargo test -p inner slow -- --ignored")
2037 );
2038 assert_eq!(
2039 at(&bare, "rust", "function", "src/lib.rs", "slow").as_deref(),
2040 Some("cargo test slow -- --ignored")
2041 );
2042 }
2043
2044 #[test]
2045 fn a_node_test_runs_only_the_named_test_even_when_the_name_holds_regular_expression_characters()
2046 {
2047 let files = [("package.json", r#"{"scripts":{"test":"node --test"}}"#)];
2048
2049 assert_eq!(
2050 script(&files, "a.test.js", "sums a.b (1+2) [3] $4").as_deref(),
2051 Some(r#"node --test-name-pattern "^sums a\\.b \\(1\\+2\\) \\[3\\] \\\$4\$" a.test.js"#)
2052 );
2053 assert_eq!(
2054 script(&files, "a.test.js", "/api/users").as_deref(),
2055 Some(r#"node --test-name-pattern "^/api/users\$" a.test.js"#)
2056 );
2057 assert_eq!(
2058 script(&files, "a.test.js", "handles ${kind} kind").as_deref(),
2059 Some(r#"node --test-name-pattern "^handles .* kind\$" a.test.js"#)
2060 );
2061 }
2062}