#[path = "common/registry.rs"] mod fixture; use std::fs; use std::path::Path; use std::process::Command; use fixture::{Registry, failure, success}; #[test] fn jsr_and_npm_registries_are_independent_and_persisted() { let jsr = Registry::new(); let npm = Registry::new(); jsr.package( "@test/jsr", "1.0.0", &[("mod.ts", "export default 'jsr';")], &[(".", "./mod.ts")], ); npm.publish_package( "npm-only", "1.0.0", serde_json::json!({}), &[("index.js", "export default 'npm';")], ); let dir = tempfile::tempdir().unwrap(); fs::write( dir.path().join("main.ts"), "import jsr from 'jsr:@test/jsr@1'; import npm from 'npm:npm-only@1'; console.log(jsr, npm);", ) .unwrap(); let run = |jsr_url: &url::Url, npm_url: &url::Url, args: &[&str]| { Command::new(env!("CARGO_BIN_EXE_avril")) .current_dir(dir.path()) .arg("run") .arg(format!("--jsr-registry={jsr_url}")) .arg(format!("--npm-registry={npm_url}")) .arg("--cache-dir=cache") .args(args) .arg("main.ts") .output() .unwrap() }; assert_eq!(success(&run(&jsr.url, &npm.url, &[])), "jsr npm\n"); let lock = fixture::read_lock(dir.path()); assert_eq!(lock["jsrRegistry"], jsr.url.as_str()); assert_eq!(lock["npmRegistry"], npm.url.as_str()); for old_name in ["registry", "jsr_registry", "npm_registry"] { assert!(lock.get(old_name).is_none()); } let jsr_requests = jsr.requests.lock().unwrap().len(); let npm_requests = npm.requests.lock().unwrap().len(); assert!(jsr_requests > 0 && npm_requests > 0); assert_eq!( success(&run(&jsr.url, &npm.url, &["--offline", "--frozen"])), "jsr npm\n" ); assert!(failure(&run(&npm.url, &npm.url, &["--offline"])).contains("JSR registry")); assert!(failure(&run(&jsr.url, &jsr.url, &["--offline"])).contains("npm registry")); assert_eq!(jsr.requests.lock().unwrap().len(), jsr_requests); assert_eq!(npm.requests.lock().unwrap().len(), npm_requests); } #[test] fn scoped_and_unscoped_config_dependencies_run() { let server = Registry::new(); server.publish_package( "leftpad", "1.0.0", serde_json::json!({}), &[("index.js", "export default 'pad';")], ); server.publish_package( "@scope/widget", "2.0.0", serde_json::json!({}), &[("index.js", "export default 'widget';")], ); let dir = tempfile::tempdir().unwrap(); fs::write( dir.path().join("avril.conf"), "deps: ['npm:leftpad@1', 'npm:@scope/widget@^2']", ) .unwrap(); fs::write( dir.path().join("main.ts"), "import pad from 'leftpad'; import widget from '@scope/widget'; console.log(pad, widget);", ) .unwrap(); assert_eq!( success(&server.run_npm(dir.path(), &["run"], "main.ts")), "pad widget\n" ); } #[test] fn import_map_aliases_and_prefixes_reach_subpaths() { let server = Registry::new(); server.publish_package( "real", "1.0.0", serde_json::json!({"exports": {".": "./mod.js", "./sub": "./sub.js"}}), &[ ("mod.js", "export default 'mod';"), ("sub.js", "export default 'sub';"), ], ); let dir = tempfile::tempdir().unwrap(); fs::write( dir.path().join("avril.conf"), "imports: {alias: 'npm:real@1', 'pref/': 'npm:real@1/'}", ) .unwrap(); fs::write( dir.path().join("main.ts"), "import a from 'alias'; import b from 'pref/sub'; console.log(a, b);", ) .unwrap(); assert_eq!( success(&server.run_npm(dir.path(), &["run"], "main.ts")), "mod sub\n" ); } #[test] fn transitive_nested_esm_imports_coexist() { let server = Registry::new(); server.publish_package( "util", "1.0.0", serde_json::json!({}), &[("index.js", "export const u = 'u1';")], ); server.publish_package( "util", "2.0.0", serde_json::json!({}), &[("index.js", "export const u = 'u2';")], ); server.publish_package( "lib", "1.0.0", serde_json::json!({"dependencies": {"util": "^2.0.0"}}), &[ ( "index.js", "export {v as nested} from './deep/nested.js'; export {u} from 'util';", ), ("deep/nested.js", "export {v} from './sibling.js';"), ("deep/sibling.js", "export {v} from '../shared.js';"), ("shared.js", "export const v = 'shared';"), ], ); server.publish_package( "app", "1.0.0", serde_json::json!({"dependencies": {"lib": "^1.0.0", "util": "^1.0.0"}}), &[( "index.js", "export * from 'lib'; export {u as u1} from 'util';", )], ); let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("avril.conf"), "deps: ['npm:app@1']").unwrap(); fs::write( dir.path().join("main.ts"), "import {nested, u, u1} from 'app'; console.log(nested, u, u1);", ) .unwrap(); assert_eq!( success(&server.run_npm(dir.path(), &["run"], "main.ts")), "shared u2 u1\n" ); } #[test] fn dynamic_imports_share_namespaces_concurrently() { let server = Registry::new(); server.publish_package( "dyn", "1.0.0", serde_json::json!({}), &[("index.js", "export const value = 7;")], ); let dir = tempfile::tempdir().unwrap(); fs::write( dir.path().join("main.ts"), "const [a, b] = await Promise.all([import('npm:dyn@1'), import('npm:dyn@1')]); console.log(a === b, a.value);", ) .unwrap(); assert_eq!( success(&server.run_npm(dir.path(), &["run"], "main.ts")), "true 7\n" ); } #[test] fn peer_auto_install_shares_explicit_providers() { let server = Registry::new(); server.publish_package( "react", "18.2.0", serde_json::json!({}), &[ ( "index.js", "globalThis.count = (globalThis.count ?? 0) + 1; export const count = globalThis.count;", ), ], ); server.publish_package( "plugin", "1.0.0", serde_json::json!({"peerDependencies": {"react": "^18.0.0"}}), &[("index.js", "export {count} from 'react';")], ); let dir = tempfile::tempdir().unwrap(); fs::write( dir.path().join("avril.conf"), "deps: ['npm:react@18.2.0', 'npm:plugin@1']", ) .unwrap(); fs::write( dir.path().join("main.ts"), "import {count as direct} from 'react'; import {count as via} from 'plugin'; console.log(direct, via);", ) .unwrap(); assert_eq!( success(&server.run_npm(dir.path(), &["run"], "main.ts")), "1 1\n" ); } #[test] fn direct_imports_preselect_peers_without_config() { let server = Registry::new(); server.publish_package( "host", "1.0.0", serde_json::json!({}), &[("index.js", "export const host = 'h1';")], ); server.publish_package( "plugin", "1.0.0", serde_json::json!({"peerDependencies": {"host": "^1.0.0"}}), &[("index.js", "export {host} from 'host';")], ); let dir = tempfile::tempdir().unwrap(); fs::write( dir.path().join("main.ts"), "import {host} from 'npm:plugin@1'; import {host as direct} from 'npm:host@1.0.0'; console.log(host, direct);", ) .unwrap(); assert_eq!( success(&server.run_npm(dir.path(), &["run"], "main.ts")), "h1 h1\n" ); } #[test] fn two_configs_isolate_peer_environments() { let server = Registry::new(); for version in ["1.0.0", "2.0.0"] { server.publish_package( "host", version, serde_json::json!({}), &[("index.js", &format!("export const version = '{version}';"))], ); } server.publish_package( "plugin", "1.0.0", serde_json::json!({"peerDependencies": {"host": "*"}}), &[("index.js", "export {version} from 'host';")], ); let dir = tempfile::tempdir().unwrap(); for (package, host) in [("a", "1.0.0"), ("b", "2.0.0")] { let root = dir.path().join(package); fs::create_dir_all(&root).unwrap(); fs::write( root.join("avril.conf"), format!("deps: ['npm:host@{host}', 'npm:plugin@1']"), ) .unwrap(); fs::write( root.join("main.ts"), "import {version} from 'plugin'; console.log(version);", ) .unwrap(); } fs::write( dir.path().join("main.ts"), "import './a/main.ts'; import './b/main.ts';", ) .unwrap(); assert_eq!( success(&server.run_npm(dir.path(), &["run"], "main.ts")), "1.0.0\n2.0.0\n" ); } #[test] fn shadowed_peers_use_the_parent_host_instance() { let server = Registry::new(); server.publish_package( "x", "1.0.0", serde_json::json!({}), &[("index.js", "export const x = 'x1';")], ); server.publish_package( "x", "2.0.0", serde_json::json!({}), &[("index.js", "export const x = 'x2';")], ); server.publish_package( "host", "1.0.0", serde_json::json!({"peerDependencies": {"x": "^1.0.0"}}), &[("index.js", "export {x} from 'x';")], ); server.publish_package( "plugin", "1.0.0", serde_json::json!({"dependencies": {"host": "^1.0.0", "x": "^2.0.0"}}), &[( "index.js", "export {x as own} from 'x'; export {x as hosted} from 'host';", )], ); let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("avril.conf"), "deps: ['npm:plugin@1']").unwrap(); fs::write( dir.path().join("main.ts"), "import {own, hosted} from 'plugin'; console.log(own, hosted);", ) .unwrap(); assert_eq!( success(&server.run_npm(dir.path(), &["run"], "main.ts")), "x2 x1\n" ); } #[test] fn typescript_sources_transpile() { let server = Registry::new(); server.publish_package( "typed", "1.0.0", serde_json::json!({"exports": {".": "./index.ts"}}), &[("index.ts", "export const value: number = 40 + 2;")], ); let dir = tempfile::tempdir().unwrap(); fs::write( dir.path().join("main.ts"), "import {value} from 'npm:typed@1'; console.log(value);", ) .unwrap(); assert_eq!( success(&server.run_npm(dir.path(), &["run"], "main.ts")), "42\n" ); } #[test] fn unsupported_kinds_fail_with_clear_errors() { let server = Registry::new(); server.publish_package( "cjspkg", "1.0.0", serde_json::json!({"type": "commonjs"}), &[("index.js", "module.exports = 1;")], ); server.publish_package( "data", "1.0.0", serde_json::json!({"exports": {".": "./data.json"}}), &[("data.json", "{\"a\": 1}")], ); let dir = tempfile::tempdir().unwrap(); // CommonJS is now supported via ESM wrappers and `require`; JSON imports // and Node builtins stay unsupported with clear errors. fs::write( dir.path().join("main.ts"), "import def from 'npm:cjspkg@1'; console.log(def);", ) .unwrap(); assert_eq!( success(&server.run_npm(dir.path(), &["run"], "main.ts")), "1\n" ); fs::write(dir.path().join("main.ts"), "import 'npm:data@1';").unwrap(); assert!(failure(&server.run_npm(dir.path(), &["run"], "main.ts")).contains("JSON")); fs::write(dir.path().join("main.ts"), "import 'node:fs';").unwrap(); assert!( failure(&server.run_npm(dir.path(), &["run"], "main.ts")) .contains("node: builtin modules are not supported") ); } #[test] fn revoked_cached_imports_and_guest_io_stay_denied() { let server = Registry::new(); server.publish_package( "sec", "1.0.0", serde_json::json!({}), &[("index.js", "export const value = 42;")], ); let dir = tempfile::tempdir().unwrap(); let grant = format!("--allow-js-exec={}sec/1.0.0/", server.url); fs::write( dir.path().join("main.ts"), "import {value} from 'npm:sec@1'; import {pledge} from 'avril:std'; console.log(value); pledge({jsExec: false}); try { await import('npm:sec@1'); throw new Error('allowed'); } catch (e) { console.log(e.message.includes('PermissionDenied')); }", ) .unwrap(); let output = server.run_npm(dir.path(), &["run", "-0", &grant], "main.ts"); assert_eq!(success(&output), "42\ntrue\n"); fs::write( dir.path().join("main.ts"), "import {value} from 'npm:sec@1'; console.log(value); try { await fetch('http://127.0.0.1:9/'); throw new Error('net allowed'); } catch (e) { console.log(e.message.includes('PermissionDenied')); }", ) .unwrap(); let output = server.run_npm(dir.path(), &["run", "-0", &grant], "main.ts"); assert_eq!(success(&output), "42\ntrue\n"); } #[test] fn reserved_tree_bypass_attempts_fail() { let server = Registry::new(); server.publish_package( "guard", "1.0.0", serde_json::json!({}), &[("index.js", "export default 1;")], ); let dir = tempfile::tempdir().unwrap(); fs::write( dir.path().join("main.ts"), "import 'npm:guard@1'; console.log('warmed');", ) .unwrap(); assert_eq!( success(&server.run_npm(dir.path(), &["run"], "main.ts")), "warmed\n" ); let tree = dir.path().join(".avril").join("npm"); let target = tree .join("default") .read_dir() .unwrap() .next() .unwrap() .unwrap() .path(); let target = target.join("guard").join("1.0.0").join("index.js"); assert!(target.is_file()); let relative = target.strip_prefix(dir.path()).unwrap(); let relative_url = url::Url::from_file_path(&target).unwrap(); // Without virtual authority even the normal import fails, and direct // reserved-tree imports fail the same way: local grants never cover // the npm tree. fs::write(dir.path().join("main.ts"), "import 'npm:guard@1';").unwrap(); let output = server.run_npm(dir.path(), &["run", "-0", "--allow-js-exec=./"], "main.ts"); assert!( failure(&output).contains("PermissionDenied"), "{}", String::from_utf8_lossy(&output.stderr) ); // Prepared but unauthorized: the tree is owned, so bypass attempts // fail on authority rather than preparation. let grant = format!("--allow-js-exec={}guard/1.0.0/", server.url); fs::write( dir.path().join("main.ts"), format!( "import 'npm:guard@1';\n\ import {{pledge}} from 'avril:std';\n\ pledge({{jsExec: false}});\n\ for (const source of ['./{}', '{}']) {{\n\ try {{ await import(source); throw new Error(source + ' allowed'); }}\n\ catch (e) {{ if (!e.message.includes('PermissionDenied')) throw e; }}\n\ }}\n\ console.log('denied');", relative.display(), relative_url, ), ) .unwrap(); let output = server.run_npm( dir.path(), &["run", "-0", "--allow-js-exec=./", grant.as_str()], "main.ts", ); assert_eq!(success(&output), "denied\n"); #[cfg(unix)] { std::os::unix::fs::symlink(&target, dir.path().join("link.js")).unwrap(); fs::write( dir.path().join("main.ts"), "import 'npm:guard@1'; await import('./link.js');", ) .unwrap(); let output = server.run_npm(dir.path(), &["run", "-0", "--allow-js-exec=./"], "main.ts"); assert!(!output.status.success()); } } #[test] fn application_maps_do_not_leak_into_packages() { let server = Registry::new(); server.publish_package( "lib", "1.0.0", serde_json::json!({}), &[("index.js", "import 'evil'; export default 1;")], ); let dir = tempfile::tempdir().unwrap(); fs::write( dir.path().join("avril.conf"), "imports: {evil: './evil.ts'}\ndeps: ['npm:lib@1']", ) .unwrap(); fs::write( dir.path().join("evil.ts"), "throw new Error('app map leaked');", ) .unwrap(); fs::write(dir.path().join("main.ts"), "import 'npm:lib@1';").unwrap(); assert!( failure(&server.run_npm(dir.path(), &["run"], "main.ts")) .contains("not a declared dependency") ); } #[test] fn project_package_json_never_changes_resolution() { let server = Registry::new(); server.publish_package( "esm", "1.0.0", serde_json::json!({}), &[("index.js", "export const value = 'esm';")], ); let dir = tempfile::tempdir().unwrap(); fs::write( dir.path().join("package.json"), "{\"type\": \"commonjs\", \"dependencies\": {\"esm\": \"evil\"}}", ) .unwrap(); fs::write( dir.path().join("main.ts"), "import {value} from 'npm:esm@1'; console.log(value);", ) .unwrap(); assert_eq!( success(&server.run_npm(dir.path(), &["run"], "main.ts")), "esm\n" ); } #[test] fn planted_avril_configs_are_not_authorities() { let server = Registry::new(); server.publish_package( "pkg", "1.0.0", serde_json::json!({}), &[("index.js", "export default 1;")], ); let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("avril.conf"), "deps: ['npm:pkg@1']").unwrap(); fs::write( dir.path().join("main.ts"), "import value from 'pkg'; console.log(value);", ) .unwrap(); assert_eq!( success(&server.run_npm(dir.path(), &["run"], "main.ts")), "1\n" ); let planted = dir.path().join(".avril").join("npm").join("default"); fs::write(planted.join("avril.conf"), "imports: {pkg: './evil.ts'}").unwrap(); fs::write(planted.join("evil.ts"), "throw new Error('planted');").unwrap(); assert_eq!( success(&server.run_npm(dir.path(), &["run"], "main.ts")), "1\n" ); } #[test] fn offline_frozen_relocated_trees_run_read_only() { let server = Registry::new(); server.publish_package( "fro", "1.0.0", serde_json::json!({}), &[("index.js", "export const value = 'fro';")], ); let warm = tempfile::tempdir().unwrap(); fs::write(warm.path().join("avril.conf"), "deps: ['npm:fro@1']").unwrap(); fs::write( warm.path().join("main.ts"), "import {value} from 'fro'; console.log(value);", ) .unwrap(); assert_eq!( success(&server.run_npm(warm.path(), &["cache"], "main.ts")), "" ); let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("avril.conf"), "deps: ['npm:fro@1']").unwrap(); fs::write( dir.path().join("main.ts"), "import {value} from 'fro'; console.log(value);", ) .unwrap(); fn copy_tree(from: &Path, to: &Path) { fs::create_dir_all(to).unwrap(); for entry in fs::read_dir(from).unwrap() { let entry = entry.unwrap(); let target = to.join(entry.file_name()); if entry.file_type().unwrap().is_dir() { copy_tree(&entry.path(), &target); } else { fs::copy(entry.path(), target).unwrap(); } } } copy_tree( &warm.path().join(".avril").join("npm"), &dir.path().join(".avril").join("npm"), ); fs::copy( warm.path().join("avril.lock"), dir.path().join("avril.lock"), ) .unwrap(); std::fs::remove_dir_all(dir.path().join(".avril").join("npm").join(".metadata")).unwrap(); std::fs::remove_dir_all(dir.path().join(".avril").join("npm").join(".artifacts")).unwrap(); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let tree = dir.path().join(".avril"); let mut stack = vec![tree.clone()]; while let Some(path) = stack.pop() { for entry in fs::read_dir(&path).unwrap() { let entry = entry.unwrap().path(); if entry.is_dir() { stack.push(entry); } } std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o555)).unwrap(); } std::fs::set_permissions( dir.path().join("avril.lock"), std::fs::Permissions::from_mode(0o444), ) .unwrap(); } let before = server.requests.lock().unwrap().len(); let output = server.run_npm(dir.path(), &["run", "--offline", "--frozen"], "main.ts"); assert_eq!(success(&output), "fro\n"); assert_eq!(server.requests.lock().unwrap().len(), before); } #[test] fn info_lists_npm_modules_with_package_metadata() { let server = Registry::new(); server.publish_package( "inf", "1.0.0", serde_json::json!({}), &[("index.js", "export default 1;")], ); let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("avril.conf"), "deps: ['npm:inf@1']").unwrap(); fs::write(dir.path().join("main.ts"), "import 'inf';").unwrap(); let output = server.run_npm(dir.path(), &["info"], "main.ts"); let info: serde_json::Value = serde_json::from_str(&success(&output)).unwrap(); let modules = info["modules"].as_object().unwrap(); let (path, module) = modules .iter() .find(|(_, module)| { module["package"]["name"] == "inf" && module["package"]["version"] == "1.0.0" }) .unwrap(); assert!(path.contains(".avril")); assert_eq!( module["url"] .as_str() .unwrap() .strip_prefix("file://") .unwrap(), path.as_str() ); } #[test] fn execution_ignores_unused_corruption_but_cache_audits_it() { let server = Registry::new(); server.publish_npm( "demand-audit", "1.0.0", &[ ( "package.json", r#"{"name":"demand-audit","version":"1.0.0","type":"module"}"#, ), ("index.js", "export const value = 7;"), ("README.md", "untouched"), ], ); let dir = tempfile::tempdir().unwrap(); fs::write( dir.path().join("main.ts"), "import { value } from 'npm:demand-audit@1'; console.log(value);", ) .unwrap(); assert_eq!( success(&server.run_npm(dir.path(), &["run"], "main.ts")).trim(), "7" ); let package = dir.path().join(".avril/npm/default"); let registry = fs::read_dir(package) .unwrap() .next() .unwrap() .unwrap() .path(); fs::write(registry.join("demand-audit/1.0.0/README.md"), "tampered").unwrap(); assert_eq!( success(&server.run_npm(dir.path(), &["run", "--offline", "--frozen"], "main.ts")).trim(), "7" ); let audit = server.run_npm(dir.path(), &["cache", "--offline", "--frozen"], "main.ts"); assert!(!audit.status.success(), "cache audit unexpectedly passed"); assert!(String::from_utf8_lossy(&audit.stderr).contains("README.md")); fs::write( registry.join("demand-audit/1.0.0/index.js"), "export const value = 8;", ) .unwrap(); let used_file = server.run_npm(dir.path(), &["run", "--offline", "--frozen"], "main.ts"); assert!( !used_file.status.success(), "tampered module unexpectedly ran" ); assert!(String::from_utf8_lossy(&used_file.stderr).contains("index.js")); fs::write( registry.join("demand-audit/1.0.0/index.js"), "export const value = 7;", ) .unwrap(); fs::write( registry.join("demand-audit/1.0.0/package.json"), r#"{"name":"changed","version":"1.0.0","type":"module"}"#, ) .unwrap(); let used_metadata = server.run_npm(dir.path(), &["run", "--offline", "--frozen"], "main.ts"); assert!( !used_metadata.status.success(), "tampered package metadata passed" ); assert!(String::from_utf8_lossy(&used_metadata.stderr).contains("package.json")); } #[cfg(unix)] #[test] fn npm_file_urls_cannot_redirect_source_through_symlinks() { let server = Registry::new(); server.publish_package("url-dependency", "1.0.0", serde_json::json!({}), &[]); server.publish_package( "url-guard", "1.0.0", serde_json::json!({ "dependencies": {"url-dependency": "1"}, "exports": {".": "./index.js", "./prepare": "./prepare.js"} }), &[ ("index.js", "export const value = 'verified';"), ("prepare.js", "export {};"), ], ); let dir = tempfile::tempdir().unwrap(); let main = dir.path().join("main.js"); fs::write( &main, "import 'npm:url-guard@1/prepare'; console.log(import.meta.resolve('npm:url-guard@1'));", ) .unwrap(); let url = success(&server.run_npm(dir.path(), &["run"], "main.js")); let source = url::Url::parse(url.trim()).unwrap().to_file_path().unwrap(); let outside = dir.path().join("outside.js"); fs::write(&outside, "export const value = 'UNVERIFIED';").unwrap(); fs::remove_file(&source).unwrap(); std::os::unix::fs::symlink(&outside, &source).unwrap(); fs::write( &main, "import 'npm:url-guard@1/prepare'; console.log((await import(import.meta.resolve('npm:url-guard@1'))).value);", ) .unwrap(); let output = server.run_npm(dir.path(), &["run", "--offline", "--frozen"], "main.js"); assert!(failure(&output).contains("npm file")); assert!(!String::from_utf8_lossy(&output.stdout).contains("UNVERIFIED")); } #[test] fn extensionless_local_require_resolves_prepared_npm_manifest_entry() { let server = Registry::new(); server.publish_package( "extensionless-demand", "1.0.0", serde_json::json!({ "type": "module", "exports": {".": "./index.js", "./cjs": "./cjs/index.js"} }), &[ ("index.js", "export const loaded = true;"), ("cjs/package.json", "{\"type\":\"commonjs\"}"), ("cjs/index.js", "exports.value = 'verified-cjs';"), ], ); let dir = tempfile::tempdir().unwrap(); fs::write( dir.path().join("main.js"), "import 'npm:extensionless-demand@1'; await import('./extensionless.cjs');", ) .unwrap(); fs::write( dir.path().join("extensionless.cjs"), "const entry = require.resolve('npm:extensionless-demand@1/cjs'); console.log(require(entry.slice(0, -3)).value);", ) .unwrap(); assert_eq!( success(&server.run_npm(dir.path(), &["run"], "main.js")), "verified-cjs\n" ); } #[test] fn failed_imports_do_not_poison_later_runs() { let server = Registry::new(); server.publish_package("ok", "1.0.0", serde_json::json!({}), &[]); let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("main.ts"), "import 'npm:ghost@9';").unwrap(); assert!( !server .run_npm(dir.path(), &["run"], "main.ts") .status .success() ); fs::write( dir.path().join("main.ts"), "import 'npm:ok@1'; console.log('healed');", ) .unwrap(); assert_eq!( success(&server.run_npm(dir.path(), &["run"], "main.ts")), "healed\n" ); } #[test] fn import_meta_urls_resolve_to_the_same_instance() { let server = Registry::new(); server.publish_package( "meta", "1.0.0", serde_json::json!({}), &[("index.js", "export const url = import.meta.url;")], ); let dir = tempfile::tempdir().unwrap(); fs::write( dir.path().join("main.ts"), "const first = await import('npm:meta@1'); const second = await import(first.url); console.log(first === second, first.url.startsWith('file:'), first.url);", ) .unwrap(); let out = success(&server.run_npm(dir.path(), &["run"], "main.ts")); assert!(out.starts_with("true true file://"), "{out}"); assert!(out.contains(".avril"), "{out}"); } #[test] fn version_scoped_grants_deny_uncovered_packages() { let server = Registry::new(); server.publish_package( "guarded", "1.0.0", serde_json::json!({}), &[("index.js", "export default 1;")], ); server.publish_package( "other", "1.0.0", serde_json::json!({}), &[("index.js", "export default 2;")], ); let dir = tempfile::tempdir().unwrap(); fs::write( dir.path().join("main.ts"), "import 'npm:guarded@1'; import 'npm:other@1';", ) .unwrap(); // Authority covers only the guarded package: the other fetch never happens. let grant = format!("--allow-js-exec={}guarded/", server.url); let output = server.run_npm(dir.path(), &["run", "-0", &grant], "main.ts"); assert!(!output.status.success()); assert!( !server .requests .lock() .unwrap() .iter() .any(|path| path.contains("other")), "denied packages must not be fetched: {:?}", server.requests.lock().unwrap() ); } #[test] fn file_grants_allow_exact_exports_but_deny_siblings() { let server = Registry::new(); server.publish_package( "files", "1.0.0", serde_json::json!({"exports": {".": "./index.js", "./secret": "./secret.js"}}), &[ ("index.js", "export default 'public';"), ("secret.js", "export default 'secret';"), ], ); let dir = tempfile::tempdir().unwrap(); let base = format!("{}files/1.0.0/", server.url); let allow = format!("--allow-js-exec={}index.js", base); let deny = format!("--deny-js-exec={}secret.js", base); fs::write( dir.path().join("main.ts"), "import value from 'npm:files@1'; console.log(value);", ) .unwrap(); assert_eq!( success(&server.run_npm(dir.path(), &["run", "-0", &allow, &deny], "main.ts")), "public\n" ); fs::write( dir.path().join("main.ts"), "import value from 'npm:files@1/secret'; console.log(value);", ) .unwrap(); assert!( failure(&server.run_npm(dir.path(), &["run", "-0", &allow, &deny], "main.ts")) .contains("PermissionDenied") ); } #[test] fn revocation_during_acquisition_denies_settled_imports() { use std::sync::atomic::Ordering; use std::time::{Duration, Instant}; let server = Registry::new(); server.publish_package( "slow", "1.0.0", serde_json::json!({}), &[("index.js", "export default 1;")], ); server.hold_tarballs.store(true, Ordering::Relaxed); let dir = tempfile::tempdir().unwrap(); let grant = format!("--allow-js-exec={}slow/", server.url); fs::write( dir.path().join("main.ts"), "const pending = import('npm:slow@1'); await new Promise((resolve) => setTimeout(resolve, 200)); const {pledge} = await import('avril:std'); pledge({jsExec: false}); try { await pending; throw new Error('allowed'); } catch (e) { console.log(e.message.includes('PermissionDenied')); }", ) .unwrap(); let child = std::process::Command::new(env!("CARGO_BIN_EXE_avril")) .current_dir(dir.path()) .args(["run", "-0", grant.as_str()]) .arg(format!("--jsr-registry={}", server.url)) .arg(format!("--npm-registry={}", server.url)) .arg(format!( "--cache-dir={}", dir.path().join("cache").display() )) .arg("main.ts") .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn() .unwrap(); let start = Instant::now(); loop { if server .requests .lock() .unwrap() .iter() .any(|path| path.ends_with(".tgz")) { break; } assert!( start.elapsed() < Duration::from_secs(30), "tarball fetch never started" ); std::thread::sleep(Duration::from_millis(10)); } // The guest pledges at ~200ms; release the tarball later so the // post-fetch authority recheck deterministically observes it. std::thread::sleep(Duration::from_millis(500)); server.hold_tarballs.store(false, Ordering::Relaxed); let output = child.wait_with_output().unwrap(); assert_eq!(success(&output), "true\n"); } #[test] fn add_pins_unversioned_npm_targets_and_caches_exports() { let server = Registry::new(); server.publish_package( "tool", "1.0.0", serde_json::json!({"exports": {".": "./mod.js"}}), &[("mod.js", "export default 'tool';")], ); server.publish_package( "tool", "1.1.0", serde_json::json!({"exports": {".": "./mod.js"}}), &[("mod.js", "export default 'tool2';")], ); let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("avril.conf"), "deps: []").unwrap(); let output = server.run_npm(dir.path(), &["add", "npm:tool"], ""); assert!( success(&output).contains("\"tool\": \"npm:tool@^1.1.0\""), "{output:?}" ); let lock: serde_json::Value = fixture::read_lock(dir.path()); assert_eq!(lock["npm"]["specifiers"]["npm:tool@^1.1.0"], "1.1.0"); } #[test] fn add_keeps_tags_and_aliases_explicit() { let server = Registry::new(); server.publish_package( "tool", "1.0.0", serde_json::json!({"exports": {".": "./mod.js", "./sub": "./sub.js"}}), &[ ("mod.js", "export default 1;"), ("sub.js", "export default 2;"), ], ); let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("avril.conf"), "deps: []").unwrap(); let output = server.run_npm(dir.path(), &["add", "npm:tool@latest"], ""); assert!( success(&output).contains("\"tool\": \"npm:tool@latest\""), "{output:?}" ); let output = server.run_npm(dir.path(), &["add", "sub", "npm:tool@1/sub"], ""); assert!( success(&output).contains("\"sub\": \"npm:tool@1/sub\""), "{output:?}" ); } #[test] fn vendor_covers_jsr_http_and_npm_side_by_side() { let server = Registry::new(); server.package( "@test/value", "1.0.0", &[("mod.ts", "export const value = 'one';")], &[(".", "./mod.ts")], ); server.publish_package( "tool", "1.0.0", serde_json::json!({}), &[("index.js", "export const value = 'tool';")], ); let dir = tempfile::tempdir().unwrap(); fs::write( dir.path().join("avril.conf"), "deps: ['jsr:@test/value@1', 'npm:tool@1']", ) .unwrap(); fs::write( dir.path().join("main.ts"), "import {value as a} from '@test/value'; import {value as b} from 'tool'; console.log(a, b);", ) .unwrap(); assert_eq!( success(&server.run_npm(dir.path(), &["run"], "main.ts")), "one tool\n" ); assert_eq!( success(&server.run_npm(dir.path(), &["vendor", "--out=vendor"], "main.ts")), "" ); assert!( dir.path() .join("vendor") .join("@test/value/1.0.0/mod.ts") .is_file() ); let npm_files: Vec<_> = walkdir(&dir.path().join("vendor").join("npm")) .into_iter() .filter(|path| path.extension().is_some_and(|ext| ext == "js")) .collect(); assert!(!npm_files.is_empty(), "vendored npm files"); assert!( npm_files .iter() .any(|path| path.to_string_lossy().contains("tool")) ); } fn walkdir(path: &std::path::Path) -> Vec { let mut out = Vec::new(); for entry in std::fs::read_dir(path).unwrap() { let entry = entry.unwrap().path(); if entry.is_dir() { out.extend(walkdir(&entry)); } else { out.push(entry); } } out } #[test] fn vendored_npm_reconstructs_offline_without_caches() { let server = Registry::new(); server.publish_package( "vend", "1.0.0", serde_json::json!({}), &[("index.js", "export const value = 'vend';")], ); let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("avril.conf"), "deps: ['npm:vend@1']").unwrap(); fs::write( dir.path().join("main.ts"), "import {value} from 'vend'; console.log(value);", ) .unwrap(); assert_eq!( success(&server.run_npm(dir.path(), &["run"], "main.ts")), "vend\n" ); assert_eq!( success(&server.run_npm(dir.path(), &["vendor", "--out=vendor"], "main.ts")), "" ); std::fs::remove_dir_all(dir.path().join(".avril")).unwrap(); let before = server.requests.lock().unwrap().len(); let run = server.run_npm( dir.path(), &["run", "--vendor=vendor", "--offline", "--frozen"], "main.ts", ); assert_eq!(success(&run), "vend\n"); assert_eq!(server.requests.lock().unwrap().len(), before); } #[test] fn file_grants_cover_exact_exports_and_nested_denials() { let server = Registry::new(); server.publish_package( "files", "1.0.0", serde_json::json!({"exports": {".": "./index.js", "./nested": "./nested/deep.js"}}), &[ ("index.js", "export {value} from './secret.js';"), ("secret.js", "export const value = 'secret';"), ("nested/deep.js", "export const value = 'deep';"), ], ); let dir = tempfile::tempdir().unwrap(); let base = format!("{}files/1.0.0/", server.url); let allow = format!("--allow-js-exec={}nested/deep.js", base); let deny = format!("--deny-js-exec={}secret.js", base); // The exact grant loads its own file; the root needs the denied // sibling through its nested import and fails. fs::write( dir.path().join("main.ts"), "import {value} from 'npm:files@1/nested'; console.log(value);", ) .unwrap(); assert_eq!( success(&server.run_npm(dir.path(), &["run", "-0", &allow, &deny], "main.ts")), "deep\n" ); fs::write( dir.path().join("main.ts"), "import {value} from 'npm:files@1'; console.log(value);", ) .unwrap(); assert!( failure(&server.run_npm(dir.path(), &["run", "-0", &allow, &deny], "main.ts")) .contains("PermissionDenied") ); } #[test] fn revocation_mid_acquisition_denies_before_use() { use std::sync::atomic::Ordering; use std::time::{Duration, Instant}; let server = Registry::new(); server.publish_package( "slow", "1.0.0", serde_json::json!({}), &[("index.js", "export default 1;")], ); server.hold_tarballs.store(true, Ordering::Relaxed); let dir = tempfile::tempdir().unwrap(); let grant = format!("--allow-js-exec={}slow/", server.url); fs::write( dir.path().join("main.ts"), "const pending = import('npm:slow@1'); await new Promise((resolve) => setTimeout(resolve, 200)); const {pledge} = await import('avril:std'); pledge({jsExec: false}); try { await pending; throw new Error('allowed'); } catch (e) { console.log(e.message.includes('PermissionDenied')); }", ) .unwrap(); let child = std::process::Command::new(env!("CARGO_BIN_EXE_avril")) .current_dir(dir.path()) .args(["run", "-0", grant.as_str()]) .arg(format!("--jsr-registry={}", server.url)) .arg(format!("--npm-registry={}", server.url)) .arg(format!( "--cache-dir={}", dir.path().join("cache").display() )) .arg("main.ts") .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn() .unwrap(); let start = Instant::now(); loop { if server .requests .lock() .unwrap() .iter() .any(|path| path.ends_with(".tgz")) { break; } assert!( start.elapsed() < Duration::from_secs(30), "tarball fetch never started" ); std::thread::sleep(Duration::from_millis(10)); } // The guest pledges at ~200ms; release later so the post-fetch // authority recheck deterministically observes the revocation. std::thread::sleep(Duration::from_millis(500)); server.hold_tarballs.store(false, Ordering::Relaxed); let output = child.wait_with_output().unwrap(); assert_eq!(success(&output), "true\n"); } #[test] fn lock_override_keeps_project_root_for_npm_storage() { let server = Registry::new(); server.publish_package( "rooted", "1.0.0", serde_json::json!({}), &[("index.js", "export default 1;")], ); let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("avril.conf"), "deps: ['npm:rooted@1']").unwrap(); fs::write( dir.path().join("main.ts"), "import 'npm:rooted@1'; console.log('ok');", ) .unwrap(); let elsewhere = tempfile::tempdir().unwrap(); let output = std::process::Command::new(env!("CARGO_BIN_EXE_avril")) .current_dir(dir.path()) .args(["run"]) .arg(format!("--jsr-registry={}", server.url)) .arg(format!("--npm-registry={}", server.url)) .arg(format!( "--cache-dir={}", dir.path().join("cache").display() )) .arg(format!( "--lock={}", elsewhere.path().join("elsewhere.lock").display() )) .arg("main.ts") .output() .unwrap(); assert_eq!(success(&output), "ok\n"); assert!(dir.path().join(".avril").join("npm").is_dir()); assert!(elsewhere.path().join("elsewhere.lock").is_file()); } #[test] fn mapped_away_npm_literals_never_touch_the_registry() { let server = Registry::new(); server.publish_package( "real", "1.0.0", serde_json::json!({}), &[("index.js", "export default 'real';")], ); let dir = tempfile::tempdir().unwrap(); fs::write( dir.path().join("avril.conf"), "imports: {'npm:blocked': './safe.ts'}", ) .unwrap(); fs::write(dir.path().join("safe.ts"), "export default 'safe';").unwrap(); fs::write( dir.path().join("main.ts"), "import a from 'npm:blocked'; import b from 'npm:real@1'; console.log(a, b);", ) .unwrap(); assert_eq!( success(&server.run_npm(dir.path(), &["run"], "main.ts")), "safe real\n" ); assert!( !server .requests .lock() .unwrap() .iter() .any(|path| path.contains("blocked")), "mapped-away literals must not be fetched" ); } #[test] fn dynamic_misses_do_not_poison_valid_imports() { let server = Registry::new(); server.publish_package( "good", "1.0.0", serde_json::json!({}), &[("index.js", "export const value = 'good';")], ); let dir = tempfile::tempdir().unwrap(); fs::write( dir.path().join("main.ts"), "const [a, b] = await Promise.allSettled([import('npm:good@1'), import('npm:missing@9')]); console.log(a.status, b.status, a.value.value); const c = await import('npm:good@1'); console.log(c.value, a.value === c);", ) .unwrap(); assert_eq!( success(&server.run_npm(dir.path(), &["run"], "main.ts")), "fulfilled rejected good\ngood true\n" ); } #[test] fn update_selects_newer_versions_and_prune_keeps_the_closure() { let server = Registry::new(); server.publish_package("up", "1.0.0", serde_json::json!({}), &[]); server.publish_package("stale", "1.0.0", serde_json::json!({}), &[]); let dir = tempfile::tempdir().unwrap(); fs::write( dir.path().join("avril.conf"), "deps: ['npm:up@*', 'npm:stale@1']", ) .unwrap(); fs::write( dir.path().join("main.ts"), "import 'npm:up@*'; import 'npm:stale@1'; console.log('ok');", ) .unwrap(); assert_eq!( success(&server.run_npm(dir.path(), &["cache"], "main.ts")), "" ); server.publish_package("up", "2.0.0", serde_json::json!({}), &[]); assert_eq!( success(&server.run_npm(dir.path(), &["update"], "main.ts")), "" ); let lock: serde_json::Value = fixture::read_lock(dir.path()); assert_eq!(lock["npm"]["specifiers"]["npm:up@*"], "2.0.0"); fs::write(dir.path().join("avril.conf"), "deps: ['npm:up@*']").unwrap(); fs::write( dir.path().join("main.ts"), "import 'npm:up@*'; console.log('ok');", ) .unwrap(); assert_eq!( success(&server.run_npm(dir.path(), &["update"], "main.ts")), "" ); // Plain updates merge: the dropped declaration stays until pruned. let lock: serde_json::Value = fixture::read_lock(dir.path()); assert!(lock["npm"]["specifiers"].get("npm:stale@1").is_some()); assert_eq!( success(&server.run_npm(dir.path(), &["update", "--prune"], "main.ts")), "" ); let lock: serde_json::Value = fixture::read_lock(dir.path()); assert!(lock["npm"]["specifiers"].get("npm:stale@1").is_none()); assert!(lock["npm"]["specifiers"].get("npm:up@*").is_some()); assert!( lock["npm"]["packages"] .as_object() .unwrap() .keys() .all(|key| key.contains("|up|")), "prune drops the whole stale closure" ); } #[test] fn includes_prepare_extra_roots() { let server = Registry::new(); server.publish_package( "inc", "1.0.0", serde_json::json!({}), &[("index.js", "export const value = 'inc';")], ); let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("main.ts"), "console.log('main');").unwrap(); assert_eq!( success(&server.run_npm(dir.path(), &["run", "--include=npm:inc@1"], "main.ts")), "main\n" ); assert!( server .requests .lock() .unwrap() .iter() .any(|path| path == "/inc"), "includes prepare their roots" ); // Includes resolve from an explicit referrer too. fs::write(dir.path().join("other.ts"), "export const other = 1;").unwrap(); assert_eq!( success(&server.run_npm( dir.path(), &[ "run", "--include-from=./other.ts", "--include=npm:inc@1", "--include-from=.", ], "main.ts" )), "main\n" ); } #[test] fn add_prefixes_map_every_esm_export() { let server = Registry::new(); server.publish_package( "tool", "1.0.0", serde_json::json!({"exports": { ".": "./mod.js", "./sub": "./sub.js", "./data": "./data.json", }}), &[ ("mod.js", "export default 1;"), ("sub.js", "export default 2;"), ("data.json", "{\"n\": 3}"), ], ); let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("avril.conf"), "imports: {}").unwrap(); let output = server.run_npm(dir.path(), &["add", "tool/", "npm:tool@1/"], ""); let stdout = success(&output); assert!(stdout.contains("\"tool/\": \"npm:tool@1/\""), "{stdout}"); // Adding the prefix warms every ESM export, so the subpath runs offline. fs::write( dir.path().join("main.ts"), "import sub from 'tool/sub'; console.log(sub);", ) .unwrap(); let before = server.requests.lock().unwrap().len(); let run = server.run_npm(dir.path(), &["run", "--offline"], "main.ts"); assert_eq!(success(&run), "2\n"); assert_eq!(server.requests.lock().unwrap().len(), before); } #[test] fn declared_but_unused_dependencies_replay_frozen() { let server = Registry::new(); server.publish_package("used", "1.0.0", serde_json::json!({}), &[]); server.publish_package("unused", "1.0.0", serde_json::json!({}), &[]); let dir = tempfile::tempdir().unwrap(); fs::write( dir.path().join("avril.conf"), "deps: ['npm:used@1', 'npm:unused@1']", ) .unwrap(); fs::write( dir.path().join("main.ts"), "import 'npm:used@1'; console.log('ok');", ) .unwrap(); assert_eq!( success(&server.run_npm(dir.path(), &["cache"], "main.ts")), "" ); // Unused declarations stay replayable without network or mutation. let before = server.requests.lock().unwrap().len(); let run = server.run_npm(dir.path(), &["run", "--offline", "--frozen"], "main.ts"); assert_eq!(success(&run), "ok\n"); assert_eq!(server.requests.lock().unwrap().len(), before); } #[test] fn relocated_trees_run_offline_without_absolute_paths() { let server = Registry::new(); server.publish_package( "reloc", "1.0.0", serde_json::json!({}), &[("index.js", "export const value = 'reloc';")], ); let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("avril.conf"), "deps: ['npm:reloc@1']").unwrap(); fs::write( dir.path().join("main.ts"), "import {value} from 'npm:reloc@1'; console.log(value);", ) .unwrap(); assert_eq!( success(&server.run_npm(dir.path(), &["run"], "main.ts")), "reloc\n" ); let lock = fs::read_to_string(dir.path().join("avril.lock")).unwrap(); assert!( !lock.contains(dir.path().to_string_lossy().as_ref()), "locks stay relocatable" ); let moved = tempfile::tempdir().unwrap(); copy_tree(dir.path(), moved.path()); let before = server.requests.lock().unwrap().len(); let run = server.run_npm(moved.path(), &["run", "--offline", "--frozen"], "main.ts"); assert_eq!(success(&run), "reloc\n"); assert_eq!(server.requests.lock().unwrap().len(), before); } fn copy_tree(source: &std::path::Path, destination: &std::path::Path) { for entry in std::fs::read_dir(source).unwrap() { let entry = entry.unwrap(); let target = destination.join(entry.file_name()); if entry.file_type().unwrap().is_dir() { std::fs::create_dir_all(&target).unwrap(); copy_tree(&entry.path(), &target); } else { std::fs::copy(entry.path(), target).unwrap(); } } } #[test] fn add_warms_string_conditional_and_pattern_exports() { let server = Registry::new(); server.publish_package( "forms", "1.0.0", serde_json::json!({"exports": { ".": {"node": "./n.cjs", "import": "./index.js"}, "./feature/*": "./dist/*.js", }}), &[ ("index.js", "export default 'root';"), ("n.cjs", "module.exports = 'node';"), ("dist/thing.js", "export default 'thing';"), ], ); let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("avril.conf"), "deps: []").unwrap(); let output = server.run_npm(dir.path(), &["add", "npm:forms"], ""); assert!( success(&output).contains("\"forms\": \"npm:forms@^1.0.0\""), "{output:?}" ); fs::write( dir.path().join("main.ts"), "import root from 'forms'; import thing from 'forms/feature/thing'; console.log(root, thing);", ) .unwrap(); let before = server.requests.lock().unwrap().len(); let run = server.run_npm(dir.path(), &["run", "--offline"], "main.ts"); assert_eq!(success(&run), "root thing\n"); assert_eq!(server.requests.lock().unwrap().len(), before); // A nested prefix warms through its own scope. let scoped = tempfile::tempdir().unwrap(); fs::write(scoped.path().join("avril.conf"), "imports: {}").unwrap(); let output = server.run_npm(scoped.path(), &["add", "feat/", "npm:forms@1/feature/"], ""); assert!( success(&output).contains("\"feat/\": \"npm:forms@1/feature/\""), "{output:?}" ); fs::write( scoped.path().join("main.ts"), "import thing from 'feat/thing'; console.log(thing);", ) .unwrap(); let before = server.requests.lock().unwrap().len(); let run = server.run_npm(scoped.path(), &["run", "--offline"], "main.ts"); assert_eq!(success(&run), "thing\n"); assert_eq!(server.requests.lock().unwrap().len(), before); } #[test] fn prefix_add_ignores_broken_exports_outside_its_scope() { let server = Registry::new(); server.publish_package( "sides", "1.0.0", serde_json::json!({"exports": { ".": "./index.js", "./good": "./good.js", "./bad": "./bad.js", }}), &[ ("index.js", "export default 0;"), ("good.js", "export default 'good';"), ("bad.js", "this is not valid javascript (((("), ], ); let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("avril.conf"), "imports: {}").unwrap(); // Warming only the good prefix never parses the broken sibling. let output = server.run_npm(dir.path(), &["add", "good", "npm:sides@1/good"], ""); assert!( success(&output).contains("\"good\": \"npm:sides@1/good\""), "{output:?}" ); fs::write( dir.path().join("main.ts"), "import good from 'good'; console.log(good);", ) .unwrap(); assert_eq!( success(&server.run_npm(dir.path(), &["run", "--offline"], "main.ts")), "good\n" ); // The whole package still fails loudly: broken files are not skipped. let output = server.run_npm(dir.path(), &["add", "sides/", "npm:sides@1/"], ""); assert!(!output.status.success()); } #[test] fn failed_caches_leave_no_npm_roots_or_artifacts() { let server = Registry::new(); server.publish_package( "broken", "1.0.0", serde_json::json!({"dependencies": {"inner": "^1.0.0"}}), &[("index.js", "export * from 'inner';")], ); server.publish_package( "inner", "1.0.0", serde_json::json!({}), &[("index.js", "export default ((((")], ); server.publish_package( "uses-node", "1.0.0", serde_json::json!({}), &[("index.js", "import 'node:fs'; export default 1;")], ); server.publish_package("fine", "1.0.0", serde_json::json!({}), &[]); for (name, spec) in [ ("bad-syntax", "npm:broken@1"), ("node-builtin", "npm:uses-node@1"), ] { let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("avril.conf"), "deps: []").unwrap(); fs::write( dir.path().join("main.ts"), format!("import '{spec}'; console.log('unreachable');"), ) .unwrap(); let output = server.run_npm(dir.path(), &["cache"], "main.ts"); assert!(!output.status.success(), "{name}"); let lock = fs::read_to_string(dir.path().join("avril.lock")).unwrap_or_default(); assert!( !lock.contains("broken") && !lock.contains("inner") && !lock.contains("uses-node"), "{name}: {lock}" ); } // The unrelated package still caches cleanly afterwards. let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("avril.conf"), "deps: []").unwrap(); fs::write( dir.path().join("main.ts"), "import 'npm:fine@1'; console.log('fine');", ) .unwrap(); assert_eq!( success(&server.run_npm(dir.path(), &["cache"], "main.ts")), "" ); let lock: serde_json::Value = fixture::read_lock(dir.path()); assert_eq!(lock["npm"]["specifiers"]["npm:fine@1"], "1.0.0"); assert!(lock["npm"]["specifiers"].as_object().unwrap().len() == 1); } #[test] fn peer_contexts_keep_separate_roots_across_rewrites() { let server = Registry::new(); server.publish_package( "plug", "1.0.0", serde_json::json!({"peerDependencies": {"host": "*"}}), &[("index.js", "export {version} from 'host';")], ); server.publish_package( "host", "1.0.0", serde_json::json!({}), &[("index.js", "export default ((((")], ); server.publish_package( "host", "2.0.0", serde_json::json!({}), &[("index.js", "export const version = 'h2';")], ); let dir = tempfile::tempdir().unwrap(); fs::write( dir.path().join("avril.conf"), "deps: ['npm:plug@1', 'npm:host@1']", ) .unwrap(); fs::write( dir.path().join("main.ts"), "import {version} from 'npm:plug@1'; console.log(version);", ) .unwrap(); // The host@1 peer context resolves but never completes: nothing is saved. let output = server.run_npm(dir.path(), &["cache"], "main.ts"); assert!(!output.status.success()); let lock = fs::read_to_string(dir.path().join("avril.lock")).unwrap_or_default(); assert!(!lock.contains("plug") && !lock.contains("host"), "{lock}"); // The host@2 context then caches; only its roots land in the lock. fs::write( dir.path().join("avril.conf"), "deps: ['npm:plug@1', 'npm:host@2']", ) .unwrap(); assert_eq!( success(&server.run_npm(dir.path(), &["cache"], "main.ts")), "" ); let lock: serde_json::Value = fixture::read_lock(dir.path()); let roots = lock["npm"]["roots"].as_object().unwrap(); assert!( roots.keys().all(|key| !key.contains("|host|1.0.0")), "{roots:?}" ); assert_eq!( success(&server.run_npm(dir.path(), &["run", "--offline", "--frozen"], "main.ts")), "h2\n" ); } #[test] fn two_add_invocations_stay_frozen_compatible() { let server = Registry::new(); server.publish_package( "plug", "1.0.0", serde_json::json!({"peerDependencies": {"host": "^1.0.0"}}), &[("index.js", "export {version} from 'host';")], ); server.publish_package( "host", "1.0.0", serde_json::json!({}), &[("index.js", "export const version = 'h1';")], ); let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("avril.conf"), "deps: []").unwrap(); assert!(success(&server.run_npm(dir.path(), &["add", "npm:plug@1"], "")).contains("plug")); assert!(success(&server.run_npm(dir.path(), &["add", "npm:host@1"], "")).contains("host")); fs::write( dir.path().join("main.ts"), "import {version} from 'plug'; console.log(version);", ) .unwrap(); // Each add cached its config context, so the joint roots are pinned. let before = server.requests.lock().unwrap().len(); let run = server.run_npm(dir.path(), &["run", "--offline", "--frozen"], "main.ts"); assert_eq!(success(&run), "h1\n"); assert_eq!(server.requests.lock().unwrap().len(), before); } #[test] fn dynamic_reimports_share_namespaces_when_frozen() { let server = Registry::new(); server.publish_package( "dyn", "1.0.0", serde_json::json!({}), &[("index.js", "export const value = 'dyn';")], ); let dir = tempfile::tempdir().unwrap(); fs::write( dir.path().join("main.ts"), "import {value} from 'npm:dyn@1'; const again = await import('npm:dyn@1'); console.log(value, again.value, again === (await import('npm:dyn@1')));", ) .unwrap(); assert_eq!( success(&server.run_npm(dir.path(), &["cache"], "main.ts")), "" ); let before = server.requests.lock().unwrap().len(); let run = server.run_npm(dir.path(), &["run", "--offline", "--frozen"], "main.ts"); assert_eq!(success(&run), "dyn dyn true\n"); assert_eq!(server.requests.lock().unwrap().len(), before); } #[test] fn sibling_peer_contexts_keep_distinct_host_namespaces() { let server = Registry::new(); // Each host instance counts its own evaluation: sharing one instance // yields equal generations, while distinct peer environments diverge. server.publish_package( "plug", "1.0.0", serde_json::json!({"peerDependencies": {"host": "*"}}), &[("index.js", "export {generation as via} from 'host';")], ); server.publish_package( "host", "1.0.0", serde_json::json!({"peerDependencies": {"flavor": "*"}, "peerDependenciesMeta": {"flavor": {"optional": true}}}), &[ ( "index.js", "globalThis.__hostEval = (globalThis.__hostEval ?? 0) + 1; export const generation = globalThis.__hostEval;", ), ], ); // Optional peers absent from a consumer stay absent: no fetch, no error. server.publish_package( "flavor", "1.0.0", serde_json::json!({}), &[("index.js", "export const flavor = 'f';")], ); for (cons, deps) in [ ( "cons-a", serde_json::json!({"dependencies": {"plug": "^1.0.0", "host": "^1.0.0", "flavor": "^1.0.0"}}), ), ( "cons-b", serde_json::json!({"dependencies": {"plug": "^1.0.0", "host": "^1.0.0"}}), ), ] { server.publish_package( cons, "1.0.0", deps, &[( "index.js", "export {generation as direct} from 'host'; export {via as viaPlug} from 'plug';", )], ); } let dir = tempfile::tempdir().unwrap(); fs::write( dir.path().join("avril.conf"), "deps: ['npm:cons-a@1', 'npm:cons-b@1']", ) .unwrap(); fs::write( dir.path().join("main.ts"), "import {direct as aDirect, viaPlug as aVia} from 'cons-a'; import {direct as bDirect, viaPlug as bVia} from 'cons-b'; console.log(aDirect === aVia, bDirect === bVia, aDirect === bDirect);", ) .unwrap(); assert_eq!( success(&server.run_npm(dir.path(), &["run"], "main.ts")), "true true false\n" ); let lock = fixture::read_lock(dir.path()); let default_registry = fs::read_dir(dir.path().join(".avril/npm/default")) .unwrap() .next() .unwrap() .unwrap() .path(); let mut peer_destinations = Vec::new(); for (instance, pin) in lock["npm"]["instances"].as_object().unwrap() { let package = pin["package"].as_str().unwrap(); if instance == package || !instance.starts_with("p-") { continue; } let mut parts = package.split('|'); let _registry = parts.next().unwrap(); let name = parts.next().unwrap(); let version = parts.next().unwrap(); let source = default_registry.join(name).join(version); let destination = dir .path() .join(".avril/npm") .join(instance) .join(default_registry.file_name().unwrap()) .join(name) .join(version); assert!(destination.join("package.json").is_file()); fs::remove_dir_all(&destination).unwrap(); fs::create_dir_all(&destination).unwrap(); copy_tree(&source, &destination); peer_destinations.push(destination); } assert_eq!( success(&server.run_npm(dir.path(), &["run", "--offline", "--frozen"], "main.ts")), "true true false\n" ); for entry in fs::read_dir(dir.path().join(".avril/npm")).unwrap() { let entry = entry.unwrap(); if entry.file_name().to_string_lossy().starts_with("p-") { fs::remove_dir_all(entry.path()).unwrap(); } } assert_eq!( success(&server.run_npm(dir.path(), &["run", "--offline", "--frozen"], "main.ts")), "true true false\n" ); assert!( peer_destinations .iter() .all(|destination| destination.join("package.json").is_file()) ); } #[test] fn peer_instance_asset_urls_use_real_paths_and_read_authority() { let server = Registry::new(); server.publish_package("asset-host", "1.0.0", serde_json::json!({}), &[]); server.publish_package( "asset-plugin", "1.0.0", serde_json::json!({"peerDependencies": {"asset-host": "*"}}), &[ ( "index.js", "import {readTextFile} from 'avril:fs'; export const asset = await readTextFile(new URL('./asset.txt', import.meta.url));", ), ("asset.txt", "peer asset"), ], ); server.publish_package( "asset-consumer", "1.0.0", serde_json::json!({"dependencies": {"asset-host": "^1.0.0", "asset-plugin": "^1.0.0"}}), &[("index.js", "export {asset} from 'asset-plugin';")], ); let dir = tempfile::tempdir().unwrap(); fs::write( dir.path().join("avril.conf"), "deps: ['npm:asset-consumer@1']", ) .unwrap(); fs::write( dir.path().join("main.ts"), "import {asset} from 'asset-consumer'; console.log(asset);", ) .unwrap(); assert_eq!( success(&server.run_npm(dir.path(), &["run", "--allow-read=.avril/npm"], "main.ts")), "peer asset\n" ); let denied = server.run_npm( dir.path(), &["run", "--offline", "--frozen", "--deny-read=.avril/npm"], "main.ts", ); assert!(failure(&denied).contains("PermissionDenied: read")); let lock = fixture::read_lock(dir.path()); let (instance, package) = lock["npm"]["instances"] .as_object() .unwrap() .iter() .find_map(|(instance, pin)| { let package = pin["package"].as_str()?; (package.contains("|asset-plugin|1.0.0") && instance.starts_with("p-")) .then_some((instance, package)) }) .unwrap(); let registry_dir = fs::read_dir(dir.path().join(".avril/npm/default")) .unwrap() .next() .unwrap() .unwrap() .path(); let mut parts = package.split('|'); let _registry = parts.next().unwrap(); let name = parts.next().unwrap(); let version = parts.next().unwrap(); let asset = dir .path() .join(".avril/npm") .join(instance) .join(registry_dir.file_name().unwrap()) .join(name) .join(version) .join("asset.txt"); assert_eq!(fs::read_to_string(&asset).unwrap(), "peer asset"); let grant = format!( "--allow-read={}", asset.strip_prefix(dir.path()).unwrap().display() ); assert_eq!( success(&server.run_npm( dir.path(), &["run", "--offline", "--frozen", &grant], "main.ts" )), "peer asset\n" ); } #[test] fn sibling_roots_share_one_host_instance() { let server = Registry::new(); for version in ["1.0.0", "2.0.0"] { server.publish_package( "x", version, serde_json::json!({}), &[("index.js", &format!("export const x = '{version}';"))], ); } server.publish_package( "host", "1.0.0", serde_json::json!({"peerDependencies": {"x": "*"}}), &[ ( "index.js", "globalThis.__hostCount = (globalThis.__hostCount ?? 0) + 1; export const count = globalThis.__hostCount; export {x} from 'x';", ), ], ); server.publish_package( "plugin", "1.0.0", serde_json::json!({"peerDependencies": {"host": "*"}, "dependencies": {"x": "^2.0.0"}}), &[( "index.js", "export {count as via} from 'host'; export {x as own} from 'x';", )], ); let dir = tempfile::tempdir().unwrap(); // host and x@1 are selected alongside plugin: plugin's peer host must // bind that exact host instance (x1) instead of reinstantiating host // under its own x2 dependency. fs::write( dir.path().join("avril.conf"), "deps: ['npm:host@1', 'npm:plugin@1', 'npm:x@1']", ) .unwrap(); fs::write( dir.path().join("main.ts"), "import {count as direct} from 'host'; import {via, own} from 'plugin'; console.log(direct, via, own);", ) .unwrap(); assert_eq!( success(&server.run_npm(dir.path(), &["run"], "main.ts")), "1 1 2.0.0\n" ); } #[test] fn add_reports_progress_phases_and_downloads() { let server = Registry::new(); server.publish_package( "tool", "1.0.0", serde_json::json!({"exports": {".": "./mod.js"}}), &[("mod.js", "export default 1;")], ); server.publish_package( "tool", "1.1.0", serde_json::json!({"exports": {".": "./mod.js"}}), &[("mod.js", "export default 2;")], ); server.package( "@test/pkg", "1.0.0", &[("mod.ts", "export default 1;")], &[(".", "./mod.ts")], ); let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("avril.conf"), "deps: []").unwrap(); let output = server.run_npm(dir.path(), &["add", "npm:tool"], ""); let stdout = success(&output); assert!( stdout.contains("\"tool\": \"npm:tool@^1.1.0\""), "{output:?}" ); assert!( !stdout.contains("Selecting"), "stdout keeps only the mapping: {stdout:?}" ); let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); for needle in ["Selecting", "Caching", "tool"] { assert!( stderr.contains(needle), "stderr must show {needle}: {stderr:?}" ); } assert!( stderr.contains("Fetching") || stderr.contains("Downloading"), "stderr must identify package downloads: {stderr:?}" ); let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("avril.conf"), "deps: []").unwrap(); let output = server.run_npm(dir.path(), &["add", "jsr:@test/pkg"], ""); let stdout = success(&output); assert!(stdout.contains("jsr:@test/pkg@^1.0.0"), "{output:?}"); let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); for needle in ["Selecting", "Caching"] { assert!( stderr.contains(needle), "jsr add must show {needle}: {stderr:?}" ); } } #[test] fn add_failure_keeps_prior_progress_and_error() { let server = Registry::new(); let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("avril.conf"), "deps: []").unwrap(); let output = server.run_npm(dir.path(), &["add", "npm:missing"], ""); let stderr = failure(&output); assert!( stderr.contains("Selecting"), "prior progress must remain: {stderr:?}" ); assert!( stderr.contains("404"), "registry failure must be preserved: {stderr:?}" ); } #[test] fn add_warm_offline_does_not_claim_downloads() { let server = Registry::new(); server.publish_package( "tool", "1.0.0", serde_json::json!({}), &[("index.js", "export default 1;")], ); let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("avril.conf"), "deps: []").unwrap(); let output = server.run_npm(dir.path(), &["add", "npm:tool"], ""); assert!(output.status.success()); let first_stderr = String::from_utf8_lossy(&output.stderr).into_owned(); assert!( first_stderr.contains("Downloading") || first_stderr.contains("Fetching"), "first add must show network work: {first_stderr:?}" ); let output = server.run_npm(dir.path(), &["add", "--offline", "npm:tool"], ""); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); assert!( !stderr.contains("Downloading") && !stderr.contains("Fetching"), "warm offline add must not claim network downloads: {stderr:?}" ); } #[test] fn other_commands_stay_silent_without_progress() { let server = Registry::new(); server.publish_package( "tool", "1.0.0", serde_json::json!({}), &[("index.js", "export default 1;")], ); let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("avril.conf"), "deps: ['npm:tool@1']").unwrap(); fs::write( dir.path().join("main.ts"), "import value from 'tool'; console.log(value);", ) .unwrap(); let output = server.run_npm(dir.path(), &["run"], "main.ts"); assert_eq!(success(&output), "1\n"); let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); for needle in [ "Selecting", "Fetching", "Downloading", "Materialized", "Caching", ] { assert!(!stderr.contains(needle), "run must stay silent: {stderr:?}"); } } #[test] fn compact_pin_end_to_end() { let server = Registry::new(); server.publish_package( "compact", "1.0.0", serde_json::json!({}), &[("index.js", "export default 1;")], ); let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("avril.conf"), "deps: ['npm:compact@1']").unwrap(); fs::write( dir.path().join("main.ts"), "import value from 'compact'; console.log(value);", ) .unwrap(); assert_eq!( success(&server.run_npm(dir.path(), &["run"], "main.ts")), "1\n" ); let lock: serde_json::Value = fixture::read_lock(dir.path()); let pins = lock["npm"]["packages"].as_object().unwrap(); assert_eq!(pins.len(), 1); let pin = pins.values().next().unwrap(); assert!( pin["manifest"] .as_str() .is_some_and(|digest| digest.starts_with("sha256-")), "{pin}" ); assert!(pin.get("files").is_none(), "{pin}"); } #[test] fn add_selects_before_fetching_broken_archives() { let server = Registry::new(); server.publish_package( "tool", "1.0.0", serde_json::json!({"exports": {".": "./mod.js"}}), &[("mod.js", "export default 1;")], ); { let mut files = server.files.lock().unwrap(); let tarball = files .keys() .find(|path| path.ends_with(".tgz")) .unwrap() .clone(); files.insert(tarball, b"not gzip".to_vec()); } let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("avril.conf"), "deps: []").unwrap(); let output = server.run_npm(dir.path(), &["add", "npm:tool"], ""); assert!(!output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); assert!( stdout.contains("npm:tool@^1.0.0"), "config must be reported: {output:?}" ); let config = fs::read_to_string(dir.path().join("avril.conf")).unwrap(); assert!( config.contains("npm:tool@^1.0.0"), "caret must be written: {config:?}" ); let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); assert!( stderr.contains("Selecting"), "selection must precede warming: {stderr:?}" ); } #[test] fn add_shares_packument_between_selection_and_warming() { let server = Registry::new(); server.publish_package( "tool", "1.0.0", serde_json::json!({"exports": {".": "./mod.js"}}), &[("mod.js", "export default 1;")], ); server.publish_package( "tool", "1.1.0", serde_json::json!({"exports": {".": "./mod.js"}}), &[("mod.js", "export default 2;")], ); let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("avril.conf"), "deps: []").unwrap(); let output = server.run_npm(dir.path(), &["add", "npm:tool"], ""); assert!(success(&output).contains("npm:tool@^1.1.0"), "{output:?}"); let lock: serde_json::Value = fixture::read_lock(dir.path()); assert_eq!(lock["npm"]["specifiers"]["npm:tool@^1.1.0"], "1.1.0"); let packuments = server .requests .lock() .unwrap() .iter() .filter(|path| path.as_str() == "/tool") .count(); assert_eq!(packuments, 1, "selection and warming must share one fetch"); } #[test] fn add_warns_and_skips_node_builtin_edges() { let server = Registry::new(); server.publish_package( "envtool", "1.0.0", serde_json::json!({"exports": { ".": "./mod.js", "./other": "./other.js", "./sibling": "./sibling.js", }}), &[ ( "mod.js", "import cp from 'node:child_process';\nimport {sibling} from './sibling.js';\nexport default [cp, sibling];", ), ( "other.js", "import fs from 'node:fs';\nexport const lazy = await import('node:fs');\nexport default fs;", ), ("sibling.js", "export const sibling = 1;"), ], ); let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("avril.conf"), "deps: []").unwrap(); let output = server.run_npm(dir.path(), &["add", "npm:envtool"], ""); assert!( success(&output).contains("\"envtool\": \"npm:envtool@^1.0.0\""), "{output:?}" ); let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); assert!(stderr.contains("Warning"), "{stderr:?}"); assert!(stderr.contains("node:child_process"), "{stderr:?}"); assert_eq!(stderr.matches("node:fs").count(), 1, "{stderr:?}"); assert_eq!( stderr.matches("node:child_process").count(), 1, "{stderr:?}" ); let lock: serde_json::Value = fixture::read_lock(dir.path()); assert_eq!(lock["npm"]["specifiers"]["npm:envtool@^1.0.0"], "1.0.0"); fs::write( dir.path().join("main.ts"), "import {sibling} from 'envtool/sibling'; console.log(sibling);", ) .unwrap(); let output = server.run_npm(dir.path(), &["run", "--offline", "--frozen"], "main.ts"); assert_eq!(success(&output), "1\n"); } #[test] fn add_still_fails_on_broken_edges_despite_builtin_warnings() { let server = Registry::new(); server.publish_package( "envbad", "1.0.0", serde_json::json!({}), &[( "index.js", "import 'node:fs';\nimport './nope.js';\nexport default 1;", )], ); server.publish_package( "needsdep", "1.0.0", serde_json::json!({"dependencies": {"gone": "^1.0.0"}}), &[("index.js", "export default 1;")], ); server.publish_package( "envsyntax", "1.0.0", serde_json::json!({}), &[ ( "index.js", "import 'node:fs';\nimport './broken.js';\nexport default 1;", ), ("broken.js", "export const = ;\n"), ], ); server.publish_package( "badexport", "1.0.0", serde_json::json!({"exports": {".": "./index.js", "./bad": "./missing.js"}}), &[("index.js", "export default 1;")], ); for (target, fatal) in [("npm:envbad", "nope.js"), ("npm:envsyntax", "broken.js")] { let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("avril.conf"), "deps: []").unwrap(); let output = server.run_npm(dir.path(), &["add", target], ""); let stderr = failure(&output); assert!( stderr.contains("Warning") && stderr.contains("node:fs"), "{target}: {stderr:?}" ); assert!( stderr.contains(fatal), "{target}: underlying failure must be reported, not just the warning: {stderr:?}" ); } for target in ["npm:needsdep", "npm:badexport"] { let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("avril.conf"), "deps: []").unwrap(); let output = server.run_npm(dir.path(), &["add", target], ""); assert!(!output.status.success(), "{target}"); } } #[test] fn builtin_edges_stay_fatal_outside_add() { let server = Registry::new(); server.publish_package( "envtool", "1.0.0", serde_json::json!({}), &[("index.js", "import fs from 'node:fs';\nexport default fs;")], ); let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("avril.conf"), "deps: []").unwrap(); let output = server.run_npm(dir.path(), &["add", "npm:envtool"], ""); assert!(output.status.success()); fs::write( dir.path().join("main.ts"), "import fs from 'envtool'; console.log(fs);", ) .unwrap(); for args in [ &["run"] as &[&str], &["cache"], &["run", "--offline", "--frozen"], ] { let output = server.run_npm(dir.path(), args, "main.ts"); let stderr = failure(&output); assert!( stderr.contains("builtin modules are not supported"), "{args:?}: {stderr:?}" ); assert!(!stderr.contains("Warning"), "{args:?}: {stderr:?}"); } }