mod common; #[path = "common/http.rs"] #[allow(dead_code)] mod http_fixture; #[path = "common/registry.rs"] mod registry_fixture; use std::fs; use std::path::Path; use std::process::{Command, Output, Stdio}; const RUNTIME: &str = r#" export const Fragment = 'fragment'; export const jsx = (tag, props, key) => ({tag, props, key}); export const jsxs = jsx; export const jsxDEV = (tag, props, key, stable, source) => ({tag, props, key, stable, source}); export const createElement = (tag, props, ...children) => ({tag, props: {...props, children}, key: props?.key}); export const h = createElement, F = Fragment; "#; fn files(dir: &Path, entries: &[(&str, &str)]) { for (name, source) in entries { let path = dir.join(name); fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write(path, source).unwrap(); } } fn cli(dir: &Path, args: &[&str]) -> Output { let child = Command::new(env!("CARGO_BIN_EXE_avril")) .current_dir(dir) .args(args) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); common::wait(child) } fn success(output: Output) -> String { assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); String::from_utf8(output.stdout).unwrap() } fn failure(output: Output, message: &str) { assert!(!output.status.success()); assert!( output.stdout.is_empty(), "{}", String::from_utf8_lossy(&output.stdout) ); assert!( String::from_utf8_lossy(&output.stderr).contains(message), "{}", String::from_utf8_lossy(&output.stderr) ); } #[test] fn jsx_and_tsx_default_to_automatic_and_generated_imports_join_the_graph() { let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ( "avril.conf", "imports: {'react/jsx-runtime': './runtime.js', react: './runtime.js'}", ), ("runtime.js", RUNTIME), ( "dep.jsx", "export const child = hello & goodbye;", ), ( "main.tsx", r#" import type {Missing} from './missing.ts'; import {child} from './dep.jsx'; const Component = (props: {name: string}) => props.name; const props = {id: 1}; const node = <>{child}; const children = node.props.children; console.log(node.tag, children[0].tag === Component, children[1].props.children, children[2].props.id, children[2].key, await Promise.resolve('done')); "#, ), ], ); assert_eq!( success(cli(dir.path(), &["run", "main.tsx"])), "fragment true hello & goodbye 1 key done\n" ); let info: serde_json::Value = serde_json::from_str(&success(cli(dir.path(), &["info", "main.tsx"]))).unwrap(); let main = dir.path().join("main.tsx"); let imports = &info["modules"][main.to_str().unwrap()]["imports"]; assert!(imports["react/jsx-runtime"].is_string()); assert!(imports["react"].is_string()); assert!(imports.get("./missing.ts").is_none()); } #[test] fn tsconfig_redirects_automatic_jsx_without_adopting_typescript_paths() { let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ( "avril.conf", "imports: {'custom/jsx-runtime': './runtime.js'}", ), ( "tsconfig.json", r#"{ // Only JSX compiler options affect execution. "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "custom", "paths": {"custom/jsx-runtime": ["./wrong.js"]}, }, }"#, ), ("runtime.js", RUNTIME), ( "wrong.js", "throw Error('TypeScript paths changed runtime resolution');", ), ( "main.jsx", "console.log(().props.title);", ), ], ); assert_eq!(success(cli(dir.path(), &["run", "main.jsx"])), "hello\n"); } #[test] fn jsonc_extends_merges_in_order_and_preserves_classic_factory_imports() { let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ("avril.conf", "{}"), ( "config/first.json", r#"{"compilerOptions":{"jsx":"react","jsxFactory":"wrong","jsxFragmentFactory":"Wrong"}}"#, ), ( "config/nested/shared.base.json", r#"{"compilerOptions":{"jsxFragmentFactory":"F"}}"#, ), ( "config/second.json", r#"{"extends":"./nested/shared.base","compilerOptions":{"jsxFactory":"h"}}"#, ), ( "tsconfig.json", "\u{feff}{\"extends\":[\"./config/first\",\"./config/second.json\"],\"compilerOptions\":{\"jsxFactory\":\"helpers.h\",},}", ), ("runtime.js", RUNTIME), ( "main.tsx", r#" import * as helpers from './runtime.js'; import {F} from './runtime.js'; const node = <>

classic

