#![cfg(unix)]
use std::os::unix::fs::PermissionsExt as _;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
struct TempProject {
path: PathBuf,
}
impl TempProject {
fn new(tag: &str) -> Self {
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"runner-nested-{tag}-{}-{unique}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).expect("create temp project dir");
Self { path }
}
fn file(self, name: &str, contents: &str) -> Self {
self.write(name, contents, false);
self
}
fn script(self, name: &str, contents: &str) -> Self {
self.write(name, contents, true);
self
}
fn write(&self, name: &str, contents: &str, executable: bool) {
let path = self.path.join(name);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("create parent dir");
}
std::fs::write(&path, contents).expect("write project file");
if executable {
let mut perms = std::fs::metadata(&path).expect("stat").permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&path, perms).expect("chmod +x");
}
}
fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TempProject {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
fn run_binary() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_run"))
}
fn tool_available(bin: &str) -> bool {
Command::new(bin)
.arg("--version")
.output()
.is_ok_and(|o| o.status.success())
}
fn run_in(dir: &Path, args: &[&str]) -> Output {
let binary = run_binary();
let bin_dir = binary.parent().expect("binary lives in a directory");
let path = std::env::var_os("PATH").unwrap_or_default();
let joined = std::env::join_paths(
std::iter::once(bin_dir.to_path_buf()).chain(std::env::split_paths(&path)),
)
.expect("PATH joins");
let mut cmd = Command::new(&binary);
for (key, _) in std::env::vars_os() {
if key
.to_string_lossy()
.to_ascii_uppercase()
.starts_with("RUNNER_")
{
cmd.env_remove(&key);
}
}
cmd.env("PATH", joined)
.arg("--dir")
.arg(dir)
.args(args)
.output()
.expect("run should execute")
}
#[test]
fn flags_that_collide_with_runners_own_reach_the_task() {
let proj = TempProject::new("forward").script("show.sh", "#!/bin/sh\necho \"show got: $*\"\n");
let output = run_in(
proj.path(),
&["./show.sh", "-p", "tsconfig.json", "--noEmit"],
);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"stdout: {stdout}\nstderr: {stderr}"
);
assert!(
stdout.contains("show got: -p tsconfig.json --noEmit"),
"every flag must reach the tool verbatim. stdout: {stdout}",
);
}
#[test]
fn forwarding_survives_a_nested_run_without_extra_delimiters() {
if !tool_available("make") {
eprintln!("skipping: `make` not found on PATH");
return;
}
let proj = TempProject::new("nested-forward")
.file(
"Makefile",
"wrap:\n\t@run -q ./show.sh -p tsconfig.json --noEmit\n",
)
.script("show.sh", "#!/bin/sh\necho \"show got: $*\"\n");
let output = run_in(proj.path(), &["wrap"]);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"stdout: {stdout}\nstderr: {stderr}"
);
assert!(
stdout.contains("show got: -p tsconfig.json --noEmit"),
"the nested run must forward, not reparse. stdout: {stdout}",
);
}
#[test]
fn a_task_that_resolves_back_to_itself_is_refused() {
if !tool_available("make") {
eprintln!("skipping: `make` not found on PATH");
return;
}
let proj = TempProject::new("cycle").file("Makefile", "loop:\n\t@run -q loop\n");
let output = run_in(proj.path(), &["loop"]);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(!output.status.success(), "a cycle must not exit clean");
assert!(
stderr.contains("recursive task resolution detected"),
"stderr: {stderr}",
);
assert!(
stderr.contains("make:loop -> make:loop"),
"the diagnostic must name the loop. stderr: {stderr}",
);
}
#[test]
fn quiet_does_not_disable_cycle_detection() {
if !tool_available("make") {
eprintln!("skipping: `make` not found on PATH");
return;
}
let proj = TempProject::new("cycle-quiet").file("Makefile", "loop:\n\t@run -q loop\n");
let output = run_in(proj.path(), &["-q", "loop"]);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(!output.status.success(), "a cycle must not exit clean");
assert!(
stderr.contains("recursive task resolution detected"),
"stderr: {stderr}",
);
}
fn aliased_dependency_project(tag: &str) -> TempProject {
TempProject::new(tag)
.file(
"package.json",
r#"{ "devDependencies": { "@typescript/native": "npm:typescript@^7" } }"#,
)
.file("package-lock.json", "{}")
.file(
"node_modules/@typescript/native/package.json",
r#"{ "name": "typescript", "version": "7.0.2", "bin": { "tsc": "./bin/tsc" } }"#,
)
.script(
"node_modules/@typescript/native/bin/tsc",
"#!/bin/sh\necho \"tsc got: $*\"\n",
)
}
#[test]
fn a_scoped_alias_resolves_to_the_binary_its_manifest_declares() {
let proj = aliased_dependency_project("alias");
let output = run_in(proj.path(), &["@typescript/native", "--noEmit"]);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"stdout: {stdout}\nstderr: {stderr}"
);
assert!(
stdout.contains("tsc got: --noEmit"),
"the declared binary should run with the args forwarded. stdout: {stdout}",
);
}
#[test]
fn explain_names_the_local_package_and_the_binary_it_picked() {
let proj = aliased_dependency_project("alias-explain");
let output = run_in(
proj.path(),
&["--explain", "@typescript/native", "--noEmit"],
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("tsc from") && stderr.contains("local dependency"),
"--explain must show which binary of which package ran. stderr: {stderr}",
);
assert!(
stderr.contains("@typescript/native"),
"--explain must name the package directory. stderr: {stderr}",
);
}
#[test]
fn a_dependency_with_no_binary_says_so_instead_of_reaching_the_registry() {
let proj = TempProject::new("no-bin")
.file(
"package.json",
r#"{ "dependencies": { "left-pad": "^1" } }"#,
)
.file("package-lock.json", "{}")
.file(
"node_modules/left-pad/package.json",
r#"{ "name": "left-pad", "version": "1.3.0" }"#,
);
let output = run_in(proj.path(), &["left-pad"]);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(!output.status.success(), "a library has nothing to run");
assert!(stderr.contains("declares no binary"), "stderr: {stderr}");
}
#[test]
fn an_ambiguous_dependency_lists_the_binaries_to_choose_from() {
let proj = TempProject::new("multi-bin")
.file("package.json", r#"{ "dependencies": { "toolkit": "^1" } }"#)
.file("package-lock.json", "{}")
.file(
"node_modules/toolkit/package.json",
r#"{ "name": "toolkit", "bin": { "alpha": "./a.js", "beta": "./b.js" } }"#,
);
let output = run_in(proj.path(), &["toolkit"]);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(!output.status.success(), "runner must not guess a binary");
assert!(
stderr.contains("alpha") && stderr.contains("beta"),
"name the options. stderr: {stderr}",
);
}