use super::*; use avril_runtime::caps::{Category, Initial}; use std::fs; #[path = "../../tests/common/http.rs"] mod http; #[path = "../../tests/common/registry.rs"] mod registry; fn options(root: &Path) -> Options { Options { jsr_registry: url::Url::parse("https://example.invalid/").unwrap(), npm_registry: url::Url::parse("https://example.invalid/").unwrap(), cache: root.join("cache"), lock: root.join("avril.lock"), project_root: root.to_owned(), offline: false, frozen: false, update: false, ephemeral_lock: false, vendor: None, } } fn refresh(root: &Path, options: Options, entries: &[&str]) -> ProjectInput { refresh_with_caps( root, options, entries, Capabilities::new(Initial::new(false), root.to_owned()), ) } fn refresh_with_caps( root: &Path, options: Options, entries: &[&str], caps: Arc, ) -> ProjectInput { let mut project = LiveProject { root: root.to_owned(), caps, options, explicit_lock: false, automatic_types: true, dirty: Arc::new(AtomicBool::new(false)), rewatch: Arc::new(AtomicBool::new(false)), watcher: Some(notify::recommended_watcher(|_| {}).unwrap()), watched: BTreeMap::new(), tracked_config: Arc::new(Mutex::new(BTreeSet::new())), }; let documents = entries .iter() .map(|entry| { let path = root.join(entry); (uri(&path), fs::read_to_string(path).unwrap()) }) .collect(); registry::runtime_handle() .block_on(project.refresh(documents)) .unwrap() } fn uri(path: &Path) -> String { avril_lsp::canonical_uri(url::Url::from_file_path(path).unwrap().as_str()).unwrap() } fn resolve(input: &ProjectInput, referrer: &str, specifier: &str) -> Option { input .imports .get(&(referrer.into(), specifier.into())) .and_then(|targets| targets.import.clone()) } fn files(root: &Path, files: &[(&str, &str)]) { for (name, source) in files { let path = root.join(name); fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write(path, source).unwrap(); } } #[test] fn workspace_roots_and_auto_imports_keep_resolution_authoritative() { let dir = tempfile::tempdir().unwrap(); let root = dir.path(); files( root, &[ ("main.ts", "export const main = 0;"), ("target.ts", "export const target = 1;"), ("other.ts", "export const other = 2;"), ("ignored.ts", "throw Error('ignored');"), ( "node_modules/pkg/index.ts", "throw Error('not a workspace root');", ), (".gitignore", "ignored.ts\n"), ("avril.conf", "imports: {'./target.ts': './other.ts'}"), ], ); let input = refresh(root, options(root), &["main.ts"]); assert!( input .sources .iter() .any(|s| s.uri == uri(&root.join("other.ts"))) ); assert!( !input .sources .iter() .any(|s| s.uri == uri(&root.join("ignored.ts"))) ); assert!(!input.sources.iter().any(|s| s.uri.contains("node_modules"))); let edges = &input.import_candidates; assert!(edges.contains(&( uri(&root.join("main.ts")), uri(&root.join("other.ts")), "./target.ts".into() ))); assert!(!edges.contains(&( uri(&root.join("main.ts")), uri(&root.join("target.ts")), "./target.ts".into() ))); } #[test] fn restricted_projects_do_not_discover_unopened_roots() { let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ("main.ts", "export const main = 0;"), ("unopened.ts", "export const unopened = 1;"), ], ); let caps = Capabilities::new(Initial::new(true), dir.path().to_owned()); let input = refresh_with_caps(dir.path(), options(dir.path()), &[], caps); assert!(!input.sources.iter().any(|s| s.uri.starts_with("file:"))); } #[test] fn declarations_preserve_package_private_versions_without_writing_project_files() { let server = registry::Registry::new(); server.package("@test/types", "1.0.0", &[ ("types.d.ts", "import type {Base} from './base.d.ts'; export interface Value extends Base { first: string; }"), ("base.d.ts", "export interface Base { id: number; }"), ], &[(".", "./types.d.ts")]); server.package( "@test/types", "2.0.0", &[("types.d.ts", "export interface Value { second: number; }")], &[(".", "./types.d.ts")], ); let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ("app/avril.conf", "imports: {dep: 'jsr:@test/types@1'}"), ("lib/avril.conf", "imports: {dep: 'jsr:@test/types@2'}"), ( "app/main.ts", "import type {Value} from 'dep'; import {second} from '../lib/mod.ts'; export const first: Value = {id: 1, first: 'ok'}; throw Error('must not execute');", ), ( "lib/mod.ts", "import type {Value} from 'dep'; export const second: Value = {second: 2};", ), ], ); let app = dir.path().join("app"); let input = refresh(&app, server.options(&app), &["main.ts"]); let first = resolve(&input, &uri(&app.join("main.ts")), "dep").unwrap(); let second = resolve(&input, &uri(&dir.path().join("lib/mod.ts")), "dep").unwrap(); assert_eq!( first, avril_lsp::canonical_uri(&format!("{}@test/types/1.0.0/types.d.ts", server.url)).unwrap() ); assert_eq!( second, avril_lsp::canonical_uri(&format!("{}@test/types/2.0.0/types.d.ts", server.url)).unwrap() ); let base = resolve(&input, &first, "./base.d.ts").unwrap(); assert!( input .sources .iter() .any(|source| source.uri == base && source.read_only) ); assert!(input.diagnostics.is_empty()); for path in ["avril.lock", "tsconfig.json", ".avril"] { assert!(!app.join(path).exists()); } server.requests.lock().unwrap().clear(); let mut options = server.options(&app); options.offline = true; let offline = refresh(&app, options, &["main.ts"]); assert_eq!( resolve(&offline, &uri(&app.join("main.ts")), "dep"), Some(first) ); assert!(server.requests.lock().unwrap().is_empty()); } #[test] fn bad_library_metadata_does_not_abort_or_inherit_consumer_aliases() { let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ("app/avril.conf", "imports: {helper: './helper.ts'}"), ( "app/main.ts", "import type {Value} from 'helper'; import '../lib/mod.ts'; export const value: Value = 'app';", ), ("app/helper.ts", "export type Value = string;"), ("lib/avril.conf", "imports: []"), ( "lib/mod.ts", "import type {Value} from 'helper'; export type LibraryValue = Value;", ), ], ); let app = dir.path().join("app"); let input = refresh(&app, options(&app), &["main.ts"]); let library = uri(&dir.path().join("lib/mod.ts")); assert!( input.diagnostics[&library] .iter() .any(|error| error.contains("Expected an object")) ); assert_eq!(resolve(&input, &library, "helper"), None); assert_eq!( resolve(&input, &uri(&app.join("main.ts")), "helper"), Some(uri(&app.join("helper.ts"))), ); } #[test] fn denied_type_override_is_not_hidden_by_an_ambient_module() { let dir = tempfile::tempdir().unwrap(); let root = dir.path().join("project"); let outside = dir.path().join("outside.d.ts"); files( dir.path(), &[ ("project/avril.conf", "{}"), ( "project/main.ts", "import './ambient.d.ts'; import type {Value} from 'alias'; export type Main = Value;", ), ( "project/ambient.d.ts", "declare module 'alias' { export interface Value {} }", ), ("outside.d.ts", "export interface Value {}"), ], ); let ambient = refresh(&root, options(&root), &["main.ts"]); assert!(ambient.diagnostics.is_empty(), "{:?}", ambient.diagnostics); files( dir.path(), &[("project/avril.conf", "types: {imports: {alias: null}}")], ); let blocked = refresh(&root, options(&root), &["main.ts"]); assert!( blocked .diagnostics .contains_key(&uri(&root.join("main.ts"))), "{:?}", blocked.diagnostics ); files( dir.path(), &[( "project/avril.conf", "types: {imports: {alias: '../outside.d.ts'}}", )], ); let mut initial = Initial::new(true); initial .grant(Category::JsExec, false, root.to_str().unwrap(), &root) .unwrap(); let caps = Capabilities::new(initial, root.clone()); let input = refresh_with_caps(&root, options(&root), &["main.ts"], caps); let main = uri(&root.join("main.ts")); assert!( input.diagnostics[&main] .iter() .any(|error| error.contains("PermissionDenied")), "{:?}", input.diagnostics ); assert!( !input .sources .iter() .any(|source| source.uri == uri(&outside)) ); } #[test] fn type_queries_references_jsdoc_and_redirects_survive_missing_dependencies() { let server = registry::Registry::new(); server.package( "@test/js", "1.0.0", &[ ( "mod.js", "/* @ts-self-types=\"./mod.d.ts\" */ export const value = 'js';", ), ("mod.d.ts", "export const value: 'declared';"), ], &[(".", "./mod.js")], ); let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ("avril.conf", "{}"), ("ref.d.ts", "export interface Reference {}"), ("query.ts", "export const value = 1;"), ("lazy.ts", "export const value = 2;"), ("jsdoc.d.ts", "export interface Doc {}"), ("param.d.ts", "export interface Param {}"), ("returns.d.ts", "export interface Result {}"), ("imported.d.ts", "export interface Imported {}"), ( "main.ts", "/// \nimport {value} from 'jsr:@test/js@1'; import type {Missing} from 'package:missing'; import type {Absent} from './absent.d.ts'; type Query = typeof import('./query.ts'); const load = () => import('./lazy.ts'); /** @type {import('./jsdoc.d.ts').Doc} */ let doc; /** @param {import('./param.d.ts').Param} value\n * @returns {import('./returns.d.ts').Result}\n * The result type.\n * @example\n * import('example-only-library');\n * ```ts\n * import('fenced-example-library');\n * ```\n * @import { Imported } from './imported.d.ts'\n */ function example(value) {} throw Error('must not run');", ), ], ); let input = refresh(dir.path(), server.options(dir.path()), &["main.ts"]); let main = uri(&dir.path().join("main.ts")); assert!( resolve(&input, &main, "jsr:@test/js@1") .unwrap() .ends_with("/mod.d.ts") ); for name in [ "ref.d.ts", "query.ts", "lazy.ts", "jsdoc.d.ts", "param.d.ts", "returns.d.ts", "imported.d.ts", ] { let target = uri(&dir.path().join(name)); assert_eq!( resolve(&input, &main, &format!("./{name}")), Some(target.clone()) ); assert!(input.sources.iter().any(|source| source.uri == target)); } assert!( input.diagnostics[&main] .iter() .any(|error| error.contains("Unsupported")) ); assert!(resolve(&input, &main, "./absent.d.ts").is_none()); assert!(resolve(&input, &main, "example-only-library").is_none()); assert!(resolve(&input, &main, "fenced-example-library").is_none()); assert!( input .diagnostics .values() .flatten() .any(|error| error.contains("absent.d.ts")) ); } #[test] fn jsx_collects_implicit_runtime_types_and_preserves_user_config() { let server = registry::Registry::new(); server.package( "@test/ui", "1.0.0", &[ ( "jsx-runtime.js", "/** @ts-self-types=\"./jsx-runtime.d.ts\" */ export const jsx = () => {};", ), ( "jsx-runtime.d.ts", "export const jsx: (tag: string, props: object) => object;", ), ], &[("./jsx-runtime", "./jsx-runtime.js")], ); let dir = tempfile::tempdir().unwrap(); let config = r#"{"compilerOptions":{"jsx":"react-jsx","jsxImportSource":"jsr:@test/ui@1"}}"#; files( dir.path(), &[ ("avril.conf", "{}"), ("tsconfig.json", config), ("types.d.ts", "export interface Props {title: string}"), ( "main.tsx", "import type {Props} from './types.d.ts'; const props: Props = {title:'hello'}; export const node =

