use std::fs;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::process::{Command, Output};
use std::time::{Duration, Instant};
use serde_json::{Value, json};
use tempfile::TempDir;
fn fixture(targets: Value) -> TempDir {
let temp = TempDir::new().unwrap();
fs::write(temp.path().join("nx.json"), "{}").unwrap();
fs::write(
temp.path().join("project.json"),
json!({"name":"app", "targets":targets}).to_string(),
)
.unwrap();
temp
}
fn command(root: &Path, args: &[&str]) -> Command {
let mut command = Command::new(env!("CARGO_BIN_EXE_qk"));
command
.current_dir(root)
.arg("--workspace")
.arg(root)
.args(args);
command.env_remove("NX_PARALLEL");
command.env_remove("NX_CACHE_DIRECTORY");
command.env_remove("NX_TASK_TARGET_PROJECT");
command.env_remove("npm_lifecycle_event");
command
}
fn run(root: &Path, args: &[&str]) -> Output {
command(root, args).output().unwrap()
}
fn success(output: Output) -> Output {
assert!(
output.status.success(),
"stdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
output
}
fn helper() -> String {
format!(
"\"{}\" --exact process_helper --nocapture",
std::env::current_exe().unwrap().display()
)
}
fn helper_target(mode: &str, id: &str) -> Value {
json!({"executor":"nx:run-commands", "options": {
"command":helper(), "forwardAllArgs": false,
"env":{"QK_TEST_MODE":mode, "QK_TEST_ID":id}
}})
}
#[test]
fn process_helper() {
let Ok(mode) = std::env::var("QK_TEST_MODE") else {
return;
};
#[cfg(windows)]
if mode == "signal-watch" {
signal_windows_watch(std::env::var("QK_WATCH_PID").unwrap().parse().unwrap());
return;
}
let id = std::env::var("QK_TEST_ID").unwrap();
let root = std::env::current_dir().unwrap();
match mode.as_str() {
"watch-portable" => {
let records = PathBuf::from(std::env::var("QK_WATCH_RECORDS").unwrap());
let n = fs::read_dir(&records)
.unwrap()
.filter_map(Result::ok)
.filter(|entry| {
entry
.path()
.extension()
.is_some_and(|extension| extension == "json")
})
.count()
+ 1;
fs::write(
records.join(format!("{n}.tmp")),
json!({
"project":std::env::var("NX_PROJECT_NAME").unwrap(),
"files":std::env::var("NX_FILE_CHANGES").unwrap()
})
.to_string(),
)
.unwrap();
fs::rename(
records.join(format!("{n}.tmp")),
records.join(format!("{n}.json")),
)
.unwrap();
let mut ticks = 0;
while !records.join(format!("release-{n}")).exists() {
ticks += 1;
fs::write(records.join(format!("{n}.tick")), ticks.to_string()).unwrap();
std::thread::sleep(Duration::from_millis(30));
}
}
"diamond" => {
let dependencies: &[&str] = match id.as_str() {
"shared" => &[],
"a" | "b" => &["shared"],
"build" => &["a", "b"],
_ => panic!("unknown diamond task"),
};
for dependency in dependencies {
assert_eq!(
fs::read_to_string(root.join(format!("{dependency}.done"))).unwrap(),
format!("{dependency}\n")
);
}
use std::io::Write;
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(root.join(format!("{id}.done")))
.unwrap();
writeln!(file, "{id}").unwrap();
}
"record" => {
let values: std::collections::BTreeMap<_, _> = std::env::vars()
.filter(|(name, _)| name.starts_with("QK_TEST_"))
.collect();
fs::write(
root.join(format!("{id}.json")),
json!({"cwd":root, "env":values}).to_string(),
)
.unwrap();
}
"barrier" => {
fs::write(root.join(format!("{id}.started")), "started").unwrap();
let other = if id == "a" { "b" } else { "a" };
wait_for(&root.join(format!("{other}.started")));
}
"exclusive" => {
fs::create_dir(root.join("exclusive.lock"))
.expect("tasks overlapped despite parallel=1");
std::thread::sleep(Duration::from_millis(80));
fs::remove_dir(root.join("exclusive.lock")).unwrap();
}
"tree" => {
let mut child = Command::new(std::env::current_exe().unwrap())
.args(["--exact", "process_helper", "--nocapture"])
.env("QK_TEST_MODE", "heartbeat")
.spawn()
.unwrap();
let _ = child.wait();
}
"heartbeat" => {
fs::write(root.join("heartbeat.started"), "started").unwrap();
for value in 0..300 {
fs::write(root.join("heartbeat"), value.to_string()).unwrap();
std::thread::sleep(Duration::from_millis(20));
}
}
"serve" => {
fs::write(root.join(format!("{id}.started")), "started").unwrap();
for value in 0..1500 {
fs::write(root.join(format!("{id}.beat")), value.to_string()).unwrap();
std::thread::sleep(Duration::from_millis(20));
}
}
"client" => {
wait_for(&root.join("serve.started"));
fs::write(root.join(format!("{id}.done")), "done").unwrap();
}
"exit-3" => {
fs::write(root.join(format!("{id}.started")), "started").unwrap();
std::process::exit(3);
}
"fail-after-start" => {
wait_for(&root.join("heartbeat.started"));
std::process::exit(9);
}
_ => panic!("unknown helper mode"),
}
}
fn wait_for(path: &Path) {
let deadline = Instant::now() + Duration::from_secs(5);
while !path.is_file() {
assert!(
Instant::now() < deadline,
"timed out waiting for {}",
path.display()
);
std::thread::sleep(Duration::from_millis(10));
}
}
#[test]
fn dependency_diamond_runs_once_before_dependents_and_streams_raw_output() {
let mut targets = json!({"output":{"command":"echo raw-output"}});
for (id, dependencies) in [
("shared", vec![]),
("a", vec!["shared"]),
("b", vec!["shared"]),
("build", vec!["a", "b"]),
] {
targets[id] = helper_target("diamond", id);
targets[id]["dependsOn"] = json!(dependencies);
}
let temp = fixture(targets);
success(run(temp.path(), &["run", "app:build", "--parallel", "2"]));
for id in ["shared", "a", "b", "build"] {
assert_eq!(
fs::read_to_string(temp.path().join(format!("{id}.done"))).unwrap(),
format!("{id}\n")
);
}
let output = success(run(temp.path(), &["run", "app:output"]));
assert_eq!(
String::from_utf8(output.stdout).unwrap().trim(),
"raw-output"
);
}
#[test]
fn failure_preserves_exit_code_skips_dependents_and_runs_independent_roots() {
let temp = fixture(json!({
"build":{"command":"echo wrong> must-not-exist", "dependsOn":["fail"]},
"fail":{"command":"exit 7"},
"independent":{"command":"echo ok> independent"}
}));
let output = run(
temp.path(),
&["run-many", "-t", "build,independent", "--parallel", "1"],
);
assert_eq!(output.status.code(), Some(7));
assert!(!temp.path().join("must-not-exist").exists());
assert!(temp.path().join("independent").exists());
let summary = String::from_utf8_lossy(&output.stderr);
assert!(summary.contains("1 of 3 tasks failed"), "{summary}");
assert!(
summary.contains("since a dependency failed: app:build"),
"{summary}"
);
let output = run(
temp.path(),
&[
"run-many",
"-t",
"build,independent",
"--parallel",
"1",
"--output-style",
"static",
],
);
assert!(String::from_utf8_lossy(&output.stderr).contains("qk: skipped app:build"));
}
#[test]
fn validates_entire_plan_before_running_and_dry_run_has_no_side_effects() {
let temp = fixture(json!({
"good":{"command":"echo wrong> must-not-exist"},
"bad":{"executor":"unknown:executor", "dependsOn":["good"]}
}));
assert!(!run(temp.path(), &["run", "app:bad"]).status.success());
assert!(!temp.path().join("must-not-exist").exists());
let output = success(run(temp.path(), &["run", "app:good", "--dry-run"]));
let graph: Value = serde_json::from_slice(&output.stdout).unwrap();
assert!(graph["tasks"]["app:good"].is_object());
assert!(!temp.path().join("must-not-exist").exists());
}
#[test]
fn configuration_cwd_tokens_and_environment_precedence() {
let mut target = helper_target("record", "result");
target["options"]["cwd"] = json!("{workspaceRoot}/working directory");
target["env"] = json!({"QK_TEST_TARGET":"top", "QK_TEST_OVERRIDE":"top"});
target["options"]["env"]["QK_TEST_OVERRIDE"] = json!("options");
target["options"]["env"]["QK_TEST_BASE_ONLY"] = json!("base");
target["configurations"] = json!({"prod":{"env":{
"QK_TEST_OVERRIDE":"configuration", "QK_TEST_PROJECT":"{projectName}:{projectRoot}"
}}});
let temp = fixture(json!({"build":target}));
fs::create_dir(temp.path().join("working directory")).unwrap();
fs::write(
temp.path().join(".env"),
"QK_TEST_FILE=base\nQK_TEST_LOCAL=base\nQK_TEST_PARENT=base\n",
)
.unwrap();
fs::write(
temp.path().join(".env.local"),
"QK_TEST_LOCAL=local\nQK_TEST_PARENT=local\n",
)
.unwrap();
success(
command(temp.path(), &["run", "app:build", "-c", "prod"])
.env("QK_TEST_MODE", "record")
.env("QK_TEST_ID", "result")
.env_remove("QK_TEST_BASE_ONLY")
.env("QK_TEST_PARENT", "parent")
.output()
.unwrap(),
);
let result: Value = serde_json::from_slice(
&fs::read(temp.path().join("working directory/result.json")).unwrap(),
)
.unwrap();
assert_eq!(
PathBuf::from(result["cwd"].as_str().unwrap())
.canonicalize()
.unwrap(),
temp.path()
.join("working directory")
.canonicalize()
.unwrap()
);
for (key, expected) in [
("QK_TEST_FILE", "base"),
("QK_TEST_LOCAL", "local"),
("QK_TEST_PARENT", "parent"),
("QK_TEST_TARGET", "top"),
("QK_TEST_OVERRIDE", "configuration"),
("QK_TEST_PROJECT", "app:."),
] {
assert_eq!(result["env"][key], expected, "{key}");
}
assert!(result["env"].get("QK_TEST_BASE_ONLY").is_none());
}
#[test]
fn parallel_tasks_overlap_and_parallel_one_serializes() {
for (mode, parallel) in [("barrier", "2"), ("exclusive", "1")] {
let temp = fixture(json!({"a":helper_target(mode, "a"), "b":helper_target(mode, "b")}));
success(run(
temp.path(),
&["run-many", "-t", "a,b", "--parallel", parallel],
));
}
}
#[test]
fn command_lists_support_sequential_order_and_stop_on_failure() {
let temp = fixture(json!({"build":{"executor":"nx:run-commands", "options":{
"commands":["echo first> order.txt", "exit 9", "echo wrong> must-not-exist"], "parallel":false
}}}));
let output = run(temp.path(), &["run", "app:build"]);
assert_eq!(output.status.code(), Some(9));
assert!(temp.path().join("order.txt").exists());
assert!(!temp.path().join("must-not-exist").exists());
}
#[test]
fn parallel_command_list_starts_both_commands() {
let temp = fixture(json!({"build":{"executor":"nx:run-commands", "options":{
"commands":["echo first> first.txt", {"command":"echo second> second.txt", "forwardAllArgs":false}]
}}}));
success(run(temp.path(), &["run", "app:build"]));
assert!(temp.path().join("first.txt").exists());
assert!(temp.path().join("second.txt").exists());
}
#[cfg(unix)]
#[test]
fn argument_tokens_and_forwarding_preserve_data_without_shell_execution() {
let temp = fixture(json!({
"named":{"command":"printf '%s' {args.value} > result.txt"},
"all":{"command":"printf '%s\\n'"},
"quoted":{"command":"echo \"{args.value}\""}
}));
let payload = "a 'quoted' $(touch injected) ; {projectName}";
success(run(
temp.path(),
&["run", "app:named", "--", "--value", payload],
));
assert_eq!(
fs::read_to_string(temp.path().join("result.txt")).unwrap(),
payload
);
assert!(!temp.path().join("injected").exists());
let output = success(run(
temp.path(),
&["run", "app:all", "--", payload, "two words"],
));
assert_eq!(
String::from_utf8(output.stdout).unwrap(),
format!("{payload}\ntwo words\n")
);
assert!(
!run(
temp.path(),
&["run", "app:quoted", "--", "--value", payload]
)
.status
.success()
);
}
#[cfg(unix)]
#[test]
fn script_executor_uses_package_manager_and_forwards_arguments() {
use std::os::unix::fs::PermissionsExt;
for manager in ["npm", "pnpm", "yarn", "bun"] {
let temp = fixture(json!({"test":{}}));
fs::write(
temp.path().join("package.json"),
json!({
"name":"app", "packageManager":format!("{manager}@1.0.0"),
"scripts":{"test":"ignored by fake manager"}
})
.to_string(),
)
.unwrap();
let bin = temp.path().join("node_modules/.bin");
fs::create_dir_all(&bin).unwrap();
let path = bin.join(manager);
fs::write(
&path,
"#!/bin/sh\nprintf '%s\\n' \"$@\" > manager-args\npwd > manager-cwd\n",
)
.unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap();
success(run(
temp.path(),
&["run", "app:test", "--", "--flag", "two words"],
));
let expected = match manager {
"npm" | "bun" => "run\ntest\n--\n--flag\ntwo words\n",
"yarn" => "test\n--flag\ntwo words\n",
_ => "run\ntest\n--flag\ntwo words\n",
};
assert_eq!(
fs::read_to_string(temp.path().join("manager-args")).unwrap(),
expected
);
let cwd = fs::read_to_string(temp.path().join("manager-cwd")).unwrap();
assert_eq!(
Path::new(cwd.trim()).canonicalize().unwrap(),
temp.path().canonicalize().unwrap()
);
}
}
#[test]
fn missing_scripts_fail_at_execution_without_running_dependencies() {
let temp = fixture(
json!({"missing":{"dependsOn":["prepare"]}, "prepare":{"command":"echo wrong> must-not-exist"}}),
);
fs::write(
temp.path().join("package.json"),
json!({"name":"app", "nx":{"includedScripts":["missing"]}}).to_string(),
)
.unwrap();
let output = run(temp.path(), &["run", "app:missing"]);
assert!(!output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("has no script"));
assert!(!temp.path().join("must-not-exist").exists());
}
#[test]
fn malformed_dotenv_does_not_expose_secret_values() {
let temp = fixture(json!({"test":{"executor":"nx:noop"}}));
fs::write(
temp.path().join(".env"),
"QK_TEST_SECRET='secret-without-closing-quote",
)
.unwrap();
let output = run(temp.path(), &["run", "app:test"]);
assert!(!output.status.success());
let error = String::from_utf8_lossy(&output.stderr);
assert!(error.contains("invalid dotenv"));
assert!(!error.contains("secret-without-closing-quote"));
}
#[cfg(unix)]
#[test]
fn failed_parallel_command_terminates_its_running_sibling() {
let temp = fixture(json!({"build":{"executor":"nx:run-commands", "options":{
"commands":[format!("QK_TEST_MODE=tree {}", helper()), format!("QK_TEST_MODE=fail-after-start {}", helper())],
"env":{"QK_TEST_ID":"tree"}
}}}));
let output = run(temp.path(), &["run", "app:build"]);
assert_eq!(output.status.code(), Some(1));
let before = fs::read(temp.path().join("heartbeat")).unwrap();
std::thread::sleep(Duration::from_millis(100));
assert_eq!(fs::read(temp.path().join("heartbeat")).unwrap(), before);
}
#[cfg(unix)]
#[test]
fn cancellation_terminates_descendants_and_returns_130() {
for signal in ["-INT", "-TERM"] {
let temp = fixture(json!({"long":helper_target("tree", "tree")}));
let mut child = command(temp.path(), &["run", "app:long"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
wait_for(&temp.path().join("heartbeat.started"));
assert!(
Command::new("/bin/kill")
.arg(signal)
.arg(child.id().to_string())
.status()
.unwrap()
.success()
);
let deadline = Instant::now() + Duration::from_secs(5);
let status = loop {
if let Some(status) = child.try_wait().unwrap() {
break status;
}
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
panic!("qk did not stop");
}
std::thread::sleep(Duration::from_millis(10));
};
assert_eq!(status.code(), Some(130));
let before = fs::read(temp.path().join("heartbeat")).unwrap();
std::thread::sleep(Duration::from_millis(100));
assert_eq!(
fs::read(temp.path().join("heartbeat")).unwrap(),
before,
"descendant survived {signal}"
);
}
}
fn continuous_target(mode: &str, id: &str) -> Value {
let mut target = helper_target(mode, id);
target["continuous"] = json!(true);
target
}
fn assert_stopped(path: &Path) {
let before = fs::read(path).unwrap();
std::thread::sleep(Duration::from_millis(150));
assert_eq!(
fs::read(path).unwrap(),
before,
"{} still running",
path.display()
);
}
#[test]
fn finite_dependents_start_after_continuous_dependency_starts_then_stop_it() {
for parallel in ["1", "3"] {
let temp = fixture(json!({
"serve": continuous_target("serve", "serve"),
"bench": {
"dependsOn": ["serve"],
"executor": "nx:run-commands",
"options": helper_target("client", "bench")["options"].clone(),
},
}));
let started = Instant::now();
let output = success(run(
temp.path(),
&["run", "app:bench", "--parallel", parallel],
));
assert!(started.elapsed() < Duration::from_secs(10));
assert!(temp.path().join("bench.done").exists());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("qk: started app:serve (continuous)"),
"{stderr}"
);
assert!(
stderr.contains("qk: stopping app:serve (no longer needed)"),
"{stderr}"
);
assert!(stderr.contains("qk: stopped app:serve"), "{stderr}");
assert_stopped(&temp.path().join("serve.beat"));
}
}
#[cfg(unix)]
#[test]
fn stopping_sends_sigterm_before_killing() {
let temp = fixture(json!({
"serve": {
"continuous": true,
"command": "trap 'echo cleaned > cleaned; exit 0' TERM; touch serve.started; while :; do sleep 0.05; done",
},
"client": {
"dependsOn": ["serve"],
"command": "while [ ! -f serve.started ]; do sleep 0.02; done",
},
}));
success(run(temp.path(), &["run", "app:client"]));
assert_eq!(
fs::read_to_string(temp.path().join("cleaned"))
.unwrap()
.trim(),
"cleaned"
);
}
#[cfg(unix)]
#[test]
fn continuous_roots_run_until_cancelled() {
let temp = fixture(json!({
"watch": continuous_target("serve", "watch"),
"serve": {
"continuous": true,
"dependsOn": ["watch"],
"executor": "nx:run-commands",
"options": helper_target("serve", "serve")["options"].clone(),
},
}));
let mut child = command(temp.path(), &["run", "app:serve", "--parallel", "1"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
wait_for(&temp.path().join("serve.started"));
std::thread::sleep(Duration::from_millis(200));
assert!(
child.try_wait().unwrap().is_none(),
"continuous root exited early"
);
assert!(
Command::new("/bin/kill")
.arg("-INT")
.arg(child.id().to_string())
.status()
.unwrap()
.success()
);
let deadline = Instant::now() + Duration::from_secs(10);
let status = loop {
if let Some(status) = child.try_wait().unwrap() {
break status;
}
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
panic!("qk did not stop");
}
std::thread::sleep(Duration::from_millis(10));
};
assert_eq!(status.code(), Some(130));
assert_stopped(&temp.path().join("serve.beat"));
assert_stopped(&temp.path().join("watch.beat"));
}
#[test]
fn continuous_task_exiting_on_its_own_reports_its_exit_code() {
let temp = fixture(json!({"serve": continuous_target("exit-3", "serve")}));
let output = run(temp.path(), &["run", "app:serve"]);
assert_eq!(output.status.code(), Some(3));
}
#[test]
fn dependents_of_continuous_tasks_run_uncached_and_say_why() {
let temp = fixture(json!({
"serve": continuous_target("serve", "serve"),
"bench": {
"cache": true,
"dependsOn": ["serve"],
"executor": "nx:run-commands",
"options": helper_target("client", "bench")["options"].clone(),
},
}));
let output = success(run(temp.path(), &["run", "app:bench"]));
assert!(String::from_utf8_lossy(&output.stderr).contains(
"qk: app:bench: cache bypassed (depends on app:serve: continuous tasks are not fingerprinted)"
));
}
#[test]
fn output_styles_follow_nx() {
let temp = fixture(json!({
"build": {"command": "echo one && echo two"},
"fail": {"command": "echo broken && exit 3"},
"cached": {"command": "echo cached", "cache": true, "inputs": []}
}));
let stdout = |args: &[&str]| {
let output = command(temp.path(), args)
.env_remove("NX_DEFAULT_OUTPUT_STYLE")
.env_remove("GITHUB_ACTIONS")
.env_remove("CI")
.env("NO_COLOR", "1")
.output()
.unwrap();
String::from_utf8(output.stdout)
.unwrap()
.replace("\r\n", "\n")
.replace(" \n", "\n")
};
let stream = stdout(&["run-many", "-t", "build", "--output-style", "stream"]);
assert_eq!(stream, "app: one\napp: two\n");
let raw = stdout(&[
"run-many",
"-t",
"build",
"--output-style",
"stream-without-prefixes",
]);
assert_eq!(raw, "one\ntwo\n");
assert_eq!(stdout(&["run-many", "-t", "build"]), "");
assert_eq!(
stdout(&["run-many", "-t", "fail"]),
"\n✖ qk run app:fail failed\n\nbroken\n"
);
assert_eq!(stdout(&["run", "app:build"]), "one\ntwo\n");
assert!(
stdout(&["run-many", "-t", "fail", "--output-style", "tui"])
.contains("> qk run app:fail\n\nbroken")
);
stdout(&["run-many", "-t", "cached", "--output-style", "static"]);
assert_eq!(
stdout(&["run-many", "-t", "cached", "--output-style", "static"]),
"\n> qk run app:cached [existing outputs match the cache, left as is]\n\ncached\n"
);
let grouped = command(
temp.path(),
&["run-many", "-t", "build", "--output-style", "static"],
)
.env("GITHUB_ACTIONS", "true")
.output()
.unwrap();
let grouped = String::from_utf8(grouped.stdout)
.unwrap()
.replace("\r\n", "\n")
.replace(" \n", "\n");
assert!(
grouped.starts_with("\n::group::✅ > qk run app:build\n\none\ntwo\n::endgroup::\n"),
"{grouped}"
);
}
#[cfg(unix)]
#[test]
fn tasks_load_their_dotenv_files_and_nx_variables_like_nx() {
let temp = TempDir::new().unwrap();
let root = temp.path();
fs::write(root.join("nx.json"), "{}").unwrap();
fs::create_dir_all(root.join("app")).unwrap();
fs::write(
root.join("app/project.json"),
json!({"name": "app", "targets": {
"show": {"command": "echo $ROOT_ONLY $SHARED $SPECIFIC $FROM_PROCESS $FROM_TARGET $NX_TASK_TARGET_PROJECT:$NX_TASK_TARGET_TARGET $FORCE_COLOR",
"options": {"env": {"FROM_TARGET": "target"}}}
}})
.to_string(),
)
.unwrap();
fs::write(
root.join(".env"),
"ROOT_ONLY=root\nSHARED=root\nFROM_TARGET=root\nFROM_PROCESS=root\n",
)
.unwrap();
fs::write(
root.join("app/.env.show"),
"SHARED=project\nSPECIFIC=show\n",
)
.unwrap();
let output = command(root, &["run", "app:show"])
.env("FROM_PROCESS", "process")
.env_remove("FORCE_COLOR")
.output()
.unwrap();
assert_eq!(
String::from_utf8(success(output).stdout).unwrap(),
"root project show process target app:show true\n"
);
}
#[cfg(unix)]
#[test]
fn unknown_run_commands_options_are_forwarded_like_nx() {
let temp = fixture(json!({
"serve": {"command": "echo serving", "options": {"port": 3000, "dependsOn": ["^tsc"]}},
"named": {"command": "echo port {args.port}", "options": {"port": 3000}}
}));
let stdout = |args: &[&str]| String::from_utf8(success(run(temp.path(), args)).stdout).unwrap();
assert_eq!(stdout(&["run", "app:serve"]), "serving --port=3000\n");
assert_eq!(
stdout(&["run", "app:serve", "--", "--port=4000"]),
"serving --port=4000\n"
);
assert_eq!(stdout(&["run", "app:named"]), "port 3000\n");
}
#[cfg(unix)]
#[test]
fn target_names_with_colons_and_missing_arguments_resolve_like_nx() {
let temp = fixture(json!({
"install:ios": {"command": "echo installing ios"},
"install": {"command": "echo install {args.platform}done", "configurations": {"ios": {"command": "echo configured ios"}}}
}));
let stdout = |args: &[&str]| String::from_utf8(success(run(temp.path(), args)).stdout).unwrap();
assert_eq!(stdout(&["run", "app:install:ios"]), "installing ios\n");
assert_eq!(
stdout(&["run", "app:install", "-c", "ios"]),
"configured ios\n"
);
assert_eq!(stdout(&["run", "app:install"]), "install done\n");
}
#[cfg(unix)]
#[test]
fn quiet_output_is_plain_text_for_agents() {
let temp = fixture(json!({
"fail": {"command": "printf '\\033[31mred failure\\033[0m\\n' && exit 2"},
"ok": {"command": "echo fine"}
}));
let output = command(temp.path(), &["run-many", "-t", "fail", "ok"])
.env_remove("FORCE_COLOR")
.env_remove("NX_DEFAULT_OUTPUT_STYLE")
.env_remove("CI")
.output()
.unwrap();
let stdout = String::from_utf8(output.stdout).unwrap();
assert_eq!(stdout, "\n✖ qk run app:fail failed\n\nred failure\n");
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(stderr.contains("1 of 2 tasks failed"), "{stderr}");
assert!(!stderr.contains("qk: checking"), "{stderr}");
}
#[cfg(unix)]
#[test]
fn the_args_option_is_forwarded_and_readable_like_nx() {
let temp = fixture(json!({
"text": {"command": "echo run", "options": {"args": "--watch=false --max-workers 2"}},
"list": {"command": "echo run", "options": {"args": ["--one", "--two=2"]}},
"named": {"command": "echo workers {args.maxWorkers}", "options": {"args": "--max-workers=3"}},
"all": {"command": "echo all {args}", "options": {"port": 1, "args": "--last"}},
"both": {"command": "echo {args} {args.port}"}
}));
let stdout = |args: &[&str]| String::from_utf8(success(run(temp.path(), args)).stdout).unwrap();
assert_eq!(
stdout(&["run", "app:text"]),
"run --watch=false --max-workers 2\n"
);
assert_eq!(
stdout(&["run", "app:text", "--", "--extra"]),
"run --watch=false --max-workers 2 --extra\n"
);
assert_eq!(stdout(&["run", "app:list"]), "run --one --two=2\n");
assert_eq!(stdout(&["run", "app:named"]), "workers 3\n");
assert_eq!(
stdout(&["run", "app:named", "--", "--maxWorkers=5"]),
"workers 5\n"
);
assert_eq!(
stdout(&["run", "app:all", "--", "--cli"]),
"all --port=1 --cli --last\n"
);
assert_eq!(
stdout(&["run", "app:text", "--", "--args=--from-cli"]),
"run --from-cli\n"
);
let both = run(temp.path(), &["run", "app:both"]);
assert!(!both.status.success());
assert!(String::from_utf8_lossy(&both.stderr).contains("cannot use both {args} and {args.*}"));
}
#[cfg(unix)]
#[test]
fn arguments_naming_run_commands_options_set_them() {
let temp = fixture(json!({
"where": {"command": "echo $(basename \"$PWD\") $MODE"}
}));
fs::create_dir(temp.path().join("sub")).unwrap();
let output = success(run(
temp.path(),
&["run", "app:where", "--", "--cwd=sub", "--env.MODE=cli"],
));
assert_eq!(String::from_utf8(output.stdout).unwrap(), "sub cli\n");
}
#[cfg(unix)]
#[test]
fn ready_when_starts_dependents_once_the_output_appears() {
let temp = fixture(json!({
"serve": {"command": "sleep 0.3; touch marker; echo 'listening on :3000'; sleep 30", "options": {"readyWhen": "listening"}},
"watch": {"command": "sleep 0.3; touch watched; echo up; sleep 30", "continuous": true, "options": {"readyWhen": ["up"]}},
"e2e": {"command": "test -e marker && test -e watched && echo e2e saw both", "dependsOn": ["serve", "watch"]},
"both": {"command": "echo one; echo two >&2; sleep 30", "options": {"readyWhen": ["one", "two"]}},
"crash": {"command": "exit 3", "options": {"readyWhen": "never"}},
"after": {"command": "echo after", "dependsOn": ["crash"]},
"serial": {"executor": "nx:run-commands", "options": {"commands": ["echo a"], "readyWhen": "a", "parallel": false}}
}));
let started = Instant::now();
let output = success(run(
temp.path(),
&["run", "app:e2e", "--output-style", "static"],
));
assert!(String::from_utf8_lossy(&output.stdout).contains("e2e saw both"));
assert!(started.elapsed() < Duration::from_secs(15));
let started = Instant::now();
success(run(temp.path(), &["run", "app:both"]));
assert!(started.elapsed() < Duration::from_secs(15));
let crashed = run(
temp.path(),
&["run", "app:after", "--output-style", "static"],
);
assert_eq!(crashed.status.code(), Some(3));
assert!(!String::from_utf8_lossy(&crashed.stdout).contains("after\n"));
let serial = run(temp.path(), &["run", "app:serial"]);
assert!(
String::from_utf8_lossy(&serial.stderr)
.contains("readyWhen can only be used when parallel is true")
);
}
#[cfg(unix)]
#[test]
fn env_files_color_and_the_options_nx_sets_itself() {
let temp = fixture(json!({
"show": {"command": "echo $FROM_FILE $BOTH $SET_OUTSIDE", "options": {
"envFile": "config/{projectName}.env", "env": {"BOTH": "option"}
}},
"missing": {"command": "true", "options": {"envFile": "absent.env"}},
"color": {"command": "echo $FORCE_COLOR", "options": {"color": true, "tty": true, "usePty": false, "streamOutput": true, "verbose": false}}
}));
fs::create_dir(temp.path().join("config")).unwrap();
fs::write(
temp.path().join("config/app.env"),
"FROM_FILE=file\nBOTH=file\nSET_OUTSIDE=file\n",
)
.unwrap();
fs::write(temp.path().join(".env"), "FROM_DOTENV=dotenv\n").unwrap();
let stdout = |output: Output| String::from_utf8(success(output).stdout).unwrap();
assert_eq!(
stdout(
command(temp.path(), &["run", "app:show"])
.env("SET_OUTSIDE", "process")
.output()
.unwrap()
),
"file option process\n"
);
let missing = run(temp.path(), &["run", "app:missing"]);
assert!(String::from_utf8_lossy(&missing.stderr).contains("envFile absent.env does not exist"));
assert_eq!(
stdout(
command(temp.path(), &["run", "app:color"])
.env("FORCE_COLOR", "0")
.output()
.unwrap()
),
"true\n"
);
let temp = fixture(
json!({"show": {"command": "echo [$FROM_DOTENV] [$FROM_FILE]", "options": {"envFile": "absent.env"}}}),
);
fs::write(temp.path().join(".env"), "FROM_DOTENV=dotenv\n").unwrap();
assert_eq!(
stdout(
command(temp.path(), &["run", "app:show"])
.env("NX_LOAD_DOT_ENV_FILES", "false")
.output()
.unwrap()
),
"[] []\n"
);
}
#[cfg(unix)]
#[test]
fn command_entries_prefix_and_colour_their_output_like_nx() {
let temp = fixture(json!({
"prefixed": {"executor": "nx:run-commands", "options": {"commands": [
{"command": "printf 'one\\n\\ntw'; sleep 0.1; printf 'o\\n'", "prefix": "[a]", "description": "documentation only"},
"echo plain"
]}},
"painted": {"executor": "nx:run-commands", "options": {"commands": [
{"command": "echo hi", "prefix": "p", "prefixColor": "blue", "color": "red", "bgColor": "bgWhite"}
]}},
"serial": {"executor": "nx:run-commands", "options": {"parallel": false, "commands": [
{"command": "echo hi", "prefix": "p"}
]}},
"unknown": {"executor": "nx:run-commands", "options": {"commands": [
{"command": "echo hi", "color": "chartreuse"}
]}}
}));
let output = success(
command(temp.path(), &["run", "app:prefixed"])
.env("NO_COLOR", "1")
.output()
.unwrap(),
);
let stdout = String::from_utf8(output.stdout).unwrap();
let mut lines: Vec<&str> = stdout.lines().collect();
lines.sort_unstable();
assert_eq!(lines, ["", "[a] one", "[a] two", "plain"], "{stdout:?}");
let output = success(
command(temp.path(), &["run", "app:painted"])
.env_remove("NO_COLOR")
.env("FORCE_COLOR", "1")
.output()
.unwrap(),
);
assert_eq!(
String::from_utf8(output.stdout).unwrap(),
"\x1b[47m\x1b[31m\x1b[1m\x1b[34mp\x1b[39m\x1b[22m hi\n\x1b[39m\x1b[49m"
);
let serial = run(temp.path(), &["run", "app:serial"]);
assert!(
String::from_utf8_lossy(&serial.stderr).contains("can only be set when parallel is true")
);
let unknown = run(temp.path(), &["run", "app:unknown"]);
assert!(String::from_utf8_lossy(&unknown.stderr).contains("\"chartreuse\" is not a colour"));
}
#[cfg(unix)]
#[test]
fn command_shapes_and_exit_codes_follow_nx() {
let temp = fixture(json!({
"words": {"executor": "nx:run-commands", "options": {"command": ["echo", "joined", "words"]}},
"nothing": {"executor": "nx:run-commands", "options": {"commands": []}},
"side": {"executor": "nx:run-commands", "options": {"commands": ["exit 4", "sleep 5"]}},
"serial": {"executor": "nx:run-commands", "options": {"parallel": false, "commands": ["exit 4", "echo never"]}},
"single": {"command": "exit 4"}
}));
let output = success(run(temp.path(), &["run", "app:words"]));
assert_eq!(String::from_utf8(output.stdout).unwrap(), "joined words\n");
success(run(temp.path(), &["run", "app:nothing"]));
let started = Instant::now();
assert_eq!(
run(temp.path(), &["run", "app:side"]).status.code(),
Some(1)
);
assert!(started.elapsed() < Duration::from_secs(4));
assert_eq!(
run(temp.path(), &["run", "app:serial"]).status.code(),
Some(4)
);
assert_eq!(
run(temp.path(), &["run", "app:single"]).status.code(),
Some(4)
);
}
#[cfg(unix)]
#[test]
fn the_package_manager_is_detected_like_nx() {
use std::os::unix::fs::PermissionsExt;
for (nx, lockfile, declared, expected) in [
(Some("yarn"), Some("pnpm-lock.yaml"), None, "yarn"),
(None, Some("bun.lock"), Some("pnpm@9.0.0"), "bun"),
(None, Some("yarn.lock"), None, "yarn"),
(None, None, Some("pnpm@9.0.0"), "pnpm"),
(None, None, None, "npm"),
] {
let temp = fixture(json!({"test": {}}));
let mut config = json!({});
if let Some(nx) = nx {
config["cli"] = json!({"packageManager": nx});
}
fs::write(temp.path().join("nx.json"), config.to_string()).unwrap();
if let Some(lockfile) = lockfile {
fs::write(temp.path().join(lockfile), "").unwrap();
}
let mut package = json!({"name": "app", "scripts": {"test": "unused"}});
if let Some(declared) = declared {
package["packageManager"] = json!(declared);
}
fs::write(temp.path().join("package.json"), package.to_string()).unwrap();
let bin = temp.path().join("node_modules/.bin");
fs::create_dir_all(&bin).unwrap();
for manager in ["npm", "pnpm", "yarn", "bun"] {
let path = bin.join(manager);
fs::write(&path, format!("#!/bin/sh\necho {manager}\n")).unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap();
}
let output = success(
command(temp.path(), &["run", "app:test"])
.env_remove("npm_config_user_agent")
.output()
.unwrap(),
);
assert_eq!(
String::from_utf8(output.stdout).unwrap().trim(),
expected,
"{nx:?} {lockfile:?} {declared:?}"
);
}
}
#[cfg(unix)]
#[test]
fn dependencies_by_glob_and_with_forwarded_options() {
let temp = fixture(json!({
"lint-js": {"command": "echo lint-js"},
"lint-css": {"command": "echo lint-css"},
"check": {"command": "echo check", "dependsOn": ["lint-*"]},
"bundle": {"command": "echo bundle {args.mode} {args.region}"},
"deploy": {
"executor": "nx:noop",
"options": {"mode": "production", "nested": {"region": "eu"}},
"configurations": {"staging": {"mode": "staging"}},
"dependsOn": [{"target": "bundle", "options": "forward"}]
}
}));
let output = success(run(
temp.path(),
&["run", "app:check", "--output-style", "static"],
));
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(
stdout.contains("lint-js\n") && stdout.contains("lint-css\n"),
"{stdout}"
);
let output = success(run(
temp.path(),
&["run", "app:deploy", "--output-style", "static"],
));
assert!(
String::from_utf8(output.stdout)
.unwrap()
.contains("bundle production\n")
);
let output = success(run(
temp.path(),
&["run", "app:deploy:staging", "--output-style", "static"],
));
assert!(
String::from_utf8(output.stdout)
.unwrap()
.contains("bundle staging\n")
);
}
#[cfg(unix)]
#[test]
fn the_task_a_run_is_for_reads_its_input() {
use std::io::Write;
let temp = fixture(json!({
"read": {"command": "read line; echo got $line"},
"other": {"command": "echo other"}
}));
let piped = |args: &[&str]| {
let mut child = command(temp.path(), args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.unwrap();
child.stdin.take().unwrap().write_all(b"hello\n").unwrap();
String::from_utf8(success(child.wait_with_output().unwrap()).stdout).unwrap()
};
assert_eq!(piped(&["run", "app:read"]), "got hello\n");
let several = piped(&["run-many", "-t", "read,other", "--output-style", "static"]);
assert!(
several.contains("got\n") && !several.contains("hello"),
"{several}"
);
}
#[cfg(unix)]
#[test]
fn the_task_a_run_is_for_gets_the_terminal() {
let temp = fixture(json!({
"read": {"command": "if [ -t 0 ]; then echo terminal; fi; read line; echo got $line"}
}));
let qk = format!(
"{} --workspace {} run app:read",
env!("CARGO_BIN_EXE_qk"),
temp.path().display()
);
let mut script = Command::new("script");
if cfg!(target_os = "macos") {
script.args(["-q", "/dev/null", "/bin/sh", "-c", &qk]);
} else {
script.args(["-qec", &qk, "/dev/null"]);
}
let mut child = script
.current_dir(temp.path())
.env_remove("NX_PARALLEL")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.unwrap();
{
use std::io::Write;
let mut stdin = child.stdin.take().unwrap();
std::thread::sleep(Duration::from_millis(500));
stdin.write_all(b"typed\n").unwrap();
std::thread::sleep(Duration::from_millis(500));
}
let started = Instant::now();
while child.try_wait().unwrap().is_none() {
assert!(
started.elapsed() < Duration::from_secs(10),
"the task never read its input"
);
std::thread::sleep(Duration::from_millis(50));
}
let mut output = String::new();
std::io::Read::read_to_string(&mut child.stdout.take().unwrap(), &mut output).unwrap();
assert!(output.contains("terminal"), "{output:?}");
assert!(output.contains("got typed"), "{output:?}");
}
#[cfg(unix)]
#[test]
fn nx_run_flags_work_as_in_nx() {
let temp = fixture(json!({
"a": {"command": "echo a"},
"b": {"command": "echo b"},
"fail": {"command": "exit 2"},
"zlast": {"command": "echo zlast"},
"build": {"command": "echo build", "configurations": {"production": {"command": "echo production build"}}},
"check": {"command": "echo check", "dependsOn": ["build"]},
"verbose": {"command": "echo verbose=$NX_VERBOSE_LOGGING skip=$NX_SKIP_NX_CACHE"},
"ping": {"command": "echo ping", "dependsOn": ["pong"]},
"pong": {"command": "echo pong", "dependsOn": ["ping"]}
}));
let output = |args: &[&str]| run(temp.path(), args);
let stdout = |args: &[&str]| String::from_utf8(success(output(args)).stdout).unwrap();
let parallel = |args: &[&str], env: Option<&str>| {
let mut command = command(
temp.path(),
&[&["run-many", "-t", "a,b", "--output-style", "quiet"], args].concat(),
);
if let Some(env) = env {
command.env("NX_PARALLEL", env);
}
let stderr = String::from_utf8(success(command.output().unwrap()).stderr).unwrap();
stderr
.lines()
.find_map(|line| {
line.strip_prefix("Parallel ")?
.split(':')
.next()
.map(str::to_owned)
})
.unwrap()
};
assert_eq!(parallel(&["--parallel=false"], None), "1");
assert_eq!(parallel(&["--parallel"], Some("2")), "2");
assert_eq!(parallel(&["--parallel"], None), "3");
assert_eq!(parallel(&["--parallel=4"], None), "4");
assert_eq!(
parallel(&["--parallel=100%"], None),
std::thread::available_parallelism().unwrap().to_string()
);
assert_eq!(
stdout(&["run", "app:build", "--prod"]),
"production build\n"
);
assert_eq!(
stdout(&["run", "app:check", "--exclude-task-dependencies"]),
"check\n"
);
let bailed = output(&[
"run-many",
"-t",
"fail,zlast",
"--parallel=1",
"--nx-bail",
"--output-style",
"static",
]);
assert!(!bailed.status.success());
assert!(!String::from_utf8_lossy(&bailed.stdout).contains("zlast\n"));
let continued = output(&[
"run-many",
"-t",
"fail,zlast",
"--parallel=1",
"--output-style",
"static",
]);
assert!(String::from_utf8_lossy(&continued.stdout).contains("zlast\n"));
assert!(
String::from_utf8_lossy(&output(&["run", "app:ping"]).stderr)
.contains("task dependency cycle")
);
let ignored = success(output(&[
"run",
"app:ping",
"--nx-ignore-cycles",
"--output-style",
"static",
]));
assert!(String::from_utf8_lossy(&ignored.stdout).contains("pong\n"));
assert!(String::from_utf8_lossy(&ignored.stderr).contains("the task graph has a cycle"));
assert_eq!(
stdout(&["run", "app:verbose", "--verbose", "--skip-nx-cache"]),
"verbose=true skip=true\n"
);
assert_eq!(
stdout(&[
"run",
"app:a",
"--batch",
"--skip-sync",
"--no-cloud",
"--tui=false",
"--runner",
"default",
"--skip-remote-cache"
]),
"a\n"
);
}
#[test]
fn empty_selections_write_machine_readable_task_graphs() {
let temp = fixture(json!({"build": {"command": "echo executed > marker"}}));
for args in [
vec!["init", "--quiet"],
vec!["add", "."],
vec![
"-c",
"user.name=qk",
"-c",
"user.email=qk@example.invalid",
"-c",
"commit.gpgsign=false",
"commit",
"-qm",
"fixture",
],
] {
success(
Command::new("git")
.current_dir(temp.path())
.args(args)
.output()
.unwrap(),
);
}
for mut args in [
vec!["run-many", "-t", "missing"],
vec!["run-many", "-t", "build", "--exclude=app"],
vec!["affected", "-t", "build", "--base=HEAD", "--head=HEAD"],
vec![
"affected",
"-t",
"build",
"--base=HEAD",
"--head=HEAD",
"--granularity=project",
],
vec!["affected", "-t", "missing", "--base=HEAD", "--head=HEAD"],
vec![
"affected",
"-t",
"missing",
"--base=HEAD",
"--head=HEAD",
"--granularity=task",
],
] {
args.push("--graph=stdout");
let output = success(run(temp.path(), &args));
let graph: Value = serde_json::from_slice(&output.stdout).unwrap();
assert!(graph["graph"]["nodes"]["app"].is_object());
assert_eq!(graph["tasks"]["roots"], json!([]));
for name in ["tasks", "dependencies", "continuousDependencies"] {
assert_eq!(graph["tasks"][name], json!({}));
}
let file = temp.path().join("empty.json");
fs::write(&file, "previous graph").unwrap();
args.pop();
let flag = format!("--graph={}", file.display());
args.push(&flag);
success(run(temp.path(), &args));
assert_eq!(
serde_json::from_slice::<Value>(&fs::read(file).unwrap()).unwrap(),
graph
);
assert!(!temp.path().join("marker").exists());
}
let disabled = success(run(
temp.path(),
&[
"affected",
"-t",
"build",
"--base=HEAD",
"--head=HEAD",
"--graph=false",
],
));
assert!(disabled.stdout.is_empty());
}
#[cfg(unix)]
#[test]
fn the_task_graph_is_written_as_nx_writes_it() {
let temp = fixture(json!({
"serve": {"command": "sleep 1", "continuous": true},
"build": {"command": "echo build", "cache": true, "outputs": ["{projectRoot}/dist"]},
"e2e": {"command": "echo e2e", "dependsOn": ["build", "serve"], "parallelism": false}
}));
let graph: Value = serde_json::from_slice(
&success(run(
temp.path(),
&["run", "app:e2e", "--graph=stdout", "--", "--grep=x"],
))
.stdout,
)
.unwrap();
assert!(graph["graph"]["nodes"]["app"].is_object());
let tasks = &graph["tasks"];
assert_eq!(tasks["roots"], json!(["app:build", "app:serve"]));
assert_eq!(tasks["dependencies"]["app:e2e"], json!(["app:build"]));
assert_eq!(
tasks["continuousDependencies"]["app:e2e"],
json!(["app:serve"])
);
let e2e = &tasks["tasks"]["app:e2e"];
assert_eq!(e2e["target"], json!({"project": "app", "target": "e2e"}));
assert_eq!(
e2e["overrides"]["__overrides_unparsed__"],
json!(["--grep=x"])
);
assert_eq!(
(e2e["parallelism"].clone(), e2e["cache"].clone()),
(json!(false), json!(false))
);
assert_eq!(tasks["tasks"]["app:build"]["outputs"], json!(["dist"]));
let file = temp.path().join("graph.json");
success(run(
temp.path(),
&["run", "app:e2e", &format!("--graph={}", file.display())],
));
assert!(file.is_file());
}
#[cfg(unix)]
#[test]
fn a_target_without_a_project_finds_one_like_nx() {
let temp = fixture(json!({"hello": {"command": "echo root"}}));
fs::create_dir_all(temp.path().join("libs/lib/src")).unwrap();
fs::write(
temp.path().join("libs/lib/project.json"),
json!({"name": "lib", "targets": {"hello": {"command": "echo lib"}}}).to_string(),
)
.unwrap();
let hello = |directory: &Path, env: Option<&str>| {
let mut command = command(temp.path(), &["run", "hello"]);
command.current_dir(directory);
match env {
Some(name) => command.env("NX_DEFAULT_PROJECT", name),
None => command.env_remove("NX_DEFAULT_PROJECT"),
};
String::from_utf8(success(command.output().unwrap()).stdout).unwrap()
};
let lib = temp.path().join("libs/lib/src");
assert_eq!(hello(temp.path(), None), "root\n");
assert_eq!(hello(temp.path(), Some("lib")), "lib\n");
assert_eq!(hello(&lib, Some("app")), "lib\n");
fs::remove_file(temp.path().join("project.json")).unwrap();
fs::write(temp.path().join("nx.json"), r#"{"defaultProject": "lib"}"#).unwrap();
assert_eq!(hello(temp.path(), None), "lib\n");
}
#[cfg(target_os = "macos")]
static AUDIT: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(target_os = "macos")]
#[test]
fn the_sandbox_reports_and_refuses_what_a_task_does_not_declare() {
let _audit = AUDIT
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let temp = fixture(json!({
"build": {
"command": "cat src/in.txt other/notes.txt .env > /dev/null; mkdir -p dist && echo out > dist/out.txt && echo stray > stray.txt",
"inputs": ["{projectRoot}/src/**/*"],
"outputs": ["{projectRoot}/dist"]
},
"clean": {"command": "cat src/in.txt > /dev/null", "inputs": ["{projectRoot}/src/**/*"]}
}));
fs::create_dir_all(temp.path().join("src")).unwrap();
fs::create_dir_all(temp.path().join("other")).unwrap();
fs::write(temp.path().join("src/in.txt"), "in").unwrap();
fs::write(temp.path().join("other/notes.txt"), "notes").unwrap();
fs::write(temp.path().join(".env"), "X=1\n").unwrap();
let report = temp.path().join("sandbox.json");
let audit = run(
temp.path(),
&[
"run-many",
"-t",
"build,clean",
"--sandbox",
"--sandbox-report",
report.to_str().unwrap(),
],
);
if String::from_utf8_lossy(&audit.stderr).contains("did not reach the system log") {
eprintln!("the sandbox's reports are not in this machine's log; audit not checked");
} else {
success(audit);
let findings: Value = serde_json::from_slice(&fs::read(&report).unwrap()).unwrap();
let build = &findings["tasks"]["app:build"];
assert_eq!(build["undeclaredReads"], json!(["other/notes.txt"]));
assert_eq!(build["unkeyedReads"], json!([".env"]));
assert_eq!(build["strayWrites"], json!(["stray.txt"]));
assert!(findings["tasks"].get("app:clean").is_none(), "{findings}");
assert!(temp.path().join("stray.txt").is_file());
fs::remove_file(temp.path().join("stray.txt")).unwrap();
}
let enforced = run(
temp.path(),
&[
"run",
"app:build",
"--sandbox=enforce",
"--output-style",
"static",
],
);
assert!(!enforced.status.success());
let stdout = String::from_utf8_lossy(&enforced.stdout);
assert!(
stdout.contains("other/notes.txt: Operation not permitted"),
"{stdout}"
);
assert!(!temp.path().join("stray.txt").exists());
assert!(temp.path().join("dist/out.txt").is_file());
}
#[cfg(target_os = "macos")]
#[test]
fn warm_suggest_lists_the_directories_a_task_wrote_outside_its_outputs() {
let _audit = AUDIT
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let temp = fixture(json!({
"build": {
"command": "mkdir -p dist .tool/a && echo out > dist/out.txt && echo cached > .tool/a/entry && echo generated > src/generated.txt",
"outputs": ["{projectRoot}/dist"]
}
}));
fs::create_dir_all(temp.path().join("src")).unwrap();
fs::write(temp.path().join("src/in.txt"), "in").unwrap();
fs::write(temp.path().join(".gitignore"), "dist/\n.tool/\n").unwrap();
let git = |args: &[&str]| {
assert!(
Command::new("git")
.current_dir(temp.path())
.args(args)
.env("GIT_CONFIG_NOSYSTEM", "1")
.status()
.unwrap()
.success()
);
};
git(&["init", "--quiet"]);
let output = run(temp.path(), &["warm", "suggest", "app:build"]);
if String::from_utf8_lossy(&output.stderr).contains("did not reach the system log") {
eprintln!("the sandbox's reports are not in this machine's log; suggest not checked");
return;
}
let stdout = String::from_utf8_lossy(&success(output).stdout).into_owned();
assert!(stdout.contains("\n .tool "), "{stdout}");
assert!(!stdout.contains("src"), "{stdout}");
}
#[cfg(target_os = "linux")]
#[test]
fn the_sandbox_refuses_what_a_task_does_not_declare_on_linux() {
let temp = fixture(json!({
"build": {
"command": "cat src/in.txt .env node_modules/dep/index.js /etc/hostname > /dev/null && ls other > /dev/null && echo out > dist/out.txt && echo ok",
"inputs": ["{projectRoot}/src/**/*"],
"outputs": ["{projectRoot}/dist"]
},
"peek": {"command": "cat other/notes.txt", "inputs": ["{projectRoot}/src/**/*"]},
"stray": {"command": "echo stray > stray.txt", "inputs": ["{projectRoot}/src/**/*"]}
}));
for directory in ["src", "other", "node_modules/dep"] {
fs::create_dir_all(temp.path().join(directory)).unwrap();
}
fs::write(temp.path().join("src/in.txt"), "in").unwrap();
fs::write(temp.path().join("other/notes.txt"), "notes").unwrap();
fs::write(temp.path().join(".env"), "X=1\n").unwrap();
fs::write(temp.path().join("node_modules/dep/index.js"), "").unwrap();
let sandboxed = |target: &str| {
run(
temp.path(),
&[
"run",
target,
"--sandbox=enforce",
"--output-style",
"static",
],
)
};
let built = success(sandboxed("app:build"));
assert!(String::from_utf8_lossy(&built.stdout).contains("ok"));
assert!(temp.path().join("dist/out.txt").is_file());
let peek = sandboxed("app:peek");
assert!(!peek.status.success());
assert!(String::from_utf8_lossy(&peek.stdout).contains("Permission denied"));
assert!(!sandboxed("app:stray").status.success());
assert!(!temp.path().join("stray.txt").exists());
let audit = run(temp.path(), &["run", "app:build", "--sandbox"]);
assert!(String::from_utf8_lossy(&audit.stderr).contains("use --sandbox=enforce"));
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[test]
fn sandbox_allows_linked_input_trees_without_granting_unrelated_access() {
let temp = fixture(json!({
"build": {
"command": "mkdir -p dist && cat alias/value.txt alias/ignored/value.txt alias/file.txt alias/nested/value.txt selected.txt > dist/out.txt",
"inputs": ["{projectRoot}/alias", "{projectRoot}/selected.txt"],
"outputs": ["{projectRoot}/dist"]
},
"peek": {
"command": "cat other/value.txt",
"inputs": ["{projectRoot}/alias"]
},
"write": {
"command": "echo changed > alias/value.txt",
"inputs": ["{projectRoot}/alias"]
}
}));
for directory in ["shared/ignored", "nested", "other"] {
fs::create_dir_all(temp.path().join(directory)).unwrap();
}
for (path, content) in [
("shared/value.txt", "direct\n"),
("shared/ignored/value.txt", "ignored\n"),
("file.txt", "file\n"),
("nested/value.txt", "nested\n"),
("other/value.txt", "unselected\n"),
] {
fs::write(temp.path().join(path), content).unwrap();
}
fs::write(temp.path().join(".gitignore"), "shared/ignored/\ndist/\n").unwrap();
for (target, alias) in [
("shared", "alias"),
("../file.txt", "shared/file.txt"),
("../nested", "shared/nested"),
("file.txt", "selected.txt"),
] {
std::os::unix::fs::symlink(target, temp.path().join(alias)).unwrap();
}
let enforced = |target: &str| run(temp.path(), &["run", target, "--sandbox=enforce"]);
success(enforced("app:build"));
assert_eq!(
fs::read_to_string(temp.path().join("dist/out.txt")).unwrap(),
"direct\nignored\nfile\nnested\nfile\n"
);
assert!(!enforced("app:peek").status.success());
assert!(!enforced("app:write").status.success());
assert_eq!(
fs::read_to_string(temp.path().join("shared/value.txt")).unwrap(),
"direct\n"
);
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[test]
fn sandbox_allows_structured_json_inputs_and_their_linked_targets() {
let temp = fixture(json!({
"build": {
"command": "mkdir -p dist && cat settings.json selected.json > dist/out.txt",
"inputs": [
{"json": "{projectRoot}/settings.json", "fields": ["selected"]},
{"json": "{projectRoot}/selected.json", "excludeFields": ["comment"]}
],
"outputs": ["{projectRoot}/dist"]
},
"peek": {
"command": "cat other.json",
"inputs": [{"json": "{projectRoot}/settings.json", "fields": ["selected"]}]
}
}));
for path in ["settings.json", "linked.json", "other.json"] {
fs::write(temp.path().join(path), "{\"selected\":1}").unwrap();
}
std::os::unix::fs::symlink("linked.json", temp.path().join("selected.json")).unwrap();
let enforced = |target: &str| run(temp.path(), &["run", target, "--sandbox=enforce"]);
success(enforced("app:build"));
assert_eq!(
fs::read_to_string(temp.path().join("dist/out.txt")).unwrap(),
"{\"selected\":1}{\"selected\":1}"
);
assert!(!enforced("app:peek").status.success());
}
#[cfg(unix)]
#[test]
fn exec_inherits_the_enclosing_task_sandbox() {
let executable = env!("CARGO_BIN_EXE_qk");
let temp = fixture(json!({
"build": {"command": format!("\"{executable}\" exec --sandbox=enforce -- echo built")}
}));
let output = success(run(temp.path(), &["run", "app:build", "--sandbox=enforce"]));
assert!(String::from_utf8_lossy(&output.stdout).contains("built"));
let output = command(temp.path(), &["run", "app:build"])
.env("QK_TASK_SANDBOX", "1")
.output()
.unwrap();
assert!(!output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("cannot apply"));
}
#[cfg(unix)]
#[test]
fn exec_runs_in_projects_dependencies_first_as_nx_does() {
let temp = TempDir::new().unwrap();
let root = temp.path();
fs::write(root.join("nx.json"), "{}").unwrap();
for (directory, project) in [
("libs/core", json!({"name": "core"})),
(
"apps/web",
json!({"name": "web", "implicitDependencies": ["core"]}),
),
("tools/other", json!({"name": "other"})),
] {
fs::create_dir_all(root.join(directory)).unwrap();
fs::write(
root.join(directory).join("project.json"),
project.to_string(),
)
.unwrap();
}
let text = |output: Output| String::from_utf8(success(output).stdout).unwrap();
let names = [
"exec",
"-p",
"web",
"--",
"echo",
"$NX_PROJECT_NAME",
"$NX_PROJECT_ROOT_PATH",
];
assert_eq!(text(run(root, &names)), "core libs/core\nweb apps/web\n");
assert!(text(run(root, &["exec", "-p", "core", "--", "pwd"])).ends_with("libs/core\n"));
assert_eq!(
text(run(
root,
&[
"exec",
"-p",
"web",
"--exclude-task-dependencies",
"--",
"echo",
"$NX_PROJECT_NAME"
]
)),
"web\n"
);
let failed = run(
root,
&[
"exec",
"-p",
"web",
"--",
"sh",
"-c",
"echo $NX_PROJECT_NAME; exit 3",
],
);
assert_eq!(failed.status.code(), Some(1));
assert_eq!(String::from_utf8_lossy(&failed.stdout), "core\n");
let inside = command(root, &["exec", "--", "echo", "$NX_PROJECT_NAME"])
.env("NX_TASK_TARGET_PROJECT", "web")
.output()
.unwrap();
assert_eq!(text(inside), "web\n");
fs::write(
root.join("apps/web/package.json"),
json!({"name": "web", "scripts": {"build": "qk exec -- echo script"}}).to_string(),
)
.unwrap();
fs::write(
root.join("apps/web/project.json"),
json!({"name": "web", "targets": {"build": {"command": "echo target"}}}).to_string(),
)
.unwrap();
let script = command(root, &["exec", "--", "echo", "script"])
.current_dir(root.join("apps/web"))
.env("npm_lifecycle_event", "build")
.output()
.unwrap();
assert!(text(script).contains("target\n"));
assert!(!run(root, &["exec", "-p", "web"]).status.success());
for option in ["--dry-run", "--sandbox=enforce"] {
let refused = run(root, &["exec", option, "--", "touch", "ran"]);
assert!(!refused.status.success());
assert!(String::from_utf8_lossy(&refused.stderr).contains("cannot apply"));
}
assert!(!root.join("ran").exists());
assert!(!root.join("tools/other/ran").exists());
}
#[cfg(unix)]
#[test]
fn watch_runs_callbacks_for_project_changes_and_cancels() {
use std::io::{BufRead, BufReader};
let temp = TempDir::new().unwrap();
let root = temp.path();
fs::write(root.join("nx.json"), "{}").unwrap();
for (directory, project) in [
(
"app",
json!({"name":"app","implicitDependencies":["lib"],
"targets":{"build":{"command":"echo build", "outputs":["{projectRoot}/dist"]}}}),
),
("lib", json!({"name":"lib"})),
] {
fs::create_dir_all(root.join(directory)).unwrap();
fs::write(
root.join(directory).join("project.json"),
project.to_string(),
)
.unwrap();
fs::write(root.join(directory).join("input.txt"), "one").unwrap();
}
let callbacks = temp.path().join("callbacks");
let script = format!(
"echo \"$NX_PROJECT_NAME|$NX_FILE_CHANGES\" >> '{}'; echo callback",
callbacks.display()
);
let mut child = WatchChild(
command(
root,
&[
"watch",
"-p",
"app",
"--includeDependencies",
"--initialRun",
"--",
&script,
],
)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap(),
);
let (send, receive) = std::sync::mpsc::channel();
let stderr = child.stderr.take().unwrap();
let reader = std::thread::spawn(move || {
for line in BufReader::new(stderr).lines() {
let _ = send.send(line.unwrap());
}
});
let deadline = Instant::now() + Duration::from_secs(10);
while !receive
.recv_timeout(deadline.saturating_duration_since(Instant::now()))
.unwrap()
.contains("qk: watching")
{}
assert_eq!(fs::read_to_string(&callbacks).unwrap(), "app|\n");
fs::write(root.join("lib/input.txt"), "two").unwrap();
let deadline = Instant::now() + Duration::from_secs(10);
while !fs::read_to_string(&callbacks).unwrap().lines().any(|line| {
line.strip_prefix("lib|")
.is_some_and(|files| files.split_whitespace().any(|file| file == "lib/input.txt"))
}) {
assert!(
Instant::now() < deadline,
"watch did not observe dependency edit; callbacks: {:?}",
fs::read_to_string(&callbacks).unwrap()
);
std::thread::sleep(Duration::from_millis(20));
}
let count = fs::read_to_string(&callbacks).unwrap().lines().count();
fs::create_dir_all(root.join("app/dist")).unwrap();
fs::write(root.join("app/dist/out.txt"), "generated").unwrap();
std::thread::sleep(Duration::from_millis(500));
assert_eq!(
fs::read_to_string(&callbacks).unwrap().lines().count(),
count
);
fs::write(root.join("nx.json"), "{").unwrap();
fs::write(root.join("lib/input.txt"), "three").unwrap();
let deadline = Instant::now() + Duration::from_secs(10);
while !receive
.recv_timeout(deadline.saturating_duration_since(Instant::now()))
.unwrap()
.contains("watch configuration not reloaded")
{}
fs::write(root.join("nx.json"), "{}").unwrap();
let deadline = Instant::now() + Duration::from_secs(10);
while fs::read_to_string(&callbacks).unwrap().lines().count() == count {
assert!(
Instant::now() < deadline,
"configuration reload lost source edits"
);
std::thread::sleep(Duration::from_millis(20));
}
assert!(
fs::read_to_string(&callbacks)
.unwrap()
.lines()
.last()
.unwrap()
.contains("lib/input.txt")
);
Command::new("kill")
.args(["-INT", &child.id().to_string()])
.status()
.unwrap();
assert_eq!(child.wait().unwrap().code(), Some(130));
reader.join().unwrap();
}
struct WatchChild(std::process::Child);
impl std::ops::Deref for WatchChild {
type Target = std::process::Child;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl std::ops::DerefMut for WatchChild {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl Drop for WatchChild {
fn drop(&mut self) {
#[cfg(windows)]
if self.0.try_wait().ok().flatten().is_none() {
let _ = Command::new("taskkill")
.args(["/PID", &self.0.id().to_string(), "/T", "/F"])
.output();
}
let _ = self.0.kill();
let _ = self.0.wait();
}
}
#[cfg(unix)]
#[test]
fn watch_all_discovers_projects_and_queues_changes() {
use std::io::{BufRead, BufReader};
let temp = TempDir::new().unwrap();
let root = temp.path().join("workspace");
fs::create_dir_all(&root).unwrap();
fs::write(root.join("nx.json"), "{}").unwrap();
let records = temp.path().join("records");
let started = temp.path().join("started");
let script = format!(
"echo start > '{}'; sleep 0.6; echo \"$NX_PROJECT_NAME|$NX_FILE_CHANGES\" >> '{}'",
started.display(),
records.display()
);
let mut child = WatchChild(
command(&root, &["watch", "--all", "--", &script])
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.unwrap(),
);
let (send, receive) = std::sync::mpsc::channel();
let stderr = child.stderr.take().unwrap();
let reader = std::thread::spawn(move || {
for line in BufReader::new(stderr).lines() {
let _ = send.send(line.unwrap());
}
});
let deadline = Instant::now() + Duration::from_secs(10);
while !receive
.recv_timeout(deadline.saturating_duration_since(Instant::now()))
.unwrap()
.contains("qk: watching")
{}
fs::create_dir_all(root.join("new")).unwrap();
fs::write(root.join("new/project.json"), r#"{"name":"new"}"#).unwrap();
fs::write(root.join("new/first.txt"), "first").unwrap();
wait_for(&started);
fs::write(root.join("new/second.txt"), "second").unwrap();
let deadline = Instant::now() + Duration::from_secs(10);
while !fs::read_to_string(&records)
.unwrap_or_default()
.contains("new/second.txt")
{
assert!(Instant::now() < deadline, "pending callback was lost");
std::thread::sleep(Duration::from_millis(20));
}
let lines = fs::read_to_string(&records).unwrap();
assert!(lines.lines().count() >= 2, "{lines}");
assert!(
lines.lines().all(|line| line.starts_with("new|")),
"{lines}"
);
Command::new("kill")
.args(["-INT", &child.id().to_string()])
.status()
.unwrap();
assert_eq!(child.wait().unwrap().code(), Some(130));
reader.join().unwrap();
}
#[test]
fn watch_portable_queues_changes_ignores_files_and_cancels_callback() {
use std::io::{BufRead, BufReader};
let temp = TempDir::new().unwrap();
let root = temp.path().join("workspace");
fs::create_dir_all(root.join("app")).unwrap();
fs::write(root.join("nx.json"), "{}").unwrap();
fs::write(root.join(".nxignore"), "app/ignored.txt\n").unwrap();
fs::write(root.join("app/project.json"), r#"{"name":"app"}"#).unwrap();
fs::write(root.join("app/first.txt"), "one").unwrap();
fs::write(root.join("app/second.txt"), "one").unwrap();
fs::write(root.join("app/ignored.txt"), "before watch").unwrap();
let records = temp.path().join("records");
fs::create_dir_all(&records).unwrap();
let callback = format!("{} --skip NX_PROJECT_NAME", helper());
let mut cmd = command(&root, &["watch", "--all", "--", &callback]);
cmd.env("QK_TEST_MODE", "watch-portable")
.env("QK_TEST_ID", "watch")
.env("QK_WATCH_RECORDS", &records)
.stdout(Stdio::null())
.stderr(Stdio::piped());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x00000010); }
let mut child = WatchChild(cmd.spawn().unwrap());
let (sender, receiver) = std::sync::mpsc::channel();
let stderr = child.stderr.take().unwrap();
let reader = std::thread::spawn(move || {
for line in BufReader::new(stderr).lines() {
let _ = sender.send(line.unwrap());
}
});
let deadline = Instant::now() + Duration::from_secs(20);
while !receiver
.recv_timeout(deadline.saturating_duration_since(Instant::now()))
.unwrap()
.contains("qk: watching")
{}
fs::write(root.join("app/ignored.txt"), "ignored").unwrap();
fs::write(root.join("app/first.txt"), "two").unwrap();
wait_for(&records.join("1.json"));
let first: Value = serde_json::from_slice(&fs::read(records.join("1.json")).unwrap()).unwrap();
assert_eq!(first["project"], "app");
let files: Vec<_> = first["files"].as_str().unwrap().split(' ').collect();
assert!(files.contains(&"app/first.txt"), "{first}");
assert!(!files.contains(&"app/ignored.txt"), "{first}");
fs::write(root.join("app/second.txt"), "two").unwrap();
fs::write(records.join("release-1"), "release").unwrap();
wait_for(&records.join("2.json"));
wait_for(&records.join("2.tick"));
let queued: Value = serde_json::from_slice(&fs::read(records.join("2.json")).unwrap()).unwrap();
assert_eq!(queued["project"], "app");
let files: Vec<_> = queued["files"].as_str().unwrap().split(' ').collect();
assert!(files.contains(&"app/second.txt"), "{queued}");
assert!(!files.contains(&"app/ignored.txt"), "{queued}");
#[cfg(unix)]
assert!(
Command::new("kill")
.args(["-INT", &child.id().to_string()])
.status()
.unwrap()
.success()
);
#[cfg(windows)]
success(
Command::new(std::env::current_exe().unwrap())
.args(["--exact", "process_helper", "--nocapture"])
.env("QK_TEST_MODE", "signal-watch")
.env("QK_WATCH_PID", child.id().to_string())
.output()
.unwrap(),
);
let deadline = Instant::now() + Duration::from_secs(20);
let status = loop {
if let Some(status) = child.try_wait().unwrap() {
break status;
}
assert!(Instant::now() < deadline, "watch did not stop on Ctrl+C");
std::thread::sleep(Duration::from_millis(20));
};
assert_eq!(status.code(), Some(130));
let ticks = fs::read(records.join("2.tick")).unwrap();
std::thread::sleep(Duration::from_millis(150));
assert_eq!(
fs::read(records.join("2.tick")).unwrap(),
ticks,
"callback survived cancellation"
);
reader.join().unwrap();
}
#[cfg(windows)]
fn signal_windows_watch(pid: u32) {
#[link(name = "Kernel32")]
unsafe extern "system" {
fn FreeConsole() -> i32;
fn AttachConsole(pid: u32) -> i32;
fn SetConsoleCtrlHandler(
handler: Option<unsafe extern "system" fn(u32) -> i32>,
add: i32,
) -> i32;
fn GenerateConsoleCtrlEvent(event: u32, group: u32) -> i32;
}
unsafe extern "system" fn ignore_signal(_: u32) -> i32 {
1
}
unsafe {
FreeConsole();
assert_ne!(AttachConsole(pid), 0, "{}", std::io::Error::last_os_error());
assert_ne!(SetConsoleCtrlHandler(Some(ignore_signal), 1), 0);
assert_ne!(
GenerateConsoleCtrlEvent(0, 0),
0,
"{}",
std::io::Error::last_os_error()
);
}
std::thread::sleep(Duration::from_millis(200));
unsafe {
FreeConsole();
}
}
#[test]
fn input_analysis_rejects_nonexecuting_options_and_invalid_previous_reports() {
let temp = fixture(json!({"build": {"command": "echo executed > marker"}}));
for flag in ["--dry-run", "--graph", "--sandbox"] {
let output = run(temp.path(), &["inputs", "analyze", "app:build", flag]);
assert!(!output.status.success());
assert!(!temp.path().join("marker").exists());
}
fs::write(
temp.path().join("previous.json"),
r#"{"schemaVersion": 999}"#,
)
.unwrap();
let output = run(
temp.path(),
&[
"inputs",
"analyze",
"app:build",
"--previous",
"previous.json",
],
);
assert!(!output.status.success());
assert!(!temp.path().join("marker").exists());
let temp = fixture(json!({
"seed": {"command": "echo executed > marker"},
"serve": {"command": "echo executed > marker", "continuous": true, "dependsOn": ["seed"]},
"ready": {"executor": "nx:run-commands", "options": {"command": "echo executed > marker", "readyWhen": "ready"}}
}));
for task in ["app:serve", "app:ready"] {
let output = run(temp.path(), &["inputs", "analyze", task]);
assert!(!output.status.success());
assert!(!temp.path().join("marker").exists());
}
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[test]
fn input_analysis_recognizes_nested_links_and_structured_link_targets() {
#[cfg(target_os = "macos")]
let _audit = AUDIT
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
#[cfg(target_os = "linux")]
if Command::new("strace").arg("-V").output().is_err() {
return;
}
let temp = fixture(json!({
"build": {
"command": "cat selected/file.txt selected/nested/value.txt settings.json other/value.txt",
"inputs": [
"{projectRoot}/selected",
{"json": "{projectRoot}/settings.json", "fields": ["value"]}
]
}
}));
for directory in ["shared", "nested", "other"] {
fs::create_dir(temp.path().join(directory)).unwrap();
}
for (path, content) in [
("file.txt", "linked file"),
("nested/value.txt", "linked directory"),
("data.json", "{\"value\":1}"),
("other/value.txt", "uncovered"),
] {
fs::write(temp.path().join(path), content).unwrap();
}
for (target, alias) in [
("shared", "selected"),
("../file.txt", "shared/file.txt"),
("../nested", "shared/nested"),
("data.json", "settings.json"),
] {
std::os::unix::fs::symlink(target, temp.path().join(alias)).unwrap();
}
let output = run(
temp.path(),
&[
"inputs",
"analyze",
"app:build",
"--report",
"analysis.json",
],
);
#[cfg(target_os = "macos")]
if String::from_utf8_lossy(&output.stderr).contains("did not reach the system log") {
return;
}
success(output);
let report: Value =
serde_json::from_slice(&fs::read(temp.path().join("analysis.json")).unwrap()).unwrap();
let task = &report["tasks"]["app:build"];
for (path, requested, category, alias) in [
("file.txt", "selected/file.txt", "input", "selected"),
(
"nested/value.txt",
"selected/nested/value.txt",
"input",
"selected",
),
(
"data.json",
"settings.json",
"structuredInput",
"settings.json",
),
] {
assert!(
!task["uncoveredAccesses"]
.as_array()
.unwrap()
.contains(&json!(path)),
"{report}"
);
let reads: Vec<_> = task["accesses"]
.as_array()
.unwrap()
.iter()
.filter(|access| {
(access["path"] == path || access["path"] == requested)
&& (access["operation"] == "readData" || access["operation"] == "openRead")
})
.collect();
#[cfg(target_os = "linux")]
assert!(!reads.is_empty(), "{report}");
for read in reads {
assert_eq!(read["category"], category, "{report}");
assert_eq!(read["keyedPath"], alias, "{report}");
}
}
assert!(
task["uncoveredAccesses"]
.as_array()
.unwrap()
.contains(&json!("other/value.txt")),
"{report}"
);
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[test]
fn input_analysis_cancellation_stops_descendants_and_saves_an_advisory_report() {
#[cfg(target_os = "macos")]
let _audit = AUDIT
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
#[cfg(target_os = "linux")]
if Command::new("strace").arg("-V").output().is_err() {
return;
}
let temp = fixture(json!({"long": helper_target("tree", "tree")}));
let mut child = WatchChild(
command(
temp.path(),
&[
"inputs",
"analyze",
"app:long",
"--report",
"cancelled.json",
],
)
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.unwrap(),
);
let deadline = Instant::now() + Duration::from_secs(15);
while !temp.path().join("heartbeat.started").exists() {
if child.try_wait().unwrap().is_some() {
use std::io::Read;
let mut stderr = String::new();
child
.stderr
.take()
.unwrap()
.read_to_string(&mut stderr)
.unwrap();
#[cfg(target_os = "macos")]
if stderr.contains("did not reach the system log") {
return;
}
panic!("recorder did not start: {stderr}");
}
assert!(
Instant::now() < deadline,
"recorder did not launch its task"
);
std::thread::sleep(Duration::from_millis(10));
}
assert!(
Command::new("/bin/kill")
.arg("-INT")
.arg(child.id().to_string())
.status()
.unwrap()
.success()
);
let deadline = Instant::now() + Duration::from_secs(15);
let status = loop {
if let Some(status) = child.try_wait().unwrap() {
break status;
}
assert!(Instant::now() < deadline, "recorder did not stop");
std::thread::sleep(Duration::from_millis(10));
};
assert_eq!(status.code(), Some(130));
assert_stopped(&temp.path().join("heartbeat"));
let report: Value =
serde_json::from_slice(&fs::read(temp.path().join("cancelled.json")).unwrap()).unwrap();
assert_eq!(report["tasks"]["app:long"]["outcome"], "cancelled");
assert!(
report["tasks"]["app:long"]["successfulRuns"]
.as_array()
.unwrap()
.is_empty()
);
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[test]
fn input_analysis_records_descendants_outputs_and_uncovered_inputs_without_cache_reuse() {
#[cfg(target_os = "macos")]
let _audit = AUDIT
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
#[cfg(target_os = "linux")]
if Command::new("strace").arg("-V").output().is_err() {
eprintln!("strace is not installed; recorder integration not checked");
return;
}
let temp = fixture(json!({
"seed": {"command": "mkdir -p dependency-dist; echo seed > dependency-dist/value", "outputs": ["{projectRoot}/dependency-dist"], "cache": true},
"build": {
"command": "cat src/a.txt src/alias.txt other/hidden.txt settings.json dependency-dist/value; test -e missing.json || :; ls templates; printf generated > generated.tmp; cat generated.tmp; mkdir -p dist; echo out > dist/out.txt; echo run >> dist/runs.txt",
"inputs": ["{projectRoot}/src/**/*", {"json": "{projectRoot}/settings.json", "fields": ["value"]}],
"outputs": ["{projectRoot}/dist"], "dependsOn": ["seed"], "cache": true,
"options": {"env": {"QK_FIXTURE_SECRET": "do-not-store-this-secret"}}
},
"fail": {"command": "cat src/a.txt; exit 7", "inputs": ["{projectRoot}/src/**/*"]}
}));
for path in ["src", "other", "templates"] {
fs::create_dir(temp.path().join(path)).unwrap();
}
fs::write(temp.path().join("src/a.txt"), "input").unwrap();
fs::write(temp.path().join("src/unused.txt"), "unused").unwrap();
fs::write(temp.path().join("other/hidden.txt"), "hidden").unwrap();
fs::write(temp.path().join("settings.json"), r#"{"value": 1}"#).unwrap();
fs::write(temp.path().join("linked.txt"), "linked").unwrap();
std::os::unix::fs::symlink("../linked.txt", temp.path().join("src/alias.txt")).unwrap();
let output = run(
temp.path(),
&[
"inputs",
"analyze",
"app:build",
"--report",
"first.json",
"--suggestions",
"draft.json",
],
);
#[cfg(target_os = "macos")]
if String::from_utf8_lossy(&output.stderr).contains("did not reach the system log") {
eprintln!("Seatbelt logs unavailable; recorder integration not checked");
return;
}
success(output);
let draft: Value =
serde_json::from_slice(&fs::read(temp.path().join("draft.json")).unwrap()).unwrap();
assert_eq!(draft["reviewRequired"], true);
let fragment = &draft["tasks"]["app:build"];
if !fragment.is_null() {
assert!(
fragment["targets"]["build"]["inputs"]
.as_array()
.unwrap()
.contains(&json!({"json": "{projectRoot}/settings.json", "fields": ["value"]}))
);
}
let project: Value =
serde_json::from_slice(&fs::read(temp.path().join("project.json")).unwrap()).unwrap();
assert_eq!(
project["targets"]["build"]["inputs"][0],
"{projectRoot}/src/**/*"
);
let bytes = fs::read(temp.path().join("first.json")).unwrap();
assert!(!String::from_utf8_lossy(&bytes).contains("do-not-store-this-secret"));
let report: Value = serde_json::from_slice(&bytes).unwrap();
let task = &report["tasks"]["app:build"];
assert_eq!(task["outcome"], "success");
assert_eq!(task["coverage"]["partial"], true);
assert!(task["configurationFragment"].is_null()); assert!(
draft["reviews"]["app:build"]["notes"]
.as_array()
.unwrap()
.iter()
.any(|note| note.as_str().unwrap().contains("retained"))
);
assert!(
task["accessReview"]["contentReads"]
.as_array()
.unwrap()
.contains(&json!("other/hidden.txt"))
);
#[cfg(target_os = "linux")]
assert!(
task["observedInputs"]
.as_array()
.unwrap()
.contains(&json!("src/a.txt")),
"{report}"
);
assert!(
!task["observedInputs"].as_array().unwrap().is_empty(),
"{report}"
);
if task["accesses"]
.as_array()
.unwrap()
.iter()
.any(|event| event["path"] == "linked.txt" || event["path"] == "src/alias.txt")
{
assert!(
task["observedInputs"]
.as_array()
.unwrap()
.contains(&json!("src/alias.txt")),
"{report}"
);
}
assert!(
!task["uncoveredAccesses"]
.as_array()
.unwrap()
.contains(&json!("linked.txt"))
);
assert!(
task["unobservedInputs"]
.as_array()
.unwrap()
.contains(&json!("src/unused.txt")),
"{report}"
);
assert!(
!task["unobservedInputs"]
.as_array()
.unwrap()
.contains(&json!("nx.json"))
);
assert!(
task["uncoveredAccesses"]
.as_array()
.unwrap()
.contains(&json!("other/hidden.txt")),
"{report}"
);
assert!(
!task["uncoveredAccesses"]
.as_array()
.unwrap()
.contains(&json!("settings.json"))
);
let events = task["accesses"].as_array().unwrap();
for (path, category) in [
("dependency-dist/value", "dependencyOutput"),
("dist/out.txt", "ownOutput"),
("generated.tmp", "generated"),
] {
assert!(
events
.iter()
.any(|event| event["path"] == path && event["category"] == category),
"{path}: {report}"
);
}
#[cfg(target_os = "linux")]
{
assert!(
events
.iter()
.any(|event| event["path"] == "src/alias.txt"
&& event["resolvedPath"] == "linked.txt"),
"{report}"
);
assert!(
events
.iter()
.any(|event| event["path"] == "missing.json" && event["result"] == "missing"),
"{report}"
);
}
success(run(
temp.path(),
&[
"inputs",
"analyze",
"app:build",
"--report",
"second.json",
"--previous",
"first.json",
],
));
assert_eq!(
fs::read_to_string(temp.path().join("dist/runs.txt")).unwrap(),
"run\nrun\n"
);
let second: Value =
serde_json::from_slice(&fs::read(temp.path().join("second.json")).unwrap()).unwrap();
assert_eq!(
second["tasks"]["app:build"]["successfulRuns"]
.as_array()
.unwrap()
.len(),
2
);
let failed = run(
temp.path(),
&["inputs", "analyze", "app:fail", "--report", "failed.json"],
);
assert_eq!(failed.status.code(), Some(7));
let failed: Value =
serde_json::from_slice(&fs::read(temp.path().join("failed.json")).unwrap()).unwrap();
assert_eq!(failed["tasks"]["app:fail"]["outcome"], "failed:7");
assert!(
failed["tasks"]["app:fail"]["candidateFilesets"]
.as_array()
.unwrap()
.is_empty()
);
}
#[test]
fn warm_suggest_rejects_options_that_skip_execution() {
let temp = fixture(json!({"build": {"command": "echo unused"}}));
for options in [vec!["--dry-run"], vec!["--graph", "graph.json"]] {
let mut args = vec!["warm", "suggest", "app:build"];
args.extend(options);
let output = run(temp.path(), &args);
assert!(!output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("requires task execution"));
assert!(!String::from_utf8_lossy(&output.stdout).contains("wrote nothing"));
}
}
#[test]
fn colon_target_inspection_uses_the_execution_identifier() {
let temp = fixture(json!({
"install": {"executor":"nx:noop", "configurations":{"ios":{}}},
"install:ios": {"executor":"nx:noop", "inputs":[], "outputs":[], "configurations":{"ci":{}}},
"test:unit:watch": {"executor":"nx:noop", "inputs":[], "outputs":[]}
}));
for (identifier, target, configuration) in [
("app:install:ios", "install:ios", None),
("app:install:ios:ci", "install:ios", Some("ci")),
("app:test:unit:watch", "test:unit:watch", None),
] {
let plan: Value = serde_json::from_slice(
&success(run(temp.path(), &["run", identifier, "--dry-run"])).stdout,
)
.unwrap();
let root = plan["roots"][0].as_str().unwrap();
assert_eq!(plan["tasks"][root]["target"], target);
assert_eq!(plan["tasks"][root]["configuration"], json!(configuration));
let inspected: Value = serde_json::from_slice(
&success(run(temp.path(), &["show", "target", identifier, "--json"])).stdout,
)
.unwrap();
assert_eq!(inspected["target"], target);
for category in ["inputs", "outputs"] {
let inspected: Value = serde_json::from_slice(
&success(run(
temp.path(),
&["show", "target", category, identifier, "--json"],
))
.stdout,
)
.unwrap();
assert_eq!(inspected["target"], target);
}
}
let configured: Value = serde_json::from_slice(
&success(run(
temp.path(),
&["show", "target", "app:install:ios", "-c", "ci", "--json"],
))
.stdout,
)
.unwrap();
assert_eq!(configured["target"], "install:ios");
assert_eq!(configured["configuration"], "ci");
}