#[path = "common/registry.rs"] mod fixture; use std::fs; use std::path::Path; use std::process::{Command, Output}; use fixture::Registry; use serde_json::json; fn run(server: &Registry, directory: &Path, args: &[&str]) -> Output { let options = server.options(directory); Command::new(env!("CARGO_BIN_EXE_avril")) .current_dir(directory) // Options alone run the entry; a leading command selects another. .args( args.first() .is_none_or(|arg| arg.starts_with('-')) .then_some("run"), ) .args(args) .arg(format!("--jsr-registry={}", options.jsr_registry)) .arg(format!("--cache-dir={}", options.cache.display())) .arg("main.ts") .output() .unwrap() } fn success(output: &Output) -> String { assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); String::from_utf8(output.stdout.clone()).unwrap() } #[test] fn runs_native_jsr_graphs_with_typescript_cycles_exports_and_package_private_dependencies() { let server = Registry::new(); server.package( "@test/value", "1.0.0", &[("mod.ts", "export const value = 'one';")], &[(".", "./mod.ts")], ); server.package( "@test/value", "2.0.0", &[("mod.ts", "export const value = 'two';")], &[(".", "./mod.ts")], ); server.package("@test/library", "1.0.0", &[ ("mod.ts", "import type {Missing} from 'npm:missing'; import {value} from 'jsr:/@test/value@^1'; import './cycle.ts'; export {value}; export const count: number = globalThis.count = (globalThis.count ?? 0) + 1; export const main = import.meta.main; export const url = import.meta.url; export const self = () => import(import.meta.url);"), ("cycle.ts", "import {value} from './mod.ts'; export const read = () => value;"), ("sub.mts", "export const value: number = 42;"), ], &[(".", "./mod.ts"), ("./sub", "./sub.mts")]); let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("avril.conf"), "imports: {lib: 'jsr:@test/library@^1', 'lib/': 'jsr:@test/library@^1/', value: 'jsr:@test/value@^2'}").unwrap(); fs::write(dir.path().join("main.ts"), "import * as a from 'lib'; import * as b from 'jsr:@test/library@^1'; import {value} from 'value'; import {value as sub} from 'lib/sub'; console.log(a === b, a.value, value, sub, a.count, a.main, (await a.self()) === a, a.url);").unwrap(); let output = run(&server, dir.path(), &[]); let stdout = success(&output); assert_eq!( stdout, format!( "true one two 42 1 false true {}@test/library/1.0.0/mod.ts\n", server.url ) ); let lock = fixture::read_lock(dir.path()); assert_eq!(lock["specifiers"]["jsr:@test/value@^1"], "1.0.0"); assert_eq!(lock["specifiers"]["jsr:@test/value@^2"], "2.0.0"); assert!( !server .requests .lock() .unwrap() .iter() .any(|path| path.contains("missing")) ); } #[test] fn computed_dynamic_imports_acquire_static_closures_and_share_evaluation() { let server = Registry::new(); server.package("@test/lazy", "1.0.0", &[ ("mod.ts", "import {value} from './dep.ts'; export {value}; export const count = globalThis.count = (globalThis.count ?? 0) + 1; export const main = import.meta.main;"), ("dep.ts", "export const value: string = 'acquired';"), ], &[(".", "./mod.ts")]); let mut metadata: serde_json::Value = serde_json::from_slice( server .files .lock() .unwrap() .get("/@test/lazy/1.0.0_meta.json") .unwrap(), ) .unwrap(); metadata["moduleGraph2"] = json!({"/mod.ts": {"dependencies": [{"type": "static", "kind": "import", "specifier": "./dep.ts"}]}}); server.files.lock().unwrap().insert( "/@test/lazy/1.0.0_meta.json".into(), serde_json::to_vec(&metadata).unwrap(), ); let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("main.ts"), "const name = 'jsr:@test/' + 'lazy@1'; const timer = new Promise(resolve => setTimeout(resolve, 0)); const [a,b,c] = await Promise.all([import(name),import(name),import(name)]); await timer; console.log(a === b, b === c, a.value, a.count, a.main); try { await import('jsr:@test/missing@1'); throw new Error('success'); } catch(e) { console.log(e.message.includes('404')); } console.log((await import(name)) === a);").unwrap(); assert_eq!( success(&run(&server, dir.path(), &[])), "true true acquired 1 false\ntrue\ntrue\n" ); let requests = server.requests.lock().unwrap(); for path in [ "/@test/lazy/meta.json", "/@test/lazy/1.0.0_meta.json", "/@test/lazy/1.0.0/mod.ts", "/@test/lazy/1.0.0/dep.ts", ] { assert_eq!( requests .iter() .filter(|request| request.as_str() == path) .count(), 1, "{requests:?}" ); } } #[test] fn published_packages_do_not_inherit_application_maps_or_escape_their_packages() { let server = Registry::new(); server.package( "@test/bare", "1.0.0", &[("mod.ts", "import 'dep';")], &[(".", "./mod.ts")], ); server.package( "@test/escape", "1.0.0", &[("mod.ts", "import '../../other/1.0.0/private.ts';")], &[(".", "./mod.ts")], ); server.package( "@test/builtin", "1.0.0", &[("mod.ts", "import 'node:fs';")], &[(".", "./mod.ts")], ); let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("avril.conf"), "imports: {dep: './dep.ts'}").unwrap(); fs::write( dir.path().join("dep.ts"), "throw new Error('inherited app map');", ) .unwrap(); for (package, expected) in [ ("bare", "Unsupported module specifier: dep"), ("escape", "escapes its registry package"), ("builtin", "node: builtin modules are not supported"), ] { fs::write( dir.path().join("main.ts"), format!("import 'jsr:@test/{package}@1';"), ) .unwrap(); let output = run(&server, dir.path(), &[]); assert!(!output.status.success()); assert!( String::from_utf8_lossy(&output.stderr).contains(expected), "{}", String::from_utf8_lossy(&output.stderr) ); } assert!( !server .requests .lock() .unwrap() .iter() .any(|path| path.contains("other")) ); } #[test] fn package_acquisition_does_not_grant_guest_network_or_filesystem_access() { let server = Registry::new(); server.package( "@test/pkg", "1.0.0", &[("mod.ts", "export const value = 42;")], &[(".", "./mod.ts")], ); let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("main.ts"), format!("import {{value}} from 'jsr:@test/pkg@1'; import {{open,errors}} from 'avril:std'; console.log(value); try {{await fetch('{}'); throw new Error('net allowed');}} catch(e) {{console.log(e.message.includes('PermissionDenied'));}} try {{await open('./cache'); throw new Error('read allowed');}} catch(e) {{console.log(e instanceof errors.PermissionDenied);}}", server.url)).unwrap(); let grant = format!("--allow-js-exec={}", server.url); let output = run(&server, dir.path(), &["-0", &grant]); assert_eq!(success(&output), "42\ntrue\ntrue\n"); } #[test] fn denied_or_revoked_remote_imports_do_not_download_code() { let server = Registry::new(); server.package( "@test/pkg", "1.0.0", &[("mod.ts", "export {};")], &[(".", "./mod.ts")], ); let dir = tempfile::tempdir().unwrap(); fs::write( dir.path().join("main.ts"), "await import('jsr:@test/pkg@1');", ) .unwrap(); let output = run(&server, dir.path(), &["-0"]); assert!(!output.status.success()); assert!(String::from_utf8_lossy(&output.stderr).contains("PermissionDenied: js-exec")); assert!(server.requests.lock().unwrap().is_empty()); fs::write(dir.path().join("main.ts"), "import {pledge} from 'avril:std'; const pending = import('jsr:@test/pkg@1'); pledge({jsExec: false}); try {await pending; throw new Error('allowed');} catch(e) {console.log(e.message.includes('PermissionDenied'));}").unwrap(); assert_eq!(success(&run(&server, dir.path(), &[])), "true\n"); assert!(server.requests.lock().unwrap().is_empty()); } #[test] fn cached_remote_modules_obey_live_file_denials_and_reject_encoded_aliases() { let server = Registry::new(); server.package( "@test/pkg", "1.0.0", &[( "mod.ts", "export const url = import.meta.url; export const alias = () => import('./mod%2Ets');", )], &[(".", "./mod.ts")], ); let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("main.ts"), "import {pledge} from 'avril:std'; const a = await import('jsr:@test/pkg@1'); try {await a.alias(); throw new Error('alias allowed');} catch(e) {console.log(e.message.includes('canonical'));} pledge({jsExec: {deny: [a.url]}}); try {await import('jsr:@test/pkg@1'); throw new Error('allowed');} catch(e) {console.log(e.message.includes('PermissionDenied'));} try {await import(a.url); throw new Error('allowed');} catch(e) {console.log(e.message.includes('PermissionDenied'));}").unwrap(); assert_eq!( success(&run(&server, dir.path(), &[])), "true\ntrue\ntrue\n" ); } #[test] fn metadata_version_denials_prevent_fetching_that_versions_manifest() { let server = Registry::new(); server.package( "@test/pkg", "1.0.0", &[("mod.ts", "export {};")], &[(".", "./mod.ts")], ); let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("main.ts"), "import 'jsr:@test/pkg@1';").unwrap(); let deny = format!("--deny-js-exec={}@test/pkg/1.0.0/", server.url); let output = run(&server, dir.path(), &[&deny]); assert!(!output.status.success()); assert_eq!( *server.requests.lock().unwrap(), vec!["/@test/pkg/meta.json"] ); } #[test] fn tooling_prepares_without_execution_and_vendored_runs_are_offline_reproducible() { let server = Registry::new(); server.package( "@test/pkg", "1.0.0", &[("mod.ts", "export const value = 1;")], &[(".", "./mod.ts")], ); server.package( "@test/lazy", "1.0.0", &[("mod.ts", "export const value = 'lazy';")], &[(".", "./mod.ts")], ); let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("main.ts"), "import {value} from 'jsr:@test/pkg@^1'; console.log(value, (await import('jsr:@test/' + 'lazy@1')).value);").unwrap(); assert_eq!(success(&run(&server, dir.path(), &["cache"])), ""); let output = run(&server, dir.path(), &["info"]); let graph: serde_json::Value = serde_json::from_str(&success(&output)).unwrap(); assert_eq!(graph["modules"].as_object().unwrap().len(), 2); server.package( "@test/pkg", "1.1.0", &[("mod.ts", "export const value = 2;")], &[(".", "./mod.ts")], ); assert_eq!(success(&run(&server, dir.path(), &["update"])), ""); assert_eq!( success(&run( &server, dir.path(), &["vendor", "--include=jsr:@test/lazy@1"] )), "" ); assert!(dir.path().join("vendor/@test/pkg/1.1.0/mod.ts").is_file()); assert!(dir.path().join("vendor/@test/lazy/1.0.0/mod.ts").is_file()); fs::remove_dir_all(dir.path().join("cache")).unwrap(); server.requests.lock().unwrap().clear(); assert_eq!( success(&run( &server, dir.path(), &["--offline", "--frozen", "--vendor=vendor"] )), "2 lazy\n" ); assert!(server.requests.lock().unwrap().is_empty()); } #[test] fn remote_typescript_stacks_use_registry_source_urls_and_original_lines() { let server = Registry::new(); server.package("@test/throws", "1.0.0", &[("mod.ts", "type Erased = {\n field: string;\n};\nexport function fail(): never {\n throw new Error('remote');\n}\n")], &[(".", "./mod.ts")]); let dir = tempfile::tempdir().unwrap(); fs::write( dir.path().join("main.ts"), "import {fail} from 'jsr:@test/throws@1'; fail();", ) .unwrap(); let output = run(&server, dir.path(), &[]); assert!(!output.status.success()); assert!( String::from_utf8_lossy(&output.stderr).contains(&format!( "at fail ({}@test/throws/1.0.0/mod.ts:5:8)", server.url )), "{}", String::from_utf8_lossy(&output.stderr) ); } #[test] fn unchanged_locks_are_not_rewritten_and_guest_grants_do_not_move_the_root() { let server = Registry::new(); server.package( "@test/pkg", "1.0.0", &[("mod.ts", "export const value = 1;")], &[(".", "./mod.ts")], ); let dir = tempfile::tempdir().unwrap(); fs::create_dir(dir.path().join("src")).unwrap(); fs::write(dir.path().join("avril.conf"), "").unwrap(); fs::write( dir.path().join("src/main.ts"), "import {value} from 'jsr:@test/pkg@^1'; console.log(value);", ) .unwrap(); let execute = |args: &[&str]| { Command::new(env!("CARGO_BIN_EXE_avril")) .current_dir(dir.path()) .args( args.first() .is_none_or(|arg| arg.starts_with('-')) .then_some("run"), ) .args(args) .arg(format!("--jsr-registry={}", server.url)) .arg("--cache-dir=cache") .arg("src/main.ts") .output() .unwrap() }; assert_eq!(success(&execute(&[])), "1\n"); let lock = dir.path().join("avril.lock"); let original = fs::read(&lock).unwrap(); let modified = fs::metadata(&lock).unwrap().modified().unwrap(); server.package( "@test/pkg", "1.1.0", &[("mod.ts", "export const value = 2;")], &[(".", "./mod.ts")], ); server.requests.lock().unwrap().clear(); let grant = format!("--allow-js-exec={}", server.url); assert_eq!(success(&execute(&["-0", &grant])), "1\n"); assert_eq!(success(&execute(&["-0", &grant, "--frozen"])), "1\n"); assert!(!dir.path().join("src/avril.lock").exists()); assert_eq!(fs::read(&lock).unwrap(), original); assert_eq!(fs::metadata(&lock).unwrap().modified().unwrap(), modified); assert!(server.requests.lock().unwrap().is_empty()); } #[test] fn vendoring_can_seed_private_computed_files_and_ignores_unusable_graph_hints() { let server = Registry::new(); server.package( "@test/private", "1.0.0", &[ ( "mod.ts", "export const load = () => import('./' + 'hidden.ts');", ), ("hidden.ts", "export const value = 42;"), ], &[(".", "./mod.ts")], ); let mut metadata: serde_json::Value = serde_json::from_slice( server .files .lock() .unwrap() .get("/@test/private/1.0.0_meta.json") .unwrap(), ) .unwrap(); metadata["moduleGraph2"] = json!(["unknown hint format"]); metadata["moduleGraph1"] = json!({"ignored": true}); server.files.lock().unwrap().insert( "/@test/private/1.0.0_meta.json".into(), serde_json::to_vec(&metadata).unwrap(), ); let dir = tempfile::tempdir().unwrap(); fs::write( dir.path().join("main.ts"), "import {load} from 'jsr:@test/private@1'; console.log((await load()).value);", ) .unwrap(); success(&run( &server, dir.path(), &[ "vendor", "--include-from=jsr:@test/private@1", "--include=./hidden.ts", ], )); assert!( dir.path() .join("vendor/@test/private/1.0.0/hidden.ts") .exists() ); fs::remove_dir_all(dir.path().join("cache")).unwrap(); server.requests.lock().unwrap().clear(); assert_eq!( success(&run( &server, dir.path(), &["--offline", "--frozen", "--vendor=vendor"] )), "42\n" ); assert!(server.requests.lock().unwrap().is_empty()); } #[test] fn named_local_packages_do_not_override_explicit_jsr_registry_requests() { let server = Registry::new(); server.package( "@test/library", "1.0.0", &[("mod.ts", "export const value = 'registry';")], &[(".", "./mod.ts")], ); let dir = tempfile::tempdir().unwrap(); fs::create_dir(dir.path().join("lib")).unwrap(); fs::write( dir.path().join("avril.conf"), "deps: [{from: './lib', name: '@test/library'}]", ) .unwrap(); fs::write( dir.path().join("lib/avril.conf"), "name: '@test/library', version: '9.0.0', exports: './mod.ts'", ) .unwrap(); fs::write( dir.path().join("lib/mod.ts"), "export const value = 'local';", ) .unwrap(); fs::write(dir.path().join("main.ts"), "import * as local from '@test/library'; import * as remote from 'jsr:@test/library@1'; console.log(local.value, remote.value, local !== remote);").unwrap(); assert_eq!( success(&run(&server, dir.path(), &[])), "local registry true\n" ); } #[test] fn jsr_dependencies_bind_their_package_name_or_override_and_stay_private() { let server = Registry::new(); server.package( "@test/library", "1.0.0", &[ ("mod.ts", "export const value = 'root';"), ("sub.ts", "export const value = 'sub';"), ], &[(".", "./mod.ts"), ("./sub", "./sub.ts")], ); server.package( "@test/value", "2.0.0", &[("mod.ts", "export const value = 'two';")], &[(".", "./mod.ts")], ); let dir = tempfile::tempdir().unwrap(); fs::create_dir(dir.path().join("local")).unwrap(); fs::write( dir.path().join("avril.conf"), "deps: ['jsr:@test/library@^1', {from: 'jsr:@test/value@^2', name: 'value'}, './local']", ) .unwrap(); fs::write( dir.path().join("local/avril.conf"), "name: 'local', exports: './mod.ts'", ) .unwrap(); fs::write( dir.path().join("local/mod.ts"), "export async function isolated() {try {await import('value'); return false;} catch(e) {return e.message.includes('Unsupported module specifier');}}", ) .unwrap(); fs::write(dir.path().join("main.ts"), "import {value as root} from '@test/library'; import {value as sub} from '@test/library/sub'; import * as direct from 'jsr:@test/library@^1'; import {value} from 'value'; import {isolated} from 'local'; console.log(root, sub, value, direct.value === root, await isolated()); for (const name of ['@test/value', '@test/library/missing']) {try {await import(name); console.log('allowed');} catch(e) {console.log(e.message.includes(name === '@test/value' ? 'Unsupported module specifier' : 'no export'));}}").unwrap(); assert_eq!( success(&run(&server, dir.path(), &[])), "root sub two true true\ntrue\ntrue\n" ); let lock = fixture::read_lock(dir.path()); assert_eq!(lock["specifiers"]["jsr:@test/library@^1"], "1.0.0"); let info: serde_json::Value = serde_json::from_str(&success(&run(&server, dir.path(), &["info"]))).unwrap(); let main = &info["modules"][dir.path().join("main.ts").to_str().unwrap()]; assert_eq!( main["package"]["dependencies"]["@test/library"], "jsr:@test/library@^1" ); assert_eq!( main["package"]["dependencies"]["value"], "jsr:@test/value@^2" ); assert_eq!( main["imports"]["@test/library/sub"], format!("{}@test/library/1.0.0/sub.ts", server.url) ); } #[test] fn direct_and_binding_imports_agree_on_selection_locking_and_modules() { let server = Registry::new(); for (version, value, sub) in [("1.0.0", "one", "sub-one"), ("1.1.0", "two", "sub-two")] { server.package( "@test/parity", version, &[ ("mod.ts", &format!("export const value = '{value}';")), ("sub.ts", &format!("export const sub = '{sub}';")), ], &[(".", "./mod.ts"), ("./sub", "./sub.ts")], ); } let direct = tempfile::tempdir().unwrap(); fs::write( direct.path().join("main.ts"), "import {value} from 'jsr:@test/parity@^1'; import {sub} from 'jsr:@test/parity@^1/sub'; console.log(value, sub);", ) .unwrap(); let bound = tempfile::tempdir().unwrap(); fs::write( bound.path().join("avril.conf"), "deps: ['jsr:@test/parity@^1']", ) .unwrap(); fs::write( bound.path().join("main.ts"), "import {value} from '@test/parity'; import {sub} from '@test/parity/sub'; console.log(value, sub);", ) .unwrap(); let direct_stdout = success(&run(&server, direct.path(), &[])); let bound_stdout = success(&run(&server, bound.path(), &[])); // The caret range resolves identically through both routes. assert_eq!(direct_stdout, bound_stdout); assert_eq!(direct_stdout, "two sub-two\n"); let direct_lock = fixture::read_lock(direct.path()); let bound_lock = fixture::read_lock(bound.path()); assert_eq!(direct_lock["specifiers"], bound_lock["specifiers"]); assert_eq!(direct_lock["specifiers"]["jsr:@test/parity@^1"], "1.1.0"); assert_eq!(direct_lock, bound_lock); let direct_info: serde_json::Value = serde_json::from_str(&success(&run(&server, direct.path(), &["info"]))).unwrap(); let bound_info: serde_json::Value = serde_json::from_str(&success(&run(&server, bound.path(), &["info"]))).unwrap(); let direct_main = &direct_info["modules"][direct.path().join("main.ts").to_str().unwrap()]["imports"]; let bound_main = &bound_info["modules"][bound.path().join("main.ts").to_str().unwrap()]["imports"]; assert_eq!( direct_main["jsr:@test/parity@^1"], bound_main["@test/parity"] ); assert_eq!( direct_main["jsr:@test/parity@^1/sub"], bound_main["@test/parity/sub"] ); }