From 7f4dd2eb8c1397a3d3a71e7be0fd6908ed11d4cc Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Thu, 24 Sep 2026 09:01:18 -0600 Subject: [PATCH] test: Move turbo.json validation cases into crate contracts --- .../turborepo-engine/src/builder/test/core.rs | 37 ++ crates/turborepo-turbo-json/src/loader.rs | 25 ++ crates/turborepo-turbo-json/src/processed.rs | 98 +++++ crates/turborepo/tests/bad_turbo_json_test.rs | 398 +----------------- .../interruptible-but-not-persistent.json | 19 - .../fixtures/turbo-configs/package-task.json | 11 - .../fixtures/turbo-configs/syntax-error.json | 21 - 7 files changed, 165 insertions(+), 444 deletions(-) delete mode 100644 turborepo-tests/integration/fixtures/turbo-configs/interruptible-but-not-persistent.json delete mode 100644 turborepo-tests/integration/fixtures/turbo-configs/package-task.json delete mode 100644 turborepo-tests/integration/fixtures/turbo-configs/syntax-error.json diff --git a/crates/turborepo-engine/src/builder/test/core.rs b/crates/turborepo-engine/src/builder/test/core.rs index ce61a6afbdfa2..933f85f9c30aa 100644 --- a/crates/turborepo-engine/src/builder/test/core.rs +++ b/crates/turborepo-engine/src/builder/test/core.rs @@ -1467,3 +1467,40 @@ fn test_nonconflicting_env_exclusions_keep_resolved_outputs() { assert_eq!(task.outputs.inclusions, ["automatic-output"]); } } + +#[test_case( + json!({ "dev": { "interruptible": true } }), + "dev", + "Interruptible tasks must be persistent." + ; "interruptible task must be persistent" +)] +#[test_case( + json!({ "build": { "inputs": [{ "mode": "dependencyOutputs" }] } }), + "build", + "dependencyOutputs mode was used for task \"build\", but this task has no dependency tasks to select." + ; "dependency outputs require a dependency task to select" +)] +fn test_task_definition_validation_errors(tasks: serde_json::Value, task: &str, expected: &str) { + let repo_root_dir = TempDir::new().unwrap(); + let repo_root = AbsoluteSystemPathBuf::try_from(repo_root_dir.path()).unwrap(); + let package_graph = mock_package_graph( + &repo_root, + package_jsons! { + repo_root, + "a" => [] + }, + ); + let loader = TestTurboJsonLoader::new(HashMap::from([( + PackageName::Root, + turbo_json(json!({ "tasks": tasks })), + )])); + let err = EngineBuilder::new(&repo_root, &package_graph, &loader, false) + .with_workspaces(vec![PackageName::from("a")]) + .with_tasks(vec![Spanned::new(TaskName::from(task).into_owned())]) + .build() + .unwrap_err(); + assert!( + err.to_string().contains(expected), + "expected {expected:?}, got {err}" + ); +} diff --git a/crates/turborepo-turbo-json/src/loader.rs b/crates/turborepo-turbo-json/src/loader.rs index 45e67d094a1bd..ed67fae15bb9e 100644 --- a/crates/turborepo-turbo-json/src/loader.rs +++ b/crates/turborepo-turbo-json/src/loader.rs @@ -1019,6 +1019,31 @@ mod tests { } } + #[test] + fn test_single_package_rejects_package_tasks() { + let root_dir = tempdir().unwrap(); + let repo_root = AbsoluteSystemPath::from_std_path(root_dir.path()).unwrap(); + let root_turbo_json = repo_root.join_component(CONFIG_FILE); + root_turbo_json + .create_with_contents(r#"{"tasks": {"build": {}, "my-app#build": {}}}"#) + .unwrap(); + + let loader = TurboJsonLoader::::single_package( + TurboJsonReader::new(repo_root.to_owned()), + root_turbo_json, + vec!["build".to_string()], + ); + let err = loader.load(&PackageName::Root).unwrap_err(); + assert!( + matches!( + &err, + LoaderError::TurboJson(Error::PackageTaskInSinglePackageMode { task_id, .. }) + if task_id == "my-app#build" + ), + "expected PackageTaskInSinglePackageMode, got {err:?}" + ); + } + #[test] fn test_workspace_turbo_json_loading() { let root_dir = tempdir().unwrap(); diff --git a/crates/turborepo-turbo-json/src/processed.rs b/crates/turborepo-turbo-json/src/processed.rs index 4d5f7359ced77..400ace80ac367 100644 --- a/crates/turborepo-turbo-json/src/processed.rs +++ b/crates/turborepo-turbo-json/src/processed.rs @@ -1225,4 +1225,102 @@ mod tests { assert!(depends_on.extends); assert_eq!(depends_on.deps.len(), 2); } + + fn process_build_task(inputs: &str) -> Result { + let json = format!(r#"{{"tasks": {{"build": {{"inputs": {inputs}}}}}}}"#); + let raw = crate::RawRootTurboJson::parse(&json, "turbo.json").expect("turbo.json parses"); + let task = raw + .tasks + .expect("tasks are present") + .0 + .remove(&crate::TaskName::from("build")) + .expect("build task is present"); + ProcessedTaskDefinition::from_raw(task.into_inner(), &FutureFlags::default()) + } + + #[test_case( + r#"["$TURBO_DEFAULT$", {"mode": "startup", "globs": ["src/**"]}]"#, + &["Legacy input strings normalize to mode \"startup\"", "Use either legacy startup inputs", "Or one structured startup input"] + ; "legacy and structured startup inputs cannot mix" + )] + #[test_case( + r#"[{"mode": "jit", "globs": ["src/generated/**"]}, {"mode": "jit", "globs": ["other/**"]}]"#, + &["duplicate structured \"jit\" input mode"] + ; "duplicate structured modes" + )] + #[test_case( + r#"[{"globs": ["src/**"]}]"#, + &["Structured input entries must specify mode"] + ; "mode is required" + )] + #[test_case( + r#"[{"mode": "runtime", "globs": ["src/**"]}]"#, + &["Unknown input mode \"runtime\""] + ; "unknown modes" + )] + #[test_case( + r#"[{"mode": "jit", "from": ["codegen"], "globs": ["src/generated/**"]}]"#, + &["from is only valid for dependencyOutputs inputs"] + ; "from outside dependency outputs" + )] + #[test_case( + r#"[{"mode": "dependencyOutputs", "withDefaults": true}]"#, + &["withDefaults is only valid for startup or jit inputs"] + ; "with defaults on dependency outputs" + )] + #[test_case( + r#"[{"mode": "startup", "globs": ["$TURBO_DEFAULT$"]}]"#, + &["Sentinel string \"$TURBO_DEFAULT$\" is not valid inside structured globs"] + ; "default sentinel inside structured globs" + )] + #[test_case( + r#"[{"mode": "jit", "globs": ["$TURBO_EXTENDS$"]}]"#, + &["Sentinel string \"$TURBO_EXTENDS$\" is not valid inside structured globs"] + ; "extends sentinel inside structured globs" + )] + #[test_case( + r#"[{"mode": "startup", "globs": ["!src/generated/**"]}]"#, + &["negative-only startup globs require withDefaults: true"] + ; "negative only startup globs" + )] + #[test_case( + r#"[{"mode": "jit", "globs": ["!src/generated/**"]}]"#, + &["negative-only jit globs require withDefaults: true"] + ; "negative only jit globs" + )] + fn test_structured_inputs_reject_invalid_configuration(inputs: &str, expected: &[&str]) { + let err = process_build_task(inputs).unwrap_err(); + assert_matches!(err, Error::StructuredInput { .. }); + let message = err.to_string(); + for fragment in expected { + assert!( + message.contains(fragment), + "expected {fragment:?} in {message:?}" + ); + } + } + + #[test_case(r#"[{"mode": "startup", "withDefaults": true, "globs": ["!src/generated/**"]}]"# ; "negative only startup globs with defaults")] + #[test_case(r#"[{"mode": "jit", "withDefaults": true, "globs": ["!src/generated/**"]}]"# ; "negative only jit globs with defaults")] + #[test_case(r#"[{"mode": "dependencyOutputs", "from": ["codegen"], "globs": ["dist/**"]}]"# ; "from on dependency outputs")] + fn test_structured_inputs_accept_valid_counterparts(inputs: &str) { + process_build_task(inputs).unwrap(); + } + + #[test] + fn test_env_rejects_pipeline_delimiter_prefix() { + let err = ProcessedEnv::new( + vec![ + Spanned::new(UnescapedString::from("NODE_ENV".to_string())), + Spanned::new(UnescapedString::from("$FOOBAR".to_string())), + ], + &FutureFlags::default(), + ) + .unwrap_err(); + let Error::InvalidEnvPrefix(err) = err else { + panic!("expected InvalidEnvPrefix, got {err:?}"); + }; + assert_eq!(err.value, "$FOOBAR"); + assert_eq!(err.key, "env"); + } } diff --git a/crates/turborepo/tests/bad_turbo_json_test.rs b/crates/turborepo/tests/bad_turbo_json_test.rs index bf610e3690988..ff07f3015a8ba 100644 --- a/crates/turborepo/tests/bad_turbo_json_test.rs +++ b/crates/turborepo/tests/bad_turbo_json_test.rs @@ -4,22 +4,11 @@ mod common; use common::{replace_turbo_json, run_turbo, setup}; -#[test] -fn test_package_task_syntax_in_workspace_config() { - let tempdir = tempfile::tempdir().unwrap(); - setup::setup_integration_test(tempdir.path(), "basic_monorepo", "npm@10.5.0", false).unwrap(); - - let app_dir = tempdir.path().join("apps/my-app"); - replace_turbo_json(&app_dir, "package-task.json"); - - let output = run_turbo(tempdir.path(), &["build"]); - assert!(!output.status.success()); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("unnecessary_package_task_syntax"), - "expected package task error: {stderr}" - ); -} +// Validation rules are covered in process by turborepo-turbo-json (parser, +// validator, structured inputs, env prefixes, single-package loading) and +// turborepo-engine (interruptible/persistent, dependencyOutputs selection). +// These smokes prove diagnostics render through the CLI and that +// --single-package reaches the single-package loader. #[test] fn test_invalid_env_var_prefix() { @@ -54,380 +43,3 @@ fn test_package_task_in_single_package_mode() { "expected single-package error: {stderr}" ); } - -#[test] -fn test_interruptible_but_not_persistent() { - let tempdir = tempfile::tempdir().unwrap(); - setup::setup_integration_test(tempdir.path(), "basic_monorepo", "npm@10.5.0", false).unwrap(); - replace_turbo_json(tempdir.path(), "interruptible-but-not-persistent.json"); - - let output = run_turbo(tempdir.path(), &["run", "build"]); - assert!(!output.status.success()); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("Interruptible tasks must be persistent"), - "expected interruptible error: {stderr}" - ); -} - -#[test] -fn test_syntax_error_in_turbo_json() { - let tempdir = tempfile::tempdir().unwrap(); - setup::setup_integration_test(tempdir.path(), "basic_monorepo", "npm@10.5.0", false).unwrap(); - replace_turbo_json(tempdir.path(), "syntax-error.json"); - - let output = run_turbo(tempdir.path(), &["build"]); - assert!(!output.status.success()); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("turbo_json_parse_error"), - "expected parse error: {stderr}" - ); -} - -fn write_turbo_json(dir: &std::path::Path, contents: &str) { - std::fs::write(dir.join("turbo.json"), contents).unwrap(); -} - -#[test] -fn test_structured_startup_cannot_mix_with_legacy_startup_inputs() { - let tempdir = tempfile::tempdir().unwrap(); - setup::setup_integration_test(tempdir.path(), "basic_monorepo", "npm@10.5.0", false).unwrap(); - - write_turbo_json( - tempdir.path(), - r#"{ - "$schema": "https://turborepo.dev/schema.json", - "tasks": { - "build": { - "inputs": [ - "$TURBO_DEFAULT$", - { - "mode": "startup", - "globs": ["src/**"] - } - ], - "outputs": ["dist/**"] - } - } -} -"#, - ); - - let output = run_turbo(tempdir.path(), &["run", "build"]); - assert!(!output.status.success()); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("Legacy input strings normalize to mode \"startup\""), - "expected duplicate startup normalization error, got: {stderr}" - ); - assert!( - stderr.contains("Use either legacy startup inputs") - && stderr.contains("Or one structured startup input"), - "expected legacy-or-structured guidance, got: {stderr}" - ); -} - -#[test] -fn test_structured_inputs_reject_duplicate_modes() { - let tempdir = tempfile::tempdir().unwrap(); - setup::setup_integration_test(tempdir.path(), "basic_monorepo", "npm@10.5.0", false).unwrap(); - - write_turbo_json( - tempdir.path(), - r#"{ - "$schema": "https://turborepo.dev/schema.json", - "tasks": { - "build": { - "inputs": [ - { - "mode": "jit", - "globs": ["src/generated/**"] - }, - { - "mode": "jit", - "globs": ["other-generated/**"] - } - ], - "outputs": ["dist/**"] - } - } -} -"#, - ); - - let output = run_turbo(tempdir.path(), &["run", "build"]); - assert!(!output.status.success()); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("duplicate") && stderr.contains("jit"), - "expected duplicate jit mode error, got: {stderr}" - ); -} - -#[test] -fn test_structured_inputs_require_mode() { - let tempdir = tempfile::tempdir().unwrap(); - setup::setup_integration_test(tempdir.path(), "basic_monorepo", "npm@10.5.0", false).unwrap(); - - write_turbo_json( - tempdir.path(), - r#"{ - "$schema": "https://turborepo.dev/schema.json", - "tasks": { - "build": { - "inputs": [ - { - "globs": ["src/**"] - } - ], - "outputs": ["dist/**"] - } - } -} -"#, - ); - - let output = run_turbo(tempdir.path(), &["run", "build"]); - assert!(!output.status.success()); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("mode"), - "expected missing structured input mode error, got: {stderr}" - ); -} - -#[test] -fn test_structured_inputs_reject_unknown_modes() { - let tempdir = tempfile::tempdir().unwrap(); - setup::setup_integration_test(tempdir.path(), "basic_monorepo", "npm@10.5.0", false).unwrap(); - - write_turbo_json( - tempdir.path(), - r#"{ - "$schema": "https://turborepo.dev/schema.json", - "tasks": { - "build": { - "inputs": [ - { - "mode": "runtime", - "globs": ["src/**"] - } - ], - "outputs": ["dist/**"] - } - } -} -"#, - ); - - let output = run_turbo(tempdir.path(), &["run", "build"]); - assert!(!output.status.success()); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("runtime") && stderr.contains("mode"), - "expected unknown structured input mode error, got: {stderr}" - ); -} - -#[test] -fn test_structured_input_from_only_allowed_for_dependency_outputs() { - let tempdir = tempfile::tempdir().unwrap(); - setup::setup_integration_test(tempdir.path(), "basic_monorepo", "npm@10.5.0", false).unwrap(); - - write_turbo_json( - tempdir.path(), - r#"{ - "$schema": "https://turborepo.dev/schema.json", - "tasks": { - "build": { - "inputs": [ - { - "mode": "jit", - "from": ["codegen"], - "globs": ["src/generated/**"] - } - ], - "outputs": ["dist/**"] - } - } -} -"#, - ); - - let output = run_turbo(tempdir.path(), &["run", "build"]); - assert!(!output.status.success()); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("from") && stderr.contains("dependencyOutputs"), - "expected from/dependencyOutputs validation error, got: {stderr}" - ); -} - -#[test] -fn test_structured_input_with_defaults_only_allowed_for_startup_or_jit() { - let tempdir = tempfile::tempdir().unwrap(); - setup::setup_integration_test(tempdir.path(), "basic_monorepo", "npm@10.5.0", false).unwrap(); - - write_turbo_json( - tempdir.path(), - r#"{ - "$schema": "https://turborepo.dev/schema.json", - "tasks": { - "build": { - "dependsOn": ["codegen"], - "inputs": [ - { - "mode": "dependencyOutputs", - "withDefaults": true - } - ], - "outputs": ["dist/**"] - }, - "codegen": { - "outputs": ["src/generated/**"] - } - } -} -"#, - ); - - let output = run_turbo(tempdir.path(), &["run", "build"]); - assert!(!output.status.success()); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("withDefaults") && stderr.contains("startup") && stderr.contains("jit"), - "expected withDefaults mode validation error, got: {stderr}" - ); -} - -#[test] -fn test_structured_input_globs_reject_sentinels() { - let tempdir = tempfile::tempdir().unwrap(); - setup::setup_integration_test(tempdir.path(), "basic_monorepo", "npm@10.5.0", false).unwrap(); - - write_turbo_json( - tempdir.path(), - r#"{ - "$schema": "https://turborepo.dev/schema.json", - "tasks": { - "build": { - "inputs": [ - { - "mode": "startup", - "globs": ["$TURBO_DEFAULT$", "$TURBO_EXTENDS$"] - } - ], - "outputs": ["dist/**"] - } - } -} -"#, - ); - - let output = run_turbo(tempdir.path(), &["run", "build"]); - assert!(!output.status.success()); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("$TURBO_DEFAULT$") || stderr.contains("$TURBO_EXTENDS$"), - "expected sentinel-in-structured-globs validation error, got: {stderr}" - ); -} - -#[test] -fn test_structured_startup_rejects_negative_only_globs_without_defaults() { - let tempdir = tempfile::tempdir().unwrap(); - setup::setup_integration_test(tempdir.path(), "basic_monorepo", "npm@10.5.0", false).unwrap(); - - write_turbo_json( - tempdir.path(), - r#"{ - "$schema": "https://turborepo.dev/schema.json", - "tasks": { - "build": { - "inputs": [ - { - "mode": "startup", - "globs": ["!src/generated/**"] - } - ], - "outputs": ["dist/**"] - } - } -} -"#, - ); - - let output = run_turbo(tempdir.path(), &["run", "build"]); - assert!(!output.status.success()); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("negative") && stderr.contains("withDefaults"), - "expected negative-only startup glob validation error, got: {stderr}" - ); -} - -#[test] -fn test_structured_jit_rejects_negative_only_globs_without_defaults() { - let tempdir = tempfile::tempdir().unwrap(); - setup::setup_integration_test(tempdir.path(), "basic_monorepo", "npm@10.5.0", false).unwrap(); - - write_turbo_json( - tempdir.path(), - r#"{ - "$schema": "https://turborepo.dev/schema.json", - "tasks": { - "build": { - "inputs": [ - { - "mode": "jit", - "globs": ["!src/generated/**"] - } - ], - "outputs": ["dist/**"] - } - } -} -"#, - ); - - let output = run_turbo(tempdir.path(), &["run", "build"]); - assert!(!output.status.success()); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("negative") && stderr.contains("withDefaults"), - "expected negative-only jit glob validation error, got: {stderr}" - ); -} - -#[test] -fn test_dependency_outputs_requires_dependency_tasks_to_select() { - let tempdir = tempfile::tempdir().unwrap(); - setup::setup_integration_test(tempdir.path(), "basic_monorepo", "npm@10.5.0", false).unwrap(); - - write_turbo_json( - tempdir.path(), - r#"{ - "$schema": "https://turborepo.dev/schema.json", - "tasks": { - "build": { - "inputs": [ - { - "mode": "dependencyOutputs" - } - ], - "outputs": ["dist/**"] - } - } -} -"#, - ); - - let output = run_turbo(tempdir.path(), &["run", "build"]); - assert!(!output.status.success()); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("dependencyOutputs") && stderr.contains("dependency"), - "expected dependencyOutputs dependency-selection validation error, got: {stderr}" - ); -} diff --git a/turborepo-tests/integration/fixtures/turbo-configs/interruptible-but-not-persistent.json b/turborepo-tests/integration/fixtures/turbo-configs/interruptible-but-not-persistent.json deleted file mode 100644 index 17c904ed2c1f0..0000000000000 --- a/turborepo-tests/integration/fixtures/turbo-configs/interruptible-but-not-persistent.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "$schema": "https://turborepo.dev/schema.json", - "globalDependencies": [ - "foo.txt" - ], - "globalEnv": [ - "SOME_ENV_VAR" - ], - "tasks": { - "build": { - "env": [ - "NODE_ENV", - "FOOBAR" - ], - "interruptible": true, - "outputs": [] - } - } -} diff --git a/turborepo-tests/integration/fixtures/turbo-configs/package-task.json b/turborepo-tests/integration/fixtures/turbo-configs/package-task.json deleted file mode 100644 index 1260cb0bd552f..0000000000000 --- a/turborepo-tests/integration/fixtures/turbo-configs/package-task.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "$schema": "https://turborepo.dev/schema.json", - "extends": ["//"], - "tasks": { - // this comment verifies that turbo can read .json files with comments - "my-app#build": { - "outputs": ["banana.txt", "apple.json"], - "inputs": ["$TURBO_DEFAULT$", ".env.local"] - } - } -} diff --git a/turborepo-tests/integration/fixtures/turbo-configs/syntax-error.json b/turborepo-tests/integration/fixtures/turbo-configs/syntax-error.json deleted file mode 100644 index 0d30fa85b739e..0000000000000 --- a/turborepo-tests/integration/fixtures/turbo-configs/syntax-error.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "$schema": "https://turborepo.dev/schema.json", - "globalDependencies": ["foo.txt"], - "globalEnv": ["SOME_ENV_VAR"], - "tasks": { - "build": { - "env": ["NODE_ENV", "$FOOBAR"], - "outputs": [] - }, - // this comment verifies that turbo can read .json files with comments - "my-app#build": { - "outputs": ["banana.txt", "apple.json"]42, - "inputs": [".env.local" - }, - - "something": {}, - "//#something": {}, - - "maybefails": {} - } -}