use avril_runtime::config::{ValueKind, parse}; #[path = "common/registry.rs"] mod fixture; use avril_runtime::config_edit::{Addition, Change, add, add_file, unversioned, with_caret}; use fixture::Registry; use std::fs; use std::path::Path; use std::process::{Command, Output}; fn registry() -> Registry { let server = Registry::new(); for version in ["1.0.0", "2.0.0"] { server.package( "@test/pkg", version, &[ ("mod.js", &format!("export const version = '{version}';")), ("sub.js", "export const sub = true;"), ], &[(".", "./mod.js"), ("./sub", "./sub.js")], ); } server } /// Runs `avril add` against `server`, caching beneath `root`. fn avril_add(server: &Registry, root: &Path, cwd: &Path, args: &[&str]) -> Output { let options = server.options(root); Command::new(env!("CARGO_BIN_EXE_avril")) .current_dir(cwd) .arg("add") .arg(format!("--jsr-registry={}", options.jsr_registry)) .arg(format!("--cache-dir={}", options.cache.display())) .args(args) .output() .unwrap() } fn import<'a>(alias: &'a str, target: &'a str) -> Addition<'a> { Addition::Import { alias, target } } /// The package bound to `binding` in a config's deps. fn dependency(source: &str, binding: &str) -> String { let ValueKind::Object(root) = parse(source).unwrap().kind else { panic!() }; let ValueKind::Array(deps) = &root .iter() .find(|member| member.key == "deps") .unwrap() .value .kind else { panic!() }; let string = |value: &avril_runtime::config::Value| match &value.kind { ValueKind::String(value) => value.clone(), _ => panic!(), }; deps.iter() .find_map(|entry| match &entry.kind { ValueKind::String(from) => (from.starts_with(&format!("jsr:{binding}@")) || *from == format!("jsr:{binding}")) .then(|| from.clone()), ValueKind::Object(fields) => { let field = |key: &str| &fields.iter().find(|field| field.key == key).unwrap().value; (string(field("name")) == binding).then(|| string(field("from"))) } _ => None, }) .unwrap() } fn target(source: &str, alias: &str) -> String { let ValueKind::Object(root) = parse(source).unwrap().kind else { panic!() }; let ValueKind::Object(imports) = &root .iter() .find(|member| member.key == "imports") .unwrap() .value .kind else { panic!() }; let ValueKind::String(value) = &imports .iter() .find(|member| member.key == alias) .unwrap() .value .kind else { panic!() }; value.clone() } #[test] fn additions_preserve_comments_other_entries_and_decoded_keys() { for source in [ "", "// last comment", "{/* inside */}", "imports: {/* keep */}", "{imports: {old: './old.ts' /* old comment */}, scopes: {} /* scope comment */}", "// heading\nimports: {\n old: './old.ts', // trailing\n}, // end\n", ] { let edited = add(source, &import("alias", "jsr:@test/pkg@^1")).unwrap().0; assert_eq!(target(&edited, "alias"), "jsr:@test/pkg@^1"); for comment in [ "// last comment", "/* inside */", "/* keep */", "/* old comment */", "/* scope comment */", "// heading", "// trailing", "// end", ] { if source.contains(comment) { assert!(edited.contains(comment)); } } if source.contains("old:") { assert_eq!(target(&edited, "old"), "./old.ts"); } } let source = "imports: {'de\\u0070': /* choice */ 'jsr:@test/pkg@1' /* reason */}"; let (edited, change) = add(source, &import("dep", "jsr:@test/pkg@2")).unwrap(); assert_eq!( change, Change::Updated { previous: Some("jsr:@test/pkg@1".into()) } ); assert_eq!( edited, source.replace("'jsr:@test/pkg@1'", "'jsr:@test/pkg@2'") ); assert_eq!(target(&edited, "dep"), "jsr:@test/pkg@2"); let edited = add("imports:{}", &import("lib/", "jsr:@test/pkg@^1/")) .unwrap() .0; assert_eq!(target(&edited, "lib/"), "jsr:@test/pkg@^1/"); } #[test] fn additions_work_with_implicit_commas_without_rewriting_existing_entries() { let source = "name: 'app'\ndeps: [\n 'jsr:@test/old@1' // pinned\n '../local'\n]\nimports: {\n old: './old.ts'\n other: './other.ts'\n}\n"; for newline in ["\n", "\r\n", "\r"] { let source = source.replace('\n', newline); let (edited, change) = add(&source, &import("alias", "jsr:@test/pkg@^1")).unwrap(); assert_eq!(change, Change::Added); assert_eq!(target(&edited, "alias"), "jsr:@test/pkg@^1"); assert_eq!(target(&edited, "old"), "./old.ts"); assert_eq!(target(&edited, "other"), "./other.ts"); let addition = Addition::Dependency { name: None, package: "jsr:@test/pkg@2", }; let (edited, change) = add(&edited, &addition).unwrap(); assert_eq!(change, Change::Added); assert_eq!(dependency(&edited, "@test/pkg"), "jsr:@test/pkg@2"); assert_eq!(dependency(&edited, "@test/old"), "jsr:@test/old@1"); for kept in [ "name: 'app'", "'jsr:@test/old@1' // pinned\n '../local'", "old: './old.ts'\n other: './other.ts'", ] { assert!(edited.contains(&kept.replace('\n', newline)), "{edited:?}"); } } } #[test] fn invalid_edits_do_not_replace_files_and_existing_modes_are_preserved() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("avril.conf"); fs::write(&path, "imports: {old: './old.ts'}").unwrap(); for (alias, target) in [ ("../dep", "npm:pkg@1"), ("dep/", "jsr:@test/pkg@1"), ("../dep", "jsr:@test/pkg@1"), ("", "jsr:@test/pkg@1"), ] { assert!(add_file(&path, &import(alias, target)).is_err()); assert_eq!( fs::read_to_string(&path).unwrap(), "imports: {old: './old.ts'}" ); } #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); add_file(&path, &import("dep", "jsr:@test/pkg@1")).unwrap(); assert_eq!( fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600 ); } } #[test] fn add_command_uses_the_nearest_config_without_serializing_it() { let dir = tempfile::tempdir().unwrap(); fs::create_dir(dir.path().join("src")).unwrap(); fs::write( dir.path().join("avril.conf"), "// chosen by hand\nimports: {old: './old.ts'},\n", ) .unwrap(); let server = registry(); let output = avril_add( &server, dir.path(), &dir.path().join("src"), &["dep", "jsr:@test/pkg@^1"], ); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); let source = fs::read_to_string(dir.path().join("avril.conf")).unwrap(); assert!(source.starts_with("// chosen by hand\n")); assert!(source.contains("old: './old.ts'")); assert_eq!(dependency(&source, "dep"), "jsr:@test/pkg@^1"); assert!(!dir.path().join("src/avril.conf").exists()); } #[cfg(unix)] #[test] fn config_outputs_do_not_follow_symlinks_or_open_fifos() { use std::os::unix::fs::symlink; let dir = tempfile::tempdir().unwrap(); let destination = dir.path().join("secret"); let path = dir.path().join("avril.conf"); fs::write(&destination, "sentinel").unwrap(); symlink(&destination, &path).unwrap(); assert!(add_file(&path, &import("dep", "jsr:@test/pkg@1")).is_err()); assert_eq!(fs::read_to_string(&destination).unwrap(), "sentinel"); fs::remove_file(&path).unwrap(); let name = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()).unwrap(); assert_eq!(unsafe { libc::mkfifo(name.as_ptr(), 0o600) }, 0); assert!(add_file(&path, &import("dep", "jsr:@test/pkg@1")).is_err()); } #[test] fn editing_and_loading_malformed_configs_have_the_same_diagnostics() { let base = url::Url::parse(if cfg!(windows) { "file:///C:/package/avril.conf" } else { "file:///package/avril.conf" }) .unwrap(); for source in [ "imports { }", "name 'app'", "[]", "{imports: {old 'broken'}}", ] { let error = avril_runtime::package_config::PackageConfig::parse(source, &base).unwrap_err(); assert_eq!( add(source, &import("dep", "jsr:@test/pkg@1")).unwrap_err(), error.render("avril.conf", source) ); } } #[cfg(unix)] #[test] fn add_does_not_shadow_an_untrusted_ancestor_config() { use std::os::unix::fs::symlink; let dir = tempfile::tempdir().unwrap(); fs::create_dir(dir.path().join("src")).unwrap(); fs::write(dir.path().join("other.conf"), "// sentinel").unwrap(); symlink("other.conf", dir.path().join("avril.conf")).unwrap(); let output = Command::new(env!("CARGO_BIN_EXE_avril")) .current_dir(dir.path().join("src")) .args(["add", "dep", "jsr:@test/pkg@1"]) .output() .unwrap(); assert!(!output.status.success()); assert!(String::from_utf8_lossy(&output.stderr).contains("Untrusted config")); assert!(!dir.path().join("src/avril.conf").exists()); assert_eq!( fs::read_to_string(dir.path().join("other.conf")).unwrap(), "// sentinel" ); } #[test] fn additions_preserve_extension_fields() { let source = r#"// project extensions custom: {nested: [null, true, 42], name: false} importMap: {specifierMap: {old: './old.ts'}} tasks: {build: {command: 'echo built', custom: {color: 'green'}}} deps: [{from: 'jsr:@test/pkg@1', name: 'pkg', custom: {optional: true}}] "#; let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("avril.conf"); fs::write(&path, source).unwrap(); add_file(&path, &import("helper", "jsr:@std/assert@^1")).unwrap(); let edited = fs::read_to_string(&path).unwrap(); assert!(edited.starts_with(source), "{edited}"); assert_eq!(target(&edited, "helper"), "jsr:@std/assert@^1"); add_file( &path, &Addition::Dependency { name: Some("pkg"), package: "jsr:@test/pkg@2", }, ) .unwrap(); let updated = fs::read_to_string(&path).unwrap(); assert_eq!(updated, edited.replace("@test/pkg@1", "@test/pkg@2")); assert_eq!(dependency(&updated, "pkg"), "jsr:@test/pkg@2"); } #[test] fn additions_preserve_metadata_dependencies_and_scope_maps() { for source in [ "name: '@app/demo', version: '0.1.0', description: 'Demo', exports: './mod.ts'", "name: '@app/demo', imports: {/* maps */}", "name: '@app/demo', deps: ['../unread' /* local */, {from: '../math', name: 'math'}], scopes: {'./test/': {helper: './helper.ts'}}", ] { let edited = add(source, &import("assert", "jsr:@std/assert@^1")) .unwrap() .0; assert_eq!(target(&edited, "assert"), "jsr:@std/assert@^1"); for unchanged in [ "name: '@app/demo'", "version: '0.1.0'", "description: 'Demo'", "exports: './mod.ts'", "/* maps */", "deps: ['../unread' /* local */, {from: '../math', name: 'math'}]", "scopes: {'./test/': {helper: './helper.ts'}}", ] { if source.contains(unchanged) { assert!(edited.contains(unchanged)); } } } } #[test] fn add_rejects_invalid_fields_and_reserved_package_names_without_replacing_files() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("avril.conf"); for (source, alias, message) in [ ("imports: []", "dep", "Expected an object"), ("name: 'math', exports: './mod.ts'", "math", "conflicts"), ( "deps: [{from: '../math', name: 'math'}]", "math/vector", "conflicts", ), ] { fs::write(&path, source).unwrap(); assert!( add_file(&path, &import(alias, "jsr:@test/pkg@1")) .unwrap_err() .to_string() .contains(message) ); assert_eq!(fs::read_to_string(&path).unwrap(), source); } } #[test] fn add_without_an_alias_depends_on_the_whole_package_and_aliases_choose_the_field() { let dir = tempfile::tempdir().unwrap(); let server = registry(); let add = |args: &[&str]| { let output = avril_add(&server, dir.path(), dir.path(), args); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); String::from_utf8(output.stderr).unwrap() }; let source = || fs::read_to_string(dir.path().join("avril.conf")).unwrap(); assert!(add(&["jsr:@test/pkg"]).contains("Added ")); assert_eq!(dependency(&source(), "@test/pkg"), "jsr:@test/pkg@^2.0.0"); assert!(add(&["jsr:@test/pkg@^1/sub"]).contains("Updated ")); assert!(add(&["@test/pkg", "jsr:@test/pkg@^1"]).contains("Unchanged ")); assert!(add(&["pkg", "jsr:@test/pkg@^1"]).contains("Added ")); assert!(add(&["sub", "jsr:@test/pkg@^1/sub"]).contains("Added ")); assert!(add(&["prefix/", "jsr:@test/pkg@^1/"]).contains("Added ")); assert!(add(&["slashed", "jsr:/@test/pkg@^1"]).contains("Added ")); let source = source(); assert_eq!(dependency(&source, "@test/pkg"), "jsr:@test/pkg@^1"); assert_eq!(dependency(&source, "pkg"), "jsr:@test/pkg@^1"); assert_eq!(dependency(&source, "slashed"), "jsr:@test/pkg@^1"); assert_eq!(target(&source, "sub"), "jsr:@test/pkg@^1/sub"); assert_eq!(target(&source, "prefix/"), "jsr:@test/pkg@^1/"); } #[test] fn edits_report_whether_the_alias_was_added_updated_or_already_mapped() { let source = "imports: {blocked: null, same: 'jsr:@test/\\u0070kg@1' /* kept */}"; for (alias, change) in [ ("fresh", Change::Added), ("blocked", Change::Updated { previous: None }), ("same", Change::Unchanged), ] { let (edited, actual) = add(source, &import(alias, "jsr:@test/pkg@1")).unwrap(); assert_eq!(actual, change, "{alias}"); assert_eq!(edited == source, change == Change::Unchanged, "{alias}"); assert_eq!(target(&edited, alias), "jsr:@test/pkg@1"); } } #[test] fn add_command_reports_the_mapping_on_stdout_and_its_status_on_stderr() { let dir = tempfile::tempdir().unwrap(); fs::create_dir(dir.path().join("src")).unwrap(); fs::write(dir.path().join("avril.conf"), "").unwrap(); let server = registry(); let add = |cwd: &str, args: &[&str]| { let output = avril_add(&server, dir.path(), &dir.path().join(cwd), args); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); ( String::from_utf8(output.stdout).unwrap(), String::from_utf8(output.stderr).unwrap(), ) }; let config = dir.path().join("avril.conf"); for (cwd, args, stdout, stderr) in [ ( ".", &["jsr:@test/pkg@1"][..], "\"@test/pkg\": \"jsr:@test/pkg@1\" in avril.conf\n".to_owned(), "Added ", ), ( "src", &["@test/pkg", "jsr:@test/pkg@2"], format!( "\"@test/pkg\": \"jsr:@test/pkg@1\" → \"jsr:@test/pkg@2\" in {}\n", config.display() ), "Updated ", ), ( ".", &["--config=avril.conf", "@test/pkg", "jsr:@test/pkg@2"], "\"@test/pkg\": \"jsr:@test/pkg@2\" in avril.conf\n".to_owned(), "Unchanged ", ), ] { let (actual_stdout, actual_stderr) = add(cwd, args); assert_eq!(actual_stdout, stdout, "{args:?}"); assert!( actual_stderr.contains(stderr), "{args:?}: {actual_stderr:?}" ); } } #[cfg(unix)] #[test] fn add_command_escapes_terminal_controls_in_config_paths() { let dir = tempfile::tempdir().unwrap(); let server = registry(); let output = avril_add( &server, dir.path(), dir.path(), &["--config=a\x1b[2J\nb/avril.conf", "jsr:@test/pkg@1"], ); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); assert_eq!( String::from_utf8(output.stdout).unwrap(), "\"@test/pkg\": \"jsr:@test/pkg@1\" in a\\u{1b}[2J\\nb/avril.conf\n" ); } #[test] fn unversioned_targets_gain_a_caret_range_that_keeps_their_subpath() { for (target, pinned) in [ ("jsr:@std/path", "jsr:@std/path@^1.2.3"), ("jsr:@std/path/posix", "jsr:@std/path@^1.2.3/posix"), ("jsr:@std/path/", "jsr:@std/path@^1.2.3/"), ("jsr:/@std/path/posix/", "jsr:@std/path@^1.2.3/posix/"), ] { let name = unversioned(target).unwrap().unwrap(); assert_eq!(name, "@std/path"); assert_eq!(with_caret(target, &name, "1.2.3"), pinned); } for target in [ "jsr:@std/path@1", "jsr:@std/path@*/posix", "jsr:/@std/path@^1/", ] { assert_eq!(unversioned(target).unwrap(), None, "{target}"); } } #[test] fn add_selects_the_newest_stable_release_unless_prereleases_are_requested() { let server = registry(); for version in ["2.1.0", "3.0.0-rc.1"] { server.package( "@test/pkg", version, &[("mod.js", "export const version = 'later';")], &[(".", "./mod.js")], ); } server.package( "@test/next", "1.0.0-beta.1", &[("mod.js", "export {};")], &[(".", "./mod.js")], ); { let mut files = server.files.lock().unwrap(); let index = files.get_mut("/@test/pkg/meta.json").unwrap(); let mut parsed: serde_json::Value = serde_json::from_slice(index).unwrap(); parsed["versions"]["2.1.0"]["yanked"] = true.into(); *index = serde_json::to_vec(&parsed).unwrap(); } let dir = tempfile::tempdir().unwrap(); let add = |args: &[&str]| avril_add(&server, dir.path(), dir.path(), args); let source = || fs::read_to_string(dir.path().join("avril.conf")).unwrap(); assert!(add(&["jsr:@test/pkg"]).status.success()); assert_eq!(dependency(&source(), "@test/pkg"), "jsr:@test/pkg@^2.0.0"); assert!(add(&["--pre", "jsr:@test/pkg"]).status.success()); assert_eq!( dependency(&source(), "@test/pkg"), "jsr:@test/pkg@^3.0.0-rc.1" ); let before = source(); for (args, message) in [ (&["jsr:@test/next"][..], "--pre allows prereleases"), ( &["--pre", "jsr:@test/pkg@1"], "only applies to targets without a version", ), ] { let output = add(args); assert!(!output.status.success(), "{args:?}"); assert!( String::from_utf8_lossy(&output.stderr).contains(message), "{args:?}: {}", String::from_utf8_lossy(&output.stderr) ); assert_eq!(source(), before); } assert!(add(&["--pre", "jsr:@test/next"]).status.success()); assert_eq!( dependency(&source(), "@test/next"), "jsr:@test/next@^1.0.0-beta.1" ); } #[test] fn add_caches_what_the_mapping_can_import_so_programs_run_offline() { let server = registry(); server.package( "@test/other", "1.0.0", &[("unused.js", "export {};")], &[("./unused", "./unused.js")], ); let dir = tempfile::tempdir().unwrap(); for args in [&["jsr:@test/pkg@1"][..], &["next/", "jsr:@test/pkg/"]] { let output = avril_add(&server, dir.path(), dir.path(), args); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); } let lock = fixture::read_lock(dir.path()); assert_eq!(lock["specifiers"]["jsr:@test/pkg@1"], "1.0.0"); assert_eq!(lock["specifiers"]["jsr:@test/pkg@^2.0.0"], "2.0.0"); fs::write( dir.path().join("main.js"), "import {version} from '@test/pkg'; import {sub} from '@test/pkg/sub'; import {sub as next} from 'next/sub'; console.log(version, sub, next);", ) .unwrap(); let requests = server.requests.lock().unwrap().len(); let output = Command::new(env!("CARGO_BIN_EXE_avril")) .arg("run") .current_dir(dir.path()) .arg(format!("--jsr-registry={}", server.url)) .arg(format!( "--cache-dir={}", dir.path().join("cache").display() )) .args(["--offline", "--frozen", "main.js"]) .output() .unwrap(); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); assert_eq!(output.stdout, b"1.0.0 true true\n"); assert_eq!(server.requests.lock().unwrap().len(), requests); let output = avril_add( &server, dir.path(), dir.path(), &["--offline", "jsr:@test/other"], ); assert!(!output.status.success()); assert_eq!(server.requests.lock().unwrap().len(), requests); } #[test] fn add_caches_registry_targets_without_the_consumers_maps_and_keeps_edits_on_failure() { let server = registry(); server.package( "@test/root", "1.0.0", &[ ("mod.js", "export const root = 1;"), ("slash.js", "export const slash = 1;"), ], &[(".", "./mod.js"), ("./slash/", "./slash.js")], ); server.package( "@test/broken", "1.0.0", &[("mod.js", "import './missing.js';")], &[(".", "./mod.js")], ); let dir = tempfile::tempdir().unwrap(); fs::write( dir.path().join("avril.conf"), "imports: {'jsr:@test/pkg@1': null}", ) .unwrap(); let add = |args: &[&str]| avril_add(&server, dir.path(), dir.path(), args); let lock = || fixture::read_lock(dir.path()); for args in [ &["lib", "jsr:@test/pkg@1"][..], &["root/", "jsr:@test/root@1/"], &["two", "jsr:@test/pkg@2"], ] { let output = add(args); assert!( output.status.success(), "{args:?}: {}", String::from_utf8_lossy(&output.stderr) ); } assert_eq!(lock()["specifiers"]["jsr:@test/pkg@1"], "1.0.0"); assert_eq!(lock()["specifiers"]["jsr:@test/root@1"], "1.0.0"); let requests = server.requests.lock().unwrap().len(); let output = add(&["--offline", "latest", "jsr:@test/pkg"]); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); assert_eq!(server.requests.lock().unwrap().len(), requests); let source = fs::read_to_string(dir.path().join("avril.conf")).unwrap(); assert_eq!(dependency(&source, "latest"), "jsr:@test/pkg@^2.0.0"); // An unchanged edit still caches, refilling a removed cache. fs::remove_dir_all(dir.path().join("cache")).unwrap(); let output = add(&["lib", "jsr:@test/pkg@1"]); assert!( String::from_utf8_lossy(&output.stderr).contains("Unchanged "), "{}", String::from_utf8_lossy(&output.stderr) ); let fetched = |path: &str| { let requests = server.requests.lock().unwrap(); requests.iter().filter(|request| *request == path).count() }; assert_eq!(fetched("/@test/pkg/1.0.0/mod.js"), 2); assert_eq!(fetched("/@test/root/1.0.0/mod.js"), 1); assert_eq!(fetched("/@test/root/1.0.0/slash.js"), 1); let output = add(&["broken", "jsr:@test/broken@1"]); assert!(!output.status.success()); assert!(String::from_utf8_lossy(&output.stderr).contains("missing.js")); let source = fs::read_to_string(dir.path().join("avril.conf")).unwrap(); assert_eq!(dependency(&source, "broken"), "jsr:@test/broken@1"); } #[test] fn dependency_additions_insert_replace_and_keep_formatting() { let dependency_of = |name, package| Addition::Dependency { name, package }; for (source, name) in [ ("", None), ("// keep\r\nname: 'app',\r\n", Some("pkg")), ("deps: [\n '../local', // local\n]", None), ("deps: []", Some("@test/pkg")), ] { let (edited, change) = add(source, &dependency_of(name, "jsr:@test/pkg@2")).unwrap(); assert_eq!(change, Change::Added, "{source:?}"); assert_eq!( dependency(&edited, name.unwrap_or("@test/pkg")), "jsr:@test/pkg@2" ); for kept in ["// keep", "name: 'app',\r\n", " '../local', // local\n"] { assert_eq!(edited.contains(kept), source.contains(kept)); } if source.contains("\r\n") { assert!(!edited.replace("\r\n", "").contains('\n'), "{edited:?}"); } } for (source, name) in [ ("deps: ['jsr:@test/pkg@1' /* pinned */, '../local']", None), ( "deps: [{from: 'jsr:@test/pkg@1', name: 'pkg'}]", Some("pkg"), ), ("deps: [{from: '../local', name: 'pkg'}]", Some("pkg")), ] { let (edited, change) = add(source, &dependency_of(name, "jsr:@test/pkg@2")).unwrap(); let previous = if source.contains("jsr:") { "jsr:@test/pkg@1" } else { "../local" }; assert_eq!( change, Change::Updated { previous: Some(previous.into()) }, "{source}" ); assert_eq!( edited, source.replace(&format!("'{previous}'"), "'jsr:@test/pkg@2'") ); let (unchanged, change) = add(&edited, &dependency_of(name, "jsr:@test/pkg@2")).unwrap(); assert_eq!((unchanged, change), (edited, Change::Unchanged)); } for (source, addition, message) in [ ( "", dependency_of(None, "jsr:@test/pkg@2/sub"), "whole packages", ), ( "", dependency_of(None, "jsr:@test/pkg@2/"), "whole packages", ), ( "", dependency_of(None, "jsr:@Test/pkg@2"), "Invalid JSR package name", ), ( "", dependency_of(Some("a/b"), "jsr:@test/pkg@2"), "binding name", ), ( "imports: {'@test/pkg/x': './x.ts'}", dependency_of(None, "jsr:@test/pkg@2"), "conflicts", ), ] { let error = add(source, &addition).unwrap_err(); assert!(error.contains(message), "{source}: {error}"); } } #[test] fn renaming_a_package_keeps_the_binding_it_replaces() { let source = "deps: ['jsr:@test/old@1', '../local']"; let addition = Addition::Dependency { name: Some("@test/old"), package: "jsr:@test/new@2", }; let (edited, change) = add(source, &addition).unwrap(); assert_eq!( change, Change::Updated { previous: Some("jsr:@test/old@1".into()) } ); assert_eq!(dependency(&edited, "@test/old"), "jsr:@test/new@2"); assert!(edited.starts_with("deps: [")); assert!(edited.ends_with(", '../local']")); } #[test] fn npm_targets_gain_caret_ranges_keep_subpaths_and_reject_versions_in_deps() { use avril_runtime::config_edit::{selects_whole_package, whole_package}; for (target, pinned, whole) in [ ("npm:left-pad", "npm:left-pad@^1.2.3", true), ("npm:left-pad/sub", "npm:left-pad@^1.2.3/sub", false), ("npm:@scope/pkg", "npm:@scope/pkg@^1.2.3", true), ] { let name = unversioned(target).unwrap().unwrap(); assert_eq!(with_caret(target, &name, "1.2.3"), pinned); assert_eq!(selects_whole_package(pinned).unwrap(), whole); } for (target, whole) in [ ("npm:left-pad@1", true), ("npm:left-pad@*/sub", false), ("npm:left-pad@latest", true), ] { assert_eq!(unversioned(target).unwrap(), None, "{target}"); assert_eq!(selects_whole_package(target).unwrap(), whole, "{target}"); } assert!(!selects_whole_package("npm:left-pad/sub").unwrap()); assert!(!selects_whole_package("npm:left-pad/").unwrap()); assert_eq!( whole_package("npm:left-pad@^1/sub").unwrap(), "npm:left-pad@^1" ); assert!(whole_package("npm:left-pad/sub").is_ok()); } #[test] fn add_warns_and_skips_node_builtin_imports_for_jsr() { let server = Registry::new(); server.package( "@test/env", "1.0.0", &[ ( "mod.ts", "import 'node:fs';\nimport {helper} from './helper.ts';\nexport const value = helper;", ), ("helper.ts", "export const helper = 'helped';"), ], &[(".", "./mod.ts")], ); server.package( "@test/typed", "1.0.0", &[( "mod.ts", "import type {Stats} from 'node:fs';\nexport const value = 1;", )], &[(".", "./mod.ts")], ); let dir = tempfile::tempdir().unwrap(); let output = avril_add(&server, dir.path(), dir.path(), &["jsr:@test/env@1"]); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); assert!( stderr.contains("Warning") && stderr.contains("node:fs"), "{stderr:?}" ); assert!( server .requests .lock() .unwrap() .iter() .any(|path| path == "/@test/env/1.0.0/helper.ts"), "traversal continues past the builtin edge" ); let output = avril_add(&server, dir.path(), dir.path(), &["jsr:@test/typed@1"]); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); assert!( !stderr.contains("node:"), "erased type-only imports warn nothing: {stderr:?}" ); }