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
20.4 KiB650 linesraw
1mod common;
2#[path = "common/http.rs"]
3#[allow(dead_code)]
4mod http_fixture;
5#[path = "common/registry.rs"]
6mod registry_fixture;
7
8use std::fs;
9use std::path::Path;
10use std::process::{Command, Output, Stdio};
11
12const RUNTIME: &str = r#"
13    export const Fragment = 'fragment';
14    export const jsx = (tag, props, key) => ({tag, props, key});
15    export const jsxs = jsx;
16    export const jsxDEV = (tag, props, key, stable, source) => ({tag, props, key, stable, source});
17    export const createElement = (tag, props, ...children) => ({tag, props: {...props, children}, key: props?.key});
18    export const h = createElement, F = Fragment;
19"#;
20
21fn files(dir: &Path, entries: &[(&str, &str)]) {
22    for (name, source) in entries {
23        let path = dir.join(name);
24        fs::create_dir_all(path.parent().unwrap()).unwrap();
25        fs::write(path, source).unwrap();
26    }
27}
28
29fn cli(dir: &Path, args: &[&str]) -> Output {
30    let child = Command::new(env!("CARGO_BIN_EXE_avril"))
31        .current_dir(dir)
32        .args(args)
33        .stdin(Stdio::null())
34        .stdout(Stdio::piped())
35        .stderr(Stdio::piped())
36        .spawn()
37        .unwrap();
38    common::wait(child)
39}
40
41fn success(output: Output) -> String {
42    assert!(
43        output.status.success(),
44        "{}",
45        String::from_utf8_lossy(&output.stderr)
46    );
47    String::from_utf8(output.stdout).unwrap()
48}
49
50fn failure(output: Output, message: &str) {
51    assert!(!output.status.success());
52    assert!(
53        output.stdout.is_empty(),
54        "{}",
55        String::from_utf8_lossy(&output.stdout)
56    );
57    assert!(
58        String::from_utf8_lossy(&output.stderr).contains(message),
59        "{}",
60        String::from_utf8_lossy(&output.stderr)
61    );
62}
63
64#[test]
65fn jsx_and_tsx_default_to_automatic_and_generated_imports_join_the_graph() {
66    let dir = tempfile::tempdir().unwrap();
67    files(
68        dir.path(),
69        &[
70            (
71                "avril.conf",
72                "imports: {'react/jsx-runtime': './runtime.js', react: './runtime.js'}",
73            ),
74            ("runtime.js", RUNTIME),
75            (
76                "dep.jsx",
77                "export const child = <b>hello &amp; goodbye</b>;",
78            ),
79            (
80                "main.tsx",
81                r#"
82            import type {Missing} from './missing.ts';
83            import {child} from './dep.jsx';
84            const Component = (props: {name: string}) => props.name;
85            const props = {id: 1};
86            const node = <><Component name="value" />{child}<i {...props} key="key" /></>;
87            const children = node.props.children;
88            console.log(node.tag, children[0].tag === Component, children[1].props.children,
89                children[2].props.id, children[2].key, await Promise.resolve('done'));
90        "#,
91            ),
92        ],
93    );
94    assert_eq!(
95        success(cli(dir.path(), &["run", "main.tsx"])),
96        "fragment true hello & goodbye 1 key done\n"
97    );
98    let info: serde_json::Value =
99        serde_json::from_str(&success(cli(dir.path(), &["info", "main.tsx"]))).unwrap();
100    let main = dir.path().join("main.tsx");
101    let imports = &info["modules"][main.to_str().unwrap()]["imports"];
102    assert!(imports["react/jsx-runtime"].is_string());
103    assert!(imports["react"].is_string());
104    assert!(imports.get("./missing.ts").is_none());
105}
106
107#[test]
108fn tsconfig_redirects_automatic_jsx_without_adopting_typescript_paths() {
109    let dir = tempfile::tempdir().unwrap();
110    files(
111        dir.path(),
112        &[
113            (
114                "avril.conf",
115                "imports: {'custom/jsx-runtime': './runtime.js'}",
116            ),
117            (
118                "tsconfig.json",
119                r#"{
120            // Only JSX compiler options affect execution.
121            "compilerOptions": {
122                "jsx": "react-jsx", "jsxImportSource": "custom",
123                "paths": {"custom/jsx-runtime": ["./wrong.js"]},
124            },
125        }"#,
126            ),
127            ("runtime.js", RUNTIME),
128            (
129                "wrong.js",
130                "throw Error('TypeScript paths changed runtime resolution');",
131            ),
132            (
133                "main.jsx",
134                "console.log((<a title='hello' />).props.title);",
135            ),
136        ],
137    );
138    assert_eq!(success(cli(dir.path(), &["run", "main.jsx"])), "hello\n");
139}
140
141#[test]
142fn jsonc_extends_merges_in_order_and_preserves_classic_factory_imports() {
143    let dir = tempfile::tempdir().unwrap();
144    files(
145        dir.path(),
146        &[
147            ("avril.conf", "{}"),
148            (
149                "config/first.json",
150                r#"{"compilerOptions":{"jsx":"react","jsxFactory":"wrong","jsxFragmentFactory":"Wrong"}}"#,
151            ),
152            (
153                "config/nested/shared.base.json",
154                r#"{"compilerOptions":{"jsxFragmentFactory":"F"}}"#,
155            ),
156            (
157                "config/second.json",
158                r#"{"extends":"./nested/shared.base","compilerOptions":{"jsxFactory":"h"}}"#,
159            ),
160            (
161                "tsconfig.json",
162                "\u{feff}{\"extends\":[\"./config/first\",\"./config/second.json\"],\"compilerOptions\":{\"jsxFactory\":\"helpers.h\",},}",
163            ),
164            ("runtime.js", RUNTIME),
165            (
166                "main.tsx",
167                r#"
168            import * as helpers from './runtime.js';
169            import {F} from './runtime.js';
170            const node = <><p>classic</p></>;
171            console.log(node.tag, node.props.children[0].tag, node.props.children[0].props.children[0]);
172        "#,
173            ),
174        ],
175    );
176    assert_eq!(
177        success(cli(dir.path(), &["run", "main.tsx"])),
178        "fragment p classic\n"
179    );
180}
181
182#[test]
183fn development_jsx_and_stack_traces_retain_source_locations() {
184    let dir = tempfile::tempdir().unwrap();
185    files(
186        dir.path(),
187        &[
188            (
189                "avril.conf",
190                "imports: {'custom/jsx-dev-runtime': './runtime.js'}",
191            ),
192            (
193                "tsconfig.json",
194                r#"{"compilerOptions":{"jsx":"react-jsxdev","jsxImportSource":"custom"}}"#,
195            ),
196            ("runtime.js", RUNTIME),
197            (
198                "main.tsx",
199                "type Gone = number;\nconst node = <p />;\nconsole.log(node.source.lineNumber, node.source.columnNumber, node.source.fileName.endsWith('main.tsx'));\nthrow Error('mapped');\n",
200            ),
201        ],
202    );
203    let output = cli(dir.path(), &["run", "main.tsx"]);
204    assert!(!output.status.success());
205    assert_eq!(output.stdout, b"2 14 true\n");
206    let stderr = String::from_utf8_lossy(&output.stderr);
207    assert!(stderr.contains("main.tsx:4:7"), "{stderr}");
208}
209
210#[test]
211fn nearest_configs_apply_only_within_their_own_package() {
212    let dir = tempfile::tempdir().unwrap();
213    files(
214        dir.path(),
215        &[
216            ("avril.conf", "{}"),
217            (
218                "tsconfig.json",
219                r#"{"compilerOptions":{"jsx":"react","jsxFactory":"h"}}"#,
220            ),
221            ("runtime.js", RUNTIME),
222            (
223                "main.tsx",
224                "import {h} from './runtime.js'; import {nested} from './src/nested.tsx'; import {library} from './lib/mod.tsx'; console.log((<p />).tag, nested.tag, library.tag);",
225            ),
226            (
227                "src/tsconfig.json",
228                r#"{"compilerOptions":{"jsx":"react","jsxFactory":"custom"}}"#,
229            ),
230            (
231                "src/nested.tsx",
232                "import {h as custom} from '../runtime.js'; export const nested = <nested />;",
233            ),
234            (
235                "lib/avril.conf",
236                "imports: {'react/jsx-runtime': '../runtime.js'}",
237            ),
238            ("lib/mod.tsx", "export const library = <library />;"),
239        ],
240    );
241    assert_eq!(
242        success(cli(dir.path(), &["run", "main.tsx"])),
243        "p nested library\n"
244    );
245}
246
247#[test]
248fn avril_conf_defaults_yield_to_tsconfig_and_stay_in_their_package() {
249    let dir = tempfile::tempdir().unwrap();
250    files(
251        dir.path(),
252        &[
253            (
254                "avril.conf",
255                "compilerOptions: {jsx: 'react', jsxFactory: 'conf', paths: {}}",
256            ),
257            ("runtime.js", RUNTIME),
258            (
259                "main.tsx",
260                "import {h as conf} from './runtime.js'; import {library} from './lib/mod.tsx'; console.log((<p />).tag, library.tag);",
261            ),
262            (
263                "lib/avril.conf",
264                "imports: {'react/jsx-runtime': '../runtime.js'}",
265            ),
266            ("lib/mod.tsx", "export const library = <library />;"),
267        ],
268    );
269    assert_eq!(
270        success(cli(dir.path(), &["run", "main.tsx"])),
271        "p library\n"
272    );
273    files(
274        dir.path(),
275        &[(
276            "main.tsx",
277            "import {h as ts} from './runtime.js'; console.log((<p />).tag);",
278        )],
279    );
280    failure(cli(dir.path(), &["run", "main.tsx"]), "conf is not defined");
281    files(
282        dir.path(),
283        &[(
284            "tsconfig.json",
285            r#"{"compilerOptions":{"jsxFactory":"ts"}}"#,
286        )],
287    );
288    assert_eq!(success(cli(dir.path(), &["run", "main.tsx"])), "p\n");
289}
290
291#[test]
292fn configs_without_avril_packages_are_discovered_in_trusted_ancestors() {
293    let dir = tempfile::tempdir().unwrap();
294    files(
295        dir.path(),
296        &[
297            (
298                "tsconfig.json",
299                r#"{"compilerOptions":{"jsx":"react","jsxFactory":"h","jsxFragmentFactory":"F"}}"#,
300            ),
301            ("runtime.js", RUNTIME),
302            (
303                "src/main.tsx",
304                "import {h,F} from '../runtime.js'; console.log((<><p /></>).tag);",
305            ),
306        ],
307    );
308    assert_eq!(
309        success(cli(dir.path(), &["run", "src/main.tsx"])),
310        "fragment\n"
311    );
312}
313
314#[test]
315fn pragmas_override_config_and_remote_jsx_never_inherits_local_settings() {
316    let dir = tempfile::tempdir().unwrap();
317    let http = http_fixture::Http::new();
318    files(
319        dir.path(),
320        &[
321            ("avril.conf", "{}"),
322            (
323                "tsconfig.json",
324                r#"{"compilerOptions":{"jsx":"react","jsxFactory":"missing"}}"#,
325            ),
326            (
327                "main.tsx",
328                "/** @jsxRuntime classic @jsx h @jsxFrag F */\nconst h = (tag: string) => tag, F = 'fragment'; console.log(<><p /></>);",
329            ),
330        ],
331    );
332    assert_eq!(success(cli(dir.path(), &["run", "main.tsx"])), "fragment\n");
333    http.source("ui/jsx-runtime", RUNTIME);
334    http.source("nested/main.tsx", &format!("/** @jsxImportSource {} */\nconst n: string = 'remote'; console.log((<p>{{n}}</p>).props.children);", http.name("ui")));
335    http.redirect("entry", "/nested/main.tsx");
336    assert_eq!(
337        success(cli(
338            dir.path(),
339            &[
340                "run",
341                "-0",
342                &format!("--allow-js-exec={}", http.url),
343                "--cache-dir=cache",
344                &http.name("entry")
345            ]
346        )),
347        "remote\n"
348    );
349}
350
351#[test]
352fn invalid_configs_and_preserved_jsx_fail_before_application_execution() {
353    for (config, expected) in [
354        (
355            r#"{"compilerOptions":{"jsx":"preserve"}}"#,
356            "cannot execute preserved JSX",
357        ),
358        (
359            r#"{"compilerOptions":{"jsx":"react-native"}}"#,
360            "cannot execute preserved JSX",
361        ),
362        (
363            r#"{"compilerOptions":{"jsx":"unknown"}}"#,
364            "Unsupported compilerOptions.jsx",
365        ),
366        (
367            r#"{"compilerOptions":{"jsxImportSource":3}}"#,
368            "jsxImportSource must be",
369        ),
370        (
371            r#"{"compilerOptions":{"jsxFactory":""}}"#,
372            "jsxFactory must be",
373        ),
374        (
375            r#"{"compilerOptions":{"jsx":"react","jsxFactory":"h()"} }"#,
376            "jsxFactory must be an identifier",
377        ),
378        (
379            r#"{"compilerOptions":{"jsxFragmentFactory":"a..b"}}"#,
380            "jsxFragmentFactory must be an identifier",
381        ),
382        (
383            r#"{"compilerOptions":[]}"#,
384            "compilerOptions must be an object",
385        ),
386        (
387            r#"{"extends":"npm:config"}"#,
388            "extends must use relative or absolute",
389        ),
390        (r#"{"extends":"./missing.json"}"#, "Cannot find tsconfig"),
391        (
392            r#"{"extends":"./tsconfig.json"}"#,
393            "Cyclic tsconfig extends",
394        ),
395        (r#"{"extends":[3]}"#, "extends entries must be paths"),
396        (r#"{"extends":3}"#, "extends must be a path"),
397        ("[]", "tsconfig must be an object"),
398        ("{bad}", "tsconfig.json"),
399        ("{'compilerOptions': {}}", "tsconfig.json"),
400    ] {
401        let dir = tempfile::tempdir().unwrap();
402        files(
403            dir.path(),
404            &[
405                ("tsconfig.json", config),
406                (
407                    "main.tsx",
408                    "console.log('executed'); export const node = <p />;",
409                ),
410            ],
411        );
412        failure(cli(dir.path(), &["run", "main.tsx"]), expected);
413    }
414    let dir = tempfile::tempdir().unwrap();
415    files(
416        dir.path(),
417        &[("main.tsx", "const n = <p></q>; console.log('executed');")],
418    );
419    failure(cli(dir.path(), &["run", "main.tsx"]), "main.tsx");
420}
421
422#[test]
423fn config_discovery_and_explicit_extends_respect_execution_authority() {
424    let dir = tempfile::tempdir().unwrap();
425    files(
426        dir.path(),
427        &[
428            ("tsconfig.json", "not json"),
429            (
430                "main.tsx",
431                "/** @jsxRuntime classic @jsx h */\nconst h = (tag: string) => tag; console.log(<p />);",
432            ),
433        ],
434    );
435    assert_eq!(success(cli(dir.path(), &["run", "-0", "main.tsx"])), "p\n");
436    failure(
437        cli(dir.path(), &["run", "-0", "--allow-js-exec=.", "main.tsx"]),
438        "tsconfig.json",
439    );
440    files(
441        dir.path(),
442        &[
443            ("tsconfig.json", r#"{"extends":"./private/base.json"}"#),
444            (
445                "private/base.json",
446                r#"{"compilerOptions":{"jsx":"react"}}"#,
447            ),
448        ],
449    );
450    failure(
451        cli(dir.path(), &["run", "--deny-js-exec=./private", "main.tsx"]),
452        "PermissionDenied",
453    );
454}
455
456#[test]
457fn generated_imports_require_the_same_authority_as_written_imports() {
458    let dir = tempfile::tempdir().unwrap();
459    files(
460        dir.path(),
461        &[
462            (
463                "avril.conf",
464                "imports: {'react/jsx-runtime': './runtime.js'}",
465            ),
466            ("runtime.js", RUNTIME),
467            (
468                "main.tsx",
469                "console.log('executed'); export const node = <p />;",
470            ),
471        ],
472    );
473    failure(
474        cli(
475            dir.path(),
476            &["run", "--deny-js-exec=./runtime.js", "main.tsx"],
477        ),
478        "PermissionDenied",
479    );
480}
481
482#[cfg(unix)]
483#[test]
484fn symlink_and_untrusted_configs_are_not_compiler_authorities() {
485    use std::os::unix::fs::{PermissionsExt, symlink};
486    let dir = tempfile::tempdir().unwrap();
487    files(
488        dir.path(),
489        &[
490            ("target.json", r#"{"compilerOptions":{"jsx":"react"}}"#),
491            (
492                "main.tsx",
493                "/** @jsxRuntime classic @jsx h */\nconst h = (tag: string) => tag; console.log(<p />);",
494            ),
495        ],
496    );
497    symlink("target.json", dir.path().join("tsconfig.json")).unwrap();
498    failure(cli(dir.path(), &["run", "main.tsx"]), "non-symlink");
499    assert_eq!(success(cli(dir.path(), &["run", "-0", "main.tsx"])), "p\n");
500    fs::remove_file(dir.path().join("tsconfig.json")).unwrap();
501    files(
502        dir.path(),
503        &[("tsconfig.json", r#"{"compilerOptions":{"jsx":"react"}}"#)],
504    );
505    fs::set_permissions(
506        dir.path().join("tsconfig.json"),
507        fs::Permissions::from_mode(0o666),
508    )
509    .unwrap();
510    failure(cli(dir.path(), &["run", "main.tsx"]), "Untrusted config");
511}
512
513#[test]
514fn test_discovery_and_workers_execute_tsx_using_their_own_loader() {
515    let dir = tempfile::tempdir().unwrap();
516    files(
517        dir.path(),
518        &[
519            (
520                "avril.conf",
521                "imports: {'react/jsx-runtime': './runtime.js'}",
522            ),
523            ("runtime.js", RUNTIME),
524            ("worker.tsx", "postMessage((<worker />).tag); close();"),
525            (
526                "ui_test.tsx",
527                r#"
528            import {test} from 'avril:test';
529            test('tsx and worker', async () => {
530                if ((<p />).tag !== 'p') throw Error('Wrong element');
531                const worker = new Worker(new URL('./worker.tsx', import.meta.url), {type:'module'});
532                const tag = await new Promise((resolve, reject) => {
533                    worker.onmessage = ({data}) => resolve(data);
534                    worker.onerror = e => {e.preventDefault(); reject(Error(e.message));};
535                });
536                worker.terminate();
537                if (tag !== 'worker') throw Error('Wrong worker element');
538            });
539        "#,
540            ),
541        ],
542    );
543    assert!(success(cli(dir.path(), &["test"])).contains("ok - tsx and worker"));
544}
545
546#[test]
547fn implicit_jsr_runtime_imports_are_locked_vendored_and_usable_offline() {
548    let dir = tempfile::tempdir().unwrap();
549    let registry = registry_fixture::Registry::new();
550    let runtime = format!("/** @ts-self-types=\"./jsx-runtime.d.ts\" */\n{RUNTIME}");
551    registry.package("@test/ui", "1.0.0", &[
552        ("jsx-runtime.js", &runtime),
553        ("jsx-runtime.d.ts", "export const jsx: (tag: string, props: object) => object; export namespace JSX { export interface IntrinsicElements { p: {}; } }"),
554    ], &[("./jsx-runtime", "./jsx-runtime.js")]);
555    files(
556        dir.path(),
557        &[
558            (
559                "tsconfig.json",
560                r#"{"compilerOptions":{"jsxImportSource":"jsr:@test/ui@1"}}"#,
561            ),
562            ("main.tsx", "console.log((<p />).tag);"),
563        ],
564    );
565    let registry_option = format!("--jsr-registry={}", registry.url);
566    assert_eq!(
567        success(cli(
568            dir.path(),
569            &["run", &registry_option, "--cache-dir=cache", "main.tsx"]
570        )),
571        "p\n"
572    );
573    let lock = registry_fixture::read_lock(dir.path());
574    assert!(lock.to_string().contains("@test/ui"));
575    success(cli(
576        dir.path(),
577        &["vendor", &registry_option, "--cache-dir=cache", "main.tsx"],
578    ));
579    fs::remove_dir_all(dir.path().join("cache")).unwrap();
580    let before = registry.requests.lock().unwrap().len();
581    assert_eq!(
582        success(cli(
583            dir.path(),
584            &[
585                "run",
586                &registry_option,
587                "--cache-dir=cache",
588                "--offline",
589                "--frozen",
590                "--vendor=vendor",
591                "main.tsx"
592            ]
593        )),
594        "p\n"
595    );
596    assert_eq!(registry.requests.lock().unwrap().len(), before);
597}
598
599#[test]
600fn denied_package_boundaries_and_later_config_revocation_do_not_leak_ancestor_settings() {
601    let dir = tempfile::tempdir().unwrap();
602    files(
603        dir.path(),
604        &[
605            (
606                "tsconfig.json",
607                r#"{"compilerOptions":{"jsx":"react","jsxFactory":"wrong"}}"#,
608            ),
609            ("child/avril.conf", "{}"),
610            ("child/main.tsx", "export const node = <p />;"),
611        ],
612    );
613    failure(
614        cli(
615            dir.path(),
616            &["run", "--deny-js-exec=./child/avril.conf", "child/main.tsx"],
617        ),
618        "react/jsx-runtime",
619    );
620    files(
621        dir.path(),
622        &[
623            ("avril.conf", "{}"),
624            (
625                "tsconfig.json",
626                r#"{"compilerOptions":{"jsxImportSource":"./runtime"}}"#,
627            ),
628            ("runtime/jsx-runtime", RUNTIME),
629            ("first.tsx", "export const node = <first />;"),
630            ("second.tsx", "export const node = <second />;"),
631            (
632                "main.js",
633                r#"
634            import {pledge} from 'avril:std';
635            console.log((await import('./first.tsx')).node.tag);
636            pledge({jsExec: {deny: ['./tsconfig.json']}});
637            try { await import('./second.tsx'); throw Error('Revoked config used'); }
638            catch (error) {
639                if (!error.message.includes('react/jsx-runtime')) throw error;
640                console.log('revoked');
641            }
642        "#,
643            ),
644        ],
645    );
646    assert_eq!(
647        success(cli(dir.path(), &["run", "main.js"])),
648        "first\nrevoked\n"
649    );
650}