char/avril

(wip) why'd you have to go and make things so complicaaaaated?

git clone https://git.t4t.associates/char/avril

Charlotte Somsnapshot: embed shared main and worker startup in the CLI688e96e

main
19.2 KiB622 linesraw
1mod common;
2#[path = "common/http.rs"]
3#[allow(dead_code)]
4mod http_fixture;
5
6use std::fs;
7use std::io::Write;
8use std::path::Path;
9use std::process::{Command, Output, Stdio};
10
11const PRINT_ARGS: &str = r#"
12    import {process} from 'avril:std';
13    console.log(JSON.stringify(process.args));
14"#;
15
16fn run(dir: &Path, args: &[&str]) -> Output {
17    let child = Command::new(env!("CARGO_BIN_EXE_avril"))
18        .current_dir(dir)
19        .args(args)
20        .stdin(Stdio::null())
21        .stdout(Stdio::piped())
22        .stderr(Stdio::piped())
23        .spawn()
24        .unwrap();
25    common::wait(child)
26}
27
28fn repl(dir: &Path, args: &[&str], source: &str) -> Output {
29    let mut child = Command::new(env!("CARGO_BIN_EXE_avril"))
30        .current_dir(dir)
31        .args(args)
32        .stdin(Stdio::piped())
33        .stdout(Stdio::piped())
34        .stderr(Stdio::piped())
35        .spawn()
36        .unwrap();
37    child
38        .stdin
39        .take()
40        .unwrap()
41        .write_all(source.as_bytes())
42        .unwrap();
43    common::wait(child)
44}
45
46fn success(output: &Output) {
47    assert!(
48        output.status.success(),
49        "{}",
50        String::from_utf8_lossy(&output.stderr)
51    );
52}
53
54fn assert_args(output: &Output, expected: &[&str]) {
55    assert_eq!(
56        serde_json::from_slice::<Vec<String>>(&output.stdout).unwrap(),
57        expected
58    );
59}
60
61#[test]
62fn script_arguments_are_verbatim_frozen_and_available_without_capabilities() {
63    let dir = tempfile::tempdir().unwrap();
64    fs::write(dir.path().join("main.js"), r#"
65        import {process} from 'avril:std';
66        if (!Array.isArray(process.args) || !Object.isFrozen(process.args)) throw Error('Not a frozen array');
67        if (process.args !== (await import('avril:std')).process.args) throw Error('Unstable identity');
68        const before = JSON.stringify(process.args);
69        for (const mutate of [
70            () => process.args.push('extra'),
71            () => process.args[0] = 'changed',
72            () => process.args.length = 0,
73            () => process.args = [],
74        ]) {
75            let denied = false;
76            try { mutate(); } catch (error) { if (!(error instanceof TypeError)) throw error; denied = true; }
77            if (!denied) throw Error('Mutation accepted');
78        }
79        if (JSON.stringify(process.args) !== before) throw Error('Arguments changed');
80        console.log(before);
81    "#).unwrap();
82    let args = [
83        "",
84        "two words",
85        "héllo 🫐",
86        "a\nb",
87        "'quotes'\"",
88        "*",
89        "$HOME",
90        "--help",
91        "-h",
92        "-0",
93        "--allow-read=/",
94        "--deny-read=/",
95        "--offline",
96        "--env-file",
97        "missing.env",
98        "--env-file=missing.env",
99        "--unknown",
100        "--",
101        "--",
102        "other.js",
103    ];
104    let mut cli = vec!["run", "-0", "main.js"];
105    cli.extend(args);
106    let output = run(dir.path(), &cli);
107    success(&output);
108    assert_args(&output, &args);
109}
110
111#[test]
112fn no_arguments_means_an_empty_array_and_a_trailing_separator_is_preserved() {
113    let dir = tempfile::tempdir().unwrap();
114    fs::write(dir.path().join("main.js"), PRINT_ARGS).unwrap();
115    for cli in [&["run", "main.js"][..], &["run", "--", "main.js"]] {
116        let output = run(dir.path(), cli);
117        success(&output);
118        assert_args(&output, &[]);
119    }
120    let output = run(dir.path(), &["run", "main.js", "--"]);
121    success(&output);
122    assert_args(&output, &["--"]);
123}
124
125#[test]
126fn argument_flags_cannot_grant_or_revoke_capabilities() {
127    let dir = tempfile::tempdir().unwrap();
128    fs::write(dir.path().join("secret"), "secret").unwrap();
129    fs::write(dir.path().join("main.js"), r#"
130        import {readTextFile} from 'avril:fs';
131        import {errors} from 'avril:std';
132        try { console.log(await readTextFile('secret')); }
133        catch (error) { if (!(error instanceof errors.PermissionDenied)) throw error; console.log('denied'); }
134    "#).unwrap();
135    let output = run(dir.path(), &["run", "-0", "main.js", "--allow-read=."]);
136    success(&output);
137    assert_eq!(output.stdout, b"denied\n");
138    let output = run(dir.path(), &["run", "main.js", "-0", "--deny-read=."]);
139    success(&output);
140    assert_eq!(output.stdout, b"secret\n");
141}
142
143#[test]
144fn environment_file_values_are_not_mistaken_for_the_entrypoint() {
145    let dir = tempfile::tempdir().unwrap();
146    fs::write(
147        dir.path().join("settings.env"),
148        "AVRIL_ARGS_FIXTURE=loaded\n",
149    )
150    .unwrap();
151    fs::write(
152        dir.path().join("main.js"),
153        r#"
154        import {env, process} from 'avril:std';
155        if (env.get('AVRIL_ARGS_FIXTURE') !== 'loaded') throw Error('Environment file not loaded');
156        console.log(JSON.stringify(process.args));
157    "#,
158    )
159    .unwrap();
160    for option in [
161        &["--env-file", "settings.env"][..],
162        &["--env-file=settings.env"],
163    ] {
164        let mut cli = vec!["run", "-0", "--allow-env=AVRIL_ARGS_FIXTURE"];
165        cli.extend(option);
166        cli.extend(["main.js", "--help", "-0", "--env-file=missing.env"]);
167        let output = run(dir.path(), &cli);
168        success(&output);
169        assert_args(&output, &["--help", "-0", "--env-file=missing.env"]);
170        cli.insert(cli.len() - 4, "--help");
171        let help = run(dir.path(), &cli);
172        success(&help);
173        assert!(String::from_utf8_lossy(&help.stdout).contains("Usage: avril run"));
174    }
175}
176
177#[test]
178fn a_separator_before_the_entry_allows_flag_shaped_filenames() {
179    let dir = tempfile::tempdir().unwrap();
180    for name in ["--help", "-0"] {
181        fs::write(dir.path().join(name), PRINT_ARGS).unwrap();
182        let output = run(dir.path(), &["run", "--", name, "--help", "--", "-0"]);
183        success(&output);
184        assert_args(&output, &["--help", "--", "-0"]);
185    }
186}
187
188#[test]
189fn remote_entrypoints_get_arguments_without_consuming_their_offline_flag() {
190    let dir = tempfile::tempdir().unwrap();
191    let http = http_fixture::Http::new();
192    http.source("main.js", PRINT_ARGS);
193    let output = run(
194        dir.path(),
195        &[
196            "run",
197            "-0",
198            "--cache-dir=cache",
199            &http.name("main.js"),
200            "--offline",
201            "--help",
202        ],
203    );
204    success(&output);
205    assert_args(&output, &["--offline", "--help"]);
206}
207
208#[test]
209fn serve_passes_arguments_without_parsing_them_as_listener_options() {
210    let dir = tempfile::tempdir().unwrap();
211    fs::write(
212        dir.path().join("main.js"),
213        format!("{PRINT_ARGS}\nthrow Error('Stopped before listening');"),
214    )
215    .unwrap();
216    let args = [
217        "--port=not-a-port",
218        "--unix=not-a-socket",
219        "--help",
220        "--env-file=missing.env",
221    ];
222    let mut cli = vec!["serve", "-0", "--port=0", "main.js"];
223    cli.extend(args);
224    let output = run(dir.path(), &cli);
225    assert!(!output.status.success());
226    assert!(String::from_utf8_lossy(&output.stderr).contains("Stopped before listening"));
227    assert_args(&output, &args);
228}
229
230#[test]
231fn workers_and_nested_workers_inherit_frozen_arguments() {
232    let dir = tempfile::tempdir().unwrap();
233    fs::write(dir.path().join("main.js"), r#"
234        import {process, avril} from 'avril:std';
235        const worker = new Worker(new URL('./worker.js', import.meta.url), {
236            type: 'module', [avril]: {caps: {env: false, sys: false}},
237        });
238        worker.onerror = event => { throw Error(event.message); };
239        worker.onmessage = ({data}) => {
240            if (JSON.stringify(data) !== JSON.stringify(process.args)) throw Error('Arguments differ');
241            console.log(JSON.stringify(data));
242            worker.terminate();
243        };
244    "#).unwrap();
245    fs::write(dir.path().join("worker.js"), r#"
246        import {process} from 'avril:std';
247        if (!Object.isFrozen(process.args)) throw Error('Mutable worker arguments');
248        const worker = new Worker(new URL('./nested.js', import.meta.url), {type: 'module'});
249        worker.onerror = event => { throw Error(event.message); };
250        worker.onmessage = ({data}) => {
251            if (JSON.stringify(data) !== JSON.stringify(process.args)) throw Error('Arguments differ');
252            postMessage(data);
253            close();
254        };
255    "#).unwrap();
256    fs::write(
257        dir.path().join("nested.js"),
258        r#"
259        import {process} from 'avril:std';
260        if (!Object.isFrozen(process.args)) throw Error('Mutable nested arguments');
261        postMessage(process.args);
262        close();
263    "#,
264    )
265    .unwrap();
266    let output = run(
267        dir.path(),
268        &[
269            "run",
270            "-0",
271            "--allow-js-exec=.",
272            "main.js",
273            "",
274            "--help",
275            "hello",
276        ],
277    );
278    success(&output);
279    assert_args(&output, &["", "--help", "hello"]);
280}
281
282#[test]
283fn repl_and_test_sessions_default_to_empty_script_arguments() {
284    let dir = tempfile::tempdir().unwrap();
285    for args in [&["repl", "-0"][..], &["repl", "-0", "--"], &["--"]] {
286        let output = repl(
287            dir.path(),
288            args,
289            "(await import('avril:std')).process.args\n",
290        );
291        success(&output);
292        assert_eq!(output.stdout, b"[]\n");
293    }
294    fs::write(dir.path().join("args_test.js"), r#"
295        import {process} from 'avril:std';
296        import {test} from 'avril:test';
297        test('empty args', () => {
298            if (process.args.length !== 0 || !Object.isFrozen(process.args)) throw Error('Unexpected args');
299        });
300    "#).unwrap();
301    for args in [
302        &["test", "-0", "args_test.js", "--filter=empty args"][..],
303        &["test", "-0", "args_test.js", "--filter=empty args", "--"],
304    ] {
305        let output = run(dir.path(), args);
306        success(&output);
307        assert!(String::from_utf8_lossy(&output.stdout).contains("ok - empty args"));
308    }
309}
310
311#[test]
312fn explicit_and_default_repls_preserve_arguments_after_the_separator() {
313    let dir = tempfile::tempdir().unwrap();
314    let args = [
315        "",
316        "two words",
317        "héllo 🫐",
318        "a\nb",
319        "--help",
320        "-h",
321        "-0",
322        "--allow-read=.",
323        "--env-file=missing.env",
324        "--unknown",
325        "--",
326        "--",
327    ];
328    let expected = serde_json::to_string(&args).unwrap();
329    let source = format!(
330        "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"
331    );
332    for prefix in [&["repl", "-0"][..], &["-0"], &["repl"], &[]] {
333        let mut cli = prefix.to_vec();
334        cli.push("--");
335        cli.extend(args);
336        let output = repl(dir.path(), &cli, &source);
337        success(&output);
338        assert_eq!(output.stdout, b"true\ntrue\n");
339    }
340}
341
342#[test]
343fn test_targets_and_runner_options_are_separate_from_shared_script_arguments() {
344    let dir = tempfile::tempdir().unwrap();
345    let args = [
346        "",
347        "héllo 🫐",
348        "a\nb",
349        "--help",
350        "-0",
351        "--no-run",
352        "--filter=other",
353        "--env-file=missing.env",
354        "--",
355        "not_a_target.js",
356    ];
357    let expected = serde_json::to_string(&args).unwrap();
358    let source = format!(
359        r#"
360        import {{process, env}} from 'avril:std';
361        import {{test}} from 'avril:test';
362        if (env.get('AVRIL_ARGS_FIXTURE') !== 'loaded') throw Error('Environment not loaded');
363        if (!Object.isFrozen(process.args) || JSON.stringify(process.args) !== JSON.stringify({expected}))
364            throw Error('Wrong arguments during collection');
365        test('chosen', () => {{
366            if (JSON.stringify(process.args) !== JSON.stringify({expected})) throw Error('Wrong arguments during test');
367        }});
368        test('filtered', () => {{ throw Error('Filter ignored'); }});
369    "#
370    );
371    for name in ["a_test.js", "b_test.js"] {
372        fs::write(dir.path().join(name), &source).unwrap();
373    }
374    fs::write(
375        dir.path().join("settings.env"),
376        "AVRIL_ARGS_FIXTURE=loaded\n",
377    )
378    .unwrap();
379    for targets in [
380        &[
381            "a_test.js",
382            "b_test.js",
383            "--filter",
384            "chosen",
385            "--env-file",
386            "settings.env",
387        ][..],
388        &["--filter=chosen", "--env-file=settings.env"],
389    ] {
390        let mut cli = vec!["test", "-0", "--allow-env=AVRIL_ARGS_FIXTURE"];
391        cli.extend(targets);
392        cli.push("--");
393        cli.extend(args);
394        let output = run(dir.path(), &cli);
395        success(&output);
396        assert_eq!(
397            String::from_utf8_lossy(&output.stdout)
398                .matches("ok - chosen")
399                .count(),
400            2
401        );
402    }
403}
404
405#[test]
406fn repl_and_test_argument_flags_cannot_change_authority() {
407    let dir = tempfile::tempdir().unwrap();
408    for restricted in [true, false] {
409        let check = format!(
410            r#"
411            const {{process, errors}} = await import('avril:std');
412            let allowed = true;
413            try {{ process.cwd(); }}
414            catch (error) {{ if (!(error instanceof errors.PermissionDenied)) throw error; allowed = false; }}
415            if (allowed !== {}) throw Error('Arguments changed authority');
416            return true;
417        "#,
418            !restricted
419        );
420        let guest = if restricted {
421            &["--allow-read=."][..]
422        } else {
423            &["-0", "--deny-read=."][..]
424        };
425        let mut cli = vec!["repl"];
426        if restricted {
427            cli.push("-0");
428        }
429        cli.push("--");
430        cli.extend(guest);
431        let source = format!("await (async () => {{{}}})()\n", check.replace('\n', " "));
432        let output = repl(dir.path(), &cli, &source);
433        success(&output);
434        assert_eq!(output.stdout, b"true\n");
435        fs::write(
436            dir.path().join("authority_test.js"),
437            format!(
438                "import {{test}} from 'avril:test'; test('authority', async () => {{{check}}});"
439            ),
440        )
441        .unwrap();
442        cli = vec!["test"];
443        if restricted {
444            cli.push("-0");
445        }
446        cli.extend(["authority_test.js", "--"]);
447        cli.extend(guest);
448        let output = run(dir.path(), &cli);
449        success(&output);
450        assert!(String::from_utf8_lossy(&output.stdout).contains("ok - authority"));
451    }
452}
453
454#[test]
455fn remote_tests_and_no_run_collection_receive_arguments() {
456    let dir = tempfile::tempdir().unwrap();
457    let http = http_fixture::Http::new();
458    let source = r#"
459        import {process} from 'avril:std';
460        import {test} from 'avril:test';
461        if (JSON.stringify(process.args) !== '["hello","--help"]') throw Error('Wrong arguments');
462        console.log('collected arguments');
463        test('remote arguments', () => { if (process.args[0] !== 'hello') throw Error('Wrong arguments'); });
464    "#;
465    http.source("args_test.js", source);
466    let output = run(
467        dir.path(),
468        &[
469            "test",
470            "-0",
471            "--cache-dir=cache",
472            &http.name("args_test.js"),
473            "--",
474            "hello",
475            "--help",
476        ],
477    );
478    success(&output);
479    assert!(String::from_utf8_lossy(&output.stdout).contains("ok - remote arguments"));
480    fs::write(
481        dir.path().join("args_test.js"),
482        format!("{source}\ntest('must not run', () => {{ throw Error('Test executed'); }});"),
483    )
484    .unwrap();
485    let output = run(
486        dir.path(),
487        &[
488            "test",
489            "-0",
490            "--no-run",
491            "args_test.js",
492            "--",
493            "hello",
494            "--help",
495        ],
496    );
497    success(&output);
498    assert!(String::from_utf8_lossy(&output.stdout).contains("collected arguments"));
499}
500
501#[test]
502fn workers_in_repl_and_test_sessions_inherit_script_arguments() {
503    let dir = tempfile::tempdir().unwrap();
504    fs::write(
505        dir.path().join("worker.js"),
506        r#"
507        import {process} from 'avril:std';
508        postMessage({args: process.args, frozen: Object.isFrozen(process.args)});
509        close();
510    "#,
511    )
512    .unwrap();
513    let check = r#"
514        const {process} = await import('avril:std');
515        const worker = new Worker('./worker.js', {type: 'module'});
516        const data = await new Promise((resolve, reject) => {
517            worker.onmessage = ({data}) => resolve(data);
518            worker.onerror = event => { event.preventDefault(); reject(Error(event.message)); };
519        });
520        worker.terminate();
521        if (!data.frozen || JSON.stringify(data.args) !== '["hello","--help"]' ||
522            JSON.stringify(data.args) !== JSON.stringify(process.args)) throw Error('Wrong worker arguments');
523        return true;
524    "#;
525    let source = format!("await (async () => {{{}}})()\n", check.replace('\n', " "));
526    let output = repl(
527        dir.path(),
528        &["repl", "-0", "--allow-js-exec=.", "--", "hello", "--help"],
529        &source,
530    );
531    success(&output);
532    assert_eq!(output.stdout, b"true\n");
533    fs::write(
534        dir.path().join("args_test.js"),
535        format!(
536            "import {{test}} from 'avril:test'; test('worker arguments', async () => {{{check}}});"
537        ),
538    )
539    .unwrap();
540    let output = run(
541        dir.path(),
542        &[
543            "test",
544            "-0",
545            "--allow-js-exec=.",
546            "args_test.js",
547            "--",
548            "hello",
549            "--help",
550        ],
551    );
552    success(&output);
553    assert!(String::from_utf8_lossy(&output.stdout).contains("ok - worker arguments"));
554}
555
556#[test]
557fn session_separators_preserve_runtime_help_and_option_validation() {
558    let dir = tempfile::tempdir().unwrap();
559    for cli in [
560        &["repl", "--help", "--", "--unknown"][..],
561        &["test", "--help", "--", "--unknown"],
562        &["--help", "--", "--unknown"],
563    ] {
564        let output = run(dir.path(), cli);
565        success(&output);
566        let text = String::from_utf8_lossy(&output.stdout);
567        assert!(
568            text.contains("Usage: avril") && text.contains("[-- args...]"),
569            "{text}"
570        );
571    }
572    for (cli, message) in [
573        (
574            &["test", "--filter", "--", "value"][..],
575            "Expected a pattern for --filter",
576        ),
577        (
578            &["test", "--env-file", "--", "value"][..],
579            "Expected a path for --env-file",
580        ),
581        (
582            &["repl", "--unknown", "--", "value"][..],
583            "Unknown option: --unknown",
584        ),
585        (
586            &["repl", "not-an-argument", "--", "value"][..],
587            "The REPL does not accept a module",
588        ),
589    ] {
590        let output = run(dir.path(), cli);
591        assert!(!output.status.success());
592        assert!(
593            String::from_utf8_lossy(&output.stderr).contains(message),
594            "{output:?}"
595        );
596    }
597    fs::write(
598        dir.path().join("-case.js"),
599        r#"
600        import {process} from 'avril:std';
601        import {test} from 'avril:test';
602        test('-- title', () => {
603            if (JSON.stringify(process.args) !== '["--","--help"]') throw Error('Wrong arguments');
604        });
605    "#,
606    )
607    .unwrap();
608    let output = run(
609        dir.path(),
610        &[
611            "test",
612            "-0",
613            "./-case.js",
614            "--filter=--",
615            "--",
616            "--",
617            "--help",
618        ],
619    );
620    success(&output);
621    assert!(String::from_utf8_lossy(&output.stdout).contains("ok - -- title"));
622}