; console.log(node.tag, node.props.children[0].tag, node.props.children[0].props.children[0]); "#, ), ], ); assert_eq!( success(cli(dir.path(), &["run", "main.tsx"])), "fragment p classic\n" ); } #[test] fn development_jsx_and_stack_traces_retain_source_locations() { let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ( "avril.conf", "imports: {'custom/jsx-dev-runtime': './runtime.js'}", ), ( "tsconfig.json", r#"{"compilerOptions":{"jsx":"react-jsxdev","jsxImportSource":"custom"}}"#, ), ("runtime.js", RUNTIME), ( "main.tsx", "type Gone = number;\nconst node =

;\nconsole.log(node.source.lineNumber, node.source.columnNumber, node.source.fileName.endsWith('main.tsx'));\nthrow Error('mapped');\n", ), ], ); let output = cli(dir.path(), &["run", "main.tsx"]); assert!(!output.status.success()); assert_eq!(output.stdout, b"2 14 true\n"); let stderr = String::from_utf8_lossy(&output.stderr); assert!(stderr.contains("main.tsx:4:7"), "{stderr}"); } #[test] fn nearest_configs_apply_only_within_their_own_package() { let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ("avril.conf", "{}"), ( "tsconfig.json", r#"{"compilerOptions":{"jsx":"react","jsxFactory":"h"}}"#, ), ("runtime.js", RUNTIME), ( "main.tsx", "import {h} from './runtime.js'; import {nested} from './src/nested.tsx'; import {library} from './lib/mod.tsx'; console.log((

).tag, nested.tag, library.tag);", ), ( "src/tsconfig.json", r#"{"compilerOptions":{"jsx":"react","jsxFactory":"custom"}}"#, ), ( "src/nested.tsx", "import {h as custom} from '../runtime.js'; export const nested = ;", ), ( "lib/avril.conf", "imports: {'react/jsx-runtime': '../runtime.js'}", ), ("lib/mod.tsx", "export const library = ;"), ], ); assert_eq!( success(cli(dir.path(), &["run", "main.tsx"])), "p nested library\n" ); } #[test] fn avril_conf_defaults_yield_to_tsconfig_and_stay_in_their_package() { let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ( "avril.conf", "compilerOptions: {jsx: 'react', jsxFactory: 'conf', paths: {}}", ), ("runtime.js", RUNTIME), ( "main.tsx", "import {h as conf} from './runtime.js'; import {library} from './lib/mod.tsx'; console.log((

).tag, library.tag);", ), ( "lib/avril.conf", "imports: {'react/jsx-runtime': '../runtime.js'}", ), ("lib/mod.tsx", "export const library = ;"), ], ); assert_eq!( success(cli(dir.path(), &["run", "main.tsx"])), "p library\n" ); files( dir.path(), &[( "main.tsx", "import {h as ts} from './runtime.js'; console.log((

).tag);", )], ); failure(cli(dir.path(), &["run", "main.tsx"]), "conf is not defined"); files( dir.path(), &[( "tsconfig.json", r#"{"compilerOptions":{"jsxFactory":"ts"}}"#, )], ); assert_eq!(success(cli(dir.path(), &["run", "main.tsx"])), "p\n"); } #[test] fn configs_without_avril_packages_are_discovered_in_trusted_ancestors() { let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ( "tsconfig.json", r#"{"compilerOptions":{"jsx":"react","jsxFactory":"h","jsxFragmentFactory":"F"}}"#, ), ("runtime.js", RUNTIME), ( "src/main.tsx", "import {h,F} from '../runtime.js'; console.log((<>

).tag);", ), ], ); assert_eq!( success(cli(dir.path(), &["run", "src/main.tsx"])), "fragment\n" ); } #[test] fn pragmas_override_config_and_remote_jsx_never_inherits_local_settings() { let dir = tempfile::tempdir().unwrap(); let http = http_fixture::Http::new(); files( dir.path(), &[ ("avril.conf", "{}"), ( "tsconfig.json", r#"{"compilerOptions":{"jsx":"react","jsxFactory":"missing"}}"#, ), ( "main.tsx", "/** @jsxRuntime classic @jsx h @jsxFrag F */\nconst h = (tag: string) => tag, F = 'fragment'; console.log(<>

);", ), ], ); assert_eq!(success(cli(dir.path(), &["run", "main.tsx"])), "fragment\n"); http.source("ui/jsx-runtime", RUNTIME); http.source("nested/main.tsx", &format!("/** @jsxImportSource {} */\nconst n: string = 'remote'; console.log((

{{n}}

).props.children);", http.name("ui"))); http.redirect("entry", "/nested/main.tsx"); assert_eq!( success(cli( dir.path(), &[ "run", "-0", &format!("--allow-js-exec={}", http.url), "--cache-dir=cache", &http.name("entry") ] )), "remote\n" ); } #[test] fn invalid_configs_and_preserved_jsx_fail_before_application_execution() { for (config, expected) in [ ( r#"{"compilerOptions":{"jsx":"preserve"}}"#, "cannot execute preserved JSX", ), ( r#"{"compilerOptions":{"jsx":"react-native"}}"#, "cannot execute preserved JSX", ), ( r#"{"compilerOptions":{"jsx":"unknown"}}"#, "Unsupported compilerOptions.jsx", ), ( r#"{"compilerOptions":{"jsxImportSource":3}}"#, "jsxImportSource must be", ), ( r#"{"compilerOptions":{"jsxFactory":""}}"#, "jsxFactory must be", ), ( r#"{"compilerOptions":{"jsx":"react","jsxFactory":"h()"} }"#, "jsxFactory must be an identifier", ), ( r#"{"compilerOptions":{"jsxFragmentFactory":"a..b"}}"#, "jsxFragmentFactory must be an identifier", ), ( r#"{"compilerOptions":[]}"#, "compilerOptions must be an object", ), ( r#"{"extends":"npm:config"}"#, "extends must use relative or absolute", ), (r#"{"extends":"./missing.json"}"#, "Cannot find tsconfig"), ( r#"{"extends":"./tsconfig.json"}"#, "Cyclic tsconfig extends", ), (r#"{"extends":[3]}"#, "extends entries must be paths"), (r#"{"extends":3}"#, "extends must be a path"), ("[]", "tsconfig must be an object"), ("{bad}", "tsconfig.json"), ("{'compilerOptions': {}}", "tsconfig.json"), ] { let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ("tsconfig.json", config), ( "main.tsx", "console.log('executed'); export const node =

