mod common; #[path = "common/http.rs"] #[allow(dead_code)] mod http_fixture; use std::fs; use std::io::Write; use std::path::Path; use std::process::{Command, Output, Stdio}; const PRINT_ARGS: &str = r#" import {process} from 'avril:std'; console.log(JSON.stringify(process.args)); "#; fn run(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 repl(dir: &Path, args: &[&str], source: &str) -> Output { let mut child = Command::new(env!("CARGO_BIN_EXE_avril")) .current_dir(dir) .args(args) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); child .stdin .take() .unwrap() .write_all(source.as_bytes()) .unwrap(); common::wait(child) } fn success(output: &Output) { assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); } fn assert_args(output: &Output, expected: &[&str]) { assert_eq!( serde_json::from_slice::>(&output.stdout).unwrap(), expected ); } #[test] fn script_arguments_are_verbatim_frozen_and_available_without_capabilities() { let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("main.js"), r#" import {process} from 'avril:std'; if (!Array.isArray(process.args) || !Object.isFrozen(process.args)) throw Error('Not a frozen array'); if (process.args !== (await import('avril:std')).process.args) throw Error('Unstable identity'); const before = JSON.stringify(process.args); for (const mutate of [ () => process.args.push('extra'), () => process.args[0] = 'changed', () => process.args.length = 0, () => process.args = [], ]) { let denied = false; try { mutate(); } catch (error) { if (!(error instanceof TypeError)) throw error; denied = true; } if (!denied) throw Error('Mutation accepted'); } if (JSON.stringify(process.args) !== before) throw Error('Arguments changed'); console.log(before); "#).unwrap(); let args = [ "", "two words", "héllo 🫐", "a\nb", "'quotes'\"", "*", "$HOME", "--help", "-h", "-0", "--allow-read=/", "--deny-read=/", "--offline", "--env-file", "missing.env", "--env-file=missing.env", "--unknown", "--", "--", "other.js", ]; let mut cli = vec!["run", "-0", "main.js"]; cli.extend(args); let output = run(dir.path(), &cli); success(&output); assert_args(&output, &args); } #[test] fn no_arguments_means_an_empty_array_and_a_trailing_separator_is_preserved() { let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("main.js"), PRINT_ARGS).unwrap(); for cli in [&["run", "main.js"][..], &["run", "--", "main.js"]] { let output = run(dir.path(), cli); success(&output); assert_args(&output, &[]); } let output = run(dir.path(), &["run", "main.js", "--"]); success(&output); assert_args(&output, &["--"]); } #[test] fn argument_flags_cannot_grant_or_revoke_capabilities() { let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("secret"), "secret").unwrap(); fs::write(dir.path().join("main.js"), r#" import {readTextFile} from 'avril:fs'; import {errors} from 'avril:std'; try { console.log(await readTextFile('secret')); } catch (error) { if (!(error instanceof errors.PermissionDenied)) throw error; console.log('denied'); } "#).unwrap(); let output = run(dir.path(), &["run", "-0", "main.js", "--allow-read=."]); success(&output); assert_eq!(output.stdout, b"denied\n"); let output = run(dir.path(), &["run", "main.js", "-0", "--deny-read=."]); success(&output); assert_eq!(output.stdout, b"secret\n"); } #[test] fn environment_file_values_are_not_mistaken_for_the_entrypoint() { let dir = tempfile::tempdir().unwrap(); fs::write( dir.path().join("settings.env"), "AVRIL_ARGS_FIXTURE=loaded\n", ) .unwrap(); fs::write( dir.path().join("main.js"), r#" import {env, process} from 'avril:std'; if (env.get('AVRIL_ARGS_FIXTURE') !== 'loaded') throw Error('Environment file not loaded'); console.log(JSON.stringify(process.args)); "#, ) .unwrap(); for option in [ &["--env-file", "settings.env"][..], &["--env-file=settings.env"], ] { let mut cli = vec!["run", "-0", "--allow-env=AVRIL_ARGS_FIXTURE"]; cli.extend(option); cli.extend(["main.js", "--help", "-0", "--env-file=missing.env"]); let output = run(dir.path(), &cli); success(&output); assert_args(&output, &["--help", "-0", "--env-file=missing.env"]); cli.insert(cli.len() - 4, "--help"); let help = run(dir.path(), &cli); success(&help); assert!(String::from_utf8_lossy(&help.stdout).contains("Usage: avril run")); } } #[test] fn a_separator_before_the_entry_allows_flag_shaped_filenames() { let dir = tempfile::tempdir().unwrap(); for name in ["--help", "-0"] { fs::write(dir.path().join(name), PRINT_ARGS).unwrap(); let output = run(dir.path(), &["run", "--", name, "--help", "--", "-0"]); success(&output); assert_args(&output, &["--help", "--", "-0"]); } } #[test] fn remote_entrypoints_get_arguments_without_consuming_their_offline_flag() { let dir = tempfile::tempdir().unwrap(); let http = http_fixture::Http::new(); http.source("main.js", PRINT_ARGS); let output = run( dir.path(), &[ "run", "-0", "--cache-dir=cache", &http.name("main.js"), "--offline", "--help", ], ); success(&output); assert_args(&output, &["--offline", "--help"]); } #[test] fn serve_passes_arguments_without_parsing_them_as_listener_options() { let dir = tempfile::tempdir().unwrap(); fs::write( dir.path().join("main.js"), format!("{PRINT_ARGS}\nthrow Error('Stopped before listening');"), ) .unwrap(); let args = [ "--port=not-a-port", "--unix=not-a-socket", "--help", "--env-file=missing.env", ]; let mut cli = vec!["serve", "-0", "--port=0", "main.js"]; cli.extend(args); let output = run(dir.path(), &cli); assert!(!output.status.success()); assert!(String::from_utf8_lossy(&output.stderr).contains("Stopped before listening")); assert_args(&output, &args); } #[test] fn workers_and_nested_workers_inherit_frozen_arguments() { let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("main.js"), r#" import {process, avril} from 'avril:std'; const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module', [avril]: {caps: {env: false, sys: false}}, }); worker.onerror = event => { throw Error(event.message); }; worker.onmessage = ({data}) => { if (JSON.stringify(data) !== JSON.stringify(process.args)) throw Error('Arguments differ'); console.log(JSON.stringify(data)); worker.terminate(); }; "#).unwrap(); fs::write(dir.path().join("worker.js"), r#" import {process} from 'avril:std'; if (!Object.isFrozen(process.args)) throw Error('Mutable worker arguments'); const worker = new Worker(new URL('./nested.js', import.meta.url), {type: 'module'}); worker.onerror = event => { throw Error(event.message); }; worker.onmessage = ({data}) => { if (JSON.stringify(data) !== JSON.stringify(process.args)) throw Error('Arguments differ'); postMessage(data); close(); }; "#).unwrap(); fs::write( dir.path().join("nested.js"), r#" import {process} from 'avril:std'; if (!Object.isFrozen(process.args)) throw Error('Mutable nested arguments'); postMessage(process.args); close(); "#, ) .unwrap(); let output = run( dir.path(), &[ "run", "-0", "--allow-js-exec=.", "main.js", "", "--help", "hello", ], ); success(&output); assert_args(&output, &["", "--help", "hello"]); } #[test] fn repl_and_test_sessions_default_to_empty_script_arguments() { let dir = tempfile::tempdir().unwrap(); for args in [&["repl", "-0"][..], &["repl", "-0", "--"], &["--"]] { let output = repl( dir.path(), args, "(await import('avril:std')).process.args\n", ); success(&output); assert_eq!(output.stdout, b"[]\n"); } fs::write(dir.path().join("args_test.js"), r#" import {process} from 'avril:std'; import {test} from 'avril:test'; test('empty args', () => { if (process.args.length !== 0 || !Object.isFrozen(process.args)) throw Error('Unexpected args'); }); "#).unwrap(); for args in [ &["test", "-0", "args_test.js", "--filter=empty args"][..], &["test", "-0", "args_test.js", "--filter=empty args", "--"], ] { let output = run(dir.path(), args); success(&output); assert!(String::from_utf8_lossy(&output.stdout).contains("ok - empty args")); } } #[test] fn explicit_and_default_repls_preserve_arguments_after_the_separator() { let dir = tempfile::tempdir().unwrap(); let args = [ "", "two words", "héllo 🫐", "a\nb", "--help", "-h", "-0", "--allow-read=.", "--env-file=missing.env", "--unknown", "--", "--", ]; let expected = serde_json::to_string(&args).unwrap(); let source = format!( "const args = (await import('avril:std')).process.args; Object.isFrozen(args) && JSON.stringify(args) === JSON.stringify({expected})\nargs === (await import('avril:std')).process.args\n" ); for prefix in [&["repl", "-0"][..], &["-0"], &["repl"], &[]] { let mut cli = prefix.to_vec(); cli.push("--"); cli.extend(args); let output = repl(dir.path(), &cli, &source); success(&output); assert_eq!(output.stdout, b"true\ntrue\n"); } } #[test] fn test_targets_and_runner_options_are_separate_from_shared_script_arguments() { let dir = tempfile::tempdir().unwrap(); let args = [ "", "héllo 🫐", "a\nb", "--help", "-0", "--no-run", "--filter=other", "--env-file=missing.env", "--", "not_a_target.js", ]; let expected = serde_json::to_string(&args).unwrap(); let source = format!( r#" import {{process, env}} from 'avril:std'; import {{test}} from 'avril:test'; if (env.get('AVRIL_ARGS_FIXTURE') !== 'loaded') throw Error('Environment not loaded'); if (!Object.isFrozen(process.args) || JSON.stringify(process.args) !== JSON.stringify({expected})) throw Error('Wrong arguments during collection'); test('chosen', () => {{ if (JSON.stringify(process.args) !== JSON.stringify({expected})) throw Error('Wrong arguments during test'); }}); test('filtered', () => {{ throw Error('Filter ignored'); }}); "# ); for name in ["a_test.js", "b_test.js"] { fs::write(dir.path().join(name), &source).unwrap(); } fs::write( dir.path().join("settings.env"), "AVRIL_ARGS_FIXTURE=loaded\n", ) .unwrap(); for targets in [ &[ "a_test.js", "b_test.js", "--filter", "chosen", "--env-file", "settings.env", ][..], &["--filter=chosen", "--env-file=settings.env"], ] { let mut cli = vec!["test", "-0", "--allow-env=AVRIL_ARGS_FIXTURE"]; cli.extend(targets); cli.push("--"); cli.extend(args); let output = run(dir.path(), &cli); success(&output); assert_eq!( String::from_utf8_lossy(&output.stdout) .matches("ok - chosen") .count(), 2 ); } } #[test] fn repl_and_test_argument_flags_cannot_change_authority() { let dir = tempfile::tempdir().unwrap(); for restricted in [true, false] { let check = format!( r#" const {{process, errors}} = await import('avril:std'); let allowed = true; try {{ process.cwd(); }} catch (error) {{ if (!(error instanceof errors.PermissionDenied)) throw error; allowed = false; }} if (allowed !== {}) throw Error('Arguments changed authority'); return true; "#, !restricted ); let guest = if restricted { &["--allow-read=."][..] } else { &["-0", "--deny-read=."][..] }; let mut cli = vec!["repl"]; if restricted { cli.push("-0"); } cli.push("--"); cli.extend(guest); let source = format!("await (async () => {{{}}})()\n", check.replace('\n', " ")); let output = repl(dir.path(), &cli, &source); success(&output); assert_eq!(output.stdout, b"true\n"); fs::write( dir.path().join("authority_test.js"), format!( "import {{test}} from 'avril:test'; test('authority', async () => {{{check}}});" ), ) .unwrap(); cli = vec!["test"]; if restricted { cli.push("-0"); } cli.extend(["authority_test.js", "--"]); cli.extend(guest); let output = run(dir.path(), &cli); success(&output); assert!(String::from_utf8_lossy(&output.stdout).contains("ok - authority")); } } #[test] fn remote_tests_and_no_run_collection_receive_arguments() { let dir = tempfile::tempdir().unwrap(); let http = http_fixture::Http::new(); let source = r#" import {process} from 'avril:std'; import {test} from 'avril:test'; if (JSON.stringify(process.args) !== '["hello","--help"]') throw Error('Wrong arguments'); console.log('collected arguments'); test('remote arguments', () => { if (process.args[0] !== 'hello') throw Error('Wrong arguments'); }); "#; http.source("args_test.js", source); let output = run( dir.path(), &[ "test", "-0", "--cache-dir=cache", &http.name("args_test.js"), "--", "hello", "--help", ], ); success(&output); assert!(String::from_utf8_lossy(&output.stdout).contains("ok - remote arguments")); fs::write( dir.path().join("args_test.js"), format!("{source}\ntest('must not run', () => {{ throw Error('Test executed'); }});"), ) .unwrap(); let output = run( dir.path(), &[ "test", "-0", "--no-run", "args_test.js", "--", "hello", "--help", ], ); success(&output); assert!(String::from_utf8_lossy(&output.stdout).contains("collected arguments")); } #[test] fn workers_in_repl_and_test_sessions_inherit_script_arguments() { let dir = tempfile::tempdir().unwrap(); fs::write( dir.path().join("worker.js"), r#" import {process} from 'avril:std'; postMessage({args: process.args, frozen: Object.isFrozen(process.args)}); close(); "#, ) .unwrap(); let check = r#" const {process} = await import('avril:std'); const worker = new Worker('./worker.js', {type: 'module'}); const data = await new Promise((resolve, reject) => { worker.onmessage = ({data}) => resolve(data); worker.onerror = event => { event.preventDefault(); reject(Error(event.message)); }; }); worker.terminate(); if (!data.frozen || JSON.stringify(data.args) !== '["hello","--help"]' || JSON.stringify(data.args) !== JSON.stringify(process.args)) throw Error('Wrong worker arguments'); return true; "#; let source = format!("await (async () => {{{}}})()\n", check.replace('\n', " ")); let output = repl( dir.path(), &["repl", "-0", "--allow-js-exec=.", "--", "hello", "--help"], &source, ); success(&output); assert_eq!(output.stdout, b"true\n"); fs::write( dir.path().join("args_test.js"), format!( "import {{test}} from 'avril:test'; test('worker arguments', async () => {{{check}}});" ), ) .unwrap(); let output = run( dir.path(), &[ "test", "-0", "--allow-js-exec=.", "args_test.js", "--", "hello", "--help", ], ); success(&output); assert!(String::from_utf8_lossy(&output.stdout).contains("ok - worker arguments")); } #[test] fn session_separators_preserve_runtime_help_and_option_validation() { let dir = tempfile::tempdir().unwrap(); for cli in [ &["repl", "--help", "--", "--unknown"][..], &["test", "--help", "--", "--unknown"], &["--help", "--", "--unknown"], ] { let output = run(dir.path(), cli); success(&output); let text = String::from_utf8_lossy(&output.stdout); assert!( text.contains("Usage: avril") && text.contains("[-- args...]"), "{text}" ); } for (cli, message) in [ ( &["test", "--filter", "--", "value"][..], "Expected a pattern for --filter", ), ( &["test", "--env-file", "--", "value"][..], "Expected a path for --env-file", ), ( &["repl", "--unknown", "--", "value"][..], "Unknown option: --unknown", ), ( &["repl", "not-an-argument", "--", "value"][..], "The REPL does not accept a module", ), ] { let output = run(dir.path(), cli); assert!(!output.status.success()); assert!( String::from_utf8_lossy(&output.stderr).contains(message), "{output:?}" ); } fs::write( dir.path().join("-case.js"), r#" import {process} from 'avril:std'; import {test} from 'avril:test'; test('-- title', () => { if (JSON.stringify(process.args) !== '["--","--help"]') throw Error('Wrong arguments'); }); "#, ) .unwrap(); let output = run( dir.path(), &[ "test", "-0", "./-case.js", "--filter=--", "--", "--", "--help", ], ); success(&output); assert!(String::from_utf8_lossy(&output.stdout).contains("ok - -- title")); }