mod common; #[path = "common/http.rs"] #[allow(dead_code)] mod http; #[path = "common/registry.rs"] mod registry; #[path = "common/lsp.rs"] #[allow(dead_code)] mod support; use base64::Engine; use serde_json::json; use std::fs; use std::path::Path; use std::process::{Command, Output, Stdio}; fn data(mime: &str, source: &str) -> String { format!( "data:{mime};base64,{}", base64::engine::general_purpose::STANDARD.encode(source) ) } fn run(root: &Path, args: &[&str]) -> Output { let child = Command::new(env!("CARGO_BIN_EXE_avril")) .current_dir(root) .args(args) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); common::wait(child) } fn stdout(output: Output) -> String { assert!( output.status.success(), "{}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); String::from_utf8(output.stdout).unwrap() } #[test] fn static_dynamic_and_mapped_data_imports_share_url_identity() { let dir = tempfile::tempdir().unwrap(); let url = data( "text/typescript", r#" export const count: number = globalThis.loads = (globalThis.loads ?? 0) + 1; export const value: string = 'héllo?#%'; export const url = import.meta.url; export const main = import.meta.main; export const self = () => import(import.meta.url); "#, ); fs::write( dir.path().join("avril.conf"), json!({"imports": {"inline": url}}).to_string(), ) .unwrap(); fs::write(dir.path().join("main.ts"), format!(r#" import * as a from {url:?}; import * as mapped from 'inline'; export {{value}} from {url:?}; const [b, c] = await Promise.all([import({url:?}), import({upper:?})]); const other = await import({fragment:?}); console.log(a === b, b === c, a === mapped, await a.self() === a); console.log(a.count, other.count, a.value, a.main, a.url === {url:?}); console.log(import.meta.resolve('inline') === a.url, import.meta.resolve({upper:?}) === a.url); "#, upper = url.replacen("data:", "DATA:", 1), fragment = format!("{url}#second"))).unwrap(); assert_eq!( stdout(run(dir.path(), &["run", "main.ts"])), "true true true true\n1 2 héllo?#% false true\ntrue true\n" ); if dir.path().join("avril.lock").exists() { assert!( !fs::read_to_string(dir.path().join("avril.lock")) .unwrap() .contains("data:") ); } } #[test] fn importer_maps_can_redirect_or_block_data_urls() { let dir = tempfile::tempdir().unwrap(); let original = "data:text/javascript,export%20const%20value=1"; let mapped = "data:text/typescript,export%20const%20value%3Anumber=42"; let blocked = "data:text/javascript,export%20const%20value=0"; fs::write( dir.path().join("avril.conf"), json!({"imports": {original: mapped, blocked: null}}).to_string(), ) .unwrap(); fs::write( dir.path().join("main.ts"), format!( r#" import {{value}} from {original:?}; console.log(value, import.meta.resolve({original:?}) === {mapped:?}); for (const action of [() => import({blocked:?}), () => import.meta.resolve({blocked:?})]) {{ try {{ await action(); throw Error('unexpected success'); }} catch (e) {{ if (!e.message.includes('blocked by the import map')) throw e; }} }} "# ), ) .unwrap(); assert_eq!(stdout(run(dir.path(), &["run", "main.ts"])), "42 true\n"); } #[test] fn data_entrypoints_decode_standard_payloads_without_io_grants() { let dir = tempfile::tempdir().unwrap(); for url in [ "data:text/javascript,console.log('hello%20%F0%9F%98%80%3F%23%25%2B'%2Cimport.meta.main)", "data:TEXT/JAVASCRIPT;charset=iso-8859-1,console.log('hello%20%F0%9F%98%80%3F%23%25%2B'%2Cimport.meta.main)#ignored", "data:application/ecmascript,console.log('hello%20%F0%9F%98%80%3F%23%25%2B'%2Cimport.meta.main)", ] { assert_eq!( stdout(run(dir.path(), &["run", "-0", url])), "hello 😀?#%+ true\n" ); } let encoded = base64::engine::general_purpose::STANDARD_NO_PAD.encode("console.log(42)"); let url = format!( "data:application/typescript;base64,{}%20{}", &encoded[..4], &encoded[4..] ); assert_eq!(stdout(run(dir.path(), &["run", "-0", &url])), "42\n"); let url = "data:text/javascript,console.log('literal?query')"; assert_eq!( stdout(run(dir.path(), &["run", "-0", url])), "literal?query\n" ); let url = "data:text/typescript,const%20x%3Anumber=42%3Bconsole.log(x)%3B%2F%2Ffile.cjs"; assert_eq!(stdout(run(dir.path(), &["run", "-0", url])), "42\n"); assert!(!dir.path().join("avril.lock").exists()); } #[test] fn malformed_and_unsupported_data_modules_fail_without_path_fallback() { let dir = tempfile::tempdir().unwrap(); for (url, expected) in [ ("data:text/javascript", "Invalid data module URL"), ( "data:text/javascript;base64,!!!", "Invalid base64 in data module", ), ( "data:text/javascript;base64,YQ===", "Invalid base64 in data module", ), ( "data:,console.log(1)", "Unsupported data module MIME type: text/plain", ), ( "data:application/json,%7B%7D", "Unsupported data module MIME type: application/json", ), ("data:text/javascript,const%20x:number=1", "SyntaxError"), ] { let output = run(dir.path(), &["run", "-0", url]); assert!(!output.status.success(), "{url}"); let error = String::from_utf8_lossy(&output.stderr); assert!(error.contains(expected), "{url}: {error}"); } } #[test] fn data_modules_have_no_relative_or_inherited_package_context() { let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("dep.ts"), "export const value = 42;").unwrap(); fs::write(dir.path().join("avril.conf"), "imports:{alias:'./dep.ts'}").unwrap(); let url = data( "text/typescript", r#" for (const name of ['./dep.ts', '../dep.ts', '/dep.ts', '?query', '#fragment', 'alias']) { for (const action of [() => import(name), () => import.meta.resolve(name)]) { try { await action(); throw Error('unexpected success'); } catch (e) { if (!/absolute import URLs|Unsupported module specifier/.test(e.message)) throw e; } } } console.log('no base'); "#, ); fs::write( dir.path().join("main.ts"), format!("await import({url:?});"), ) .unwrap(); assert_eq!(stdout(run(dir.path(), &["run", "main.ts"])), "no base\n"); } #[test] fn data_module_dependencies_obey_live_execution_authority() { let dir = tempfile::tempdir().unwrap(); let dep = dir.path().join("dep.ts"); fs::write( &dep, "export const value: number = 42; export const url = import.meta.url;", ) .unwrap(); let dep = url::Url::from_file_path(dep).unwrap().to_string(); let url = data( "text/typescript", &format!( r#" import {{pledge}} from 'avril:std'; const target = {dep:?}; const first = await import(target); console.log(first.value, import.meta.resolve(target) === first.url); pledge({{jsExec: false}}); try {{ await import(target); throw Error('allowed cached import'); }} catch (e) {{ if (!e.message.includes('PermissionDenied')) throw e; }} console.log('narrowed'); "# ), ); let denied = run(dir.path(), &["run", "-0", &url]); assert!(!denied.status.success()); assert!(String::from_utf8_lossy(&denied.stderr).contains("PermissionDenied")); assert_eq!( stdout(run( dir.path(), &["run", "-0", "--allow-js-exec=dep.ts", &url] )), "42 true\nnarrowed\n" ); let static_url = data( "text/typescript", &format!("import {{value}} from {dep:?}; console.log(value);"), ); assert_eq!( stdout(run( dir.path(), &["run", "-0", "--allow-js-exec=dep.ts", &static_url] )), "42\n" ); } #[test] fn data_modules_import_http_under_normal_authority() { let server = http::Http::new(); server.source( "dep.ts", "export const value: number = 42; export const url = import.meta.url;", ); let dir = tempfile::tempdir().unwrap(); let dep = server.url.join("dep.ts").unwrap().to_string(); let url = data( "text/typescript", &format!( "import {{value, url}} from {dep:?}; console.log(value, import.meta.resolve({dep:?}) === url);" ), ); let denied = run(dir.path(), &["run", "-0", &url]); assert!(!denied.status.success()); assert!(String::from_utf8_lossy(&denied.stderr).contains("PermissionDenied")); assert!(server.requests.lock().unwrap().is_empty()); let grant = format!("--allow-js-exec={}", server.url); assert_eq!( stdout(run(dir.path(), &["run", "-0", &grant, &url])), "42 true\n" ); } #[test] fn data_modules_import_registry_packages_without_inheriting_bare_bindings() { let server = registry::Registry::new(); let npm_inline = data("text/javascript", "export const value = 42;"); let npm_source = format!( "export {{value}} from {npm_inline:?}; if(import.meta.resolve({npm_inline:?}) !== {npm_inline:?}) throw Error('data URL identity');" ); server.publish_npm( "inline-dep", "1.0.0", &[ ( "package.json", r#"{"name":"inline-dep","version":"1.0.0","type":"module","exports":"./mod.js"}"#, ), ("mod.js", &npm_source), ], ); let inline = data("text/typescript", "export const value: number = 43;"); let jsr_source = format!("export {{value}} from {inline:?};"); server.package( "@test/inline", "1.0.0", &[("mod.ts", &jsr_source)], &[(".", "./mod.ts")], ); let dir = tempfile::tempdir().unwrap(); let registry = format!("--npm-registry={}", server.url); let jsr_registry = format!("--jsr-registry={}", server.url); let cache = format!("--cache-dir={}", dir.path().join("cache").display()); let url = data( "text/typescript", r#" import {value} from 'npm:inline-dep@1'; import {value as jsr} from 'jsr:@test/inline@1'; console.log(value, (await import(import.meta.resolve('npm:inline-dep@1'))).value, jsr); "#, ); let denied = run(dir.path(), &["run", "-0", ®istry, &cache, &url]); assert!(!denied.status.success()); assert!(String::from_utf8_lossy(&denied.stderr).contains("PermissionDenied")); assert!(server.requests.lock().unwrap().is_empty()); let grant = format!("--allow-js-exec={}", server.url); assert_eq!( stdout(run( dir.path(), &["run", "-0", &grant, ®istry, &jsr_registry, &cache, &url] )), "42 42 43\n" ); } #[test] fn data_workers_use_typescript_and_inherit_authority() { let dir = tempfile::tempdir().unwrap(); let dep = dir.path().join("dep.ts"); fs::write(&dep, "export const value: number = 42;").unwrap(); let dep = url::Url::from_file_path(dep).unwrap().to_string(); let worker = data( "text/typescript", &format!( r#" import {{value}} from {dep:?}; const answer: number = value; postMessage([answer, import.meta.url === location.href, import.meta.main]); close(); "# ), ); let main = data( "text/typescript", &format!( r#" const worker = new Worker({worker:?}, {{type: 'module'}}); const result = await new Promise((resolve, reject) => {{ worker.onmessage = event => resolve(event.data); worker.onerror = reject; }}); console.log(JSON.stringify(result)); "# ), ); assert_eq!( stdout(run( dir.path(), &["run", "-0", "--allow-js-exec=dep.ts", &main] )), "[42,true,true]\n" ); let denied = run(dir.path(), &["run", "-0", &main]); assert!(!denied.status.success()); assert!(String::from_utf8_lossy(&denied.stderr).contains("PermissionDenied")); } #[test] fn bundling_uses_data_module_mime_types_and_preserves_source_maps() { let dir = tempfile::tempdir().unwrap(); let dep = data( "text/typescript", "export const value: number = globalThis.answer ?? 42; // misleading.cjs", ); fs::write( dir.path().join("main.ts"), format!("import {{value}} from {dep:?}; console.log(value);"), ) .unwrap(); stdout(run( dir.path(), &["bundle", "main.ts", "-o", "dist/main.js", "--sourcemap"], )); assert_eq!(stdout(run(dir.path(), &["run", "dist/main.js"])), "42\n"); let map: serde_json::Value = serde_json::from_slice(&fs::read(dir.path().join("dist/main.js.map")).unwrap()).unwrap(); assert!( map["sources"].as_array().unwrap().contains(&json!(dep)), "{map}" ); let entry = data( "text/typescript", "const value: number = 43; console.log(value);", ); stdout(run(dir.path(), &["bundle", &entry, "-o", "dist/entry.js"])); assert_eq!(stdout(run(dir.path(), &["run", "dist/entry.js"])), "43\n"); let unnamed = run(dir.path(), &["bundle", &entry, "--outdir", "other"]); assert!(!unnamed.status.success()); assert!(String::from_utf8_lossy(&unnamed.stderr).contains("explicit -o")); } #[test] fn typescript_data_errors_map_back_to_original_source_locations() { let dir = tempfile::tempdir().unwrap(); let bad = data( "text/typescript", "type Erased = {\n field: string;\n};\nconst value: number = 42;\nthrow new Error('data failure');", ); let main = data( "text/typescript", &format!( r#" try {{ await import({bad:?}); throw Error('did not throw'); }} catch (e) {{ if (!e.stack.includes({location:?})) throw e; console.log('mapped'); }} "#, location = format!("{bad}:5:7") ), ); assert_eq!(stdout(run(dir.path(), &["run", "-0", &main])), "mapped\n"); } #[test] fn editor_navigation_opens_immutable_data_module_sources() { let Some(backend) = support::backend() else { eprintln!("skipping: AVRIL_TEST_TYPESCRIPT is not set"); return; }; let dir = tempfile::tempdir().unwrap(); let url = "data:text/typescript,export%20const%20value%3A42=42%3B%20%2F%2F%20%FF.cjs"; let other = url.replace("value", "%76alue"); let main = format!( "import {{value}} from {url:?}; import {{value as other}} from {other:?}; value; other;" ); fs::write(dir.path().join("main.ts"), &main).unwrap(); let mut server = support::Server::start(dir.path(), &backend, &["--offline"]); server.initialize(); server.open("main.ts", &main); assert!( server .hover("main.ts", &main, "value") .contains("value: 42") ); let links = server.definitions("main.ts", &main, "value"); let target = links[0]["targetUri"].as_str().unwrap(); assert!(target.starts_with("avril-builtin:/data/"), "{links:?}"); assert!(target.ends_with(".ts"), "{target}"); let contents = server .call("avril/readDocument", json!({"uri": target})) .unwrap(); assert_eq!(contents, "export const value:42=42; // �.cjs"); assert!( server .hover_uri(target, contents.as_str().unwrap(), "value") .contains("value: 42") ); let other_links = server.definitions("main.ts", &main, "other"); assert_ne!(links[0]["targetUri"], other_links[0]["targetUri"]); let rename = server.call( "textDocument/rename", json!({ "textDocument": {"uri": target}, "position": {"line": 0, "character": 14}, "newName": "changed" }), ); assert!( rename.is_err(), "immutable data source accepted rename: {rename:?}" ); server.close(); } #[test] fn checking_data_modules_uses_mime_languages_and_reports_type_errors() { let Some(backend) = support::backend() else { eprintln!("skipping: AVRIL_TEST_TYPESCRIPT is not set"); return; }; let dir = tempfile::tempdir().unwrap(); let backend = format!("--typescript={}", backend.display()); let ts = data( "text/typescript", "export const value: number = 42; // misleading.cjs", ); let js = data( "text/javascript", "export const text = 'hello'; // misleading.ts", ); let source = format!( "import {{value}} from {ts:?}; import {{text}} from {js:?}; const n: number = value; const s: string = text;" ); fs::write(dir.path().join("main.ts"), &source).unwrap(); stdout(run( dir.path(), &["check", &backend, "--offline", "main.ts"], )); let broken = source.replace("const n: number", "const n: string"); fs::write(dir.path().join("main.ts"), broken).unwrap(); let output = run(dir.path(), &["check", &backend, "--offline", "main.ts"]); assert!(!output.status.success()); assert!(String::from_utf8_lossy(&output.stdout).contains("TS2322")); let bad_js = data( "text/javascript", "// @ts-check\n/** @type {number} */ export const value = 'wrong';", ); let output = run(dir.path(), &["check", &backend, "--offline", &bad_js]); assert!(!output.status.success()); assert!(String::from_utf8_lossy(&output.stdout).contains("TS2322")); let bad = data("text/typescript", "export const value: number = 'wrong';"); let output = run(dir.path(), &["check", &backend, "--offline", &bad]); assert!(!output.status.success()); let diagnostics = String::from_utf8_lossy(&output.stdout); assert!(diagnostics.contains("TS2322"), "{diagnostics}"); assert!(diagnostics.contains(&format!("{bad}:1:")), "{diagnostics}"); for (url, message) in [ ( "data:application/json,%7B%7D", "Unsupported data module MIME type", ), ( "data:text/typescript;base64,!!!", "Invalid base64 in data module", ), ] { let output = run(dir.path(), &["check", &backend, "--offline", url]); assert!(!output.status.success()); let diagnostics = String::from_utf8_lossy(&output.stdout); assert!(diagnostics.contains(message), "{diagnostics}"); assert!(diagnostics.contains(&format!("{url}:1:")), "{diagnostics}"); } }