;", ), ], ); failure(cli(dir.path(), &["run", "main.tsx"]), expected); } let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[("main.tsx", "const n =

; console.log('executed');")], ); failure(cli(dir.path(), &["run", "main.tsx"]), "main.tsx"); } #[test] fn config_discovery_and_explicit_extends_respect_execution_authority() { let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ("tsconfig.json", "not json"), ( "main.tsx", "/** @jsxRuntime classic @jsx h */\nconst h = (tag: string) => tag; console.log(

);", ), ], ); assert_eq!(success(cli(dir.path(), &["run", "-0", "main.tsx"])), "p\n"); failure( cli(dir.path(), &["run", "-0", "--allow-js-exec=.", "main.tsx"]), "tsconfig.json", ); files( dir.path(), &[ ("tsconfig.json", r#"{"extends":"./private/base.json"}"#), ( "private/base.json", r#"{"compilerOptions":{"jsx":"react"}}"#, ), ], ); failure( cli(dir.path(), &["run", "--deny-js-exec=./private", "main.tsx"]), "PermissionDenied", ); } #[test] fn generated_imports_require_the_same_authority_as_written_imports() { let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ( "avril.conf", "imports: {'react/jsx-runtime': './runtime.js'}", ), ("runtime.js", RUNTIME), ( "main.tsx", "console.log('executed'); export const node =

