use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use serde_json::{Value, json};
use tempfile::TempDir;
fn isolated_command(program: impl AsRef<std::ffi::OsStr>) -> Command {
let mut command = Command::new(program);
command
.env(
"GIT_CONFIG_GLOBAL",
if cfg!(windows) { "NUL" } else { "/dev/null" },
)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env_remove("NX_WORKSPACE_ROOT_PATH");
for name in [
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
"GIT_CONFIG",
"GIT_CONFIG_PARAMETERS",
"GIT_CONFIG_COUNT",
"GIT_OBJECT_DIRECTORY",
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_IMPLICIT_WORK_TREE",
"GIT_GRAFT_FILE",
"GIT_INDEX_FILE",
"GIT_NO_REPLACE_OBJECTS",
"GIT_REPLACE_REF_BASE",
"GIT_PREFIX",
"GIT_SHALLOW_FILE",
"GIT_COMMON_DIR",
] {
command.env_remove(name);
}
command
}
fn fixture() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../examples/basic")
.canonicalize()
.unwrap()
}
fn qk(args: &[&str]) -> Output {
isolated_command(env!("CARGO_BIN_EXE_qk"))
.arg("--workspace")
.arg(fixture())
.args(args)
.output()
.unwrap()
}
fn successful_json(output: Output) -> Value {
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
output.stderr.is_empty(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
serde_json::from_slice(&output.stdout).unwrap()
}
#[test]
fn lists_projects_in_nx_graph_order_and_format() {
let output = qk(&["show", "projects", "--json"]);
assert!(output.status.success());
assert_eq!(
String::from_utf8(output.stdout).unwrap(),
"[\"worker\",\"codegen\",\"core\",\"web\"]\n"
);
let output = qk(&["show", "projects"]);
assert!(output.status.success());
assert_eq!(
String::from_utf8(output.stdout).unwrap(),
"worker\ncodegen\ncore\nweb\n"
);
}
#[test]
fn applies_cli_selectors_and_excludes() {
assert_eq!(
successful_json(qk(&[
"show",
"projects",
"-p",
"tag:scope:*,worker",
"--exclude",
"core",
"--json"
])),
json!(["worker", "web"])
);
}
#[test]
fn discovers_workspace_from_nested_project_and_shows_normalized_configuration() {
let output = isolated_command(env!("CARGO_BIN_EXE_qk"))
.current_dir(fixture().join("apps/web/worker"))
.args(["show", "project", "web", "--json"])
.output()
.unwrap();
let project = successful_json(output);
assert_eq!(project["root"], "apps/web");
assert_eq!(project["targets"]["build"]["cache"], true);
assert_eq!(
project["targets"]["build"]["options"]["command"],
"echo build web"
);
}
#[test]
fn show_project_defaults_to_the_current_directory_s_project_as_in_nx() {
let show = |directory: &str, args: &[&str]| {
let output = qk_in(directory, args);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap()
};
let json = show("apps/web/worker", &["show", "project", "--json"]);
assert_eq!(json.lines().count(), 1);
assert_eq!(
serde_json::from_str::<Value>(&json).unwrap()["name"],
"worker"
);
let json = show(".", &["show", "project", "we*", "--json"]);
assert_eq!(serde_json::from_str::<Value>(&json).unwrap()["name"], "web");
let summary = show("apps/web", &["show", "project"]);
assert!(
summary.starts_with("Name: web\nRoot: apps/web\n"),
"{summary}"
);
assert!(summary.contains("- build: echo build web"), "{summary}");
}
#[test]
fn graph_file_matches_stdout_and_is_relative_to_the_workspace_root() {
let temp = TempDir::new().unwrap();
let file = temp.path().join("graph.json");
let output = isolated_command(env!("CARGO_BIN_EXE_qk"))
.current_dir(temp.path())
.arg("graph")
.arg(format!("--file={}", file.display()))
.arg("--workspace")
.arg(fixture())
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(output.stdout.is_empty());
let file: Value = serde_json::from_slice(&std::fs::read(file).unwrap()).unwrap();
assert_eq!(file, successful_json(qk(&["graph"])));
assert_eq!(file["graph"]["nodes"]["web"]["data"]["root"], "apps/web");
assert_eq!(file, successful_json(qk(&["graph", "--file", "stdout"])));
let workspace = TempDir::new().unwrap();
std::fs::write(workspace.path().join("nx.json"), "{}").unwrap();
std::fs::create_dir_all(workspace.path().join("app")).unwrap();
std::fs::write(
workspace.path().join("app/project.json"),
r#"{"name":"app"}"#,
)
.unwrap();
let graph = |file: &str| {
isolated_command(env!("CARGO_BIN_EXE_qk"))
.current_dir(workspace.path().join("app"))
.args(["graph", "--file", file])
.output()
.unwrap()
};
assert!(graph("out/nested/graph.json").status.success());
assert!(workspace.path().join("out/nested/graph.json").is_file());
for file in ["graph.html", "graph.txt"] {
let output = graph(file);
assert_eq!(output.status.code(), Some(1), "{file}");
assert!(!workspace.path().join(file).exists());
}
assert!(
String::from_utf8_lossy(&graph("graph.txt").stderr)
.contains("Please specify a filename with either .json or .html extension.")
);
}
#[test]
fn unknown_projects_and_unimplemented_commands_fail_without_stdout() {
for args in [vec!["show", "project", "missing"], vec!["release"]] {
let output = qk(&args);
assert!(!output.status.success());
assert!(output.stdout.is_empty());
assert!(!output.stderr.is_empty());
}
}
#[test]
fn help_and_version_work_without_a_workspace() {
let temp = TempDir::new().unwrap();
for argument in ["--help", "--version"] {
let output = isolated_command(env!("CARGO_BIN_EXE_qk"))
.current_dir(temp.path())
.arg(argument)
.output()
.unwrap();
assert!(output.status.success());
assert!(String::from_utf8_lossy(&output.stdout).contains("qk"));
}
let output = isolated_command(env!("CARGO_BIN_EXE_qk"))
.current_dir(temp.path())
.args(["show", "projects"])
.output()
.unwrap();
assert!(!output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("no workspace found"));
}
fn planned(output: Output) -> Vec<String> {
let graph = successful_json(output);
let mut tasks: Vec<_> = graph["tasks"]
.as_object()
.unwrap()
.keys()
.cloned()
.collect();
tasks.sort();
tasks
}
fn qk_in(directory: &str, args: &[&str]) -> Output {
isolated_command(env!("CARGO_BIN_EXE_qk"))
.current_dir(fixture().join(directory))
.args(args)
.output()
.unwrap()
}
#[test]
fn nx_shorthand_runs_target_of_named_project() {
for args in [
vec!["build", "web", "--dry-run"],
vec!["web:build", "--dry-run"],
vec!["run", "web:build", "--dry-run"],
] {
assert!(
planned(qk(&args)).contains(&"web:build".to_owned()),
"{args:?}"
);
}
let output = isolated_command(env!("CARGO_BIN_EXE_qk"))
.args(["check", "worker", "--dry-run", "--workspace"])
.arg(fixture())
.output()
.unwrap();
assert_eq!(planned(output), ["worker:check"]);
}
#[test]
fn nx_shorthand_uses_the_most_specific_project_of_the_current_directory() {
assert_eq!(
planned(qk_in("apps/web/worker", &["check", "--dry-run"])),
["worker:check"]
);
assert!(planned(qk_in("apps/web", &["build", "--dry-run"])).contains(&"web:build".to_owned()));
assert!(
planned(qk_in("apps/web", &["run", "test", "--dry-run"])).contains(&"web:test".to_owned())
);
let outside = qk_in(".", &["build", "--dry-run"]);
assert!(!outside.status.success());
assert!(
String::from_utf8_lossy(&outside.stderr)
.contains("no project contains the current directory")
);
}
#[test]
fn nx_shorthand_reports_unknown_targets_and_leaves_nx_commands_alone() {
let output = qk(&["serv", "web", "--dry-run"]);
assert!(!output.status.success());
assert!(
String::from_utf8_lossy(&output.stderr).contains(r#"project "web" has no target "serv""#),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let output = qk(&["format:check"]);
assert!(String::from_utf8_lossy(&output.stderr).contains("unrecognized subcommand"));
}
#[test]
fn run_many_accepts_space_separated_lists_like_nx() {
let spaced = planned(qk(&[
"run-many",
"-t",
"build",
"test",
"--exclude",
"core",
"worker",
"--dry-run",
]));
let commas = planned(qk(&[
"run-many",
"-t",
"build,test",
"--exclude=core,worker",
"--dry-run",
]));
assert_eq!(spaced, commas);
assert!(spaced.contains(&"web:test".to_owned()));
assert!(!spaced.iter().any(|task| task.starts_with("worker:")));
}
#[test]
fn camel_case_flags_mean_their_kebab_case_spelling_like_nx() {
let kebab = planned(qk(&[
"run-many",
"-t",
"build",
"--output-style=static",
"--exclude-task-dependencies",
"--dry-run",
]));
let camel = planned(qk(&[
"run-many",
"-t",
"build",
"--outputStyle=static",
"--excludeTaskDependencies",
"--dryRun",
]));
assert_eq!(camel, kebab);
let forwarded = qk(&["run", "web:build", "--dry-run", "--", "--outputStyle"]);
let plan = String::from_utf8(forwarded.stdout).unwrap();
assert!(plan.contains("\"--outputStyle\""), "{plan}");
}
fn planned_args(output: Output) -> Value {
let graph = successful_json(output);
graph["tasks"]
.as_object()
.unwrap()
.iter()
.map(|(id, task)| (id.clone(), task["args"].clone()))
.collect()
}
#[test]
fn unknown_options_and_extra_positionals_reach_the_task_as_in_nx() {
for args in [
vec![
"test",
"web",
"--coverage",
"--max-workers",
"2",
"--dry-run",
],
vec![
"run",
"web:test",
"--coverage",
"--max-workers=2",
"--dry-run",
],
vec![
"web:test",
"--dry-run",
"--coverage",
"--",
"--max-workers",
"2",
],
] {
let tasks = planned_args(qk(&args));
let forwarded = tasks["web:test"].as_array().unwrap();
assert_eq!(forwarded[0], "--coverage", "{args:?}");
assert!(forwarded.len() >= 2, "{args:?}: {forwarded:?}");
}
assert_eq!(
planned_args(qk(&[
"test",
"web",
"--fooBar=1",
"--no-thing",
"x",
"--dryRun"
]))["web:test"],
json!(["--fooBar=1", "--no-thing", "x"])
);
let tasks = planned_args(qk(&["run-many", "-t", "test", "--coverage", "--dry-run"]));
assert_eq!(tasks["web:test"], json!(["--coverage"]));
let tasks = planned_args(qk(&[
"affected:test",
"--files=apps/web/project.json",
"--watch",
"--dry-run",
]));
assert_eq!(tasks["web:test"], json!(["--watch"]));
}
#[test]
fn boolean_options_take_values_as_yargs_does() {
for args in [
vec!["--nxBail=true", "--skip-nx-cache=false"],
vec!["--nx-bail", "false", "--skipNxCache", "true"],
vec!["--no-nx-bail", "--no-skip-nx-cache", "--verbose=false"],
] {
let mut command = vec!["run-many", "-t", "build"];
command.extend(&args);
command.push("--dry-run");
let output = qk(&command);
assert!(
output.status.success(),
"{args:?}: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
planned_args(output)
.as_object()
.unwrap()
.values()
.all(|args| args == &json!([])),
"{args:?}"
);
}
}
#[test]
fn run_takes_projects_and_targets_as_nx_run_does() {
for args in [
vec!["run", "-p", "web", "-t", "build", "--dry-run"],
vec!["run", "--project=we*", "--target", "build", "--dry-run"],
vec!["run", "we*:b", "--dry-run"],
vec!["b", "web", "--dry-run"],
] {
assert!(
planned(qk(&args)).contains(&"web:build".to_owned()),
"{args:?}"
);
}
assert_eq!(planned(qk(&["t", "web", "--dry-run"])), ["web:test"]);
let output = qk(&["run", "w*:build", "--dry-run"]);
assert!(!output.status.success());
assert!(
String::from_utf8_lossy(&output.stderr).contains("Multiple projects matched"),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let output = qk(&["print-affected"]);
assert!(!output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("nx show projects --affected"));
}
#[test]
fn a_project_with_a_run_target_runs_it_by_name() {
let temp = TempDir::new().unwrap();
std::fs::write(temp.path().join("nx.json"), "{}").unwrap();
std::fs::write(
temp.path().join("project.json"),
json!({"name": "tool", "targets": {"run": {"command": "echo ran"}}}).to_string(),
)
.unwrap();
let qk = |args: &[&str]| {
isolated_command(env!("CARGO_BIN_EXE_qk"))
.current_dir(temp.path())
.args(args)
.output()
.unwrap()
};
assert_eq!(planned(qk(&["tool", "--dry-run"])), ["tool:run"]);
assert_eq!(planned(qk(&["run", "tool", "--dry-run"])), ["tool:run"]);
}
#[test]
fn run_many_takes_the_deprecated_all_and_target() {
let all = planned(qk(&["run-many", "--target", "build", "--all", "--dry-run"]));
assert!(all.contains(&"web:build".to_owned()));
let none = qk(&["run-many", "--target", "build", "--all=false", "--graph"]);
let graph = successful_json(none);
assert_eq!(graph["tasks"]["tasks"], json!({}));
}
#[test]
fn change_options_conflict_as_in_nx() {
for args in [
vec!["--head", "HEAD"],
vec!["--files", "a", "--base", "main"],
vec!["--uncommitted", "--untracked"],
vec!["--untracked", "--head", "HEAD", "--base", "main"],
vec!["--stdin", "--base", "main"],
] {
let mut command = vec!["show", "projects", "--affected"];
command.extend(&args);
let output = qk(&command);
assert_eq!(output.status.code(), Some(2), "{args:?}");
}
}
#[test]
fn camel_case_spellings_of_nx_options_are_accepted() {
for flag in [
"--skipNxCache",
"--disableNxCache",
"--skipRemoteCache",
"--disableRemoteCache",
"--maxParallel=2",
"--nxBail",
"--nxIgnoreCycles",
"--skipSync",
"--useAgents",
"--tuiAutoExit=false",
] {
let output = qk(&["run-many", "-t", "build", flag, "--dry-run"]);
assert!(
output.status.success(),
"{flag}: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let output = qk(&["reset", "--onlyCache", "--help"]);
assert!(output.status.success());
}
#[test]
fn run_many_configuration_applies_only_where_defined_like_nx() {
let tasks = planned(qk(&[
"run-many",
"-t",
"smoke",
"build",
"-c",
"loud",
"--dry-run",
]));
assert!(
tasks.contains(&"codegen:smoke:loud".to_owned()),
"{tasks:?}"
);
assert!(tasks.contains(&"web:build".to_owned()), "{tasks:?}");
}
#[test]
fn root_configuration_fallback_preserves_the_requested_name_for_dependencies() {
let temp = TempDir::new().unwrap();
std::fs::write(temp.path().join("nx.json"), "{}").unwrap();
for (name, mut target) in [
(
"app",
json!({"dependsOn":["tool:build"], "configurations":{"prod":{}}}),
),
(
"tool",
json!({"defaultConfiguration":"dev", "dependsOn":["leaf:build"], "configurations":{"dev":{}}}),
),
("leaf", json!({"configurations":{"prod":{}}})),
] {
target["executor"] = json!("nx:noop");
std::fs::create_dir(temp.path().join(name)).unwrap();
std::fs::write(
temp.path().join(name).join("project.json"),
json!({"name":name, "targets":{"build":target}}).to_string(),
)
.unwrap();
}
for args in [
vec!["run-many"],
vec![
"affected",
"--files=tool/project.json",
"--granularity=task",
],
] {
let graph = successful_json(
isolated_command(env!("CARGO_BIN_EXE_qk"))
.arg("--workspace")
.arg(temp.path())
.args(args)
.args(["-t", "build", "-p", "tool", "-c", "prod", "--dry-run"])
.output()
.unwrap(),
);
assert_eq!(graph["roots"], json!(["tool:build:dev"]));
assert_eq!(
graph["tasks"]["tool:build:dev"]["dependencies"],
json!(["leaf:build:prod"])
);
assert!(graph["tasks"]["leaf:build:prod"].is_object());
}
let inspect = |args: &[&str]| {
successful_json(
isolated_command(env!("CARGO_BIN_EXE_qk"))
.arg("--workspace")
.arg(temp.path())
.args(args)
.output()
.unwrap(),
)
};
assert_eq!(
inspect(&["show", "target", "app:build", "-c", "prod", "--json"]),
inspect(&["show", "target", "app:build:prod", "--json"])
);
}
#[test]
fn affected_selects_touched_projects_and_their_dependents() {
let output = qk(&[
"show",
"projects",
"--affected",
"--files",
"packages/core/index.js",
"--json",
]);
assert_eq!(
String::from_utf8(output.stdout).unwrap(),
"[\"worker\",\"core\",\"web\"]\n"
);
let tasks = planned(qk(&[
"affected",
"-t",
"build",
"--files",
"packages/core/index.js",
"--dry-run",
]));
assert_eq!(tasks, ["codegen:build", "core:build", "web:build"]);
let output = qk(&["affected", "-t", "build", "--files", "README.md"]);
assert!(output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("no affected tasks"));
}
#[test]
fn show_affected_explains_why() {
let files = "packages/core/index.js,tools/codegen/x.ts";
let output = qk(&["show", "affected", "web", "--files", files]);
assert!(output.status.success());
let text = String::from_utf8(output.stdout).unwrap();
assert!(
text.contains("web is affected because it depends on codegen:"),
"{text}"
);
assert!(text.contains(" web -> codegen (implicit)"), "{text}");
assert!(text.contains("also depends on affected core"), "{text}");
assert!(text.contains(" tools/codegen/x.ts changed"), "{text}");
let report = successful_json(qk(&["show", "affected", "web", "--files", files, "--json"]));
assert_eq!(report["affected"], true);
assert_eq!(report["chain"][0]["dependsOn"], "codegen");
assert_eq!(report["chain"][1]["reasons"][0]["reason"], "file");
let output = qk(&["show", "affected", "--files", "README.md"]);
let text = String::from_utf8(output.stdout).unwrap();
assert!(text.starts_with("1 changed file between"), "{text}");
assert_eq!(text.lines().count(), 1, "{text}");
let output = qk(&["show", "affected", "core", "--files", "README.md"]);
assert!(
String::from_utf8(output.stdout)
.unwrap()
.contains("core is not affected.")
);
assert!(!qk(&["show", "affected", "missing"]).status.success());
}
#[test]
fn task_granularity_selects_tasks_whose_inputs_changed() {
let readme = "packages/core/README.md";
let tasks = planned(qk(&[
"affected",
"-t",
"build",
"--granularity",
"project",
"--files",
readme,
"--dry-run",
]));
assert!(tasks.contains(&"core:build".to_owned()), "{tasks:?}");
let output = qk(&[
"affected",
"-t",
"build",
"--granularity",
"task",
"--files",
readme,
]);
assert!(output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("no affected tasks"));
let text = String::from_utf8(
qk(&[
"show",
"tasks",
"-t",
"build",
"test",
"--affected",
"--files",
"packages/core/index.js",
])
.stdout,
)
.unwrap();
assert!(
text.contains("core:build input packages/core/index.js changed"),
"{text}"
);
let report = successful_json(qk(&[
"show",
"tasks",
"-t",
"build",
"--affected",
"--files",
readme,
"--json",
]));
assert_eq!(report["tasks"], json!({}));
}
#[test]
fn graph_can_include_the_packages_the_lockfile_installs() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tools/parity/fixture");
let output = isolated_command(env!("CARGO_BIN_EXE_qk"))
.args(["--workspace", root.to_str().unwrap(), "graph", "--external"])
.output()
.unwrap();
let graph = &successful_json(output)["graph"];
let react_dom = "npm:react-dom@19.1.0(react@19.1.0)";
assert_eq!(
graph["externalNodes"][react_dom]["data"]["packageName"],
"react-dom"
);
let targets = |source: &str| -> Vec<String> {
graph["dependencies"][source]
.as_array()
.unwrap()
.iter()
.map(|edge| edge["target"].as_str().unwrap().to_owned())
.collect()
};
assert!(targets("web").contains(&react_dom.to_owned()));
assert!(targets(react_dom).contains(&"npm:scheduler@0.26.0".to_owned()));
let plain = isolated_command(env!("CARGO_BIN_EXE_qk"))
.args(["--workspace", root.to_str().unwrap(), "graph"])
.output()
.unwrap();
assert!(
successful_json(plain)["graph"]
.get("externalNodes")
.is_none()
);
}
#[test]
fn graph_focus_and_exclude_keep_what_nx_keeps() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tools/parity/fixture");
let graph = |args: &[&str]| {
let output = isolated_command(env!("CARGO_BIN_EXE_qk"))
.args(["--workspace", root.to_str().unwrap(), "graph"])
.args(args)
.output()
.unwrap();
successful_json(output)["graph"].clone()
};
let names =
|object: &Value| -> Vec<String> { object.as_object().unwrap().keys().cloned().collect() };
let focused = graph(&["--focus", "ui"]);
assert_eq!(
names(&focused["nodes"]),
["@fixture/utils", "tooling", "ui", "web", "web-e2e"]
);
assert_eq!(names(&focused["nodes"]), names(&focused["dependencies"]));
let excluded = graph(&["--focus", "ui", "--exclude", "web", "--print"]);
assert_eq!(
names(&excluded["nodes"]),
["@fixture/utils", "tooling", "ui", "web-e2e"]
);
assert_eq!(excluded["dependencies"]["web-e2e"][0]["target"], "web");
assert_eq!(graph(&["--file", "stdout"]), graph(&[]));
let external = graph(&["--focus", "web", "--external"]);
assert!(external["dependencies"].get("mobile").is_none());
let output = isolated_command(env!("CARGO_BIN_EXE_qk"))
.args([
"--workspace",
root.to_str().unwrap(),
"graph",
"--focus",
"missing",
])
.output()
.unwrap();
assert!(!output.status.success());
assert!(output.stdout.is_empty());
}
#[test]
fn show_projects_filters_by_type_and_target_like_nx() {
let text = |args: &[&str]| {
let output = qk(args);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap()
};
assert_eq!(text(&["show", "projects", "--type", "app"]), "web\n");
assert_eq!(
text(&["show", "projects", "-t", "build", "--sep", " "]),
"codegen core web\n"
);
assert_eq!(
text(&[
"show",
"projects",
"--with-target",
"build",
"--type",
"lib",
"--json"
]),
"[\"codegen\",\"core\"]\n"
);
assert!(
!qk(&["show", "projects", "--json", "--sep", ","])
.status
.success()
);
}
#[test]
fn inspects_targets_inputs_and_outputs_without_running_commands() {
let temp = TempDir::new().unwrap();
let root = temp.path();
std::fs::write(
root.join("nx.json"),
r#"{"namedInputs":{"source":["{projectRoot}/src/**/*"]}}"#,
)
.unwrap();
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::create_dir_all(root.join("dist")).unwrap();
std::fs::write(root.join("src/a.ts"), "source").unwrap();
std::fs::write(root.join("dist/a.js"), "output").unwrap();
std::fs::write(root.join("project.json"), json!({"name":"app", "targets":{
"build":{"command":"echo never", "inputs":["source",{"env":"FOO"},{"runtime":"echo ran > runtime-ran"}],
"outputs":["{projectRoot}/dist", "{options.missing}"],
"configurations":{"release":{"command":"echo release"}}}
}}).to_string()).unwrap();
let run = |args: &[&str]| {
isolated_command(env!("CARGO_BIN_EXE_qk"))
.current_dir(root)
.args(args)
.output()
.unwrap()
};
let target = successful_json(run(&[
"show",
"target",
"app:build",
"-c",
"release",
"--json",
]));
assert_eq!(target["options"]["command"], "echo release");
let inputs = successful_json(run(&["show", "target", "inputs", "build", "--json"]));
assert!(
inputs["files"]
.as_array()
.unwrap()
.contains(&json!("src/a.ts"))
);
assert_eq!(inputs["environment"], json!(["FOO"]));
assert_eq!(inputs["runtime"], json!(["echo ran > runtime-ran"]));
assert!(!root.join("runtime-ran").exists());
assert!(
run(&[
"show",
"target",
"inputs",
"app:build",
"--check",
"src",
"FOO",
".",
"./"
])
.status
.success()
);
assert!(
!run(&["show", "target", "inputs", "app:build", "--check", "absent"])
.status
.success()
);
let outputs = successful_json(run(&["show", "target", "outputs", "app:build", "--json"]));
assert!(
outputs["expandedOutputs"]
.as_array()
.unwrap()
.contains(&json!("dist/a.js"))
);
assert_eq!(outputs["unresolvedOutputs"], json!(["{options.missing}"]));
assert!(
run(&[
"show",
"target",
"outputs",
"app:build",
"--check",
"dist/future.js"
])
.status
.success()
);
assert!(
!run(&[
"show",
"target",
"outputs",
"app:build",
"--check",
"src/a.ts"
])
.status
.success()
);
}
#[test]
fn affected_reads_changed_paths_from_stdin() {
use std::io::Write;
use std::process::Stdio;
let temp = TempDir::new().unwrap();
let root = temp.path();
std::fs::write(root.join("nx.json"), "{}").unwrap();
std::fs::create_dir_all(root.join("app/src")).unwrap();
std::fs::write(
root.join("app/project.json"),
r#"{"name":"app","targets":{"build":{"executor":"nx:noop"}}}"#,
)
.unwrap();
std::fs::write(root.join("app/src/a file.ts"), "source").unwrap();
let run = |args: &[&str], input: &str| {
let mut child = isolated_command(env!("CARGO_BIN_EXE_qk"))
.current_dir(root)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.take()
.unwrap()
.write_all(input.as_bytes())
.unwrap();
child.wait_with_output().unwrap()
};
assert_eq!(
successful_json(run(
&["show", "projects", "--stdin", "--json"],
"app/src/a file.ts\r\n\n"
)),
json!(["app"])
);
assert_eq!(
successful_json(run(&["show", "projects", "--stdin", "--json"], "")),
json!([])
);
assert!(
run(
&["affected", "-t", "build", "--stdin"],
"app/src/a file.ts\n"
)
.status
.success()
);
assert_eq!(
successful_json(run(
&[
"show",
"projects",
"--stdin",
"--files",
"app/src/a file.ts",
"--json"
],
""
)),
successful_json(run(
&["show", "projects", "--stdin", "--json"],
"app/src/a file.ts\n"
))
);
}
#[test]
fn doctor_reports_unsupported_features_without_side_effects() {
let temp = TempDir::new().unwrap();
let root = temp.path();
std::fs::write(root.join("nx.json"), r#"{"plugins":["missing-plugin"]}"#).unwrap();
std::fs::write(
root.join("project.json"),
json!({"name":"app","targets":{
"build":{"command":"echo ran > ran","syncGenerators":["missing:sync"]},
"native":{"executor":"missing:build"}
}})
.to_string(),
)
.unwrap();
let output = isolated_command(env!("CARGO_BIN_EXE_qk"))
.current_dir(root)
.args(["doctor", "--json"])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(1));
let report: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(report["errors"], 1);
assert_eq!(report["warnings"], 2);
assert_eq!(report["schemaVersion"], 1);
assert!(
report["acceptedNoopOptions"]
.as_array()
.unwrap()
.contains(&json!("--batch"))
);
assert!(
report["acceptedNoopOptions"]
.as_array()
.unwrap()
.contains(&json!("--no-tui"))
);
assert!(!root.join("ran").exists());
std::fs::write(
root.join("project.json"),
r#"{"name":"app","targets":{"build":{"command":"echo never"}}}"#,
)
.unwrap();
let run = |strict: bool| {
let mut command = isolated_command(env!("CARGO_BIN_EXE_qk"));
command.current_dir(root).arg("doctor");
if strict {
command.arg("--strict");
}
command.output().unwrap()
};
assert!(run(false).status.success());
assert_eq!(run(true).status.code(), Some(1));
std::fs::write(
root.join("nx.json"),
r#"{"sync":{"globalGenerators":["missing:sync"]}}"#,
)
.unwrap();
assert_eq!(run(true).status.code(), Some(1));
let output = isolated_command(env!("CARGO_BIN_EXE_qk"))
.current_dir(root)
.args(["doctor", "--json"])
.output()
.unwrap();
assert!(output.status.success());
let report: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(report["warnings"], 1);
assert_eq!(
report["findings"][0]["location"],
"nx.json.sync.globalGenerators"
);
std::fs::write(root.join("nx.json"), "{}").unwrap();
assert!(run(true).status.success());
}
#[test]
fn doctor_names_dependency_globs_that_select_nothing() {
let temp = TempDir::new().unwrap();
let root = temp.path();
std::fs::write(root.join("nx.json"), "{}").unwrap();
let reads = |pattern: &str| json!([{"dependentTasksOutputFiles": pattern}]);
std::fs::write(
root.join("project.json"),
json!({"name":"app","targets":{
"codegen":{"command":"echo ran > ran","outputs":["{projectRoot}/gen"]},
"build":{"command":"true","dependsOn":["codegen"],"inputs":reads("**/manifest.json")},
"lint":{"command":"true","inputs":reads("**/manifest.json")},
"check":{"command":"true","dependsOn":["codegen"],"inputs":reads("**/*.tsx")},
"compile":{"command":"true","outputs":["{projectRoot}/dist"]},
"types":{"command":"true","dependsOn":["codegen","compile"],"inputs":reads("**/*.tsx")}
}})
.to_string(),
)
.unwrap();
std::fs::create_dir_all(root.join("gen")).unwrap();
std::fs::write(root.join("gen/manifest.json"), "{}").unwrap();
let output = isolated_command(env!("CARGO_BIN_EXE_qk"))
.current_dir(root)
.args(["doctor", "--json"])
.output()
.unwrap();
assert!(output.status.success());
let report: Value = serde_json::from_slice(&output.stdout).unwrap();
let findings: Vec<(&str, &str)> = report["findings"]
.as_array()
.unwrap()
.iter()
.filter(|finding| finding["code"] == "dependency-outputs-unmatched")
.map(|finding| {
(
finding["location"].as_str().unwrap(),
finding["message"].as_str().unwrap(),
)
})
.collect();
assert_eq!(findings.len(), 2, "{report}");
assert_eq!(findings[0].0, r#"dependentTasksOutputFiles "**/*.tsx""#);
assert!(
findings[0].1.contains("1 target: check (1 task)"),
"{report}"
);
assert_eq!(
findings[1].0,
r#"dependentTasksOutputFiles "**/manifest.json""#
);
assert!(
findings[1].1.contains("1 target: lint (1 task)"),
"{report}"
);
assert!(!root.join("ran").exists());
std::fs::write(
root.join("project.json"),
json!({"name":"app","targets":{
"a":{"command":"true","dependsOn":["b"]},
"b":{"command":"true","dependsOn":["a"]}
}})
.to_string(),
)
.unwrap();
let output = isolated_command(env!("CARGO_BIN_EXE_qk"))
.current_dir(root)
.args(["doctor", "--json"])
.output()
.unwrap();
let report: Value = serde_json::from_slice(&output.stdout).unwrap();
assert!(
report["findings"]
.as_array()
.unwrap()
.iter()
.any(|finding| finding["code"] == "dependency-outputs-unchecked"),
"{report}"
);
}
#[test]
fn show_target_includes_qk_fields() {
let temp = TempDir::new().unwrap();
let root = temp.path();
std::fs::write(
root.join("nx.json"),
json!({"targetDefaults": {"build": {"qk:threads": {"max": 2}}}}).to_string(),
)
.unwrap();
std::fs::write(
root.join("project.json"),
json!({"name":"app","targets":{"build":{
"command":"true",
"qk:warm":{"paths":["{projectRoot}/.cache"]}
}}})
.to_string(),
)
.unwrap();
let target = successful_json(
isolated_command(env!("CARGO_BIN_EXE_qk"))
.current_dir(root)
.args(["show", "target", "app:build", "--json"])
.output()
.unwrap(),
);
assert_eq!(
target["qk:warm"],
json!({"paths": ["{projectRoot}/.cache"]}),
"{target}"
);
assert_eq!(target["qk:threads"], json!({"max": 2}), "{target}");
}
#[test]
fn doctor_names_a_qk_sandbox_that_is_not_a_boolean() {
let temp = TempDir::new().unwrap();
let root = temp.path();
std::fs::write(root.join("nx.json"), "{}").unwrap();
std::fs::write(
root.join("project.json"),
json!({"name":"app","targets":{
"off":{"command":"true","qk:sandbox":false},
"typo":{"command":"true","qk:sandbox":"false"}
}})
.to_string(),
)
.unwrap();
let report = successful_json(
isolated_command(env!("CARGO_BIN_EXE_qk"))
.current_dir(root)
.args(["doctor", "--json"])
.output()
.unwrap(),
);
let invalid: Vec<&Value> = report["findings"]
.as_array()
.unwrap()
.iter()
.filter(|finding| finding["code"] == "invalid-field")
.collect();
assert_eq!(invalid.len(), 1, "{report}");
assert_eq!(invalid[0]["location"], "app:typo");
}
#[test]
fn unknown_qk_fields_are_ignored_and_reported_by_doctor() {
let temp = TempDir::new().unwrap();
let root = temp.path();
std::fs::write(root.join("nx.json"), "{}").unwrap();
std::fs::write(
root.join("project.json"),
json!({"name":"app","targets":{"build":{
"command":"echo ran> ran",
"qk:warm":[
{"paths":["{projectRoot}/scratch"], "fromNewerQk":true},
{"group":"shared", "env":{"SHARED":"{warm}"}, "alsoNewer":1}
],
"qk:threads":{"max":2, "threadsNewer":1}
}}})
.to_string(),
)
.unwrap();
let output = isolated_command(env!("CARGO_BIN_EXE_qk"))
.current_dir(root)
.args(["run", "app:build"])
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(output.status.success(), "{stderr}");
assert!(!stderr.contains("ignored ("), "{stderr}");
assert!(root.join("ran").exists());
let doctor = |args: &[&str]| {
isolated_command(env!("CARGO_BIN_EXE_qk"))
.current_dir(root)
.arg("doctor")
.args(args)
.output()
.unwrap()
};
let output = doctor(&["--json"]);
assert!(output.status.success());
let report: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(report["warnings"], 3);
for (index, field) in [
"qk:warm.fromNewerQk",
"qk:warm.alsoNewer",
"qk:threads.threadsNewer",
]
.iter()
.enumerate()
{
let finding = &report["findings"][index];
assert_eq!(finding["code"], "unknown-field");
assert_eq!(finding["location"], "app:build");
assert!(
finding["message"].as_str().unwrap().contains(field),
"{finding}"
);
}
assert_eq!(doctor(&["--strict"]).status.code(), Some(1));
}
#[test]
fn reachability_profile_leaves_out_projects_and_explains_them() {
let temp = TempDir::new().unwrap();
let root = temp.path();
let git = |args: &[&str]| {
let output = isolated_command("git")
.current_dir(root)
.args(["-c", "user.name=qk", "-c", "user.email=qk@example.invalid"])
.args(args)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
};
let write = |path: &str, content: &str| {
let path = root.join(path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, content).unwrap();
};
write(
"nx.json",
&json!({
"qk:reachability": {
"app": {
"anchors": ["{projectRoot}/src/main.ts"],
"sources": ["{projectRoot}/src/**/*", "{workspaceRoot}/libs/*/src/**/*"]
}
},
"qk:affectedProfiles": {"reach": {"reachability": {"default": "app"}}}
})
.to_string(),
);
write(
"apps/app/project.json",
&json!({
"name": "app",
"implicitDependencies": ["lib"],
"targets": {"build": {"command": "echo build", "inputs": ["default", "^default"]}}
})
.to_string(),
);
write(
"apps/app/src/main.ts",
"import { used } from \"../../../libs/lib/src/used\";\nexport const app = used;\n",
);
write(
"apps/app-e2e/project.json",
&json!({
"name": "app-e2e",
"implicitDependencies": ["app"],
"targets": {"e2e": {
"command": "echo e2e",
"inputs": ["default", "^default"],
"qk:reachability": {
"anchors": ["{workspaceRoot}/apps/app/src/main.ts"],
"sources": ["{workspaceRoot}/apps/app/src/**/*", "{workspaceRoot}/libs/*/src/**/*"]
}
}}
})
.to_string(),
);
write("libs/lib/project.json", &json!({"name": "lib"}).to_string());
write("libs/lib/src/used.ts", "export const used = 1;\n");
write("libs/lib/src/unused.ts", "export const unused = 1;\n");
git(&["init", "--quiet", "--initial-branch=main"]);
git(&["add", "."]);
git(&["commit", "--quiet", "-m", "base"]);
write("libs/lib/src/unused.ts", "export const unused = 2;\n");
git(&["add", "."]);
git(&["commit", "--quiet", "-m", "head"]);
let run = |args: &[&str]| {
let mut args = args.to_vec();
args.extend([
"--affected-profile",
"reach",
"--base",
"HEAD^",
"--head",
"HEAD",
]);
isolated_command(env!("CARGO_BIN_EXE_qk"))
.current_dir(root)
.args(&args)
.output()
.unwrap()
};
assert_eq!(
successful_json(run(&["show", "projects", "--affected", "--json"])),
json!(["lib"])
);
assert_eq!(
successful_json(run(&[
"show",
"projects",
"--affected",
"--affected-targets",
"build,e2e",
"--json"
])),
json!([])
);
let ordinary = isolated_command(env!("CARGO_BIN_EXE_qk"))
.current_dir(root)
.args([
"show",
"projects",
"--affected",
"--affected-targets",
"build",
"--json",
])
.args(["--base", "HEAD^", "--head", "HEAD"])
.output()
.unwrap();
assert_eq!(successful_json(ordinary), json!(["app"]));
let output = isolated_command(env!("CARGO_BIN_EXE_qk"))
.current_dir(root)
.args(["show", "projects", "--affected-targets", "build"])
.output()
.unwrap();
assert!(!output.status.success());
assert!(
String::from_utf8_lossy(&output.stderr).contains("add --affected"),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let output = run(&["show", "affected"]);
assert!(output.status.success());
let text = String::from_utf8(output.stdout).unwrap();
assert!(!text.contains("after projection"), "{text}");
assert!(text.contains("Left out by import reachability:"), "{text}");
assert!(
text.contains("app-e2e left out: none of its 1 affected task runs"),
"{text}"
);
assert!(
text.contains("app left out: none of its 1 affected task runs"),
"{text}"
);
let output = run(&["show", "affected", "app"]);
let text = String::from_utf8(output.stdout).unwrap();
assert!(
text.contains("app is left out: import reachability left out each task of it a change affects.\n app:build left out: nothing it imports changed\n"),
"{text}"
);
let explanation = successful_json(run(&["show", "affected", "app", "--json"]));
assert_eq!(explanation["affected"], false);
assert_eq!(
explanation["reachability"],
json!({"decision": "leftOut", "tasks": {"app:build": {
"decision": "leftOut",
"anchors": ["apps/app/src/main.ts"],
"cases": [],
"changed": ["libs/lib/src/unused.ts"]
}}})
);
write("libs/lib/src/used.ts", "export const used = 2;\n");
git(&["add", "."]);
git(&["commit", "--quiet", "-m", "used"]);
let output = run(&["show", "affected", "app"]);
let text = String::from_utf8(output.stdout).unwrap();
assert!(
text.contains(
"Import reachability kept: app:build runs (whole task: apps/app/src/main.ts imports libs/lib/src/used.ts)\n"
),
"{text}"
);
assert_eq!(
successful_json(run(&[
"show",
"projects",
"--affected",
"--affected-targets",
"build",
"--json"
])),
json!(["app"])
);
let explanation = successful_json(run(&["show", "affected", "app", "--json"]));
assert_eq!(explanation["reachability"]["decision"], "kept");
assert_eq!(explanation["reachability"]["task"], "app:build");
assert_eq!(
explanation["reachability"]["taskDecision"]["why"]["kind"],
"reached"
);
}
#[test]
fn reachability_profile_runs_only_the_cases_a_change_reaches() {
let temp = TempDir::new().unwrap();
let root = temp.path();
let git = |args: &[&str]| {
let output = isolated_command("git")
.current_dir(root)
.args(["-c", "user.name=qk", "-c", "user.email=qk@example.invalid"])
.args(args)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
};
let write = |path: &str, content: &str| {
let path = root.join(path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, content).unwrap();
};
let command = if cfg!(windows) {
r#"if defined QK_AFFECTED_CASES (type "%QK_AFFECTED_CASES%") else (echo every case)"#
} else {
r#"if [ -n "$QK_AFFECTED_CASES" ]; then cat "$QK_AFFECTED_CASES"; else echo every case; fi"#
};
write(
"nx.json",
&json!({"qk:affectedProfiles": {"reach": {"reachability": true}}}).to_string(),
);
write(
"apps/app/project.json",
&json!({
"name": "app",
"targets": {
"visual": {
"command": command,
"cache": true,
"qk:reachability": {
"anchors": ["{projectRoot}/visual/shell.ts"],
"cases": ["{projectRoot}/visual/cases/*.ts"],
"sources": ["{projectRoot}/visual/**/*.ts"]
}
}
}
})
.to_string(),
);
write("apps/app/visual/shell.ts", "export const shell = 1;\n");
write(
"apps/app/visual/cases/first.ts",
"export const first = 1;\n",
);
write(
"apps/app/visual/cases/second.ts",
"export const second = 1;\n",
);
git(&["init", "--quiet", "--initial-branch=main"]);
git(&["add", "."]);
git(&["commit", "--quiet", "-m", "base"]);
write(
"apps/app/visual/cases/second.ts",
"export const second = 2;\n",
);
git(&["add", "."]);
git(&["commit", "--quiet", "-m", "head"]);
let run = || {
let output = isolated_command(env!("CARGO_BIN_EXE_qk"))
.current_dir(root)
.args(["affected", "-t", "visual", "--granularity", "task"])
.args([
"--affected-profile",
"reach",
"--base",
"HEAD^",
"--head",
"HEAD",
])
.args(["--output-style", "static"])
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).into_owned()
+ &String::from_utf8_lossy(&output.stderr)
};
for _ in 0..2 {
let output = run();
assert!(
output.contains("apps/app/visual/cases/second.ts"),
"{output}"
);
assert!(
!output.contains("apps/app/visual/cases/first.ts"),
"{output}"
);
assert!(!output.contains("every case"), "{output}");
}
write("apps/app/visual/shell.ts", "export const shell = 2;\n");
git(&["add", "."]);
git(&["commit", "--quiet", "-m", "shell"]);
let output = run();
assert!(output.contains("every case"), "{output}");
}
#[test]
fn affected_profile_lists_projects_explains_projection_and_rejects_task_selection() {
let temp = TempDir::new().unwrap();
let root = temp.path();
let git = |args: &[&str]| {
let output = isolated_command("git")
.current_dir(root)
.args(args)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
};
for (path, content) in [
("nx.json", json!({"qk:affectedProfiles":{"runtime":{"projections":[{"name":"runtime","command":["git","-C","{revisionRoot}","show","HEAD:manifest.json"],"sources":["schemas/**"],"outputs":["apps/app/generated/**"],"timeoutSeconds":if cfg!(unix) { 3 } else { 60 }}]}}}).to_string()),
("schemas/project.json", json!({"name":"schema"}).to_string()),
("schemas/schema.txt", "before".into()),
("apps/app/project.json", json!({"name":"app","implicitDependencies":["schema"],"targets":{"build":{"command":"echo build"}}}).to_string()),
("manifest.json", json!({"version":1,"artifacts":{"apps/app/generated/runtime.js":"same"}}).to_string()),
] {
let path = root.join(path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, content).unwrap();
}
git(&["init", "--quiet", "--initial-branch=main"]);
git(&["add", "."]);
git(&[
"-c",
"user.name=qk",
"-c",
"user.email=qk@example.invalid",
"commit",
"--quiet",
"-m",
"base",
]);
std::fs::write(root.join("schemas/schema.txt"), "after").unwrap();
git(&["add", "."]);
git(&[
"-c",
"user.name=qk",
"-c",
"user.email=qk@example.invalid",
"commit",
"--quiet",
"-m",
"head",
]);
let run = |args: &[&str]| {
isolated_command(env!("CARGO_BIN_EXE_qk"))
.current_dir(root)
.env_remove("NX_HEAD")
.args(args)
.output()
.unwrap()
};
let comparison = [
"--affected-profile",
"runtime",
"--base",
"HEAD^",
"--head",
"HEAD",
];
for selection in [
vec![],
vec!["--files", "schemas/schema.txt"],
vec!["--files", "apps/app/project.json"],
vec!["--stdin"],
vec!["--uncommitted"],
vec!["--untracked"],
] {
let mut args = vec![
"show",
"projects",
"--json",
"--affected-profile",
"runtime",
"--fail-on-projection-fallback=adapter",
];
if selection.is_empty() {
args.extend(["--base", "HEAD^"]);
}
args.extend(selection);
let output = run(&args);
assert!(!output.status.success(), "{args:?}");
assert!(output.stdout.is_empty());
assert!(String::from_utf8_lossy(&output.stderr).contains("strict projection selection"));
}
let output = isolated_command(env!("CARGO_BIN_EXE_qk"))
.current_dir(root)
.env("NX_HEAD", "HEAD")
.args([
"show",
"projects",
"--json",
"--affected-profile",
"runtime",
"--base",
"HEAD^",
"--fail-on-projection-fallback=adapter",
])
.output()
.unwrap();
assert_eq!(successful_json(output), json!([]));
let mut args = vec!["show", "projects", "--affected", "--json"];
args.extend(comparison);
assert_eq!(successful_json(run(&args)), json!([]));
let mut args = vec!["show", "affected", "app", "--json"];
args.extend(comparison);
let explanation = successful_json(run(&args));
assert_eq!(explanation["affected"], false);
assert_eq!(explanation["projections"][0]["status"], "applied");
assert_eq!(
explanation["projections"][0]["sources"],
json!(["schemas/schema.txt"])
);
assert_eq!(explanation["originalFiles"], json!(["schemas/schema.txt"]));
assert_eq!(explanation["files"], json!([]));
let mut args = vec!["show", "affected", "app"];
args.extend(comparison);
let output = run(&args);
assert!(output.status.success());
let text = String::from_utf8(output.stdout).unwrap();
assert!(text.contains("1 changed file between"));
assert!(text.contains("0 files after projection."));
assert!(text.contains("1 source change, 0 artifact changes"));
let output = run(&[
"show",
"projects",
"--affected-profile",
"runtime",
"--files",
"schemas/schema.txt",
"--fail-on-projection-fallback",
]);
assert!(!output.status.success());
assert!(output.stdout.is_empty());
assert!(String::from_utf8_lossy(&output.stderr).contains("committed base/head"));
let output = run(&["show", "projects", "--fail-on-projection-fallback"]);
assert!(!output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("--affected-profile"));
let mut args = vec!["affected", "-t", "build", "--granularity", "task"];
args.extend(comparison);
let output = run(&args);
assert!(!output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("applies to project selection"));
let output = run(&[
"show",
"tasks",
"-t",
"build",
"--affected-profile",
"runtime",
]);
assert!(!output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("add --affected"));
let mut args = vec!["show", "tasks", "-t", "build", "--affected"];
args.extend(comparison);
let output = run(&args);
assert!(!output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("applies to project selection"));
let output = run(&[
"show",
"projects",
"--affected-profile",
"missing",
"--base",
"HEAD^",
"--head",
"HEAD",
]);
assert!(!output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("unknown affected profile"));
std::fs::write(root.join("schemas/schema.txt"), "runtime change").unwrap();
std::fs::write(
root.join("manifest.json"),
json!({"version":1,"artifacts":{
"apps/app/generated/runtime.js":"changed",
"apps/app/generated/extra.js":"new"
}})
.to_string(),
)
.unwrap();
git(&["add", "."]);
git(&[
"-c",
"user.name=qk",
"-c",
"user.email=qk@example.invalid",
"commit",
"--quiet",
"-m",
"runtime",
]);
let mut args = vec!["show", "affected"];
args.extend(comparison);
let output = run(&args);
assert!(output.status.success());
let text = String::from_utf8(output.stdout).unwrap();
assert!(text.contains("2 changed files between"));
assert!(text.contains("3 files after projection."));
assert!(text.contains("1 source change, 2 artifact changes"));
args.push("--json");
let report = successful_json(run(&args));
assert_eq!(report["originalFiles"].as_array().unwrap().len(), 2);
assert_eq!(report["files"].as_array().unwrap().len(), 3);
let worktrees_before = isolated_command("git")
.current_dir(root)
.args(["worktree", "list", "--porcelain"])
.output()
.unwrap()
.stdout;
let mut hooked = isolated_command(env!("CARGO_BIN_EXE_qk"));
hooked
.current_dir(root)
.env_remove("NX_HEAD")
.env("GIT_DIR", root.join(".git"))
.env("GIT_WORK_TREE", root)
.env("GIT_INDEX_FILE", root.join(".git/index"))
.env("GIT_OBJECT_DIRECTORY", root.join(".git/objects"))
.env("GIT_COMMON_DIR", root.join(".git"))
.env("GIT_PREFIX", "hook/")
.env("GIT_CONFIG_COUNT", "1")
.env("GIT_CONFIG_KEY_0", "core.worktree")
.env("GIT_CONFIG_VALUE_0", root);
hooked
.args([
"show",
"projects",
"--json",
"--fail-on-projection-fallback=adapter",
])
.args(comparison);
assert_eq!(successful_json(hooked.output().unwrap()), json!(["app"]));
assert_eq!(
isolated_command("git")
.current_dir(root)
.args(["worktree", "list", "--porcelain"])
.output()
.unwrap()
.stdout,
worktrees_before
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let wrapper = TempDir::new().unwrap();
let git_path = wrapper.path().join("git");
std::fs::write(
&git_path,
r#"#!/bin/sh
if [ "$QK_TEST_GIT_FAILURE_MODE" = metadata ] && [ "$1" = diff ] && [ "$2" = --quiet ]; then
echo 'fatal: simulated metadata comparison failure' >&2
exit 128
fi
if [ "$QK_TEST_GIT_FAILURE_MODE" = checkout ] && [ "$1" = -c ] && [ "$3" = worktree ] && [ "$4" = add ]; then
"$QK_TEST_REAL_GIT" "$@" || exit $?
previous=
current=
for argument do previous="$current"; current="$argument"; done
"$QK_TEST_REAL_GIT" worktree lock "$previous" || exit $?
sleep 30
exit 0
fi
exec "$QK_TEST_REAL_GIT" "$@"
"#,
)
.unwrap();
std::fs::set_permissions(&git_path, std::fs::Permissions::from_mode(0o755)).unwrap();
let real_git = isolated_command("/bin/sh")
.args(["-c", "command -v git"])
.output()
.unwrap();
assert!(real_git.status.success());
let mut search_path = vec![wrapper.path().to_path_buf()];
search_path.extend(std::env::split_paths(&std::env::var_os("PATH").unwrap()));
for (mode, policy) in [
("metadata", "adapter"),
("metadata", "all"),
("checkout", "adapter"),
("checkout", "all"),
] {
let started = std::time::Instant::now();
let mut args = vec!["show", "affected", "--json"];
args.extend(comparison);
let output = isolated_command(env!("CARGO_BIN_EXE_qk"))
.current_dir(root)
.env_remove("NX_HEAD")
.env("PATH", std::env::join_paths(&search_path).unwrap())
.env("QK_TEST_GIT_FAILURE_MODE", mode)
.env(
"QK_TEST_REAL_GIT",
String::from_utf8(real_git.stdout.clone()).unwrap().trim(),
)
.args(args)
.arg(format!("--fail-on-projection-fallback={policy}"))
.output()
.unwrap();
let diagnostic = String::from_utf8_lossy(&output.stderr);
if mode == "metadata" {
assert!(
diagnostic.contains("cannot compare workspace metadata"),
"{diagnostic}"
);
assert!(diagnostic.contains("128"), "{diagnostic}");
assert!(
diagnostic.contains("simulated metadata comparison failure"),
"{diagnostic}"
);
} else {
assert!(
diagnostic.contains("git worktree add timed out"),
"{diagnostic}"
);
assert!(started.elapsed() < std::time::Duration::from_secs(10));
assert_eq!(
isolated_command("git")
.current_dir(root)
.args(["worktree", "list", "--porcelain"])
.output()
.unwrap()
.stdout,
worktrees_before
);
}
assert!(!diagnostic.contains("workspace configuration or tool installation changed"));
if policy == "adapter" {
assert!(output.status.success());
let explanation: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(explanation["projections"][0]["fallbackKind"], "comparison");
assert_eq!(explanation["files"], explanation["originalFiles"]);
assert_eq!(
explanation["files"],
json!(["manifest.json", "schemas/schema.txt"])
);
} else {
assert!(!output.status.success());
assert!(output.stdout.is_empty());
}
}
}
std::fs::write(
root.join("package.json"),
r#"{"dependencies":{"example-tool":"1.0.0"}}"#,
)
.unwrap();
std::fs::write(
root.join("schemas/schema.txt"),
"schema with dependency change",
)
.unwrap();
git(&["add", "."]);
git(&[
"-c",
"user.name=qk",
"-c",
"user.email=qk@example.invalid",
"commit",
"--quiet",
"-m",
"dependency",
]);
let mut args = vec![
"show",
"projects",
"--json",
"--fail-on-projection-fallback=adapter",
];
args.extend(comparison);
let output = run(&args);
assert!(output.status.success());
let notice = String::from_utf8(output.stderr).unwrap();
assert_eq!(notice.lines().count(), 1);
assert!(notice.starts_with("qk: notice: affected projection runtime:"));
assert!(notice.contains("workspace configuration or tool installation changed"));
let projects: Value = serde_json::from_slice(&output.stdout).unwrap();
assert!(projects.as_array().unwrap().contains(&json!("app")));
args[3] = "--fail-on-projection-fallback=all";
assert!(!run(&args).status.success());
args[3] = "--fail-on-projection-fallback=adapter";
args[1] = "affected";
let output = run(&args);
assert!(output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).starts_with("qk: notice:"));
let explanation: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(explanation["projections"][0]["fallbackKind"], "comparison");
std::fs::write(root.join("schemas/schema.txt"), "schema with output drift").unwrap();
std::fs::write(
root.join("manifest.json"),
r#"{"version":1,"artifacts":{"apps/stray/generated/runtime.js":"new"}}"#,
)
.unwrap();
git(&["add", "."]);
git(&[
"-c",
"user.name=qk",
"-c",
"user.email=qk@example.invalid",
"commit",
"--quiet",
"-m",
"drift",
]);
args[1] = "projects";
let output = run(&args);
assert!(!output.status.success());
assert!(output.stdout.is_empty());
assert!(String::from_utf8_lossy(&output.stderr).contains("apps/stray/generated/runtime.js"));
}
#[test]
fn every_package_json_is_a_project_when_nx_is_told_to_infer_them_all() {
let temp = TempDir::new().unwrap();
std::fs::write(temp.path().join("nx.json"), "{}").unwrap();
std::fs::create_dir_all(temp.path().join("tools/script")).unwrap();
std::fs::write(
temp.path().join("tools/script/package.json"),
r#"{"name": "script"}"#,
)
.unwrap();
let projects = |infer: &str| {
let output = isolated_command(env!("CARGO_BIN_EXE_qk"))
.current_dir(temp.path())
.env("NX_INFER_ALL_PACKAGE_JSONS", infer)
.args(["show", "projects", "--json"])
.output()
.unwrap();
successful_json(output)
};
assert_eq!(projects("false"), json!([]));
assert_eq!(projects("true"), json!(["script"]));
std::fs::write(temp.path().join("package.json"), r#"{"name": "root"}"#).unwrap();
assert_eq!(projects("true"), json!([]));
}
#[test]
fn the_workspace_root_can_be_set_as_in_nx() {
let temp = TempDir::new().unwrap();
let elsewhere = TempDir::new().unwrap();
std::fs::write(temp.path().join("nx.json"), "{}").unwrap();
std::fs::write(temp.path().join("project.json"), r#"{"name": "app"}"#).unwrap();
let output = isolated_command(env!("CARGO_BIN_EXE_qk"))
.current_dir(elsewhere.path())
.env("NX_WORKSPACE_ROOT_PATH", temp.path())
.args(["show", "projects", "--json"])
.output()
.unwrap();
assert_eq!(successful_json(output), json!(["app"]));
}
#[test]
fn graph_has_no_packages_when_the_lockfile_is_not_analysed() {
let temp = TempDir::new().unwrap();
std::fs::write(
temp.path().join("nx.json"),
r#"{"pluginsConfig": {"@nx/js": {"analyzeLockfile": false}}}"#,
)
.unwrap();
std::fs::write(temp.path().join("project.json"), r#"{"name": "app"}"#).unwrap();
std::fs::write(temp.path().join("pnpm-lock.yaml"), "not: [a lockfile").unwrap();
let output = isolated_command(env!("CARGO_BIN_EXE_qk"))
.args([
"--workspace",
temp.path().to_str().unwrap(),
"graph",
"--external",
])
.output()
.unwrap();
assert_eq!(successful_json(output)["graph"]["externalNodes"], json!({}));
}
#[test]
fn show_project_prints_the_graph_node_and_matches_patterns_as_nx() {
let temp = TempDir::new().unwrap();
std::fs::write(temp.path().join("nx.json"), "{}").unwrap();
for (name, config) in [
(
"app",
r#"{"name": "app", "implicitDependencies": ["tag:lib", "!b"]}"#,
),
("a", r#"{"name": "a", "tags": ["lib"]}"#),
("b", r#"{"name": "b", "tags": ["lib"]}"#),
] {
std::fs::create_dir(temp.path().join(name)).unwrap();
std::fs::write(temp.path().join(name).join("project.json"), config).unwrap();
}
let output = isolated_command(env!("CARGO_BIN_EXE_qk"))
.args(["--workspace", temp.path().to_str().unwrap()])
.args(["show", "project", "app", "--json"])
.output()
.unwrap();
assert_eq!(
successful_json(output)["implicitDependencies"],
json!(["a", "!b"])
);
let core = successful_json(qk(&["show", "project", "CORE", "--json"]));
assert_eq!(core["name"], "core");
let output = qk(&["show", "project", "apps/**", "--json"]);
assert!(!output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("Multiple projects matched"));
let output = qk(&["show", "project", "absent", "--json"]);
assert!(String::from_utf8_lossy(&output.stderr).contains("Could not find project absent"));
}