#[path = "common/http.rs"] #[allow(dead_code)] mod http; #[path = "common/lsp.rs"] #[allow(dead_code)] mod support; use std::fs; use std::process::Command; use serde_json::json; use sha2::Digest; use support::{Server, avril, backend, files}; fn hover_contains( server: &mut Server, path: &str, text: &str, symbol: &str, expected: &str, ) -> bool { server.hover(path, text, symbol).contains(expected) } #[test] fn live_avril_resolution_unsaved_graphs_and_disk_config_invalidation() { let Some(backend) = backend() else { eprintln!("skipping: AVRIL_TEST_TYPESCRIPT is not set"); return; }; let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let main = "import {value} from \"alias\"; import {process} from \"avril:std\"; const checked: number = value; value; process.args; throw Error(\"must not execute\");"; let mut config = json!({ "imports": {"alias": "./dep.ts"}, "scopes": { "./a.ts": {"scoped": "./one.ts"}, "./b.ts": {"scoped": "./two.ts"} } }); files( root, &[ ("avril.conf", &config.to_string()), ("main.ts", main), ("dep.ts", "export const value = 42 as const;"), ("other.ts", "export const value = 99 as const;"), ("a.ts", "import {value} from \"scoped\"; value;"), ("b.ts", "import {value} from \"scoped\"; value;"), ("one.ts", "export const value = 1 as const;"), ("two.ts", "export const value = 2 as const;"), ( "types.js", "// @ts-self-types=\"./decl.d.ts\"\nexport const declared = \"wrong\";", ), ("decl.d.ts", "export declare const declared: 123;"), ( "lib/avril.conf", "name:'@demo/lib', exports:{'.':'./mod.ts'}, imports:{alias:'./private.ts'}", ), ("lib/mod.ts", "export {value} from \"alias\";"), ("lib/private.ts", "export const value = 17 as const;"), ], ); let mut server = Server::start(root, &backend, &["--offline"]); server.initialize(); server.open("main.ts", main); // Local aliases and embedded builtins. assert!(hover_contains( &mut server, "main.ts", main, "value", "value: 42" )); let links = server.definitions("main.ts", main, "value"); assert_eq!(links[0]["targetUri"], json!(server.uri("dep.ts"))); let links = server.definitions("main.ts", main, "args"); assert!( links[0]["targetUri"] .as_str() .unwrap() .starts_with("avril-builtin:") ); // Unsaved dependency edits and version rejection. server.open("dep.ts", "export const value = \"edited\" as const;"); assert!(hover_contains( &mut server, "main.ts", main, "value", "value: \"edited\"" )); assert!( server .diagnostics("main.ts") .iter() .any(|d| d["code"] == json!(2322)) ); server.change("dep.ts", "export const value = 0;", 0); assert!(hover_contains( &mut server, "main.ts", main, "value", "value: \"edited\"" )); server.notify( "textDocument/didClose", json!({"textDocument": {"uri": server.uri("dep.ts")}}), ); assert!(hover_contains( &mut server, "main.ts", main, "value", "value: 42" )); // New unsaved imports grow the graph and follow declaration redirects. let text = "import {declared} from \"./types.js\"; declared;"; server.change("main.ts", text, 2); assert!(hover_contains( &mut server, "main.ts", text, "declared", "declared: 123" )); let links = server.definitions("main.ts", text, "declared"); assert_eq!(links[0]["targetUri"], json!(server.uri("decl.d.ts"))); server.change("main.ts", main, 3); // Plain files retain avril module boundaries and top-level await. for value in [1, 2] { let path = format!("plain{value}.ts"); let text = format!("const value = {value}; await Promise.resolve(value); value;"); files(root, &[(&path, &text)]); server.open(&path, &text); assert!(hover_contains( &mut server, &path, &text, "value", &format!("value: {value}") )); assert!(server.diagnostics(&path).is_empty()); } // Exact per-referrer scopes. for (path, value) in [("a.ts", 1), ("b.ts", 2)] { let text = fs::read_to_string(root.join(path)).unwrap(); server.open(path, &text); assert!(hover_contains( &mut server, path, &text, "value", &format!("value: {value}") )); } // On-disk dependency and configuration changes need no snapshot regeneration. fs::write(root.join("dep.ts"), "export const value = 43 as const;").unwrap(); server.eventually(|s| hover_contains(s, "main.ts", main, "value", "value: 43")); config["imports"]["alias"] = json!("./other.ts"); fs::write(root.join("avril.conf"), config.to_string()).unwrap(); server.eventually(|s| hover_contains(s, "main.ts", main, "value", "value: 99")); // Local package exports and private maps. fs::write( root.join("avril.conf"), "deps:[{from:'./lib',name:'library'}]", ) .unwrap(); let text = "import {value} from \"library\"; value;"; server.change("main.ts", text, 4); assert!(hover_contains( &mut server, "main.ts", text, "value", "value: 17" )); let links = server.definitions("main.ts", text, "value"); assert_eq!(links[0]["targetUri"], json!(server.uri("lib/private.ts"))); // Bad configuration fails closed and then recovers. fs::write(root.join("avril.conf"), "imports: {").unwrap(); server.notify("workspace/didChangeWatchedFiles", json!({"changes": []})); let text = "import {value} from \"alias\"; value;"; server.change("main.ts", text, 5); let links = server.definitions("main.ts", text, "value"); assert_eq!(links.len(), 1, "{links:?}"); assert_eq!(links[0]["targetUri"], server.uri("main.ts")); assert_eq!( support::selected(text, &links[0]["targetSelectionRange"]), "value" ); assert!( server .diagnostics("main.ts") .iter() .any(|d| d["message"].as_str().unwrap().contains("avril.conf")) ); fs::write(root.join("avril.conf"), config.to_string()).unwrap(); server.eventually(|s| hover_contains(s, "main.ts", text, "value", "value: 99")); assert!( !root.join(".avril").exists(), "No editor snapshot directory" ); assert!( !root.join("avril.lock").exists(), "Editor must not write lockfiles" ); assert!( !root.join("tsconfig.json").exists(), "Editor must not create tsconfig files" ); server.close(); } #[test] fn ignore_files_allow_startup_and_live_rule_changes() { let Some(backend) = backend() else { eprintln!("skipping: AVRIL_TEST_TYPESCRIPT is not set"); return; }; let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let main = "export const value = 42;"; files( root, &[ ("main.ts", main), (".gitignore", "git-ignored.ts\n"), (".ignore", "ignored.ts\n"), ("git-ignored.ts", "export const gitIgnoredValue = 1;"), ("ignored.ts", "export const ignoredValue = 2;"), ], ); let mut server = Server::start(root, &backend, &["--offline"]); server.initialize(); server.open("main.ts", main); for ignore in [".gitignore", ".ignore"] { fs::read_to_string(root.join(ignore)).unwrap(); } assert!(server.hover("main.ts", main, "value").contains("value: 42")); let symbols = server .call("workspace/symbol", json!({"query": "IgnoredValue"})) .unwrap(); assert_eq!(symbols, json!([])); for (ignore, symbol) in [ (".gitignore", "gitIgnoredValue"), (".ignore", "ignoredValue"), ] { fs::write(root.join(ignore), "\n").unwrap(); server.eventually(|s| { let symbols = s .call("workspace/symbol", json!({"query": symbol})) .unwrap(); symbols .as_array() .unwrap() .iter() .any(|s| s["name"] == symbol) }); } server.close(); } #[test] fn live_project_enforces_js_exec_authority_for_overlaid_documents() { let Some(backend) = backend() else { eprintln!("skipping: AVRIL_TEST_TYPESCRIPT is not set"); return; }; let dir = tempfile::tempdir().unwrap(); let root = dir.path(); files(root, &[("main.ts", "export const value = 42;")]); let mut server = Server::start(root, &backend, &["-0", "--offline"]); server.initialize(); let text = "export const value = 99; value;"; server.open("main.ts", text); let error = server .call( "textDocument/hover", json!({ "textDocument":{"uri":server.uri("main.ts")}, "position":support::position_at(text, text.rfind("value").unwrap()), }), ) .unwrap_err(); assert_eq!(error.code, -32602, "{error:?}"); assert!( server .diagnostics("main.ts") .iter() .any(|d| d["message"].as_str().unwrap().contains("PermissionDenied")) ); server.close(); } #[test] fn live_jsx_programs_honor_scoped_configuration_and_external_extends() { let Some(backend) = backend() else { eprintln!("skipping: AVRIL_TEST_TYPESCRIPT is not set"); return; }; let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let external = tempfile::tempdir().unwrap(); let jsx = "export namespace JSX { interface Element {} interface IntrinsicElements { panel: {count:number} } } export declare function jsx(...args:unknown[]): JSX.Element; export declare const jsxs:typeof jsx;"; let main = "export const view = ; view;"; files( root, &[ ("avril.conf", "imports:{'ui/jsx-runtime':'./runtime.d.ts'}"), ( "tsconfig.json", &json!({"extends": external.path().join("base.json").to_str().unwrap()}) .to_string(), ), ("main.tsx", main), ("runtime.d.ts", jsx), ( "classic/tsconfig.json", &json!({"compilerOptions": {"jsx": "react", "jsxFactory": "h"}}).to_string(), ), ( "classic/main.tsx", "import {view as imported} from \"../main.tsx\"; declare function h(...args:unknown[]):unknown; const view = ; view; imported;", ), ], ); let base = |source: &str| { fs::write( external.path().join("base.json"), json!({"compilerOptions": {"jsx": "react-jsx", "jsxImportSource": source}}).to_string(), ) .unwrap(); }; base("ui"); let mut server = Server::start(root, &backend, &["--offline"]); server.initialize(); server.open("main.tsx", main); server.hover("main.tsx", main, "view"); assert!( !server .diagnostics("main.tsx") .iter() .any(|d| d["code"] == json!(2875) || d["code"] == json!(2307)) ); let classic = fs::read_to_string(root.join("classic/main.tsx")).unwrap(); server.open("classic/main.tsx", &classic); assert!(server.hover("main.tsx", main, "view").contains("Element")); server.hover("classic/main.tsx", &classic, "view"); let links = server.definitions("classic/main.tsx", &classic, "imported"); assert_eq!(links[0]["targetUri"], json!(server.uri("main.tsx"))); assert!(!server.diagnostics("classic/main.tsx").iter().any(|d| { [2875, 2307, 2874] .iter() .any(|code| d["code"] == json!(code)) })); base("missing-ui"); server.eventually(|s| { s.hover("main.tsx", main, "view"); s.diagnostics("main.tsx") .iter() .any(|d| d["code"] == json!(2875) || d["code"] == json!(2307)) }); base("ui"); server.eventually(|s| s.hover("main.tsx", main, "view").contains("Element")); base("missing-again"); server.eventually(|s| { s.hover("main.tsx", main, "view"); s.diagnostics("main.tsx") .iter() .any(|d| d["code"] == json!(2875) || d["code"] == json!(2307)) }); server.close(); } #[test] fn live_jsr_resolution_shares_cache_lock_vendor_integrity_and_acquisition() { let Some(backend) = backend() else { eprintln!("skipping: AVRIL_TEST_TYPESCRIPT is not set"); return; }; let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let registry = http::Http::new(); let sources = [ ( "mod.js", "// @ts-self-types=\"./mod.d.ts\"\nexport const answer = 123;", ), ("mod.d.ts", "export declare const answer: 123;"), ]; let mut manifest = serde_json::Map::new(); for (name, text) in sources { registry.source(&format!("@demo/data/1.0.0/{name}"), text); let hash = format!("{:x}", sha2::Sha256::digest(text.as_bytes())); manifest.insert( format!("/{name}"), json!({"size": text.len(), "checksum": format!("sha256-{hash}")}), ); } registry.source( "@demo/data/meta.json", &json!({"versions": {"1.0.0": {}}}).to_string(), ); registry.source( "@demo/data/1.0.0_meta.json", &json!({"manifest": manifest, "exports": {".": "./mod.js"}, "moduleGraph2": {}}) .to_string(), ); let main = "import {answer} from \"data\"; answer;"; files( root, &[ ("avril.conf", "imports:{data:'jsr:@demo/data@1'}"), ("main.ts", main), ], ); let flags = [ format!("--jsr-registry={}", registry.url), format!("--cache-dir={}", root.join("cache").display()), ]; let flag_refs: Vec<&str> = flags.iter().map(String::as_str).collect(); // Only explicit runtime preparation commands may write the lock. let cached = Command::new(avril()) .current_dir(root) .args(["cache"]) .args(&flag_refs) .arg("main.ts") .output() .unwrap(); assert!( cached.status.success(), "{}", String::from_utf8_lossy(&cached.stderr) ); let lock = fs::read_to_string(root.join("avril.lock")).unwrap(); for mode in ["online", "offline", "vendor"] { if mode == "vendor" { let vendored = Command::new(avril()) .current_dir(root) .args(["vendor"]) .args(&flag_refs) .args(["--offline", "--frozen"]) .arg(format!("--out={}", root.join("vendor").display())) .arg("main.ts") .output() .unwrap(); assert!( vendored.status.success(), "{}", String::from_utf8_lossy(&vendored.stderr) ); // Runtime vendoring covers executable inputs; supply the type artifact too. files(root, &[("vendor/@demo/data/1.0.0/mod.d.ts", sources[1].1)]); } let before = registry.requests.lock().unwrap().len(); let mut options: Vec = flags.to_vec(); options.push("--frozen".into()); if mode != "online" { options.push("--offline".into()); } if mode == "vendor" { options.push(format!("--vendor={}", root.join("vendor").display())); options.push(format!( "--cache-dir={}", root.join("empty-cache").display() )); } let option_refs: Vec<&str> = options.iter().map(String::as_str).collect(); let mut server = Server::start(root, &backend, &option_refs); server.initialize(); server.open("main.ts", main); assert!(hover_contains( &mut server, "main.ts", main, "answer", "answer: 123" )); let links = server.definitions("main.ts", main, "answer"); let target = links[0]["targetUri"].as_str().unwrap().to_owned(); assert!( target.starts_with("avril-http:") && target.ends_with("mod.d.ts"), "{target}" ); let remote = server .call("avril/readDocument", json!({"uri": target})) .unwrap() .as_str() .unwrap() .to_owned(); assert!(remote.contains("answer: 123")); assert_eq!( server .call( "avril/readDocument", json!({"uri": target.replace("%40", "@")}) ) .unwrap() .as_str() .unwrap(), remote ); assert!( server .hover_uri(&target, &remote, "answer") .contains("answer: 123") ); if mode == "vendor" { let path = root.join("vendor/@demo/data/1.0.0/mod.d.ts"); fs::write(&path, "export declare const answer: 999;").unwrap(); server.eventually(|s| { s.hover("main.ts", main, "answer"); s.diagnostics_uri(&target).iter().any(|d| { let message = d["message"].as_str().unwrap().to_lowercase(); message.contains("integrity") || message.contains("checksum") || message.contains("size") }) }); let links = server.definitions("main.ts", main, "answer"); assert_eq!(links.len(), 1, "{links:?}"); // The verified JavaScript source remains navigable; the corrupted // declaration must never supply the target or its text. assert_eq!(links[0]["targetUri"], target.replace("mod.d.ts", "mod.js")); assert_eq!( support::selected(sources[0].1, &links[0]["targetSelectionRange"]), "answer" ); fs::write(&path, sources[1].1).unwrap(); server.eventually(|s| { let links = s.definitions("main.ts", main, "answer"); links[0]["targetUri"] == json!(target) && s.diagnostics_uri(&target).is_empty() }); } assert_eq!(fs::read_to_string(root.join("avril.lock")).unwrap(), lock); if mode != "online" { assert_eq!( registry.requests.lock().unwrap().len(), before, "Offline language tooling made network requests" ); } server.close(); } let before = registry.requests.lock().unwrap().len(); let denied_options = [ flag_refs[0], flag_refs[1], &format!("--deny-js-exec={}", registry.url), ]; let mut denied = Server::start(root, &backend, &denied_options); denied.initialize(); denied.open("main.ts", main); denied.hover("main.ts", main, "answer"); assert!( denied .diagnostics("main.ts") .iter() .any(|d| d["message"].as_str().unwrap().contains("PermissionDenied")) ); let links = denied.definitions("main.ts", main, "answer"); assert_eq!(links.len(), 1, "{links:?}"); assert_eq!(links[0]["targetUri"], denied.uri("main.ts")); assert_eq!( support::selected(main, &links[0]["targetSelectionRange"]), "answer" ); assert_eq!( registry.requests.lock().unwrap().len(), before, "Denied resolution made registry requests" ); assert_eq!(fs::read_to_string(root.join("avril.lock")).unwrap(), lock); denied.close(); }