use avril_runtime::package_config::{PackageConfig, Source, TaskCommand}; use avril_runtime::registry::{npm::parse_npm_request, parse_jsr_request}; use url::Url; fn base() -> Url { Url::from_file_path(std::env::temp_dir().join("avril-schema/avril.conf")).unwrap() } #[test] fn metadata_exports_and_maps_are_validated_without_filesystem_discovery() { let config = PackageConfig::parse( r#" name: '@app/demo', version: '1.2.3-beta.1', description: 'A local package', exports: {'.': './mod.ts', './vector': './src/vector.ts'}, imports: { assert: 'jsr:@std/assert@^1', 'assert/': 'jsr:@std/assert@^1/', blocked: null, '../shared/util.ts': './util.ts', }, deps: [ '../math', {from: '../format', name: '@app/format'}, 'jsr:@std/path@^1', {from: 'jsr:/@std/path@^0.2', name: 'path-old'}, 'npm:left-pad@1', {from: 'npm:@scope/widget@^2', name: 'widget'}, ], scopes: {'./tests/': {assert: './test-assert.ts'}, './tests/unit/': {assert: null}}, "#, &base(), ) .unwrap(); assert_eq!(config.name.as_deref(), Some("@app/demo")); assert_eq!(config.version.as_deref(), Some("1.2.3-beta.1")); assert_eq!(config.description.as_deref(), Some("A local package")); assert!( config.exports["./vector"] .target .path() .ends_with("src/vector.ts") ); let dependencies: Vec<_> = config .dependencies .iter() .map(|dependency| (dependency.name.as_deref(), &dependency.source)) .collect(); assert_eq!( dependencies, [ ( None, &Source::Local(base().join("../math").unwrap().to_file_path().unwrap()) ), ( Some("@app/format"), &Source::Local(base().join("../format").unwrap().to_file_path().unwrap()) ), ( Some("@std/path"), &Source::Jsr(parse_jsr_request("jsr:@std/path@^1").unwrap()), ), ( Some("path-old"), &Source::Jsr(parse_jsr_request("jsr:/@std/path@^0.2").unwrap()), ), ( Some("left-pad"), &Source::Npm(parse_npm_request("npm:left-pad@1").unwrap()), ), ( Some("widget"), &Source::Npm(parse_npm_request("npm:@scope/widget@^2").unwrap()), ), ] ); let referrer = base().join("./main.ts").unwrap(); for (specifier, target) in [ ("@std/path", "jsr:@std/path@^1"), ("@std/path/posix", "jsr:@std/path@^1/posix"), ("path-old/posix/join", "jsr:/@std/path@^0.2/posix/join"), ("left-pad", "npm:left-pad@1"), ("left-pad/sub", "npm:left-pad@1/sub"), ("widget/sub", "npm:@scope/widget@^2/sub"), ] { assert_eq!( config .registry_map .resolve(specifier, &referrer) .unwrap() .as_deref(), Some(target) ); } for specifier in ["@std/path-extra", "path-old/../x", "@std/path/a%2fb"] { assert!( !matches!( config.registry_map.resolve(specifier, &referrer), Ok(Some(_)) ), "{specifier}" ); } let referrer = base().join("./main.ts").unwrap(); assert_eq!( config .import_map .resolve("assert", &referrer) .unwrap() .as_deref(), Some("jsr:@std/assert@^1") ); assert!( config .import_map .resolve("blocked", &referrer) .unwrap_err() .to_string() .contains("blocked") ); assert_eq!( config .import_map .resolve("assert/equals", &referrer) .unwrap() .as_deref(), Some("jsr:@std/assert@^1/equals") ); assert_eq!( config .import_map .resolve("../shared/util.ts", &referrer) .unwrap(), Some(base().join("./util.ts").unwrap().to_string()) ); let referrer = base().join("./tests/main.ts").unwrap(); assert_eq!( config.import_map.resolve("assert", &referrer).unwrap(), Some(base().join("./test-assert.ts").unwrap().to_string()) ); let referrer = base().join("./tests/unit/main.ts").unwrap(); assert!(config.import_map.resolve("assert", &referrer).is_err()); assert_eq!( config .import_map .resolve("assert/equals", &referrer) .unwrap() .as_deref(), Some("jsr:@std/assert@^1/equals") ); assert_eq!( PackageConfig::parse("exports: './mod.ts'", &base()) .unwrap() .exports .len(), 1 ); assert!( PackageConfig::parse("// empty", &base()) .unwrap() .exports .is_empty() ); } #[test] fn unknown_options_are_ignored_at_every_config_level() { for extra in [ "null", "true", "42", "'custom'", "[null, true, 42, {nested: 'value'}]", "{name: false, tasks: [], deps: {nested: 'value'}}", ] { let config = PackageConfig::parse( &format!( "custom: {extra}, importMap: {{}}, name: 'app', imports: {{helper: './helper.ts'}}, deps: [{{from: '../math', name: 'math', custom: {extra}, version: '1'}}], tasks: {{build: {{command: 'echo built', custom: {extra}, cwd: '.'}}}}" ), &base(), ) .unwrap(); assert_eq!(config.name.as_deref(), Some("app")); assert_eq!(config.dependencies.len(), 1); assert_eq!(config.dependencies[0].name.as_deref(), Some("math")); assert_eq!( config.dependencies[0].source, Source::Local(base().join("../math").unwrap().to_file_path().unwrap()) ); assert_eq!( config.tasks["build"].command, Some(TaskCommand::Shell("echo built".into())) ); assert_eq!( config.import_map.resolve("helper", &base()).unwrap(), Some(base().join("./helper.ts").unwrap().to_string()) ); } } #[test] fn malformed_headers_and_paths_have_source_spans() { for (source, message) in [ ("name: null", "Expected a string"), ("name: '@scope'", "bare package name"), ("name: 'a/b'", "bare package name"), ("name: 'jsr:@scope/pkg'", "bare package name"), ("version: 'next'", "Invalid package version"), ("description: []", "Expected a string"), ("exports: false", "Expected an object"), ("exports: {'./*': './mod.ts'}", "exact"), ("exports: {'./a/../b': './mod.ts'}", "exact"), ("exports: './'", "file inside"), ("exports: './sub/..'", "file inside"), ("exports: '../private.ts'", "package-relative"), ("exports: './sub/../../private.ts'", "file inside"), ("exports: './%2e%2e/private.ts'", "file inside"), ( "exports: './x%2f..%2f..%2fprivate.ts'", "Invalid package path", ), ("exports: './x%5c..%5cprivate.ts'", "Invalid package path"), ("exports: './mod.ts?raw'", "queries or fragments"), ("exports: './mod.ts#fragment'", "queries or fragments"), ("exports: './%00.ts'", "without NUL"), ("exports: './%FF.ts'", "UTF-8"), ("imports: []", "Expected an object"), ("scopes: {'./tests/': []}", "Expected an object"), ("deps: {math: '../math'}", "Expected an array"), ("deps: [false]", "Expected a dependency path"), ("deps: [['../math']]", "Expected a dependency path"), ("deps: ['/math']", "config-relative"), ("deps: ['math']", "config-relative"), ("deps: ['./%2e%2e%5cmath']", "Invalid package path"), ("deps: [{from: '../math'}]", "require both"), ("deps: [{name: 'math'}]", "require both"), ( "deps: [{from: '../math', name: false}]", "Expected a string", ), ("deps: [{from: false, name: 'math'}]", "Expected a string"), ( "deps: [{from: '../math', name: 'math/sub'}]", "binding name", ), ("deps: [{from: '/math', name: 'math'}]", "config-relative"), ("deps: ['jsr:@std/path@^1/posix']", "whole packages"), ("deps: ['jsr:@std/path@^1/']", "whole packages"), ("deps: ['jsr:@std/path/posix']", "whole packages"), ("deps: ['jsr:@std/path@latest']", "version tags"), ("deps: ['jsr:path@1']", "must be scoped"), ("deps: ['jsr:@Test/path@1']", "Invalid JSR package name"), ( "deps: [{from: 'jsr:@test/p.kg@1', name: 'pkg'}]", "Invalid JSR package name", ), ("deps: ['jsr:']", "jsr:"), ("deps: ['npm:left-pad/sub']", "whole packages"), ("deps: [{path: '../math', name: 'math'}]", "require both"), ( "deps: [{from: './%2e%2e%2fmath', name: 'math'}]", "Invalid package path", ), ] { let error = PackageConfig::parse(source, &base()).unwrap_err(); assert!(error.message.contains(message), "{source}: {error}"); assert!(!source[error.span.clone()].is_empty(), "{source}: {error}"); assert!(error.render("avril.conf", source).contains("avril.conf:1:")); } } #[test] fn collisions_are_about_bindings_and_include_scoped_or_blocked_aliases() { for source in [ "name: 'math', deps: [{from: '../math', name: 'math'}]", "deps: [{from: '../math', name: 'math'}, {from: '../other', name: 'math'}]", "name: 'math', imports: {math: './other.ts'}", "name: 'math', imports: {'math/private': null}", "deps: [{from: '../math', name: 'math'}], imports: {'math/': './other/'}", "deps: [{from: '../math', name: '@acme/math'}], imports: {'@acme/': './other/'}", "deps: [{from: '../math', name: 'math'}], scopes: {'./tests/': {math: './other.ts'}}", "deps: ['jsr:@acme/math@1', {from: 'jsr:@acme/math@2', name: '@acme/math'}]", "name: '@acme/math', deps: ['jsr:@acme/math@1']", "deps: ['jsr:@acme/math@1'], imports: {'@acme/math/vector': './vector.ts'}", "deps: ['jsr:@acme/math@1'], scopes: {'./tests/': {'@acme/': './other/'}}", ] { let error = PackageConfig::parse(source, &base()).unwrap_err(); assert!( ["conflicts", "Duplicate"] .iter() .any(|message| error.message.contains(message)), "{source}: {error}" ); assert!( source[error.span.clone()].contains("math"), "{source}: {error}" ); } PackageConfig::parse("name: '@acme/math', deps: [{from: '../math', name: 'math'}, {from: '../other', name: 'math-next'}, {from: '../math', name: 'math-copy'}], imports: {'@acme/math-next': './other.ts'}", &base()).unwrap(); } #[test] fn binding_diagnostics_point_at_the_binding_and_its_first_definition() { for (source, at, previous) in [ ( "deps: [{from: '../a', name: 'math'}, {from: '../b', name: 'm\\u0061th'}]", "'m\\u0061th'", "'math'", ), ( "name: 'math', deps: [{from: '../math', name: \"math\"}]", "\"math\"", "'math'", ), ] { let error = PackageConfig::parse(source, &base()).unwrap_err(); assert_eq!(&source[error.span.clone()], at, "{source}: {error}"); assert_eq!( &source[error.previous.clone().unwrap()], previous, "{source}: {error}" ); } } #[test] fn path_only_dependencies_bind_declared_names_with_the_same_validation() { let names: &[(&str, Option<&str>)] = &[ ("math", Some("@acme/math")), ("math-v2", Some("@acme/math")), ("anonymous", None), ]; let declared = |source: &Source| { let Source::Local(directory) = source else { panic!("{source:?}") }; names .iter() .find(|(path, _)| directory.ends_with(path)) .map(|(_, name)| name.map(str::to_owned)) .ok_or_else(|| "unreadable".to_owned()) }; let source = "deps: ['../math', {from: '../math-v2', name: 'math-next'}, {from: '../math', name: 'math'}]"; let config = PackageConfig::parse(source, &base()).unwrap(); let bindings = futures::executor::block_on( config.bindings(|source| std::future::ready(declared(&source))), ) .unwrap(); let bindings: Vec<_> = bindings .iter() .map(|(name, source)| { let Source::Local(path) = source else { panic!("{source:?}") }; (name.as_str(), path.file_name().unwrap().to_str().unwrap()) }) .collect(); assert_eq!( bindings, [ ("@acme/math", "math"), ("math", "math"), ("math-next", "math-v2") ] ); for (source, at, message, declarer) in [ ( "deps: ['../anonymous']", "'../anonymous'", "declares no name", "anonymous", ), ( "deps: ['../unreadable']", "'../unreadable'", "unreadable", "unreadable", ), ( "deps: ['../math', '../math-v2']", "'../math-v2'", "Duplicate", "math-v2", ), ( "deps: [{from: '../other', name: '@acme/math'}, '../math']", "'../math'", "Duplicate", "math", ), ( "deps: ['../math', {from: '../other', name: '@acme/math'}]", "'@acme/math'", "Duplicate", "math", ), ( "deps: ['jsr:@acme/math@1', '../math']", "'../math'", "Duplicate", "math", ), ( "name: '@acme/math', deps: ['../math']", "'../math'", "self name", "math", ), ( "imports: {'@acme/': './vendor/'}, deps: ['../math']", "'../math'", "import key", "math", ), ( "scopes: {'./tests/': {'@acme/math/vector': null}}, deps: ['../math']", "'../math'", "import key", "math", ), ] { let config = PackageConfig::parse(source, &base()).unwrap(); let error = futures::executor::block_on( config.bindings(|source| std::future::ready(declared(&source))), ) .unwrap_err(); assert_eq!(&source[error.span.clone()], at, "{source}: {error}"); assert!(error.message.contains(message), "{source}: {error}"); let declarer = base().join(&format!("../{declarer}/avril.conf")).unwrap(); assert!( error .message .contains(declarer.to_file_path().unwrap().to_str().unwrap()), "{source}: {error}" ); } } #[test] fn tasks_accept_string_and_object_forms() { let config = PackageConfig::parse( r#" tasks: { build: 'avril run build.ts', 'build:client': {command: 'avril run client.ts', description: 'Build the client'}, serve: {command: 'avril run server.ts', dependencies: ['build', 'build:client'], envFile: '.env.serve'}, all: {dependencies: ['serve']}, }, "#, &base(), ) .unwrap(); assert_eq!(config.tasks.len(), 4); assert_eq!( config.tasks["build"].command, Some(TaskCommand::Shell("avril run build.ts".into())) ); assert_eq!( config.tasks["build:client"].description.as_deref(), Some("Build the client") ); let dependencies: Vec<_> = config.tasks["serve"] .dependencies .iter() .map(|dependency| dependency.name.as_str()) .collect(); assert_eq!(dependencies, ["build", "build:client"]); assert_eq!( config.tasks["serve"].env_file.as_ref().unwrap().path, ".env.serve" ); assert_eq!(config.tasks["all"].command, None); } #[test] fn parallel_tasks_run_their_dependencies_at_once() { let config = PackageConfig::parse( "tasks: {a: 'echo a', b: 'echo b', all: {dependencies: ['a', 'b'], parallel: true}, seq: {dependencies: ['a']}}", &base(), ) .unwrap(); assert!(config.tasks["all"].parallel); assert!(!config.tasks["seq"].parallel); assert!(!config.tasks["a"].parallel); // parallel: false needs no dependencies; it is the default. PackageConfig::parse("tasks: {a: {command: 'echo a', parallel: false}}", &base()).unwrap(); for (source, message) in [ ( "tasks: {all: {dependencies: ['a'], parallel: 'yes'}}", "Expected a boolean", ), ( "tasks: {all: {command: 'echo a', parallel: true}}", "requires 'dependencies'", ), ( "tasks: {all: {dependencies: [], parallel: true}}", "requires 'dependencies'", ), ( "tasks: {a: {command: 'echo a', envFile: 3}}", "Expected a string", ), ( "tasks: {a: {command: 'echo a', envFile: ''}}", "Task env file paths cannot be empty", ), ("tasks: {a: true}", "'parallel'"), ] { let error = PackageConfig::parse(source, &base()).unwrap_err(); assert!(error.message.contains(message), "{source}: {error}"); assert!(!source[error.span.clone()].is_empty(), "{source}: {error}"); } } #[test] fn cached_tasks_declare_inputs_outputs_and_env_names() { let config = PackageConfig::parse( r#" tasks: { site: { command: 'avril run build-site.ts', files: ['src/**/*.ts', 'templates/', 'avril.conf'], output: ['dist/'], env: ['SITE_ENV', '_PRIVATE2'], }, }, "#, &base(), ) .unwrap(); let site = &config.tasks["site"]; let files: Vec<_> = site .files .as_ref() .unwrap() .iter() .map(|pattern| pattern.pattern.as_str()) .collect(); assert_eq!(files, ["src/**/*.ts", "templates/", "avril.conf"]); assert_eq!(site.output[0].pattern, "dist/"); assert_eq!(site.env, ["SITE_ENV", "_PRIVATE2"]); // Uncached tasks carry no caching fields. let config = PackageConfig::parse("tasks: {a: 'echo hi'}", &base()).unwrap(); assert_eq!(config.tasks["a"].files, None); for (source, message) in [ ( "tasks: {a: {command: 'x', output: ['dist/']}}", "require 'files'", ), ( "tasks: {a: {command: 'x', env: ['HOME']}}", "require 'files'", ), ( "tasks: {a: {command: 'x', files: 'src/**'}}", "array of glob patterns", ), ( "tasks: {a: {command: 'x', files: [3]}}", "Expected a string", ), ("tasks: {a: {command: 'x', files: ['']}}", "cannot be empty"), ( "tasks: {a: {command: 'x', files: ['src\\\\**']}}", "separator", ), ( "tasks: {a: {command: 'x', files: ['src/[abc']}}", "invalid range pattern", ), ( "tasks: {a: {command: 'x', files: [], output: ['../dist']}}", "inside the package", ), ( "tasks: {a: {command: 'x', files: [], output: ['/dist']}}", "inside the package", ), ( "tasks: {a: {command: 'x', files: [], output: ['.']}}", "inside the package", ), ( "tasks: {a: {command: 'x', files: [], env: 'HOME'}}", "array of environment variable names", ), ( "tasks: {a: {command: 'x', files: [], env: ['1BAD']}}", "environment variable name", ), ( "tasks: {a: {command: 'x', files: [], env: ['A-B']}}", "environment variable name", ), ( "tasks: {a: {command: 'x', files: [], env: ['A', 'A']}}", "Duplicate task env name", ), ] { let error = PackageConfig::parse(source, &base()).unwrap_err(); assert!(error.message.contains(message), "{source}: {error}"); assert!(!source[error.span.clone()].is_empty(), "{source}: {error}"); } } #[test] fn malformed_tasks_have_source_spans() { for (source, message) in [ ("tasks: []", "Expected an object"), ("tasks: {'my task': 'echo hi'}", "Task names"), ("tasks: {'': 'echo hi'}", "Task names"), ("tasks: {build: ''}", "cannot be empty"), ("tasks: {build: ' '}", "cannot be empty"), ("tasks: {build: {}}", "must define"), ("tasks: {build: {command: 3}}", "Expected a string"), ("tasks: {build: {description: 'x'}}", "must define"), ( "tasks: {build: {command: 3, cwd: '.'}}", "Expected a string", ), ( "tasks: {build: {command: 'x', dependencies: 'build'}}", "array of task names", ), ( "tasks: {build: {command: 'x', dependencies: [3]}}", "Expected a string", ), ( "tasks: {build: {command: 'x', dependencies: ['a b']}}", "Expected a task name", ), ( "tasks: {build: {command: 'x', dependencies: ['a', 'a']}}", "Duplicate task dependency", ), ("tasks: {build: true}", "Expected a task command"), ] { let error = PackageConfig::parse(source, &base()).unwrap_err(); assert!(error.message.contains(message), "{source}: {error}"); assert!(!source[error.span.clone()].is_empty(), "{source}: {error}"); } // Unknown and cyclic references are for the runner to judge: configs are // parsed for many commands that never run tasks. PackageConfig::parse( "tasks: {a: {command: 'x', dependencies: ['b', 'missing']}, b: {command: 'y', dependencies: ['a']}}", &base(), ) .unwrap(); } #[test] fn http_packages_use_url_paths_and_confined_exports() { let base = Url::parse("https://example.com/pkg/avril.conf").unwrap(); let config = PackageConfig::parse("name: 'pkg', exports: './src/mod.ts', deps: ['../dep/', {from: 'http://other.example/lib/', name: 'other'}, 'jsr:@std/assert@1']", &base).unwrap(); assert_eq!( config.exports["."].target.as_str(), "https://example.com/pkg/src/mod.ts" ); assert_eq!( config.dependencies[0].source, Source::Http(Url::parse("https://example.com/dep/").unwrap()) ); assert_eq!(config.dependencies[1].name.as_deref(), Some("other")); let config = PackageConfig::parse("deps: ['https://example.com/pkg/']", &self::base()).unwrap(); assert_eq!( config.dependencies[0].source, Source::Http(base.join("./").unwrap()) ); for (source, message) in [ ("exports: './%2e%2e/private.ts'", "inside its package"), ("exports: './sub/.'", "inside its package"), ("exports: './x%2fprivate.ts'", "Invalid package path"), ("exports: './mod.ts?raw'", "queries or fragments"), ("exports: './%00.ts'", "without NUL"), ("deps: ['../dep']", "ending with '/'"), ("deps: ['https://user@example.com/pkg/']", "credential-free"), ( "deps: ['https://example.com/pkg/?v=1']", "queries or fragments", ), ( "deps: ['https://example.com/pkg/#v1']", "queries or fragments", ), ( "deps: ['https://example.com/pkg/%2f/']", "Invalid package path", ), ("deps: ['file:///tmp/pkg/']", "Dependencies must"), ] { let error = PackageConfig::parse(source, &base).unwrap_err(); assert!(error.message.contains(message), "{source}: {error}"); } } #[test] fn compiler_options_keep_all_keys_and_validate_jsx_ones() { let config = PackageConfig::parse( "compilerOptions: { jsx: 'react-jsxdev', jsxImportSource: 'ui', jsxFactory: 'h.x', target: 'es2020', paths: {} }", &base(), ) .unwrap(); assert_eq!( serde_json::Value::Object(config.compiler_options), serde_json::json!({"jsx": "react-jsxdev", "jsxImportSource": "ui", "jsxFactory": "h.x", "target": "es2020", "paths": {}}) ); for (source, message) in [ ( "compilerOptions: { jsx: 'preact' }", "Unsupported compilerOptions.jsx", ), ("compilerOptions: { jsx: '' }", "nonempty string"), ("compilerOptions: { jsxFactory: 42 }", "nonempty string"), ( "compilerOptions: { jsxFactory: 'not a factory' }", "dotted member expression", ), ("compilerOptions: []", "Expected an object"), ] { let error = PackageConfig::parse(source, &base()).unwrap_err(); assert!(error.message.contains(message), "{source}: {error}"); } }