;", ), ], ); failure( cli( dir.path(), &["run", "--deny-js-exec=./runtime.js", "main.tsx"], ), "PermissionDenied", ); } #[cfg(unix)] #[test] fn symlink_and_untrusted_configs_are_not_compiler_authorities() { use std::os::unix::fs::{PermissionsExt, symlink}; let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ("target.json", r#"{"compilerOptions":{"jsx":"react"}}"#), ( "main.tsx", "/** @jsxRuntime classic @jsx h */\nconst h = (tag: string) => tag; console.log(

);", ), ], ); symlink("target.json", dir.path().join("tsconfig.json")).unwrap(); failure(cli(dir.path(), &["run", "main.tsx"]), "non-symlink"); assert_eq!(success(cli(dir.path(), &["run", "-0", "main.tsx"])), "p\n"); fs::remove_file(dir.path().join("tsconfig.json")).unwrap(); files( dir.path(), &[("tsconfig.json", r#"{"compilerOptions":{"jsx":"react"}}"#)], ); fs::set_permissions( dir.path().join("tsconfig.json"), fs::Permissions::from_mode(0o666), ) .unwrap(); failure(cli(dir.path(), &["run", "main.tsx"]), "Untrusted config"); } #[test] fn test_discovery_and_workers_execute_tsx_using_their_own_loader() { let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ( "avril.conf", "imports: {'react/jsx-runtime': './runtime.js'}", ), ("runtime.js", RUNTIME), ("worker.tsx", "postMessage(().tag); close();"), ( "ui_test.tsx", r#" import {test} from 'avril:test'; test('tsx and worker', async () => { if ((

).tag !== 'p') throw Error('Wrong element'); const worker = new Worker(new URL('./worker.tsx', import.meta.url), {type:'module'}); const tag = await new Promise((resolve, reject) => { worker.onmessage = ({data}) => resolve(data); worker.onerror = e => {e.preventDefault(); reject(Error(e.message));}; }); worker.terminate(); if (tag !== 'worker') throw Error('Wrong worker element'); }); "#, ), ], ); assert!(success(cli(dir.path(), &["test"])).contains("ok - tsx and worker")); } #[test] fn implicit_jsr_runtime_imports_are_locked_vendored_and_usable_offline() { let dir = tempfile::tempdir().unwrap(); let registry = registry_fixture::Registry::new(); let runtime = format!("/** @ts-self-types=\"./jsx-runtime.d.ts\" */\n{RUNTIME}"); registry.package("@test/ui", "1.0.0", &[ ("jsx-runtime.js", &runtime), ("jsx-runtime.d.ts", "export const jsx: (tag: string, props: object) => object; export namespace JSX { export interface IntrinsicElements { p: {}; } }"), ], &[("./jsx-runtime", "./jsx-runtime.js")]); files( dir.path(), &[ ( "tsconfig.json", r#"{"compilerOptions":{"jsxImportSource":"jsr:@test/ui@1"}}"#, ), ("main.tsx", "console.log((

).tag);"), ], ); let registry_option = format!("--jsr-registry={}", registry.url); assert_eq!( success(cli( dir.path(), &["run", ®istry_option, "--cache-dir=cache", "main.tsx"] )), "p\n" ); let lock = registry_fixture::read_lock(dir.path()); assert!(lock.to_string().contains("@test/ui")); success(cli( dir.path(), &["vendor", ®istry_option, "--cache-dir=cache", "main.tsx"], )); fs::remove_dir_all(dir.path().join("cache")).unwrap(); let before = registry.requests.lock().unwrap().len(); assert_eq!( success(cli( dir.path(), &[ "run", ®istry_option, "--cache-dir=cache", "--offline", "--frozen", "--vendor=vendor", "main.tsx" ] )), "p\n" ); assert_eq!(registry.requests.lock().unwrap().len(), before); } #[test] fn denied_package_boundaries_and_later_config_revocation_do_not_leak_ancestor_settings() { let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ( "tsconfig.json", r#"{"compilerOptions":{"jsx":"react","jsxFactory":"wrong"}}"#, ), ("child/avril.conf", "{}"), ("child/main.tsx", "export const node =

;"), ], ); failure( cli( dir.path(), &["run", "--deny-js-exec=./child/avril.conf", "child/main.tsx"], ), "react/jsx-runtime", ); files( dir.path(), &[ ("avril.conf", "{}"), ( "tsconfig.json", r#"{"compilerOptions":{"jsxImportSource":"./runtime"}}"#, ), ("runtime/jsx-runtime", RUNTIME), ("first.tsx", "export const node = ;"), ("second.tsx", "export const node = ;"), ( "main.js", r#" import {pledge} from 'avril:std'; console.log((await import('./first.tsx')).node.tag); pledge({jsExec: {deny: ['./tsconfig.json']}}); try { await import('./second.tsx'); throw Error('Revoked config used'); } catch (error) { if (!error.message.includes('react/jsx-runtime')) throw error; console.log('revoked'); } "#, ), ], ); assert_eq!( success(cli(dir.path(), &["run", "main.js"])), "first\nrevoked\n" ); }