;", ), ], ); let input = refresh(dir.path(), server.options(dir.path()), &["main.tsx"]); let main = uri(&dir.path().join("main.tsx")); assert!( resolve(&input, &main, "jsr:@test/ui@1/jsx-runtime") .unwrap() .ends_with("jsx-runtime.d.ts") ); assert_eq!( resolve(&input, &main, "./types.d.ts"), Some(uri(&dir.path().join("types.d.ts"))) ); assert_eq!( input.per_file_options[&main]["jsxImportSource"], "jsr:@test/ui@1" ); assert!(input.diagnostics.is_empty()); assert_eq!( fs::read_to_string(dir.path().join("tsconfig.json")).unwrap(), config ); } #[test] fn preserved_jsx_and_bad_configs_do_not_abort_live_generations() { let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ("avril.conf", "{}"), ("main.tsx", "export const node =

;"), ], ); let main = uri(&dir.path().join("main.tsx")); for mode in ["preserve", "react-native"] { fs::write( dir.path().join("tsconfig.json"), format!(r#"{{"compilerOptions":{{"jsx":"{mode}"}}}}"#), ) .unwrap(); let input = refresh(dir.path(), options(dir.path()), &["main.tsx"]); assert_eq!(input.per_file_options[&main]["jsx"], mode); assert!(input.diagnostics.is_empty()); } files( dir.path(), &[("tsconfig.json", r#"{"extends":"./absent.json"}"#)], ); let input = refresh(dir.path(), options(dir.path()), &["main.tsx"]); assert!( input.diagnostics[&main] .iter() .any(|error| error.contains("Cannot find tsconfig")) ); files(dir.path(), &[("avril.conf", "deps: [")]); let input = refresh(dir.path(), options(dir.path()), &["main.tsx"]); assert!(!input.diagnostics[&main].is_empty()); assert!(input.sources.iter().any(|source| source.uri == main)); } #[test] fn avril_conf_jsx_defaults_follow_edits_within_their_package() { let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ("avril.conf", "compilerOptions: {jsxImportSource: 'first'}"), ("main.tsx", "export const node =

;"), ("lib/avril.conf", "{}"), ("lib/widget.tsx", "export const widget = ;"), ], ); let main = uri(&dir.path().join("main.tsx")); let widget = uri(&dir.path().join("lib/widget.tsx")); for (config, tsconfig, expected) in [ ("compilerOptions: {jsxImportSource: 'first'}", None, "first"), ( "compilerOptions: {jsxImportSource: 'second'}", None, "second", ), ( "compilerOptions: {jsxImportSource: 'second'}", Some(r#"{"compilerOptions":{"jsxImportSource":"ts"}}"#), "ts", ), ] { files(dir.path(), &[("avril.conf", config)]); if let Some(tsconfig) = tsconfig { files(dir.path(), &[("tsconfig.json", tsconfig)]); } let input = refresh( dir.path(), options(dir.path()), &["main.tsx", "lib/widget.tsx"], ); assert_eq!(input.per_file_options[&main]["jsxImportSource"], expected); assert!( input.per_file_options[&widget] .get("jsxImportSource") .is_none() ); } } #[test] fn remote_redirects_use_final_bases_and_missing_package_names_are_diagnostics() { let http = http::Http::new(); let dir = tempfile::tempdir().unwrap(); http.redirect("start", "/nested/mod.ts"); http.source("nested/mod.ts", "export {value} from './dep.ts';"); http.source("nested/dep.ts", "export const value: number = 42;"); files( dir.path(), &[ ("avril.conf", "{}"), ( "main.ts", &format!("import {{value}} from '{}';", http.name("start")), ), ], ); let input = refresh(dir.path(), options(dir.path()), &["main.ts"]); let main = uri(&dir.path().join("main.ts")); let remote = resolve(&input, &main, &http.name("start")).unwrap(); let dependency = resolve(&input, &remote, "./dep.ts").unwrap(); assert!(dependency.ends_with("/nested/dep.ts")); assert!( input .sources .iter() .any(|source| source.uri == dependency && source.read_only) ); files( dir.path(), &[( "avril.conf", &format!("deps: ['{}']", http.name("missing/")), )], ); let input = refresh(dir.path(), options(dir.path()), &["main.ts"]); assert!( input.diagnostics[&main] .iter() .any(|error| error.contains("Cannot read the name")) ); assert!(input.sources.iter().any(|source| source.uri == main)); } #[test] fn remote_package_ownership_is_stable_in_both_import_orders() { let http = http::Http::new(); let dir = tempfile::tempdir().unwrap(); http.source( "pkg/child/avril.conf", "name: 'child', exports: './mod.ts', imports: {helper: './helper.ts'}", ); http.source("pkg/child/mod.ts", "export {value} from 'helper';"); http.source("pkg/child/helper.ts", "export const value = 7;"); http.source("pkg/wrong.ts", "throw Error('parent map leaked');"); files( dir.path(), &[ ( "avril.conf", &format!( "deps: ['{}', '{}']", http.name("pkg/"), http.name("pkg/child/") ), ), ("x.ts", "import * as a from 'outer'; export {a};"), ("y.ts", "import * as b from 'child'; export {b};"), ], ); for nested in [true, false] { http.source("pkg/avril.conf", &format!( "name: 'outer', exports: './child/mod.ts', imports: {{helper: './wrong.ts'}}, deps: [{}]", if nested {"{from: './child/', name: 'nested'}"} else {""}, )); for imports in [ "import './x.ts'; import './y.ts';", "import './y.ts'; import './x.ts';", ] { files(dir.path(), &[("main.ts", imports)]); let cache = dir.path().join("cache"); if cache.exists() { fs::remove_dir_all(cache).unwrap(); } let input = refresh(dir.path(), options(dir.path()), &["main.ts"]); if nested { assert!(input.diagnostics.is_empty(), "{:?}", input.diagnostics); assert_eq!( resolve(&input, &http.name("pkg/child/mod.ts"), "helper"), Some(http.name("pkg/child/helper.ts")), ); } else { assert!( input .diagnostics .values() .flatten() .any(|error| error.contains("Conflicting remote package ownership")) ); } } } } #[test] fn registry_prefixed_configs_are_rejected_without_fetching_or_writing_locks() { let http = http::Http::new(); let dir = tempfile::tempdir().unwrap(); let mut options = options(dir.path()); options.jsr_registry = http.url.clone(); let lock = format!( "version: 1\njsrRegistry: \"{}\"\nspecifiers: {{}}\npackages: {{}}\nremote: {{}}\n", http.url ); files( dir.path(), &[("avril.lock", &lock), ("main.ts", "import 'pkg';")], ); for dependency in [ format!("'{}'", http.name("pkg/")), format!("{{from: '{}', name: 'pkg'}}", http.name("pkg/")), ] { files( dir.path(), &[("avril.conf", &format!("deps: [{dependency}]"))], ); let input = refresh(dir.path(), options.clone(), &["main.ts"]); assert!( input .diagnostics .values() .flatten() .any(|error| error.contains("configured JSR registry")) ); assert!(http.requests.lock().unwrap().is_empty()); assert_eq!( fs::read_to_string(dir.path().join("avril.lock")).unwrap(), lock ); } }