#[path = "common/registry.rs"] mod fixture; use avril_runtime::registry::{ PackageStore, npm::resolution::{NpmResolver, NpmRootRequest, ResolutionMode, ResolvedNpmModule}, }; use fixture::Registry; use futures::executor::block_on; use std::collections::BTreeMap; use std::sync::Arc; fn publish_stub(server: &Registry, name: &str, manifest: serde_json::Value, paths: &[&str]) { let mut sources: Vec<_> = paths.iter().map(|path| (*path, "export {};")).collect(); if !paths.contains(&"index.js") { sources.push(("index.js", "export default 0;")); } server.publish_package(name, "1.0.0", manifest, &sources); } fn resolve_types(resolver: &NpmResolver, request: &str) -> ResolvedNpmModule { block_on(resolver.resolve_request(request, ResolutionMode::TypeImport)).unwrap() } #[test] fn type_resolution_can_read_declarations_without_changing_execution_behavior() { use avril_runtime::registry::npm::package::NpmModuleKind; let server = Registry::new(); publish_stub( &server, "declared-types", serde_json::json!({"types": "./index.d.ts"}), &["index.d.ts"], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); let root = resolve(&resolver, "npm:declared-types@1"); let declaration = resolver .resolve_from_file("./index.d.ts", &root.path, ResolutionMode::TypeImport) .unwrap(); assert_eq!(declaration.format, NpmModuleKind::Declaration); assert!( resolver .resolve_from_file("./index.d.ts", &root.path, ResolutionMode::Import) .is_err() ); } #[test] fn package_declared_types_and_declaration_extension_substitution_resolve() { use avril_runtime::registry::npm::package::NpmModuleKind; let server = Registry::new(); publish_stub( &server, "types-entry", serde_json::json!({ "types": "types/main.d.ts", "typings": "./types/legacy.d.ts" }), &[ "types/main.d.ts", "types/legacy.d.ts", "src/value.d.ts", "index.d.ts", ], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); let entry = resolve_types(&resolver, "npm:types-entry@1"); assert!(entry.path.ends_with("types/main.d.ts")); assert_eq!(entry.format, NpmModuleKind::Declaration); let reference = resolver .resolve_from_file("../src/value.js", &entry.path, ResolutionMode::TypeImport) .unwrap(); assert!(reference.path.ends_with("src/value.d.ts")); assert_eq!(reference.format, NpmModuleKind::Declaration); assert!( resolver .resolve_from_file("../src/value.js", &entry.path, ResolutionMode::Import) .is_err() ); } #[test] fn types_versions_requires_an_explicit_backend_version_and_only_affects_types() { let server = Registry::new(); publish_stub( &server, "types-versions-version", serde_json::json!({ "types": "./types/index.d.ts", "typesVersions": { ">=5.0.0 <6.0.0": { "types/index.d.ts": ["ts5/index.d.ts"] } } }), &["index.js", "types/index.d.ts", "ts5/index.d.ts"], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); let runtime = resolve(&resolver, "npm:types-versions-version@1"); assert!(runtime.path.ends_with("index.js")); let request = "npm:types-versions-version@1"; let missing = block_on(resolver.resolve_request(request, ResolutionMode::TypeImport)).unwrap_err(); assert!(missing.to_string().contains("set_typescript_version")); assert!(resolver.set_typescript_version("latest").is_err()); resolver.set_typescript_version("5.4.3").unwrap(); let typed = block_on(resolver.resolve_request(request, ResolutionMode::TypeImport)).unwrap(); assert!(typed.path.ends_with("ts5/index.d.ts")); assert!( resolve(&resolver, "npm:types-versions-version@1") .path .ends_with("index.js") ); } #[test] fn types_versions_uses_first_matching_range_exact_and_best_prefix_patterns() { let server = Registry::new(); publish_stub( &server, "types-versions-patterns", serde_json::json!({ "types": "./types/main.d.ts", "typings": "./types/legacy.d.ts", "typesVersions": { ">=5.0.0": { "types/main.d.ts": ["ts5/missing.d.ts", "ts5/main.d.ts"], "feature/*": ["ts5/general/*.d.ts"], "feature/*.js": ["ts5/suffix/*.d.ts"], "feature/specific/*": ["ts5/specific/*.d.ts"], "exact": ["ts5/exact.d.ts"] }, ">=4.0.0": { "types/main.d.ts": ["ts4/main.d.ts"] } } }), &[ "types/main.d.ts", "types/legacy.d.ts", "ts5/main.d.ts", "ts4/main.d.ts", "ts5/general/other.d.ts", "ts5/specific/item.d.ts", "ts5/exact.d.ts", "ts5/general/other.js.d.ts", "ts5/suffix/other.d.ts", "feature/specific/fallback.d.ts", ], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); resolver.set_typescript_version("5.4.2").unwrap(); let roots = block_on(resolver.prepare(&[NpmRootRequest { request: "npm:types-versions-patterns@1".into(), context: BTreeMap::new(), }])) .unwrap(); for (subpath, target) in [ ("", "ts5/main.d.ts"), ("/feature/other", "ts5/general/other.d.ts"), ("/feature/specific/item", "ts5/specific/item.d.ts"), ("/feature/other.js", "ts5/general/other.js.d.ts"), ("/exact", "ts5/exact.d.ts"), ( "/feature/specific/fallback", "feature/specific/fallback.d.ts", ), ] { let request = format!("npm:types-versions-patterns@1{subpath}"); let resolved = resolver .resolve_in(&roots, &request, ResolutionMode::TypeImport) .unwrap(); assert!( resolved.path.ends_with(target), "{request}: {}", resolved.path.display() ); } } #[test] fn types_versions_no_match_and_malformed_metadata_fall_back_without_affecting_runtime() { let server = Registry::new(); publish_stub( &server, "types-versions-fallback", serde_json::json!({ "types": "./types/index.d.ts", "typesVersions": { "not a range": { "*": ["ts5/*.d.ts"] }, ">=5.0.0": { "*": ["../escape.d.ts"] } } }), &["index.js", "types/index.d.ts"], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); assert!( resolve(&resolver, "npm:types-versions-fallback@1") .path .ends_with("index.js") ); let module = block_on( resolver.resolve_request("npm:types-versions-fallback@1", ResolutionMode::TypeImport), ) .unwrap(); assert!(module.path.ends_with("types/index.d.ts")); publish_stub( &server, "types-versions-no-match", serde_json::json!({ "types": "./types/base.d.ts", "typesVersions": { ">=5.0.0": { "*": ["ts5/*"] } } }), &["types/base.d.ts", "ts5/index.d.ts"], ); let no_match = block_on( resolver.resolve_request("npm:types-versions-no-match@1", ResolutionMode::TypeImport), ); assert!(no_match.is_err()); resolver.set_typescript_version("4.9.5").unwrap(); let no_match = resolve_types(&resolver, "npm:types-versions-no-match@1"); assert!(no_match.path.ends_with("types/base.d.ts")); } #[test] fn exports_precede_types_versions_and_do_not_require_a_backend_version() { let server = Registry::new(); publish_stub( &server, "types-versions-exports", serde_json::json!({ "exports": { ".": { "types": "./exports.d.ts", "default": "./index.js" } }, "typesVersions": { "*": { "*": ["mapped/*"] } } }), &["index.js", "exports.d.ts", "mapped/index.d.ts"], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); let typed = resolve_types(&resolver, "npm:types-versions-exports@1"); assert!(typed.path.ends_with("exports.d.ts")); } #[test] fn types_versions_matches_typescript_prereleases_like_the_compiler() { let server = Registry::new(); let cases = [ ("wildcard", "*", "7.1.0-dev.20261013.1", true), ("partial_lower", ">=4.9", "4.9.0-beta", true), ("partial_upper", "<4.9", "4.9.0-beta", false), ("explicit_upper", "<4.9.0", "4.9.0-beta", true), ("explicit_lower", ">=4.9.0", "4.9.0-beta", false), ("spaced", ">= 4.9 < 5", "4.9.0-beta", true), ( "prerelease_bound", ">=7.1.0-dev.20261013.1", "7.1.0-dev.20261013.2", true, ), ("tilde_upper", "~4.9", "4.10.0-beta", true), ("tilde_lower", "~4.9", "4.9.0-beta", false), ("caret_upper", "^4.9", "5.0.0-beta", true), ("hyphen_upper", "4.9 - 5", "6.0.0-beta", true), ("hyphen_exact", "4.9 - 5.0.0-beta", "5.0.0", false), ("prefixed_partial", "v4.9", "4.9.0", false), ( "wildcard_prerelease_lower", "4.9.x-beta", "4.9.0-alpha", true, ), ( "wildcard_prerelease_upper", "4.9.x-beta", "4.10.0-beta", false, ), ]; for (name, range, version, mapped) in cases { let package = format!("types-versions-prerelease-{name}"); let mut types_versions = serde_json::Map::new(); types_versions.insert( range.to_owned(), serde_json::json!({ "types/base.d.ts": ["mapped.d.ts"] }), ); publish_stub( &server, &package, serde_json::json!({ "types": "./types/base.d.ts", "typesVersions": types_versions }), &["types/base.d.ts", "mapped.d.ts"], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); resolver.set_typescript_version(version).unwrap(); let request = format!("npm:{package}@1"); let resolved = resolve_types(&resolver, &request); assert_eq!( resolved.path.ends_with("mapped.d.ts"), mapped, "{range} with {version}" ); } } #[test] fn legacy_type_subpaths_resolve_declarations_without_runtime_files() { let server = Registry::new(); publish_stub( &server, "legacy-types-subpaths", serde_json::json!({}), &["sub.d.ts", "nested/index.d.ts"], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); let roots = block_on(resolver.prepare(&[NpmRootRequest { request: "npm:legacy-types-subpaths@1".into(), context: BTreeMap::new(), }])) .unwrap(); assert!( resolver .resolve_in( &roots, "npm:legacy-types-subpaths@1/sub", ResolutionMode::TypeImport ) .unwrap() .path .ends_with("sub.d.ts") ); assert!( resolver .resolve_in( &roots, "npm:legacy-types-subpaths@1/nested", ResolutionMode::TypeImport ) .unwrap() .path .ends_with("nested/index.d.ts") ); } #[test] fn malformed_type_fields_do_not_break_execution_but_fail_type_resolution() { let server = Registry::new(); publish_stub( &server, "malformed-types", serde_json::json!({"types": 17}), &["index.js"], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); let runtime = resolve(&resolver, "npm:malformed-types@1"); assert!(runtime.path.ends_with("index.js")); let error = block_on(resolver.resolve_request("npm:malformed-types@1", ResolutionMode::TypeImport)) .unwrap_err(); assert!(error.to_string().contains("types"), "{error}"); } #[test] fn hash_import_type_targets_use_declaration_substitution() { let server = Registry::new(); publish_stub( &server, "hash-types", serde_json::json!({"imports": {"#types": "./types/alias.js"}}), &["types/alias.d.ts"], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); let root = resolve(&resolver, "npm:hash-types@1"); let declaration = resolver .resolve_from_file("#types", &root.path, ResolutionMode::TypeImport) .unwrap(); assert!(declaration.path.ends_with("types/alias.d.ts")); } #[test] fn declaration_import_equals_require_uses_require_type_conditions() { let server = Registry::new(); publish_stub( &server, "require-types-dep", serde_json::json!({ "exports": {".": {"require": {"types": "./index.d.cts", "default": "./index.cjs"}}} }), &["index.d.cts"], ); publish_stub( &server, "require-types-owner", serde_json::json!({ "types": "./index.d.ts", "dependencies": {"require-types-dep": "1.0.0"} }), &["index.d.ts"], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); let declaration = resolve_types(&resolver, "npm:require-types-owner@1"); let dep = resolver .resolve_from_file( "require-types-dep", &declaration.path, ResolutionMode::TypeRequire, ) .unwrap(); assert!(dep.path.ends_with("index.d.cts")); } #[test] fn declaration_index_is_the_legacy_fallback_without_declared_entry_fields() { let server = Registry::new(); publish_stub( &server, "types-index-fallback", serde_json::json!({"main": "./runtime.js"}), &["runtime.js", "index.d.ts"], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); let module = resolve_types(&resolver, "npm:types-index-fallback@1"); assert!(module.path.ends_with("index.d.ts")); } #[test] fn exports_type_conditions_keep_import_require_and_encapsulation() { let server = Registry::new(); publish_stub( &server, "types-conditions", serde_json::json!({ "exports": { ".": { "import": {"types": "./types/import.d.ts", "default": "./index.js"}, "require": {"types": "./types/require.d.cts", "default": "./index.cjs"} }, "./public": {"types": "./types/public.d.ts", "default": "./public.js"} } }), &[ "types/import.d.ts", "types/require.d.cts", "types/public.d.ts", "private.d.ts", ], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); let roots = block_on(resolver.prepare(&[NpmRootRequest { request: "npm:types-conditions@1".into(), context: BTreeMap::new(), }])) .unwrap(); let import = resolver .resolve_in(&roots, "npm:types-conditions@1", ResolutionMode::TypeImport) .unwrap(); let require = resolver .resolve_in( &roots, "npm:types-conditions@1", ResolutionMode::TypeRequire, ) .unwrap(); let require_from_file = resolver .resolve_from_file( &require.path.display().to_string(), &import.path, ResolutionMode::TypeRequire, ) .unwrap(); assert!(require_from_file.path.ends_with("types/require.d.cts")); assert!(import.path.ends_with("types/import.d.ts")); assert!(require.path.ends_with("types/require.d.cts")); let public = resolver .resolve_in( &roots, "npm:types-conditions@1/public", ResolutionMode::TypeImport, ) .unwrap(); assert!(public.path.ends_with("types/public.d.ts")); assert!( resolver .resolve_in( &roots, "npm:types-conditions@1/private.d.ts", ResolutionMode::TypeImport ) .is_err() ); } #[test] fn warm_instance_checks_its_own_nested_metadata() { let server = Registry::new(); server.publish_package( "instance-metadata", "1.0.0", serde_json::json!({ "exports": "./lib/index.js", "dependencies": {"instance-metadata": "1.0.0"} }), &[ ("lib/package.json", r#"{"type":"module"}"#), ("lib/index.js", "export default 1;"), ], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); let module = resolve(&resolver, "npm:instance-metadata@1"); resolver.store().save().unwrap(); let metadata = module.path.parent().unwrap().join("package.json"); // Replace only this instance's entry, leaving the shared backing intact. std::fs::remove_file(&metadata).unwrap(); std::fs::write(&metadata, r#"{"type":"commonjs"}"#).unwrap(); let mut options = server.options(dir.path()); options.offline = true; options.frozen = true; let warm = fixture::resolver(options); let error = block_on(warm.resolve_request("npm:instance-metadata@1", ResolutionMode::Import)) .unwrap_err() .to_string(); assert!(error.contains("integrity mismatch"), "{error}"); assert!(error.contains("lib/package.json"), "{error}"); } #[test] fn nested_metadata_is_consumed_only_when_resolution_needs_it() { use avril_runtime::registry::npm::package::NpmModuleKind; let server = Registry::new(); server.publish_package( "lazy-scopes", "1.0.0", serde_json::json!({}), &[ ("broken/package.json", "{oops"), ("broken/mod.js", "export default 1;"), ("broken/mod.mjs", "export default 1;"), ("broken/mod.cjs", "module.exports = 1;"), ("broken/mod.ts", "export default 1;"), ("broken/mod.d.ts", "export declare const x: number;"), ("bad-type/package.json", r#"{"type":"invalid"}"#), ("bad-type/mod.js", "export default 1;"), ], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); let root = resolve(&resolver, "npm:lazy-scopes@1"); for (file, kind) in [ ("mod.mjs", NpmModuleKind::Esm), ("mod.cjs", NpmModuleKind::CommonJs), ("mod.ts", NpmModuleKind::TypeScript), ] { let module = resolver .resolve_from_file( &format!("./broken/{file}"), &root.path, ResolutionMode::Import, ) .unwrap(); assert_eq!(module.format, kind); } assert_eq!( resolver .check_access(&root.path.parent().unwrap().join("broken/mod.d.ts")) .unwrap(), NpmModuleKind::Declaration ); for (path, message) in [ ("./broken/mod.js", "Invalid nested"), ("./bad-type/mod.js", "Unsupported package type"), ] { let error = resolver .resolve_from_file(path, &root.path, ResolutionMode::Import) .unwrap_err(); assert!(error.to_string().contains(message), "{error}"); } let error = resolver .resolve_from_file("./broken", &root.path, ResolutionMode::Require) .unwrap_err(); assert!(error.to_string().contains("Invalid nested"), "{error}"); } #[test] fn export_warming_propagates_required_scope_errors_but_skips_unsupported_files() { let server = Registry::new(); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); for (name, fields) in [ ("legacy-scopes", serde_json::json!({})), ( "mapped-scopes", serde_json::json!({"exports": {"./*": "./*"}}), ), ] { server.publish_package( name, "1.0.0", fields, &[ ("safe/package.json", "{oops"), ("safe/mod.mjs", "export default 1;"), ("safe/addon.node", "unused"), ("broken/package.json", "{oops"), ("broken/mod.js", "export default 1;"), ], ); assert_eq!( block_on(resolver.export_requests(&format!("npm:{name}@1/safe"))).unwrap(), [format!("npm:{name}@1/safe/mod.mjs")] ); let error = block_on(resolver.export_requests(&format!("npm:{name}@1/broken"))).unwrap_err(); assert!(error.to_string().contains("Invalid nested"), "{error}"); } } #[test] fn nested_scopes_share_verified_metadata_and_preserve_type_inheritance() { use avril_runtime::registry::npm::package::NpmModuleKind; let server = Registry::new(); server.publish_package( "scope-snapshot", "1.0.0", serde_json::json!({}), &[ ("cjs/package.json", r#"{"type":"commonjs"}"#), ("cjs/sub/package.json", r#"{"main":"./chosen.js"}"#), ("cjs/sub/chosen.js", "module.exports = 1;"), ("cjs/sub/index.js", "module.exports = 2;"), ("cjs/sub/esm/package.json", r#"{"type":"module"}"#), ("cjs/sub/esm/mod.js", "export default 3;"), ], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); let root = resolve(&resolver, "npm:scope-snapshot@1"); let chosen = resolver .resolve_from_file("./cjs/sub/chosen.js", &root.path, ResolutionMode::Import) .unwrap(); assert_eq!(chosen.format, NpmModuleKind::CommonJs); let esm = resolver .resolve_from_file("./cjs/sub/esm/mod.js", &root.path, ResolutionMode::Import) .unwrap(); assert_eq!(esm.format, NpmModuleKind::Esm); let metadata = chosen.path.parent().unwrap().join("package.json"); std::fs::write(&metadata, r#"{"type":"module","main":"./index.js"}"#).unwrap(); // Already consumed metadata remains one verified snapshot, not new disk input. let directory = resolver .resolve_from_file("./cjs/sub", &root.path, ResolutionMode::Require) .unwrap(); assert_eq!(directory.path, chosen.path); assert_eq!(directory.format, NpmModuleKind::CommonJs); assert!(resolver.audit().is_err()); } #[test] fn unused_nested_metadata_tampering_is_detected_on_use_and_by_audit() { let server = Registry::new(); server.publish_package( "unused-scope", "1.0.0", serde_json::json!({}), &[ ("sub/package.json", r#"{"type":"module"}"#), ("sub/mod.js", "export default 1;"), ], ); let dir = tempfile::tempdir().unwrap(); let first = server.resolver(dir.path()); let root = resolve(&first, "npm:unused-scope@1"); first.store().save().unwrap(); let metadata = root.path.parent().unwrap().join("sub/package.json"); std::fs::write(&metadata, r#"{"type":"commonjs"}"#).unwrap(); let mut options = server.options(dir.path()); options.offline = true; options.frozen = true; let warm = fixture::resolver(options); let root = resolve(&warm, "npm:unused-scope@1"); let error = warm .resolve_from_file("./sub/mod.js", &root.path, ResolutionMode::Import) .unwrap_err(); assert!(error.to_string().contains("integrity mismatch"), "{error}"); assert!(error.to_string().contains("sub/package.json"), "{error}"); assert!(warm.audit().is_err()); } #[test] fn cached_nested_scopes_do_not_bypass_live_authority() { use avril_runtime::caps::{Capabilities, Category, Initial}; let server = Registry::new(); server.publish_package( "scope-authority", "1.0.0", serde_json::json!({}), &[ ("sub/package.json", r#"{"type":"module","main":"./mod.js"}"#), ("sub/mod.js", "export default 1;"), ], ); let dir = tempfile::tempdir().unwrap(); let version_url = format!("{}scope-authority/1.0.0/", server.url); let mut initial = Initial::new(true); initial .grant(Category::JsExec, false, &version_url, dir.path()) .unwrap(); let caps = Capabilities::new(initial, dir.path().to_owned()); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())) .unwrap() .with_capabilities(caps.clone()); let resolver = NpmResolver::new(Arc::new(store)); let root = resolve(&resolver, "npm:scope-authority@1"); resolver .resolve_from_file("./sub", &root.path, ResolutionMode::Require) .unwrap(); caps.pledge(vec![(Category::JsExec, None, vec![version_url])]) .unwrap(); for specifier in ["./sub", "./sub/mod.js"] { let error = resolver .resolve_from_file(specifier, &root.path, ResolutionMode::Require) .unwrap_err(); assert!(error.to_string().contains("PermissionDenied"), "{error}"); } } #[test] fn full_audit_obeys_live_source_authority() { use avril_runtime::caps::{Capabilities, Category, Initial}; let server = Registry::new(); server.publish_package("audit-authority", "1.0.0", serde_json::json!({}), &[]); let dir = tempfile::tempdir().unwrap(); let version_url = format!("{}audit-authority/1.0.0/", server.url); let mut initial = Initial::new(true); initial .grant(Category::JsExec, false, &version_url, dir.path()) .unwrap(); let caps = Capabilities::new(initial, dir.path().to_owned()); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())) .unwrap() .with_capabilities(caps.clone()); let resolver = NpmResolver::new(Arc::new(store)); resolve(&resolver, "npm:audit-authority@1"); resolver.audit().unwrap(); caps.pledge(vec![(Category::JsExec, None, vec![version_url])]) .unwrap(); assert!( resolver .audit() .unwrap_err() .to_string() .contains("PermissionDenied") ); } fn identity(server: &Registry, name: &str, version: &str) -> String { format!("{}|{name}|{version}", server.url.as_str()) } fn resolve(resolver: &NpmResolver, spec: &str) -> ResolvedNpmModule { block_on(resolver.resolve_request(spec, ResolutionMode::Import)).unwrap() } #[test] fn module_entry_hint_is_file_scoped_and_independent_of_resolution_order() { use avril_runtime::registry::npm::package::NpmModuleKind; let server = Registry::new(); for module in ["lib/entry.js", "./lib/entry.js"] { let dir = tempfile::tempdir().unwrap(); server.publish_npm( "dual-entry", "1.0.0", &[ ( "package.json", &serde_json::json!({ "name": "dual-entry", "version": "1.0.0", "module": module, "main": "./lib/main.js" }) .to_string(), ), ("lib/entry.js", "export default 42;"), ("lib/main.js", "module.exports = 7;"), ("lib/sibling.js", "module.exports = 8;"), ], ); let resolver = server.resolver(dir.path()); let direct = resolve(&resolver, "npm:dual-entry@1/lib/entry.js"); assert_eq!(direct.format, NpmModuleKind::Esm); assert_eq!( resolver.check_access(&direct.path).unwrap(), NpmModuleKind::Esm ); let root = resolve(&resolver, "npm:dual-entry@1"); assert_eq!(root.path, direct.path); assert_eq!(root.format, NpmModuleKind::Esm); for mode in [ResolutionMode::Import, ResolutionMode::Require] { let same = resolver .resolve_from_file("./entry.js", &root.path, mode) .unwrap(); assert_eq!(same.path, root.path); assert_eq!(same.format, NpmModuleKind::Esm); let sibling = resolver .resolve_from_file("./sibling.js", &root.path, mode) .unwrap(); assert_eq!(sibling.format, NpmModuleKind::CommonJs); } let main = block_on(resolver.resolve_request("npm:dual-entry@1", ResolutionMode::Require)) .unwrap(); assert!(main.path.ends_with("lib/main.js")); assert_eq!(main.format, NpmModuleKind::CommonJs); } } #[test] fn entry_hints_preserve_explicit_extensions_and_main_package_types() { use avril_runtime::registry::npm::package::NpmModuleKind; let server = Registry::new(); for (fields, file, expected) in [ ( serde_json::json!({"module": "entry.cjs"}), "entry.cjs", NpmModuleKind::CommonJs, ), ( serde_json::json!({"main": "entry.js"}), "entry.js", NpmModuleKind::CommonJs, ), ( serde_json::json!({"main": "entry.js", "type": "module"}), "entry.js", NpmModuleKind::Esm, ), ( serde_json::json!({"main": "entry.mjs"}), "entry.mjs", NpmModuleKind::Esm, ), ( serde_json::json!({"main": "entry.cjs", "type": "module"}), "entry.cjs", NpmModuleKind::CommonJs, ), ] { let dir = tempfile::tempdir().unwrap(); let mut package = serde_json::json!({"name": "entry-format", "version": "1.0.0"}); package .as_object_mut() .unwrap() .extend(fields.as_object().unwrap().clone()); server.publish_npm( "entry-format", "1.0.0", &[("package.json", &package.to_string()), (file, "")], ); let resolver = server.resolver(dir.path()); let entry = resolve(&resolver, "npm:entry-format@1"); assert!(entry.path.ends_with(file)); assert_eq!(entry.format, expected); assert_eq!(resolver.check_access(&entry.path).unwrap(), expected); } } #[test] fn roots_alias_subpaths_share_prepared_packages() { let server = Registry::new(); server.publish_package( "lib", "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(); let resolver = server.resolver(dir.path()); block_on(resolver.prepare(&[NpmRootRequest { request: "npm:lib@^1/sub".into(), context: BTreeMap::new(), }])) .unwrap(); let sub = resolve(&resolver, "npm:lib@^1/sub"); assert!(sub.path.ends_with("sub.js")); let root = resolve(&resolver, "npm:lib@^1"); assert!(root.path.ends_with("mod.js")); assert_eq!(sub.instance, root.instance); } #[test] fn transitive_versions_coexist_per_consumer() { let server = Registry::new(); server.publish_package("util", "1.0.0", serde_json::json!({}), &[]); server.publish_package("util", "2.0.0", serde_json::json!({}), &[]); server.publish_package( "lib", "1.0.0", serde_json::json!({"dependencies": {"util": "^2.0.0"}}), &[("index.js", "export * from 'util';")], ); 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 * from 'util';")], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); block_on(resolver.prepare(&[NpmRootRequest { request: "npm:app@1".into(), context: BTreeMap::new(), }])) .unwrap(); let app = resolve(&resolver, "npm:app@1"); let from_app = resolver .resolve_from_file("util", &app.path, ResolutionMode::Import) .unwrap(); assert!(from_app.path.to_string_lossy().contains("/util/1.0.0/")); let lib = resolver .resolve_from_file("lib", &app.path, ResolutionMode::Import) .unwrap(); let from_lib = resolver .resolve_from_file("util", &lib.path, ResolutionMode::Import) .unwrap(); assert!(from_lib.path.to_string_lossy().contains("/util/2.0.0/")); assert_ne!(from_app.path, from_lib.path); } #[test] fn shared_dependencies_share_instances() { let server = Registry::new(); server.publish_package("shared", "1.0.0", serde_json::json!({}), &[]); server.publish_package( "a", "1.0.0", serde_json::json!({"dependencies": {"shared": "^1.0.0"}}), &[], ); server.publish_package( "b", "1.0.0", serde_json::json!({"dependencies": {"shared": "~1.0.0"}}), &[], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); block_on(resolver.prepare(&[ NpmRootRequest { request: "npm:a@1".into(), context: BTreeMap::new(), }, NpmRootRequest { request: "npm:b@1".into(), context: BTreeMap::new(), }, ])) .unwrap(); let a = resolve(&resolver, "npm:a@1"); let b = resolve(&resolver, "npm:b@1"); let from_a = resolver .resolve_from_file("shared", &a.path, ResolutionMode::Import) .unwrap(); let from_b = resolver .resolve_from_file("shared", &b.path, ResolutionMode::Import) .unwrap(); assert_eq!(from_a.path, from_b.path); assert_eq!(from_a.instance, from_b.instance); } #[test] fn peer_bindings_share_providers_and_ignore_import_order() { let server = Registry::new(); server.publish_package("react", "18.2.0", serde_json::json!({}), &[]); server.publish_package( "plugin", "1.0.0", serde_json::json!({"peerDependencies": {"react": "^18.0.0"}}), &[], ); server.publish_package( "app", "1.0.0", serde_json::json!({"dependencies": {"react": "18.2.0", "plugin": "1.0.0"}}), &[], ); let context = BTreeMap::from([("react".into(), identity(&server, "react", "18.2.0"))]); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); block_on(resolver.prepare(&[ NpmRootRequest { request: "npm:app@1".into(), context: context.clone(), }, NpmRootRequest { request: "npm:plugin@1".into(), context: context.clone(), }, ])) .unwrap(); let app = resolve(&resolver, "npm:app@1"); let plugin = resolve(&resolver, "npm:plugin@1"); let via_app = resolver .resolve_from_file("react", &app.path, ResolutionMode::Import) .unwrap(); let via_plugin = resolver .resolve_from_file("react", &plugin.path, ResolutionMode::Import) .unwrap(); assert_eq!(via_app.path, via_plugin.path); let dir2 = tempfile::tempdir().unwrap(); let reversed = server.resolver(dir2.path()); block_on(reversed.prepare(&[ NpmRootRequest { request: "npm:plugin@1".into(), context: context.clone(), }, NpmRootRequest { request: "npm:app@1".into(), context, }, ])) .unwrap(); let first = resolve(&resolver, "npm:plugin@1"); let second = resolve(&reversed, "npm:plugin@1"); assert_eq!( second.instance, first.instance, "instance ids must not depend on root order" ); } #[test] fn peer_contexts_fork_instances() { let server = Registry::new(); server.publish_package("react", "17.0.0", serde_json::json!({}), &[]); server.publish_package("react", "18.2.0", serde_json::json!({}), &[]); server.publish_package( "plugin", "1.0.0", serde_json::json!({"peerDependencies": {"react": "*"}}), &[], ); server.publish_package( "h17", "1.0.0", serde_json::json!({"dependencies": {"react": "17.0.0", "plugin": "1.0.0"}}), &[], ); server.publish_package( "h18", "1.0.0", serde_json::json!({"dependencies": {"react": "18.2.0", "plugin": "1.0.0"}}), &[], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); block_on(resolver.prepare(&[ NpmRootRequest { request: "npm:h17@1".into(), context: BTreeMap::new(), }, NpmRootRequest { request: "npm:h18@1".into(), context: BTreeMap::new(), }, ])) .unwrap(); let h17 = resolve(&resolver, "npm:h17@1"); let h18 = resolve(&resolver, "npm:h18@1"); let p17 = resolver .resolve_from_file("plugin", &h17.path, ResolutionMode::Import) .unwrap(); let p18 = resolver .resolve_from_file("plugin", &h18.path, ResolutionMode::Import) .unwrap(); assert_ne!(p17.instance, p18.instance); assert_ne!(p17.path, p18.path); let r17 = resolver .resolve_from_file("react", &p17.path, ResolutionMode::Import) .unwrap(); let r18 = resolver .resolve_from_file("react", &p18.path, ResolutionMode::Import) .unwrap(); assert!(r17.path.to_string_lossy().contains("/react/17.0.0/")); assert!(r18.path.to_string_lossy().contains("/react/18.2.0/")); } #[test] fn ordinary_cycles_terminate() { let server = Registry::new(); server.publish_package( "a", "1.0.0", serde_json::json!({"dependencies": {"b": "1.0.0"}}), &[], ); server.publish_package( "b", "1.0.0", serde_json::json!({"dependencies": {"a": "1.0.0"}}), &[], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); block_on(resolver.prepare(&[NpmRootRequest { request: "npm:a@1".into(), context: BTreeMap::new(), }])) .unwrap(); let a = resolve(&resolver, "npm:a@1"); let b = resolver .resolve_from_file("b", &a.path, ResolutionMode::Import) .unwrap(); let back = resolver .resolve_from_file("a", &b.path, ResolutionMode::Import) .unwrap(); assert_eq!(back.path, a.path); } #[test] fn absent_optional_peers_are_skipped() { let server = Registry::new(); server.publish_package( "op", "1.0.0", serde_json::json!({ "peerDependencies": {"ghost": "^1.0.0"}, "peerDependenciesMeta": {"ghost": {"optional": true}}, }), &[], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); block_on(resolver.prepare(&[NpmRootRequest { request: "npm:op@1".into(), context: BTreeMap::new(), }])) .unwrap(); let module = resolve(&resolver, "npm:op@1"); assert_eq!(module.instance, identity(&server, "op", "1.0.0")); } #[test] fn exports_select_exact_patterns_and_arrays() { let server = Registry::new(); server.publish_package( "exp", "1.0.0", serde_json::json!({"exports": { ".": "./mod.js", "./feature": "./feature.js", "./features/*.js": "./mapped/*.js", "./nested": {"avril": "./avril.js", "default": "./default.js"}, "./ordered": [{"avril": "./ordered-avril.js"}, "./ordered-fallback.js"], }}), &[ ("mod.js", "export default 1;"), ("feature.js", "export default 2;"), ("mapped/thing.js", "export default 3;"), ("avril.js", "export default 4;"), ("default.js", "export default 5;"), ("ordered-avril.js", "export default 6;"), ("ordered-fallback.js", "export default 7;"), ], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); block_on(resolver.prepare(&[NpmRootRequest { request: "npm:exp@1".into(), context: BTreeMap::new(), }])) .unwrap(); for (request, file) in [ ("npm:exp@1", "mod.js"), ("npm:exp@1/feature", "feature.js"), ("npm:exp@1/features/thing.js", "mapped/thing.js"), ("npm:exp@1/nested", "avril.js"), ("npm:exp@1/ordered", "ordered-avril.js"), ] { assert!( resolve(&resolver, request).path.ends_with(file), "{request}" ); } assert!( block_on(resolver.resolve_request("npm:exp@1/private", ResolutionMode::Import)) .unwrap_err() .to_string() .contains("no export") ); } #[test] fn null_exports_block_and_mixed_exports_fail() { let server = Registry::new(); server.publish_package( "nulled", "1.0.0", serde_json::json!({"exports": {".": "./mod.js", "./blocked": null}}), &[ ("mod.js", "export default 1;"), ("blocked.js", "export default 2;"), ], ); server.publish_package( "mixed", "1.0.0", serde_json::json!({"exports": {".": "./mod.js", "import": "./mod.js"}}), &[("mod.js", "export default 1;")], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); block_on(resolver.prepare(&[NpmRootRequest { request: "npm:nulled@1".into(), context: BTreeMap::new(), }])) .unwrap(); assert!( block_on(resolver.resolve_request("npm:nulled@1/blocked", ResolutionMode::Import)) .unwrap_err() .to_string() .contains("blocked") ); assert!( block_on(resolver.prepare(&[NpmRootRequest { request: "npm:mixed@1".into(), context: BTreeMap::new(), }])) .unwrap_err() .to_string() .contains("mixes") ); } #[test] fn module_kinds_allow_cjs_for_require_wrappers_but_reject_json_native() { let server = Registry::new(); server.publish_package( "cjs", "1.0.0", serde_json::json!({"type": "commonjs"}), &[("index.js", "module.exports = 1;")], ); server.publish_package( "esm", "1.0.0", serde_json::json!({"type": "module"}), &[("index.js", "export default 1;")], ); server.publish_package( "legacy", "1.0.0", serde_json::json!({"exports": {".": "./main.cjs", "./typed": "./typed.cts"}}), &[ ("main.cjs", "module.exports = 1;"), ("typed.cts", "export default 1;"), ], ); server.publish_package( "data", "1.0.0", serde_json::json!({"exports": {".": "./data.json"}}), &[("data.json", "{}")], ); server.publish_package( "native", "1.0.0", serde_json::json!({"exports": {".": "./binding.node"}}), &[("binding.node", "binary")], ); server.publish_package( "nested", "1.0.0", serde_json::json!({"type": "commonjs"}), &[ ("index.js", "module.exports = 1;"), ("esm/package.json", "{\"type\": \"module\"}"), ("esm/mod.js", "export default 1;"), ], ); server.publish_package( "types", "1.0.0", serde_json::json!({"exports": {".": "./index.d.ts"}}), &[ ("index.js", "export default 0;"), ("index.d.ts", "export declare const x: number;"), ], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); for root in [ "npm:cjs@1", "npm:esm@1", "npm:legacy@1", "npm:data@1", "npm:native@1", "npm:nested@1", "npm:types@1", ] { block_on(resolver.prepare(&[NpmRootRequest { request: root.into(), context: BTreeMap::new(), }])) .unwrap(); } for (specifier, rel) in [ ("npm:cjs@1", "index.js"), ("npm:esm@1", "index.js"), ("npm:legacy@1", "main.cjs"), ("npm:data@1", "data.json"), ] { let path = if specifier == "npm:data@1" { let registry = std::fs::read_dir(dir.path().join(".avril/npm/default")) .unwrap() .next() .unwrap() .unwrap() .path(); registry.join("data/1.0.0/data.json") } else { resolve(&resolver, specifier).path }; assert!(path.ends_with(rel)); let original = std::fs::read(&path).unwrap(); std::fs::write(&path, b"tampered").unwrap(); let error = resolver.npm_source(&path).unwrap_err(); assert!(error.to_string().contains(rel), "{error}"); std::fs::write(path, original).unwrap(); } assert!( block_on(resolver.resolve_request("npm:cjs@1", ResolutionMode::Import)) .unwrap() .format == avril_runtime::registry::npm::package::NpmModuleKind::CommonJs ); assert!( block_on(resolver.resolve_request("npm:legacy@1", ResolutionMode::Import)) .unwrap() .format == avril_runtime::registry::npm::package::NpmModuleKind::CommonJs ); // Explicit `.cts` is CommonJS (transpiled preserving CJS) for `require` // and ESM wrappers, even when its content uses ESM syntax (which then // fails at execution, not resolution). assert!( block_on(resolver.resolve_request("npm:legacy@1/typed", ResolutionMode::Import)) .unwrap() .format == avril_runtime::registry::npm::package::NpmModuleKind::CommonJs ); assert!( block_on(resolver.resolve_request("npm:data@1", ResolutionMode::Import)) .unwrap_err() .to_string() .contains("JSON") ); assert!( block_on(resolver.resolve_request("npm:native@1", ResolutionMode::Import)) .unwrap_err() .to_string() .contains("Native") ); let nested = resolve(&resolver, "npm:nested@1/esm/mod.js"); assert!(nested.path.ends_with("mod.js")); assert!( block_on(resolver.resolve_request("npm:types@1", ResolutionMode::Import)) .unwrap_err() .to_string() .contains("no executable code") ); } #[test] fn resolution_is_confined_to_manifests() { let server = Registry::new(); server.publish_package( "confined", "1.0.0", serde_json::json!({"exports": {".": "./mod.js", "./evil": "./node_modules/x/index.js"}}), &[ ("mod.js", "export default 1;"), ("node_modules/x/index.js", "export default 2;"), ], ); let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("local.js"), "local").unwrap(); let resolver = server.resolver(dir.path()); block_on(resolver.prepare(&[NpmRootRequest { request: "npm:confined@1".into(), context: BTreeMap::new(), }])) .unwrap(); let root = resolve(&resolver, "npm:confined@1"); assert!( resolver .resolve_from_file("../local.js", &root.path, ResolutionMode::Import) .unwrap_err() .to_string() .contains("escapes") ); assert!( block_on(resolver.resolve_request("npm:confined@1/evil", ResolutionMode::Import)) .unwrap_err() .to_string() .contains("escapes") ); assert!( resolver .resolve_from_file("fs", &root.path, ResolutionMode::Import) .unwrap_err() .to_string() .contains("not a declared dependency") ); assert!( resolver .resolve_from_file( "./mod.js", &dir.path().join("local.js"), ResolutionMode::Import ) .unwrap_err() .to_string() .contains("does not own") ); let unowned = dir .path() .join(".avril") .join("npm") .join("phantom") .join("x.js"); assert!(resolver.is_reserved_npm_path(&unowned)); assert!( resolver .resolve_from_file("./mod.js", &unowned, ResolutionMode::Import) .unwrap_err() .to_string() .contains("not owned") ); } #[test] fn frozen_offline_graphs_read_from_locks_and_trees() { let server = Registry::new(); server.publish_package( "fro", "1.0.0", serde_json::json!({}), &[("index.js", "export default 0;")], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); block_on(resolver.prepare(&[NpmRootRequest { request: "npm:fro@1".into(), context: BTreeMap::new(), }])) .unwrap(); resolver.store().save().unwrap(); let lock_before = std::fs::read(dir.path().join("avril.lock")).unwrap(); // Download caches go away; the tree and lock stay. 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(); let warmed = server.requests.lock().unwrap().len(); let mut offline = server.options(dir.path()); offline.offline = true; offline.frozen = true; let store = std::sync::Arc::new(PackageStore::new(fixture::runtime_handle(), offline).unwrap()); let cold = NpmResolver::new(store); block_on(cold.prepare(&[NpmRootRequest { request: "npm:fro@1".into(), context: BTreeMap::new(), }])) .unwrap(); let module = resolve(&cold, "npm:fro@1"); assert_eq!(cold.npm_source(&module.path).unwrap(), "export default 0;"); assert_eq!(server.requests.lock().unwrap().len(), warmed); cold.store().save().unwrap(); assert_eq!( std::fs::read(dir.path().join("avril.lock")).unwrap(), lock_before ); } #[test] fn scoped_instances_and_sibling_prefixes_have_component_ownership() { let server = Registry::new(); server.publish_package("@scope/pkg", "1.0.0", serde_json::json!({}), &[]); server.publish_package("@scope/pkg-sibling", "1.0.0", serde_json::json!({}), &[]); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); let scoped = resolve(&resolver, "npm:@scope/pkg@1"); let sibling = resolve(&resolver, "npm:@scope/pkg-sibling@1"); assert_eq!( resolver .resolve_from_file("./index.js", &scoped.path, ResolutionMode::Import) .unwrap() .instance, scoped.instance ); assert_eq!( resolver .resolve_from_file("./index.js", &sibling.path, ResolutionMode::Import) .unwrap() .instance, sibling.instance ); let lookalike = scoped .path .parent() .unwrap() .with_file_name("1.0.0-sibling") .join("index.js"); assert!(resolver.is_reserved_npm_path(&lookalike)); assert!( resolver .resolve_from_file("./index.js", &lookalike, ResolutionMode::Import) .unwrap_err() .to_string() .contains("not owned") ); } #[test] fn ownership_misses_are_not_cached_before_late_preparation() { let server = Registry::new(); server.publish_package("late-owner", "1.0.0", serde_json::json!({}), &[]); let dir = tempfile::tempdir().unwrap(); let writer = server.resolver(dir.path()); let root = resolve(&writer, "npm:late-owner@1"); writer.store().save().unwrap(); let resolver = server.resolver(dir.path()); assert!( resolver .resolve_from_file("./index.js", &root.path, ResolutionMode::Import) .unwrap_err() .to_string() .contains("not owned") ); block_on(resolver.prepare(&[NpmRootRequest { request: "npm:late-owner@1".into(), context: BTreeMap::new(), }])) .unwrap(); assert_eq!( resolver .resolve_from_file("./index.js", &root.path, ResolutionMode::Import) .unwrap() .instance, root.instance ); } #[test] fn late_roots_never_rebind_evaluated_instances() { let server = Registry::new(); server.publish_package("one", "1.0.0", serde_json::json!({}), &[]); server.publish_package("two", "1.0.0", serde_json::json!({}), &[]); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); block_on(resolver.prepare(&[NpmRootRequest { request: "npm:one@1".into(), context: BTreeMap::new(), }])) .unwrap(); let first = resolve(&resolver, "npm:one@1"); block_on(resolver.prepare(&[ NpmRootRequest { request: "npm:one@1".into(), context: BTreeMap::new(), }, NpmRootRequest { request: "npm:two@1".into(), context: BTreeMap::new(), }, ])) .unwrap(); assert_eq!(resolve(&resolver, "npm:one@1").instance, first.instance); assert_eq!( resolver .resolve_from_file("./index.js", &first.path, ResolutionMode::Import) .unwrap() .instance, first.instance ); } #[test] fn incompatible_existing_peers_are_explicit_errors() { let server = Registry::new(); server.publish_package("react", "17.0.0", serde_json::json!({}), &[]); server.publish_package( "plugin", "1.0.0", serde_json::json!({"peerDependencies": {"react": "^18.0.0"}}), &[], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); let context = BTreeMap::from([("react".into(), identity(&server, "react", "17.0.0"))]); assert!( block_on(resolver.prepare(&[NpmRootRequest { request: "npm:plugin@1".into(), context, }])) .unwrap_err() .to_string() .contains("wants ^18.0.0") ); } #[test] fn platform_gating_applies_to_regular_but_skips_optional() { let server = Registry::new(); server.publish_package( "alien", "1.0.0", serde_json::json!({"os": ["fictitious-os-xyz"]}), &[], ); server.publish_package( "host", "1.0.0", serde_json::json!({"optionalDependencies": {"alien": "^1.0.0"}}), &[], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); assert!( block_on(resolver.prepare(&[NpmRootRequest { request: "npm:alien@1".into(), context: BTreeMap::new(), }])) .unwrap_err() .to_string() .contains("not supported on this platform") ); block_on(resolver.prepare(&[NpmRootRequest { request: "npm:host@1".into(), context: BTreeMap::new(), }])) .unwrap(); let host = resolve(&resolver, "npm:host@1"); assert!( resolver .resolve_from_file("alien", &host.path, ResolutionMode::Import) .unwrap_err() .to_string() .contains("not a declared dependency") ); } #[test] fn dev_dependencies_are_ignored_and_aliases_resolve() { let server = Registry::new(); server.publish_package("other", "2.0.0", serde_json::json!({}), &[]); server.publish_package( "app", "1.0.0", serde_json::json!({ "dependencies": {"real": "npm:other@^2.0.0"}, "devDependencies": {"ghost-dev": "^9.9.9"}, }), &[], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); block_on(resolver.prepare(&[NpmRootRequest { request: "npm:app@1".into(), context: BTreeMap::new(), }])) .unwrap(); let app = resolve(&resolver, "npm:app@1"); let real = resolver .resolve_from_file("real", &app.path, ResolutionMode::Import) .unwrap(); assert!(real.path.to_string_lossy().contains("/other/2.0.0/")); } #[test] fn unsupported_dependency_protocols_are_clear_errors() { let server = Registry::new(); server.publish_package( "weird", "1.0.0", serde_json::json!({"dependencies": {"local": "file:../other"}}), &[], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); assert!( block_on(resolver.prepare(&[NpmRootRequest { request: "npm:weird@1".into(), context: BTreeMap::new(), }])) .unwrap_err() .to_string() .contains("unsupported protocol") ); } #[test] fn frozen_graphs_validate_completeness() { let server = Registry::new(); server.publish_package("whole", "1.0.0", serde_json::json!({}), &[]); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); block_on(resolver.prepare(&[NpmRootRequest { request: "npm:whole@1".into(), context: BTreeMap::new(), }])) .unwrap(); resolver.store().save().unwrap(); let mut lock: serde_json::Value = fixture::read_lock(dir.path()); let instances = lock["npm"]["instances"].as_object_mut().unwrap(); let victim = instances.keys().next().unwrap().clone(); instances.remove(&victim); std::fs::write(dir.path().join("avril.lock"), format!("{lock}\n")).unwrap(); // The pruned lock references an instance that no longer exists. let mut frozen = server.options(dir.path()); frozen.frozen = true; assert!( PackageStore::new(fixture::runtime_handle(), frozen) .map(|_| ()) .unwrap_err() .to_string() .contains("missing npm instance") ); } #[test] fn update_reselects_new_versions() { let server = Registry::new(); server.publish_package("up", "1.0.0", serde_json::json!({}), &[]); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); block_on(resolver.prepare(&[NpmRootRequest { request: "npm:up".into(), context: BTreeMap::new(), }])) .unwrap(); assert!( resolve(&resolver, "npm:up") .path .to_string_lossy() .contains("/up/1.0.0/") ); resolver.store().save().unwrap(); server.publish_package("up", "2.0.0", serde_json::json!({}), &[]); let mut updating = server.options(dir.path()); updating.update = true; let store = std::sync::Arc::new(PackageStore::new(fixture::runtime_handle(), updating).unwrap()); let resolver = NpmResolver::new(store); block_on(resolver.prepare(&[NpmRootRequest { request: "npm:up".into(), context: BTreeMap::new(), }])) .unwrap(); assert!( resolve(&resolver, "npm:up") .path .to_string_lossy() .contains("/up/2.0.0/") ); } #[test] fn nested_relative_imports_resolve_from_the_referrer() { let server = Registry::new(); server.publish_package( "deep", "1.0.0", serde_json::json!({"type": "module"}), &[ ("index.js", "export * from './lib/mod.js';"), ( "lib/mod.js", "export * from './sibling.js'; export * from '../shared.js';", ), ("lib/sibling.js", "export default 1;"), ("shared.js", "export default 2;"), ], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); block_on(resolver.prepare(&[NpmRootRequest { request: "npm:deep@1".into(), context: BTreeMap::new(), }])) .unwrap(); let entry = resolve(&resolver, "npm:deep@1/lib/mod.js"); assert!(entry.path.ends_with("lib/mod.js")); let sibling = resolver .resolve_from_file("./sibling.js", &entry.path, ResolutionMode::Import) .unwrap(); assert!(sibling.path.ends_with("lib/sibling.js")); let shared = resolver .resolve_from_file("../shared.js", &entry.path, ResolutionMode::Import) .unwrap(); assert!(shared.path.ends_with("shared.js")); assert!( resolver .resolve_from_file("../../escape.js", &entry.path, ResolutionMode::Import) .unwrap_err() .to_string() .contains("escapes") ); assert!( resolver .resolve_from_file("..%2fshared.js", &entry.path, ResolutionMode::Import) .unwrap_err() .to_string() .contains("Invalid") ); } #[test] fn external_imports_keep_their_subpaths() { let server = Registry::new(); server.publish_package( "other", "1.0.0", serde_json::json!({"exports": {".": "./mod.js", "./util": "./util.js"}}), &[ ("mod.js", "export default 1;"), ("util.js", "export default 2;"), ], ); server.publish_package( "user", "1.0.0", serde_json::json!({ "dependencies": {"other": "^1.0.0"}, "imports": {"#tool": "other/util"}, }), &[("index.js", "import '#tool';")], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); block_on(resolver.prepare(&[NpmRootRequest { request: "npm:user@1".into(), context: BTreeMap::new(), }])) .unwrap(); let entry = resolve(&resolver, "npm:user@1"); let tool = resolver .resolve_from_file("#tool", &entry.path, ResolutionMode::Import) .unwrap(); assert!(tool.path.ends_with("util.js")); assert!(tool.path.to_string_lossy().contains("/other/1.0.0/")); } #[test] fn conditional_exports_never_serve_unlisted_subpaths() { let server = Registry::new(); server.publish_package( "guarded", "1.0.0", serde_json::json!({"exports": {"import": "./main.js"}}), &[ ("main.js", "export default 1;"), ("private.js", "export default 2;"), ], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); block_on(resolver.prepare(&[NpmRootRequest { request: "npm:guarded@1".into(), context: BTreeMap::new(), }])) .unwrap(); assert!( resolve(&resolver, "npm:guarded@1") .path .ends_with("main.js") ); assert!( block_on(resolver.resolve_request("npm:guarded@1/private", ResolutionMode::Import)) .unwrap_err() .to_string() .contains("no export") ); } #[test] fn concurrent_prepares_share_work_without_deadlock() { let server = Registry::new(); server.publish_package("slow", "1.0.0", serde_json::json!({}), &[]); server.publish_package( "top", "1.0.0", serde_json::json!({"dependencies": {"slow": "^1.0.0"}}), &[], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); let root = |request: &str| NpmRootRequest { request: request.into(), context: BTreeMap::new(), }; // Bounded: a deadlock would trip the timeout instead of hanging the suite. let (sender, receiver) = std::sync::mpsc::channel(); std::thread::scope(|scope| { scope.spawn(|| { let pair = block_on(futures::future::join( resolver.prepare(&[root("npm:top@1")]), resolver.prepare(&[root("npm:slow@1")]), )); let _ = sender.send(pair); }); let (first, second) = receiver .recv_timeout(std::time::Duration::from_secs(60)) .unwrap(); first.unwrap(); second.unwrap(); }); for request in ["npm:top@1", "npm:slow@1"] { let module = resolve(&resolver, request); assert!(resolver.npm_source(&module.path).is_ok()); } } #[test] fn peerless_wrappers_fork_across_host_contexts() { let server = Registry::new(); server.publish_package("react", "17.0.0", serde_json::json!({}), &[]); server.publish_package("react", "18.2.0", serde_json::json!({}), &[]); server.publish_package( "plugin", "1.0.0", serde_json::json!({"peerDependencies": {"react": "*"}}), &[], ); server.publish_package( "wrapper", "1.0.0", serde_json::json!({"dependencies": {"plugin": "1.0.0"}}), &[], ); server.publish_package( "h17", "1.0.0", serde_json::json!({"dependencies": {"react": "17.0.0", "wrapper": "1.0.0"}}), &[], ); server.publish_package( "h18", "1.0.0", serde_json::json!({"dependencies": {"react": "18.2.0", "wrapper": "1.0.0"}}), &[], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); block_on(resolver.prepare(&[ NpmRootRequest { request: "npm:h17@1".into(), context: BTreeMap::new(), }, NpmRootRequest { request: "npm:h18@1".into(), context: BTreeMap::new(), }, ])) .unwrap(); let h17 = resolve(&resolver, "npm:h17@1"); let h18 = resolve(&resolver, "npm:h18@1"); let w17 = resolver .resolve_from_file("wrapper", &h17.path, ResolutionMode::Import) .unwrap(); let w18 = resolver .resolve_from_file("wrapper", &h18.path, ResolutionMode::Import) .unwrap(); assert_ne!( w17.instance, w18.instance, "wrapper contexts must not share" ); assert_ne!(w17.path, w18.path); } #[test] fn peer_cycles_terminate() { let server = Registry::new(); server.publish_package( "pa", "1.0.0", serde_json::json!({"peerDependencies": {"pb": "^1.0.0"}}), &[], ); server.publish_package( "pb", "1.0.0", serde_json::json!({"peerDependencies": {"pa": "^1.0.0"}}), &[], ); server.publish_package( "top", "1.0.0", serde_json::json!({"dependencies": {"pa": "1.0.0", "pb": "1.0.0"}}), &[], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); block_on(resolver.prepare(&[NpmRootRequest { request: "npm:top@1".into(), context: BTreeMap::new(), }])) .unwrap(); let top = resolve(&resolver, "npm:top@1"); let pa = resolver .resolve_from_file("pa", &top.path, ResolutionMode::Import) .unwrap(); let pb = resolver .resolve_from_file("pb", &top.path, ResolutionMode::Import) .unwrap(); assert_ne!(pa.path, pb.path); assert!(resolver.npm_source(&pa.path).is_ok()); assert!(resolver.npm_source(&pb.path).is_ok()); } #[test] fn missing_deep_frozen_instances_are_errors() { let server = Registry::new(); server.publish_package( "leaf", "1.0.0", serde_json::json!({"peerDependencies": {"react": "^18.0.0"}}), &[], ); server.publish_package("react", "18.2.0", serde_json::json!({}), &[]); server.publish_package( "mid", "1.0.0", serde_json::json!({"dependencies": {"leaf": "^1.0.0", "react": "18.2.0"}}), &[], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); block_on(resolver.prepare(&[NpmRootRequest { request: "npm:mid@1".into(), context: BTreeMap::new(), }])) .unwrap(); resolver.store().save().unwrap(); let mut lock: serde_json::Value = fixture::read_lock(dir.path()); let root_targets: Vec = lock["npm"]["roots"] .as_object() .unwrap() .values() .map(|value| value.as_str().unwrap().to_owned()) .collect(); let victim = lock["npm"]["instances"] .as_object() .unwrap() .keys() .find(|key| !root_targets.contains(key)) .cloned() .unwrap(); lock["npm"]["instances"] .as_object_mut() .unwrap() .remove(&victim); std::fs::write(dir.path().join("avril.lock"), format!("{lock}\n")).unwrap(); // Dangling instance edges are rejected when the frozen lock opens, so a // frozen prepare can never run on a partial graph. let mut frozen = server.options(dir.path()); frozen.frozen = true; assert!( PackageStore::new(fixture::runtime_handle(), frozen) .map(|_| ()) .unwrap_err() .to_string() .contains("missing npm instance") ); } #[test] fn updated_dependency_versions_reselect_without_conflicts() { let server = Registry::new(); server.publish_package("dep", "1.0.0", serde_json::json!({}), &[]); server.publish_package( "app", "1.0.0", serde_json::json!({"dependencies": {"dep": ">=1.0.0 <3.0.0"}}), &[], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); block_on(resolver.prepare(&[NpmRootRequest { request: "npm:app@1".into(), context: BTreeMap::new(), }])) .unwrap(); let before = resolve(&resolver, "npm:app@1"); resolver.store().save().unwrap(); server.publish_package("dep", "2.0.0", serde_json::json!({}), &[]); let mut updating = server.options(dir.path()); updating.update = true; let store = std::sync::Arc::new(PackageStore::new(fixture::runtime_handle(), updating).unwrap()); let resolver = NpmResolver::new(store); block_on(resolver.prepare(&[NpmRootRequest { request: "npm:app@1".into(), context: BTreeMap::new(), }])) .unwrap(); let after = resolve(&resolver, "npm:app@1"); assert_ne!(before.instance, after.instance); resolver.store().save().unwrap(); } #[test] fn preselected_providers_win_regardless_of_root_order() { let server = Registry::new(); server.publish_package("host", "1.0.0", serde_json::json!({}), &[]); server.publish_package("host", "1.1.0", serde_json::json!({}), &[]); server.publish_package( "plugin", "1.0.0", serde_json::json!({"peerDependencies": {"host": "^1.0.0"}}), &[], ); for order in [false, true] { let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); let mut roots = vec![ NpmRootRequest { request: "npm:host@1.0.0".into(), context: BTreeMap::new(), }, NpmRootRequest { request: "npm:plugin@^1".into(), context: BTreeMap::new(), }, ]; if order { roots.reverse(); } block_on(resolver.prepare(&roots)).unwrap(); let plugin = resolve(&resolver, "npm:plugin@^1"); let host = resolver .resolve_from_file("host", &plugin.path, ResolutionMode::Import) .unwrap(); assert!( host.path.to_string_lossy().contains("/host/1.0.0/"), "order {order}: plugin must bind the preselected host 1.0.0" ); } // Without the host root the same plugin floats to latest instead. let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); block_on(resolver.prepare(&[NpmRootRequest { request: "npm:plugin@^1".into(), context: BTreeMap::new(), }])) .unwrap(); let plugin = resolve(&resolver, "npm:plugin@^1"); let host = resolver .resolve_from_file("host", &plugin.path, ResolutionMode::Import) .unwrap(); assert!(host.path.to_string_lossy().contains("/host/1.1.0/")); } #[test] fn cyclic_identities_ignore_unrelated_roots_and_batches() { let server = Registry::new(); server.publish_package( "ca", "1.0.0", serde_json::json!({"dependencies": {"cb": "1.0.0"}}), &[], ); server.publish_package( "cb", "1.0.0", serde_json::json!({"dependencies": {"ca": "1.0.0"}}), &[], ); server.publish_package("solo", "1.0.0", serde_json::json!({}), &[]); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); let alone = block_on(resolver.prepare(&[NpmRootRequest { request: "npm:ca@1".into(), context: BTreeMap::new(), }])) .unwrap(); let with_other = block_on(resolver.prepare(&[NpmRootRequest { request: "npm:solo@1".into(), context: BTreeMap::new(), }])) .unwrap(); assert_eq!( alone.roots.values().next().unwrap(), &resolve(&resolver, "npm:ca@1").instance, "re-preparing alongside an unrelated root must not rebind" ); assert!(with_other.roots.len() == 1); let dir = tempfile::tempdir().unwrap(); let fresh = server.resolver(dir.path()); block_on(fresh.prepare(&[ NpmRootRequest { request: "npm:solo@1".into(), context: BTreeMap::new(), }, NpmRootRequest { request: "npm:ca@1".into(), context: BTreeMap::new(), }, ])) .unwrap(); assert_eq!( resolve(&fresh, "npm:ca@1").instance, resolve(&resolver, "npm:ca@1").instance, "cycle identities are batch independent" ); } #[test] fn same_request_in_two_contexts_resolves_through_handles() { let server = Registry::new(); server.publish_package("host", "1.0.0", serde_json::json!({}), &[]); server.publish_package("host", "2.0.0", serde_json::json!({}), &[]); server.publish_package( "plugin", "1.0.0", serde_json::json!({"peerDependencies": {"host": "*"}}), &[], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); let handle_a = block_on(resolver.prepare(&[NpmRootRequest { request: "npm:plugin@1".into(), context: BTreeMap::from([("host".into(), identity(&server, "host", "1.0.0"))]), }])) .unwrap(); let handle_b = block_on(resolver.prepare(&[NpmRootRequest { request: "npm:plugin@1".into(), context: BTreeMap::from([("host".into(), identity(&server, "host", "2.0.0"))]), }])) .unwrap(); let a = resolver .resolve_in(&handle_a, "npm:plugin@1", ResolutionMode::Import) .unwrap(); let b = resolver .resolve_in(&handle_b, "npm:plugin@1", ResolutionMode::Import) .unwrap(); assert_ne!(a.instance, b.instance); assert_ne!(a.path, b.path); assert_eq!( resolver .resolve_in(&handle_a, "npm:plugin@1", ResolutionMode::Import) .unwrap() .path, a.path ); // A bare request across both contexts stays ambiguous instead of // silently picking one. assert!( block_on(resolver.resolve_request("npm:plugin@1", ResolutionMode::Import)) .unwrap_err() .to_string() .contains("several peer contexts") ); } #[test] fn missing_roots_rebuild_from_specifier_pins() { let server = Registry::new(); server.publish_package( "re", "3.0.0", serde_json::json!({}), &[("index.js", "export default 0;")], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); block_on(resolver.prepare(&[NpmRootRequest { request: "npm:re@3".into(), context: BTreeMap::new(), }])) .unwrap(); resolver.store().save().unwrap(); let mut lock: serde_json::Value = fixture::read_lock(dir.path()); lock["npm"]["roots"].as_object_mut().unwrap().clear(); lock["npm"]["instances"].as_object_mut().unwrap().clear(); std::fs::write(dir.path().join("avril.lock"), format!("{lock}\n")).unwrap(); // Specifier and artifact pins survive; the root entry does not. let store = std::sync::Arc::new( PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(), ); let rebuilt = NpmResolver::new(store); block_on(rebuilt.prepare(&[NpmRootRequest { request: "npm:re@3".into(), context: BTreeMap::new(), }])) .unwrap(); let module = resolve(&rebuilt, "npm:re@3"); assert_eq!( rebuilt.npm_source(&module.path).unwrap(), "export default 0;" ); } #[test] fn new_contexts_cannot_overwrite_locked_instance_bindings() { let server = Registry::new(); server.publish_package("left", "1.0.0", serde_json::json!({}), &[]); server.publish_package("right", "1.0.0", serde_json::json!({}), &[]); server.publish_package( "top", "1.0.0", serde_json::json!({"dependencies": {"left": "1.0.0"}}), &[], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); block_on(resolver.prepare(&[ NpmRootRequest { request: "npm:top@1".into(), context: BTreeMap::new(), }, NpmRootRequest { request: "npm:right@1".into(), context: BTreeMap::new(), }, ])) .unwrap(); resolver.store().save().unwrap(); let mut lock: serde_json::Value = fixture::read_lock(dir.path()); let right = identity(&server, "right", "1.0.0"); let instances = lock["npm"]["instances"].as_object_mut().unwrap(); let top_key = instances .iter() .find(|(_, pin)| { pin["package"] .as_str() .is_some_and(|id| id.contains("|top|")) }) .map(|(key, _)| key.clone()) .unwrap(); instances[top_key.as_str()]["deps"]["left"] = serde_json::Value::String(right); std::fs::write(dir.path().join("avril.lock"), format!("{lock}\n")).unwrap(); let store = std::sync::Arc::new( PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(), ); let tampered = NpmResolver::new(store); // This root-only context was never recorded. Its new resolution must not // overwrite an instance already assigned different authoritative bindings. assert!( block_on(tampered.prepare(&[NpmRootRequest { request: "npm:top@1".into(), context: BTreeMap::new(), }])) .unwrap_err() .to_string() .contains("changed") ); } #[test] fn failed_prepares_leave_retryable_state() { let server = Registry::new(); server.publish_package( "flaky", "1.0.0", serde_json::json!({"dependencies": {"ghost": "^9.9.9"}}), &[], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); assert!( block_on(resolver.prepare(&[NpmRootRequest { request: "npm:flaky@1".into(), context: BTreeMap::new(), }])) .is_err() ); assert!(resolver.owning_instance(&dir.path().join("x.js")).is_none()); server.publish_package("ghost", "9.9.9", serde_json::json!({}), &[]); block_on(resolver.prepare(&[NpmRootRequest { request: "npm:flaky@1".into(), context: BTreeMap::new(), }])) .unwrap(); let module = resolve(&resolver, "npm:flaky@1"); assert!(resolver.npm_source(&module.path).is_ok()); } #[test] fn malformed_peer_ranges_are_errors() { let server = Registry::new(); server.publish_package( "bad", "1.0.0", serde_json::json!({"peerDependencies": {"ghost": "^^invalid^^"}}), &[], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); assert!( block_on(resolver.prepare(&[NpmRootRequest { request: "npm:bad@1".into(), context: BTreeMap::new(), }])) .unwrap_err() .to_string() .contains("Invalid") ); } #[test] fn frozen_save_graph_writes_nothing() { let server = Registry::new(); server.publish_package("sg", "1.0.0", serde_json::json!({}), &[]); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); block_on(resolver.prepare(&[NpmRootRequest { request: "npm:sg@1".into(), context: BTreeMap::new(), }])) .unwrap(); resolver.store().save().unwrap(); let lock_before = std::fs::read(dir.path().join("avril.lock")).unwrap(); let mut frozen = server.options(dir.path()); frozen.frozen = true; let store = std::sync::Arc::new(PackageStore::new(fixture::runtime_handle(), frozen).unwrap()); store .save_graph( &std::collections::BTreeSet::from(["npm:sg@1".to_owned()]), &std::collections::HashSet::new(), ) .unwrap(); assert_eq!( std::fs::read(dir.path().join("avril.lock")).unwrap(), lock_before ); let _ = resolver; } #[test] fn frozen_new_roots_are_missing_without_network() { let server = Registry::new(); server.publish_package("known", "1.0.0", serde_json::json!({}), &[]); server.publish_package("fresh", "1.0.0", serde_json::json!({}), &[]); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); block_on(resolver.prepare(&[NpmRootRequest { request: "npm:known@1".into(), context: BTreeMap::new(), }])) .unwrap(); resolver.store().save().unwrap(); let requests = server.requests.lock().unwrap().len(); let mut frozen = server.options(dir.path()); frozen.frozen = true; let store = std::sync::Arc::new(PackageStore::new(fixture::runtime_handle(), frozen).unwrap()); let frozen_resolver = NpmResolver::new(store); assert!( block_on(frozen_resolver.prepare(&[NpmRootRequest { request: "npm:fresh@1".into(), context: BTreeMap::new(), }])) .unwrap_err() .to_string() .contains("Frozen lockfile is missing") ); assert_eq!(server.requests.lock().unwrap().len(), requests); } #[test] fn failed_contexts_leave_no_lock_pollution() { let server = Registry::new(); server.publish_package("host", "1.0.0", serde_json::json!({}), &[]); server.publish_package( "plugin", "1.0.0", serde_json::json!({"peerDependencies": {"host": "^1.0.0"}}), &[], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); let bad = BTreeMap::from([("host".into(), "http://127.0.0.1:9/|host|9.9.9".into())]); assert!( block_on(resolver.prepare(&[NpmRootRequest { request: "npm:plugin@1".into(), context: bad.clone(), }])) .is_err() ); let good = BTreeMap::from([("host".into(), identity(&server, "host", "1.0.0"))]); block_on(resolver.prepare(&[NpmRootRequest { request: "npm:plugin@1".into(), context: good, }])) .unwrap(); resolver.store().save().unwrap(); let lock = std::fs::read_to_string(dir.path().join("avril.lock")).unwrap(); assert!(!lock.contains("9.9.9"), "{lock}"); assert!(!lock.contains("127.0.0.1:9"), "{lock}"); } #[test] fn same_plugin_shares_host_across_matching_contexts() { let server = Registry::new(); server.publish_package("host", "1.0.0", serde_json::json!({}), &[]); server.publish_package( "plugin", "1.0.0", serde_json::json!({"peerDependencies": {"host": "^1.0.0"}}), &[], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); let context = BTreeMap::from([("host".into(), identity(&server, "host", "1.0.0"))]); let first = block_on(resolver.prepare(&[NpmRootRequest { request: "npm:plugin@1".into(), context: context.clone(), }])) .unwrap(); let second = block_on(resolver.prepare(&[NpmRootRequest { request: "npm:plugin@1".into(), context, }])) .unwrap(); assert_eq!(first.roots, second.roots); } #[test] fn concurrent_selects_share_one_identity() { let server = Registry::new(); server.publish_package("race", "1.2.0", serde_json::json!({}), &[]); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); let (a, b) = block_on(futures::future::join( resolver.prepare(&[NpmRootRequest { request: "npm:race@^1".into(), context: BTreeMap::new(), }]), resolver.prepare(&[NpmRootRequest { request: "npm:race@~1.2.0".into(), context: BTreeMap::new(), }]), )); a.unwrap(); b.unwrap(); let first = resolve(&resolver, "npm:race@^1"); let second = resolve(&resolver, "npm:race@~1.2.0"); assert_eq!(first.path, second.path); assert_eq!(first.instance, second.instance); } #[test] fn late_dynamic_roots_reuse_prepared_namespaces() { 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';")], ); server.publish_package( "extra", "1.0.0", serde_json::json!({"dependencies": {"plug": "^1.0.0"}}), &[("index.js", "export * from 'plug';")], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); block_on(resolver.prepare(&[NpmRootRequest { request: "npm:plug@1".into(), context: BTreeMap::new(), }])) .unwrap(); let before = resolve(&resolver, "npm:plug@1"); block_on(resolver.prepare(&[NpmRootRequest { request: "npm:extra@1".into(), context: BTreeMap::new(), }])) .unwrap(); let after = resolve(&resolver, "npm:plug@1"); assert_eq!(before.instance, after.instance); assert_eq!(before.path, after.path); let via_extra = resolve(&resolver, "npm:extra@1"); assert!(resolver.npm_source(&via_extra.path).is_ok()); } fn export_requests(resolver: &NpmResolver, specifier: &str) -> Vec { block_on(resolver.export_requests(specifier)).unwrap() } #[test] fn export_requests_covers_string_conditional_and_legacy_roots() { let server = Registry::new(); server.publish_package( "str", "1.0.0", serde_json::json!({"exports": "./index.js"}), &[("index.js", "export default 1;")], ); server.publish_package( "cond", "1.0.0", serde_json::json!({"exports": {"require": "./c.cjs", "import": "./e.mjs"}}), &[ ("e.mjs", "export default 1;"), ("c.cjs", "module.exports = 1;"), ], ); server.publish_package( "leg", "1.0.0", serde_json::json!({}), &[("index.js", "export default 1;")], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); // Range forms, so frozen runs pin the same roots the runtime prepares. assert_eq!(export_requests(&resolver, "npm:str@1"), ["npm:str@1"]); assert_eq!(export_requests(&resolver, "npm:cond@1"), ["npm:cond@1"]); assert_eq!(export_requests(&resolver, "npm:leg@1"), ["npm:leg@1"]); // The root selects the ESM branch, never the CommonJS one. let root = resolve(&resolver, "npm:cond@1"); assert!(root.path.to_string_lossy().ends_with("e.mjs")); } #[test] fn export_requests_inverts_wildcards_to_public_subpaths() { let server = Registry::new(); server.publish_package( "wild", "1.0.0", serde_json::json!({"exports": { ".": "./index.js", "./feature/*": "./dist/*.js", "./blocked": null, "./cjs": "./c.cjs", }}), &[ ("index.js", "export default 0;"), ("dist/foo.js", "export default 1;"), ("dist/bar.js", "export default 2;"), ("feature/foo.js", "export default 'private';"), ("c.cjs", "module.exports = 3;"), ], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); let requests = export_requests(&resolver, "npm:wild@1"); // CommonJS is warmable now that ESM wrappers support it. assert_eq!( requests, [ "npm:wild@1", "npm:wild@1/cjs", "npm:wild@1/feature/bar", "npm:wild@1/feature/foo" ] ); // The public subpath resolves through the pattern, not the private file. let feature = block_on(resolver.resolve_request("npm:wild@1/feature/foo", ResolutionMode::Import)) .unwrap(); assert!(feature.path.to_string_lossy().ends_with("dist/foo.js")); } #[test] fn export_requests_respects_scopes_and_condition_arrays() { let server = Registry::new(); server.publish_package( "scoped", "1.0.0", serde_json::json!({"exports": { ".": "./index.js", "./sub": "./sub.js", "./sub/deep": {"import": [null, "./deep.mjs"], "default": "./deep.cjs"}, "./other": "./other.js", }}), &[ ("index.js", "export default 0;"), ("sub.js", "export default 1;"), ("deep.mjs", "export default 2;"), ("other.js", "export default 3;"), ], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); assert_eq!( export_requests(&resolver, "npm:scoped@1/sub/"), ["npm:scoped@1/sub", "npm:scoped@1/sub/deep"] ); // A missing fallback entry does not fail the array; the ESM hit wins. let deep = block_on(resolver.resolve_request("npm:scoped@1/sub/deep", ResolutionMode::Import)) .unwrap(); assert!(deep.path.to_string_lossy().ends_with("deep.mjs")); } fn tarball_path(name: &str, version: &str) -> String { let leaf = name.rsplit('/').next().unwrap(); format!("/{name}/-/{leaf}-{version}.tgz") } fn wait_for_requests(server: &Registry, want: &[String], since: usize) { use std::sync::atomic::Ordering; use std::time::{Duration, Instant}; let deadline = Instant::now() + Duration::from_secs(15); loop { let logged = server.requests.lock().unwrap(); if want.iter().all(|path| logged[since..].contains(path)) { return; } drop(logged); if Instant::now() > deadline { server.hold_tarballs.store(false, Ordering::Relaxed); server.hold_paths.lock().unwrap().clear(); panic!("timed out waiting for requests {want:?}"); } std::thread::sleep(Duration::from_millis(10)); } } fn wait_for_responses(server: &Registry, want: &[String], since: usize) { use std::sync::atomic::Ordering; use std::time::{Duration, Instant}; let deadline = Instant::now() + Duration::from_secs(15); loop { let done = server.responses.lock().unwrap(); if want.iter().all(|path| done[since..].contains(path)) { return; } drop(done); if Instant::now() > deadline { server.hold_tarballs.store(false, Ordering::Relaxed); server.hold_paths.lock().unwrap().clear(); panic!("timed out waiting for responses {want:?}"); } std::thread::sleep(Duration::from_millis(10)); } } fn wait_for_materialized(events: &Arc>>, want: &str) { use std::time::{Duration, Instant}; let deadline = Instant::now() + Duration::from_secs(15); loop { if events.lock().unwrap().iter().any(|event| event == want) { return; } if Instant::now() > deadline { panic!("timed out waiting for materialized {want:?}"); } std::thread::sleep(Duration::from_millis(10)); } } #[test] fn dependency_archives_download_concurrently() { use std::sync::atomic::Ordering; let server = Registry::new(); server.concurrent.store(true, Ordering::Relaxed); server.publish_package( "app", "1.0.0", serde_json::json!({"dependencies": {"left": "^1.0.0", "right": "^1.0.0"}}), &[("index.js", "export default 0;")], ); for name in ["left", "right"] { server.publish_package( name, "1.0.0", serde_json::json!({}), &[("index.js", "export default 0;")], ); } let held = [ tarball_path("left", "1.0.0"), tarball_path("right", "1.0.0"), ]; server .hold_paths .lock() .unwrap() .extend(held.iter().cloned()); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); let handle = std::thread::spawn(move || { block_on(resolver.prepare(&[NpmRootRequest { request: "npm:app@1".into(), context: BTreeMap::new(), }])) }); wait_for_requests(&server, &held, 0); assert!( !handle.is_finished(), "both archives must be in flight at once" ); assert!( server.max_tarballs.load(Ordering::SeqCst) >= 2, "downloads must overlap" ); server.hold_paths.lock().unwrap().clear(); handle.join().unwrap().unwrap(); } #[test] fn wide_graphs_stay_within_the_download_bound() { use std::sync::atomic::Ordering; let server = Registry::new(); server.concurrent.store(true, Ordering::Relaxed); let mut deps = serde_json::Map::new(); for index in 0..20 { let name = format!("leaf{index:02}"); deps.insert(name.clone(), serde_json::Value::String("^1.0.0".into())); server.publish_package( &name, "1.0.0", serde_json::json!({}), &[("index.js", "export default 0;")], ); } server.publish_package( "app1", "1.0.0", serde_json::json!({"dependencies": serde_json::Value::Object(deps.clone())}), &[("index.js", "export default 0;")], ); let mut deps2 = serde_json::Map::new(); for index in 20..30 { let name = format!("leaf{index:02}"); deps2.insert(name.clone(), serde_json::Value::String("^1.0.0".into())); server.publish_package( &name, "1.0.0", serde_json::json!({}), &[("index.js", "export default 0;")], ); } server.publish_package( "app2", "1.0.0", serde_json::json!({"dependencies": serde_json::Value::Object(deps2)}), &[("index.js", "export default 0;")], ); let dir = tempfile::tempdir().unwrap(); let store = Arc::new(PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap()); let first = NpmResolver::new(store.clone()); let second = NpmResolver::new(store); let hold_all: Vec = (0..30) .map(|index| tarball_path(&format!("leaf{index:02}"), "1.0.0")) .collect(); server .hold_paths .lock() .unwrap() .extend(hold_all.iter().cloned()); let prepare = |resolver: NpmResolver, request: &str| { let request = request.to_owned(); std::thread::spawn(move || { block_on(resolver.prepare(&[NpmRootRequest { request, context: BTreeMap::new(), }])) }) }; let first_handle = prepare(first, "npm:app1@1"); let second_handle = prepare(second, "npm:app2@1"); { use std::time::{Duration, Instant}; let deadline = Instant::now() + Duration::from_secs(15); while server.active_tarballs.load(Ordering::SeqCst) < 8 { if Instant::now() > deadline { server.hold_paths.lock().unwrap().clear(); panic!("parallel downloads never reached the bound"); } std::thread::sleep(Duration::from_millis(10)); } } let max = server.max_tarballs.load(Ordering::SeqCst); assert!(max > 1, "downloads must overlap: {max}"); assert!(max <= 8, "downloads must stay bounded: {max}"); server.hold_paths.lock().unwrap().clear(); first_handle.join().unwrap().unwrap(); second_handle.join().unwrap().unwrap(); assert!( server.max_tarballs.load(Ordering::SeqCst) <= 8, "bound holds through completion" ); } #[test] fn same_name_aliases_resolve_independently_of_sibling_timing() { use std::sync::atomic::Ordering; let server = Registry::new(); server.concurrent.store(true, Ordering::Relaxed); for version in ["1.0.0", "2.0.0"] { server.publish_package( "foo", version, serde_json::json!({}), &[("index.js", "export default 0;")], ); } for name in ["x", "y"] { server.publish_package( name, "1.0.0", serde_json::json!({}), &[("index.js", "export default 0;")], ); } server.publish_package( "app", "1.0.0", serde_json::json!({"dependencies": { "a": "npm:foo@^1.0.0", "b": "npm:foo@>=1.0.0 <3.0.0", "x": "npm:x@1", "y": "npm:y@1", }}), &[("index.js", "export default 0;")], ); let run = |held: &str| { use avril_runtime::registry::ProgressEvent; let since = server.requests.lock().unwrap().len(); let dir = tempfile::tempdir().unwrap(); let materialized = Arc::new(std::sync::Mutex::new(Vec::::new())); let sink_materialized = materialized.clone(); let store = Arc::new( PackageStore::new(fixture::runtime_handle(), server.options(dir.path())) .unwrap() .with_progress(Arc::new(move |event| { if let ProgressEvent::NpmMaterialized { name, version } = event { sink_materialized .lock() .unwrap() .push(format!("{name}@{version}")); } })), ); let resolver = NpmResolver::new(store.clone()); server .hold_paths .lock() .unwrap() .insert(tarball_path(held, "1.0.0")); let handle = std::thread::spawn(move || { block_on(resolver.prepare(&[NpmRootRequest { request: "npm:app@1".into(), context: BTreeMap::new(), }])) }); let other = if held == "x" { "y" } else { "x" }; wait_for_requests(&server, &[tarball_path(held, "1.0.0")], since); wait_for_materialized(&materialized, &format!("{other}@1.0.0")); server.hold_paths.lock().unwrap().clear(); handle.join().unwrap().unwrap(); store.save().unwrap(); let lock = fixture::read_lock(dir.path()); assert_eq!(lock["npm"]["specifiers"]["npm:foo@^1.0.0"], "1.0.0"); assert_eq!(lock["npm"]["specifiers"]["npm:foo@>=1.0.0 <3.0.0"], "1.0.0"); lock }; let first = run("x"); let second = run("y"); assert_eq!(first, second); } #[test] fn sibling_failures_report_the_first_request_and_retry_cleanly() { use std::sync::atomic::Ordering; let server = Registry::new(); server.concurrent.store(true, Ordering::Relaxed); for name in ["bad1", "bad2", "good"] { server.publish_package( name, "1.0.0", serde_json::json!({}), &[("index.js", "export default 0;")], ); } let bad2_tarball = tarball_path("bad2", "1.0.0"); let bad1_tarball = tarball_path("bad1", "1.0.0"); let good_tarball = tarball_path("good", "1.0.0"); let bad2_original = server.files.lock().unwrap()[&bad2_tarball].clone(); server.files.lock().unwrap().remove(&bad1_tarball); server .files .lock() .unwrap() .insert(bad2_tarball.clone(), b"not gzip".to_vec()); server .hold_paths .lock() .unwrap() .insert(bad1_tarball.clone()); server.publish_package( "app", "1.0.0", serde_json::json!({"dependencies": {"abad": "npm:bad1@1", "bads": "npm:bad2@1", "fine": "npm:good@1"}}), &[ ("index.js", "export default 0;"), ], ); let dir = tempfile::tempdir().unwrap(); let store = Arc::new(PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap()); let resolver = Arc::new(NpmResolver::new(store)); let logged = server.requests.lock().unwrap().len(); let responded = server.responses.lock().unwrap().len(); let worker = resolver.clone(); let handle = std::thread::spawn(move || { block_on(worker.prepare(&[NpmRootRequest { request: "npm:app@1".into(), context: BTreeMap::new(), }])) }); wait_for_requests(&server, std::slice::from_ref(&bad1_tarball), logged); wait_for_responses(&server, &[bad2_tarball.clone(), good_tarball], responded); server.hold_paths.lock().unwrap().clear(); let error = handle.join().unwrap().unwrap_err().to_string(); assert!( error.contains("bad1"), "first input error must win: {error:?}" ); server .files .lock() .unwrap() .insert(bad2_tarball, bad2_original); server.publish_package( "bad1", "1.0.0", serde_json::json!({}), &[("index.js", "export default 0;")], ); let handle = block_on(resolver.prepare(&[NpmRootRequest { request: "npm:app@1".into(), context: BTreeMap::new(), }])) .unwrap(); assert!(!handle.roots.is_empty(), "retry must establish roots"); } #[test] fn export_requests_propagates_resolution_errors() { let server = Registry::new(); server.publish_package( "gone", "1.0.0", serde_json::json!({"exports": {".": "./missing.js"}}), &[("index.js", "export default 0;")], ); let dir = tempfile::tempdir().unwrap(); let resolver = server.resolver(dir.path()); block_on(resolver.export_requests("npm:gone@1")).unwrap_err(); }