char/avril

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

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

Charlotte Somdeslop: consolidate public type and registry test utils7325e8c

main
41.5 KiB1213 linesraw
1#[allow(dead_code)]
2#[path = "common/http.rs"]
3mod http;
4#[path = "common/registry.rs"]
5mod registry;
6
7use std::fs;
8use std::path::Path;
9use std::process::Command;
10
11use registry::{Registry, files, success};
12
13fn avril(dir: &Path, server: &Registry, command: &str, args: &[&str]) -> Result<String, String> {
14    let output = server.command(dir, command).args(args).output().unwrap();
15    let stdout = String::from_utf8(output.stdout).unwrap();
16    let stderr = String::from_utf8(output.stderr).unwrap();
17    if output.status.success() {
18        Ok(stdout)
19    } else {
20        assert!(!stderr.is_empty(), "failures explain themselves");
21        Err(stderr)
22    }
23}
24
25fn bundle(dir: &Path, server: &Registry, args: &[&str]) -> Result<String, String> {
26    avril(dir, server, "bundle", args)
27}
28
29/// Runs a bundle's entry from a copy of its output directory alone, so it
30/// can only depend on what was emitted.
31fn execute(output: &Path, entry: &str) -> String {
32    let copy = tempfile::tempdir().unwrap();
33    for file in fs::read_dir(output).unwrap() {
34        let file = file.unwrap();
35        fs::copy(file.path(), copy.path().join(file.file_name())).unwrap();
36    }
37    let output = Command::new(env!("CARGO_BIN_EXE_avril"))
38        .current_dir(copy.path())
39        .args(["run", entry])
40        .output()
41        .unwrap();
42    success(&output)
43}
44
45fn listing(dir: &Path) -> Vec<String> {
46    let mut names: Vec<_> = fs::read_dir(dir)
47        .unwrap()
48        .map(|entry| entry.unwrap().file_name().into_string().unwrap())
49        .collect();
50    names.sort();
51    names
52}
53
54#[test]
55fn options_are_validated_before_bundling() {
56    let server = Registry::new();
57    let dir = tempfile::tempdir().unwrap();
58    files(dir.path(), &[("main.js", "console.log(1);")]);
59    let help = bundle(dir.path(), &server, &["--help"]).unwrap();
60    for option in [
61        "-o, --out",
62        "--outdir",
63        "--minify",
64        "--sourcemap",
65        "--no-splitting",
66        "--target",
67        "--define",
68    ] {
69        assert!(help.contains(option), "{help}");
70    }
71    for (args, message) in [
72        (&["main.js"][..], "-o <output>"),
73        (
74            &["main.js", "-o", "dist/app.js", "--target", "es5"],
75            "Unsupported --target",
76        ),
77        (
78            &["main.js", "-o", "dist/app.js", "--define", "novalue"],
79            "KEY=VALUE",
80        ),
81        (
82            &["main.js", "-o", "dist/app.js", "--define=9lives=1"],
83            "KEY=VALUE",
84        ),
85        (
86            &["main.js", "-o", "dist/app.js", "--filter=x"],
87            "not an option",
88        ),
89        (&["main.js", "-o", "dist/app.txt"], ".js or .mjs"),
90        (&["main.js", "-o", "dist/out?query.js"], "filename"),
91        (&["main.js", "-o", "dist/out#hash.js"], "filename"),
92        (&["main.js", "-o", "dist/out%25.js"], "filename"),
93        (&["main.js", "-o", "dist/out name.js"], "filename"),
94    ] {
95        let error = bundle(dir.path(), &server, args).unwrap_err();
96        assert!(error.contains(message), "{args:?}: {error}");
97    }
98    assert!(!dir.path().join("dist").exists());
99}
100
101#[test]
102fn multiple_entries_share_modules_and_keep_exports_and_maps() {
103    let server = Registry::new();
104    let dir = tempfile::tempdir().unwrap();
105    files(
106        dir.path(),
107        &[
108            (
109                "src/app.ts",
110                "export { state } from './shared.ts'; export const app: string = 'app';",
111            ),
112            (
113                "src/admin.ts",
114                "export { state } from './shared.ts'; export const admin: string = 'admin'; export const lazy = () => import('./lazy.ts');",
115            ),
116            (
117                "src/shared.ts",
118                "export const state = { message: 'SHARED-MARKER' };",
119            ),
120            (
121                "src/lazy.ts",
122                "export const message: string = 'LAZY-MARKER';",
123            ),
124            ("dist/keep.txt", "untouched"),
125        ],
126    );
127    bundle(
128        dir.path(),
129        &server,
130        &[
131            "src/app.ts",
132            "--outdir",
133            "dist",
134            "src/admin.ts",
135            "--sourcemap",
136        ],
137    )
138    .unwrap();
139    let output = dir.path().join("dist");
140    let names = listing(&output);
141    for name in ["app.js", "admin.js", "app.js.map", "admin.js.map"] {
142        assert!(names.iter().any(|file| file == name), "{names:?}");
143    }
144    for marker in ["SHARED-MARKER", "LAZY-MARKER"] {
145        let chunks: Vec<_> = names
146            .iter()
147            .filter(|name| {
148                name.ends_with(".js")
149                    && fs::read_to_string(output.join(name))
150                        .unwrap()
151                        .contains(marker)
152            })
153            .collect();
154        assert_eq!(chunks.len(), 1, "{marker}: {chunks:?}");
155        assert!(
156            maps_back(&output, chunks[0], marker).ends_with(if marker == "SHARED-MARKER" {
157                "src/shared.ts"
158            } else {
159                "src/lazy.ts"
160            })
161        );
162    }
163    files(
164        &output,
165        &[(
166            "check.js",
167            "import { state as a, app } from './app.js'; import { state as b, admin, lazy } from './admin.js'; console.log(a === b, app, admin, (await lazy()).message);",
168        )],
169    );
170    assert_eq!(execute(&output, "check.js"), "true app admin LAZY-MARKER\n");
171    assert_eq!(
172        fs::read_to_string(output.join("keep.txt")).unwrap(),
173        "untouched"
174    );
175}
176
177#[test]
178fn multiple_entry_options_and_names_are_validated() {
179    let server = Registry::new();
180    let dir = tempfile::tempdir().unwrap();
181    files(
182        dir.path(),
183        &[
184            ("a/main.ts", "export const a = 1;"),
185            ("b/main.js", "export const b = 2;"),
186            ("other.ts", "export const c = 3;"),
187        ],
188    );
189    for (args, message) in [
190        (
191            &["a/main.ts", "other.ts", "-o", "dist/app.js"][..],
192            "use --outdir",
193        ),
194        (
195            &["a/main.ts", "--outdir", "dist", "-o", "dist/app.js"],
196            "cannot be combined",
197        ),
198        (
199            &["a/main.ts", "other.ts", "--outdir=dist", "--no-splitting"],
200            "--no-splitting requires exactly one",
201        ),
202        (
203            &["a/main.ts", "b/main.js", "--outdir=dist"],
204            "Duplicate entry output filename: main.js",
205        ),
206        (
207            &["a/main.ts", "a/main.ts", "--outdir=dist"],
208            "Duplicate entry output filename",
209        ),
210        (&["--outdir=dist"], "Usage:"),
211        (&["a/main.ts", "--outdir"], "Expected a value"),
212    ] {
213        let error = bundle(dir.path(), &server, args).unwrap_err();
214        assert!(error.contains(message), "{args:?}: {error}");
215        assert!(!dir.path().join("dist").exists());
216    }
217    bundle(
218        dir.path(),
219        &server,
220        &["a/main.ts", "--outdir=dist", "--no-splitting"],
221    )
222    .unwrap();
223    assert_eq!(listing(&dir.path().join("dist")), ["main.js"]);
224}
225
226#[test]
227fn entry_basenames_are_not_silently_rewritten() {
228    let server = Registry::new();
229    let dir = tempfile::tempdir().unwrap();
230    files(
231        dir.path(),
232        &[
233            ("+page.ts", "console.log('plus');"),
234            ("_page.ts", "console.log('underscore');"),
235            ("[page].ts", "console.log('brackets');"),
236        ],
237    );
238    let error = bundle(
239        dir.path(),
240        &server,
241        &["+page.ts", "[page].ts", "--outdir=dist"],
242    )
243    .unwrap_err();
244    assert!(error.contains("Unsupported output filename"), "{error}");
245    assert!(!dir.path().join("dist").exists());
246    bundle(
247        dir.path(),
248        &server,
249        &["+page.ts", "_page.ts", "--outdir=dist"],
250    )
251    .unwrap();
252    assert_eq!(listing(&dir.path().join("dist")), ["+page.js", "_page.js"]);
253    assert_eq!(execute(&dir.path().join("dist"), "+page.js"), "plus\n");
254    assert_eq!(
255        execute(&dir.path().join("dist"), "_page.js"),
256        "underscore\n"
257    );
258}
259
260#[test]
261fn each_entry_uses_its_own_configuration() {
262    let server = Registry::new();
263    let remote = http::Http::new();
264    remote.source("remote.ts", "export const remote: number = 42;");
265    let dir = tempfile::tempdir().unwrap();
266    files(
267        dir.path(),
268        &[
269            (
270                "a/avril.conf",
271                "compilerOptions: {jsx: 'react', jsxFactory: 'h'}",
272            ),
273            (
274                "a/app.tsx",
275                "const h = (tag) => 'a:' + tag; export const app = <div/>;",
276            ),
277            (
278                "b/avril.conf",
279                "compilerOptions: {jsx: 'react', jsxFactory: 'j'}",
280            ),
281            (
282                "b/admin.tsx",
283                "const j = (tag) => 'b:' + tag; export const admin = <div/>;",
284            ),
285        ],
286    );
287    bundle(
288        dir.path(),
289        &server,
290        &[
291            "--outdir=dist",
292            "--",
293            "a/app.tsx",
294            "b/admin.tsx",
295            &remote.name("remote.ts"),
296        ],
297    )
298    .unwrap();
299    files(
300        &dir.path().join("dist"),
301        &[(
302            "check.js",
303            "import { app } from './app.js'; import { admin } from './admin.js'; import { remote } from './remote.js'; console.log(app, admin, remote);",
304        )],
305    );
306    assert_eq!(
307        execute(&dir.path().join("dist"), "check.js"),
308        "a:div b:div 42\n"
309    );
310}
311
312#[test]
313fn each_entry_gets_an_implicit_permission_grant() {
314    let server = Registry::new();
315    let remote = http::Http::new();
316    remote.source("remote.ts", "export const remote: number = 42;");
317    let dir = tempfile::tempdir().unwrap();
318    files(
319        dir.path(),
320        &[
321            ("a/app.ts", "export const app: string = 'app';"),
322            ("b/admin.ts", "export const admin: string = 'admin';"),
323        ],
324    );
325    bundle(
326        dir.path(),
327        &server,
328        &[
329            "-0",
330            "--outdir=dist",
331            "a/app.ts",
332            "b/admin.ts",
333            &remote.name("remote.ts"),
334        ],
335    )
336    .unwrap();
337    files(
338        &dir.path().join("dist"),
339        &[(
340            "check.js",
341            "import { app } from './app.js'; import { admin } from './admin.js'; import { remote } from './remote.js'; console.log(app, admin, remote);",
342        )],
343    );
344    assert_eq!(
345        execute(&dir.path().join("dist"), "check.js"),
346        "app admin 42\n"
347    );
348}
349
350#[test]
351fn multiple_entry_failures_leave_all_outputs_untouched() {
352    let server = Registry::new();
353    let dir = tempfile::tempdir().unwrap();
354    files(
355        dir.path(),
356        &[
357            ("app.ts", "console.log('app');"),
358            ("admin.js", "console.log('admin');"),
359        ],
360    );
361    let original = fs::read(dir.path().join("admin.js")).unwrap();
362    let error = bundle(dir.path(), &server, &["app.ts", "admin.js", "--outdir=."]).unwrap_err();
363    assert!(error.contains("overwrite a bundle input"), "{error}");
364    assert!(!dir.path().join("app.js").exists());
365    assert_eq!(fs::read(dir.path().join("admin.js")).unwrap(), original);
366    bundle(
367        dir.path(),
368        &server,
369        &["app.ts", "admin.js", "--outdir=dist"],
370    )
371    .unwrap();
372    let before = snapshot(&dir.path().join("dist"));
373    files(dir.path(), &[("admin.js", "import './missing.js';")]);
374    bundle(
375        dir.path(),
376        &server,
377        &["app.ts", "admin.js", "--outdir=dist"],
378    )
379    .unwrap_err();
380    assert_eq!(snapshot(&dir.path().join("dist")), before);
381    #[cfg(unix)]
382    {
383        use std::os::unix::fs::symlink;
384        files(dir.path(), &[("admin.js", "console.log('admin');")]);
385        fs::remove_file(dir.path().join("dist/admin.js")).unwrap();
386        symlink(
387            dir.path().join("admin.js"),
388            dir.path().join("dist/admin.js"),
389        )
390        .unwrap();
391        let before = snapshot(&dir.path().join("dist"));
392        let error = bundle(
393            dir.path(),
394            &server,
395            &["app.ts", "admin.js", "--outdir=dist"],
396        )
397        .unwrap_err();
398        assert!(error.contains("symlink"), "{error}");
399        assert_eq!(snapshot(&dir.path().join("dist")), before);
400        symlink(dir.path().join("missing"), dir.path().join("dangling")).unwrap();
401        bundle(
402            dir.path(),
403            &server,
404            &["app.ts", "admin.js", "--outdir=dangling"],
405        )
406        .unwrap_err();
407        assert!(!dir.path().join("missing").exists());
408    }
409}
410
411#[test]
412fn compiles_each_module_kind_and_links_them() {
413    let server = Registry::new();
414    let dir = tempfile::tempdir().unwrap();
415    files(
416        dir.path(),
417        &[
418            (
419                "avril.conf",
420                "compilerOptions: {jsx: 'react', jsxFactory: 'h'}, imports: {helper: './helper.cjs'}",
421            ),
422            (
423                "main.tsx",
424                r#"import { greet } from "./greet.ts";
425import data from "./data.json";
426import { answer } from "./native.cjs";
427import { fromCts } from "./typed.cts";
428import { a } from "./cycle-a.js";
429import "./bindings.js";
430const h = (tag: string, props: unknown, ...children: string[]) => `<${tag}>${children.join("")}`;
431const lazy = await import("./lazy.ts");
432console.log(<b>{greet("web")}</b>, data.answer, answer, fromCts, a, lazy.heavy);
433console.log(process.env.NODE_ENV, import.meta.url.endsWith("/app.js"));
434export const secret = () => process.env.AVRIL_SECRET;
435"#,
436            ),
437            (
438                "greet.ts",
439                "export const greet = (name: string): string => `hi ${name}`;\nexport const unusedExport = 'TREE-SHAKEN';",
440            ),
441            ("data.json", r#"{"answer": 42}"#),
442            (
443                "native.cjs",
444                "const helper = require('helper');\nmodule.exports = { answer: helper(require('./data.json').answer) };",
445            ),
446            ("helper.cjs", "module.exports = (value) => value + 1;"),
447            (
448                "typed.cts",
449                "import dep = require('./dep.cjs');\nconst fromCts: string = dep;\nmodule.exports = { fromCts };",
450            ),
451            ("dep.cjs", "module.exports = 'cts';"),
452            (
453                "cycle-a.js",
454                "import { b } from './cycle-b.js';\nexport const a = 'a' + b;",
455            ),
456            (
457                "cycle-b.js",
458                "import { a } from './cycle-a.js';\nexport const b = 'b';\nexport const usesA = () => a;",
459            ),
460            ("lazy.ts", "export const heavy: string = 'lazy';"),
461            // Bindings that merely share host features' names are ordinary code.
462            (
463                "bindings.js",
464                "{ const require = { resolve: (x) => x }; console.log(require.resolve('ok')); }\nclass URL { constructor(a, b) { console.log(a, typeof b); } }\nnew URL('./x.png', import.meta.url);\nconsole.log(typeof require !== 'symbol');\nif (typeof module !== 'undefined') module.exports = 42;\nconsole.log(this === undefined, typeof module);",
465            ),
466        ],
467    );
468    bundle(
469        dir.path(),
470        &server,
471        &[
472            "main.tsx",
473            "-o",
474            "dist/app.js",
475            "--define",
476            "process.env.NODE_ENV=\"production\"",
477        ],
478    )
479    .unwrap();
480    assert_eq!(
481        execute(&dir.path().join("dist"), "app.js"),
482        "ok\n./x.png string\ntrue\ntrue undefined\n<b>hi web 42 43 cts ab lazy\nproduction true\n"
483    );
484    assert_eq!(listing(&dir.path().join("dist")).len(), 2);
485    let entry = fs::read_to_string(dir.path().join("dist/app.js")).unwrap();
486    assert!(!entry.contains("TREE-SHAKEN"), "{entry}");
487    assert!(entry.contains("process.env.AVRIL_SECRET"), "{entry}");
488    assert!(!entry.contains(dir.path().to_str().unwrap()), "{entry}");
489}
490
491#[test]
492fn splitting_minification_and_targets_preserve_behavior() {
493    let server = Registry::new();
494    let dir = tempfile::tempdir().unwrap();
495    files(
496        dir.path(),
497        &[
498            (
499                "main.js",
500                "import data from './data.json';\nconst pick = (x) => x?.answer ?? data.answer;\nconst load = async () => (await import('./lazy.js')).heavy;\nload().then((heavy) => console.log(pick({}), heavy));",
501            ),
502            ("data.json", r#"{"answer": 42}"#),
503            (
504                "lazy.js",
505                "export const heavy = 'lazy';\nexport async function* unused() {}",
506            ),
507        ],
508    );
509    for (args, chunks, absent) in [
510        (&[][..], 2, &[][..]),
511        (&["--no-splitting"], 1, &["import("][..]),
512        (&["--minify", "--target", "es2019"], 2, &["?.", "??", "\n"]),
513        (&["--target", "es2015"], 2, &["async ", "await "]),
514    ] {
515        let _ = fs::remove_dir_all(dir.path().join("dist"));
516        let mut all = vec!["main.js", "-o", "dist/app.js"];
517        all.extend(args);
518        bundle(dir.path(), &server, &all).unwrap();
519        let names = listing(&dir.path().join("dist"));
520        assert_eq!(names.len(), chunks, "{args:?}: {names:?}");
521        for name in &names {
522            let code = fs::read_to_string(dir.path().join("dist").join(name)).unwrap();
523            for text in absent {
524                assert!(!code.trim_end().contains(text), "{args:?} {name}: {code}");
525            }
526        }
527        assert_eq!(
528            execute(&dir.path().join("dist"), "app.js"),
529            "42 lazy\n",
530            "{args:?}"
531        );
532    }
533}
534
535/// Checks that the string literal containing `marker` in an emitted chunk
536/// maps to its exact original line and column in the original contents,
537/// returning the source name.
538fn maps_back(dir: &Path, chunk: &str, marker: &str) -> String {
539    let js = fs::read_to_string(dir.join(chunk)).unwrap();
540    assert!(
541        js.ends_with(&format!("//# sourceMappingURL={chunk}.map")),
542        "{js}"
543    );
544    let prefix = &js[..js.find(marker).unwrap()];
545    let line = prefix.matches('\n').count() as u32;
546    let column = prefix.rsplit('\n').next().unwrap().len() as u32;
547    let map = fs::read_to_string(dir.join(format!("{chunk}.map"))).unwrap();
548    let map = oxc_sourcemap::SourceMap::from_json_string(&map).unwrap();
549    let token = map
550        .lookup_token(&map.generate_lookup_table(), line, column)
551        .unwrap();
552    let source = token.get_source_id().unwrap();
553    let original = map.get_source_content(source).unwrap();
554    let (line, text) = original
555        .lines()
556        .enumerate()
557        .find(|(_, text)| text.contains(marker))
558        .unwrap();
559    assert_eq!(
560        (token.get_src_line(), token.get_src_col()),
561        (line as u32, text.find(marker).unwrap() as u32 - 1),
562        "{chunk}: {marker}"
563    );
564    let name = map.get_source(source).unwrap().to_owned();
565    if !name.starts_with("http:") {
566        assert_eq!(original, fs::read_to_string(dir.join(&name)).unwrap());
567    }
568    name
569}
570
571#[test]
572fn source_maps_lead_back_to_original_files() {
573    let server = Registry::new();
574    server.publish_npm(
575        "typeless",
576        "1.0.0",
577        &[
578            (
579                "package.json",
580                &serde_json::json!({"name": "typeless", "version": "1.0.0", "main": "index.js"})
581                    .to_string(),
582            ),
583            (
584                "index.js",
585                "module.exports = function crash() {\n  throw new Error('CJS-MARKER');\n};",
586            ),
587        ],
588    );
589    let http = http::Http::new();
590    http.source(
591        "remote.ts?v=1",
592        "export const remote = (n: number): string => 'REMOTE-MARKER' + n;",
593    );
594    http.redirect("alias", "/remote.ts?v=1");
595    let dir = tempfile::tempdir().unwrap();
596    files(
597        dir.path(),
598        &[
599            (
600                "avril.conf",
601                "deps: ['npm:typeless@1.0.0'], compilerOptions: {jsx: 'react', jsxFactory: 'h'}",
602            ),
603            (
604                "web/main.tsx",
605                &format!(
606                    "import crash from 'typeless';\nimport {{ raw }} from './raw.js';\nimport {{ remote }} from '{}';\nconst h = (tag: string, ...rest: unknown[]) => tag;\nconst view = (n: number) => <p>{{n}}</p>;\nconsole.log(view(1), remote(1), raw, crash, 'ENTRY-MARKER');\nconst late = await import('./late.ts');\nconsole.log(late.late);",
607                    http.name("alias")
608                ),
609            ),
610            ("web/raw.js", "export const raw = () =>\n  'RAW-MARKER';\n"),
611            (
612                "web/late.ts",
613                "export const late: string =\n    'LATE-MARKER';",
614            ),
615        ],
616    );
617    for minify in [false, true] {
618        let mut args = vec!["web/main.tsx", "-o", "dist/app.js", "--sourcemap"];
619        if minify {
620            args.push("--minify");
621        }
622        bundle(dir.path(), &server, &args).unwrap();
623        let dist = dir.path().join("dist");
624        let late = listing(&dist)
625            .into_iter()
626            .find(|name| name.starts_with("late-") && name.ends_with(".js"))
627            .unwrap();
628        assert_eq!(
629            maps_back(&dist, "app.js", "ENTRY-MARKER"),
630            "../web/main.tsx"
631        );
632        assert_eq!(maps_back(&dist, &late, "LATE-MARKER"), "../web/late.ts");
633        assert_eq!(maps_back(&dist, "app.js", "RAW-MARKER"), "../web/raw.js");
634        assert_eq!(
635            maps_back(&dist, "app.js", "REMOTE-MARKER"),
636            http.name("alias")
637        );
638        let typeless = maps_back(&dist, "app.js", "CJS-MARKER");
639        assert!(
640            typeless.ends_with("/typeless/1.0.0/index.js") && !typeless.starts_with('/'),
641            "{typeless}"
642        );
643        fs::remove_dir_all(dist).unwrap();
644    }
645}
646
647#[test]
648fn npm_packages_resolve_their_browser_branches() {
649    let server = Registry::new();
650    server.publish_package(
651        "cond",
652        "1.0.0",
653        serde_json::json!({"type": "module", "exports": {".": {"avril": "./avril.js", "node": "./node.js", "browser": "./browser.js", "default": "./default.js"}}}),
654        &[
655            ("avril.js", "export default 'avril';"),
656            ("node.js", "export default 'node';"),
657            ("browser.js", "export default 'browser';"),
658            ("default.js", "export default 'default';"),
659        ],
660    );
661    server.publish_package(
662        "legacy",
663        "1.0.0",
664        serde_json::json!({"type": "module", "main": "./node.js", "module": "./esm.js", "browser": "./web.js", "sideEffects": {"malformed": true}}),
665        &[
666            ("node.js", "export default 'node';"),
667            ("esm.js", "export default 'esm';"),
668            ("web.js", "export default 'web';"),
669        ],
670    );
671    server.publish_package(
672        "remap",
673        "1.0.0",
674        serde_json::json!({"type": "module", "browser": {"./server": "./general.js", "./server.js": "./shim.js", "./ignored.js": 42}}),
675        &[
676            (
677                "index.js",
678                "export { value } from './server.js';\nexport { default as ignored } from './ignored.js';",
679            ),
680            ("server.js", "export const value = 'server';"),
681            ("shim.js", "export const value = 'shim';"),
682            ("general.js", "export const value = 'extensionless';"),
683            ("ignored.js", "export default 'kept';"),
684        ],
685    );
686    server.publish_npm(
687        "swap",
688        "1.0.0",
689        &[
690            (
691                "package.json",
692                &serde_json::json!({"name": "swap", "version": "1.0.0"}).to_string(),
693            ),
694            ("index.js", "module.exports = 'swapped';"),
695        ],
696    );
697    server.publish_npm(
698        "actual",
699        "1.0.0",
700        &[
701            (
702                "package.json",
703                &serde_json::json!({"name": "actual", "version": "1.0.0", "exports": {"./chosen": "./value.cjs"}}).to_string(),
704            ),
705            ("value.cjs", "module.exports = 'exact';"),
706        ],
707    );
708    server.publish_npm(
709        "legacy-cjs",
710        "1.0.0",
711        &[
712            (
713                "package.json",
714                &serde_json::json!({
715                    "name": "legacy-cjs",
716                    "version": "1.0.0",
717                    "dependencies": {"swap": "1.0.0", "actual": "1.0.0"},
718                    "imports": {"#dep": {"import": "./i.mjs", "require": "./r.cjs"}},
719                    "browser": {
720                        "fs": false,
721                        "./nothing.js": false,
722                        "buffer": "swap",
723                        "dep/subpath": "./sub-shim.js",
724                        "original/sub": "actual/chosen",
725                        "alpha": "beta",
726                        "beta": "alpha",
727                    },
728                }).to_string(),
729            ),
730            (
731                "index.js",
732                "const nothing = require('./nothing.js');\nmodule.exports = {\n  fs: Object.keys(require('fs')),\n  swapped: require('buffer'),\n  sub: require('dep/subpath'),\n  exact: require('original/sub'),\n  nothing,\n  kinds: Promise.all([require('#dep'), import('#dep').then((m) => m.default)]),\n};\nreturn;\nthrow new Error('unreachable');",
733            ),
734            ("cyclic.js", "module.exports = require('alpha');"),
735            ("nothing.js", "module.exports = 'excluded';"),
736            ("sub-shim.js", "module.exports = 'full-subpath';"),
737            ("i.mjs", "export default 'IMPORT';"),
738            ("r.cjs", "module.exports = 'REQUIRE';"),
739        ],
740    );
741    server.publish_package(
742        "effects",
743        "1.0.0",
744        serde_json::json!({"type": "module", "sideEffects": ["./{init,other}.js"]}),
745        &[
746            (
747                "index.js",
748                "import './init.js';\nexport const retained = globalThis.INIT;",
749            ),
750            ("init.js", "globalThis.INIT = 'retained';"),
751        ],
752    );
753    server.publish_package(
754        "pure",
755        "1.0.0",
756        serde_json::json!({"type": "module", "sideEffects": ["*.css"]}),
757        &[
758            (
759                "index.js",
760                "export { used } from './used.js';\nexport { unused } from './unused.js';",
761            ),
762            ("used.js", "export const used = 'used';"),
763            (
764                "unused.js",
765                "globalThis.DROPPED = true;\nexport const unused = 1;",
766            ),
767        ],
768    );
769    server.publish_package(
770        "esm-shaped",
771        "1.0.0",
772        serde_json::json!({"type": "module"}),
773        &[
774            (
775                "index.js",
776                "if (typeof exports !== 'undefined') exports.value = 1;\nglobalThis.ESM_SHAPED = [this === undefined, typeof exports];",
777            ),
778        ],
779    );
780    server.publish_npm(
781        "dual",
782        "1.0.0",
783        &[
784            (
785                "package.json",
786                &serde_json::json!({"name": "dual", "version": "1.0.0", "exports": {".": {"browser": {"require": "./browser.cjs"}, "require": "./node.cjs"}}}).to_string(),
787            ),
788            ("browser.cjs", "module.exports = 'browser-require';"),
789            ("node.cjs", "module.exports = 'node';"),
790        ],
791    );
792    let dir = tempfile::tempdir().unwrap();
793    files(
794        dir.path(),
795        &[
796            (
797                "avril.conf",
798                "deps: ['npm:cond@1.0.0', 'npm:legacy@1.0.0', 'npm:remap@1.0.0', 'npm:legacy-cjs@1.0.0', 'npm:effects@1.0.0', 'npm:pure@1.0.0', 'npm:esm-shaped@1.0.0', {from: 'npm:dual@1.0.0', name: 'aliased'}],\nscopes: {'./sub/': {helper: './shared/helper.cjs'}}",
799            ),
800            // Local `require` maps through the owning config, then selects npm browser branches.
801            ("shared/helper.cjs", "module.exports = require('aliased');"),
802            ("sub/scoped.cjs", "module.exports = require('helper');"),
803            (
804                "main.js",
805                r#"import cond from 'cond';
806import legacy from 'legacy';
807import { value, ignored } from 'remap';
808import pkg from 'legacy-cjs';
809import excluded, { named } from 'legacy-cjs/nothing.js';
810import { retained } from 'effects';
811import { used as pure } from 'pure';
812import scoped from './sub/scoped.cjs';
813import 'esm-shaped';
814console.log(JSON.stringify({
815  cond, legacy, value, ignored, retained, pure, dropped: globalThis.DROPPED, scoped, esmShaped: globalThis.ESM_SHAPED,
816  fs: pkg.fs, swapped: pkg.swapped, sub: pkg.sub, exact: pkg.exact, kinds: await pkg.kinds,
817  excluded: Object.keys(excluded), same: excluded === pkg.nothing, named: named === undefined,
818}));
819"#,
820            ),
821            (
822                "run.js",
823                "import cond from 'cond';\nimport legacy from 'legacy';\nconsole.log(cond, legacy);",
824            ),
825            ("cyclic.js", "import 'legacy-cjs/cyclic.js';"),
826        ],
827    );
828    bundle(dir.path(), &server, &["main.js", "-o", "dist/app.js"]).unwrap();
829    assert_eq!(
830        execute(&dir.path().join("dist"), "app.js"),
831        concat!(
832            r#"{"cond":"browser","legacy":"web","value":"shim","ignored":"kept","retained":"retained","pure":"used","scoped":"browser-require","esmShaped":[true,"undefined"],"#,
833            r#""fs":[],"swapped":"swapped","sub":"full-subpath","exact":"exact","kinds":["REQUIRE","IMPORT"],"#,
834            r#""excluded":[],"same":true,"named":true}"#,
835            "\n"
836        )
837    );
838    // Browser metadata never changes execution, malformed or not.
839    assert_eq!(
840        avril(dir.path(), &server, "run", &["run.js"]).unwrap(),
841        "avril esm\n"
842    );
843    let error = bundle(dir.path(), &server, &["cyclic.js", "-o", "dist/cyclic.js"]).unwrap_err();
844    assert!(
845        error.contains("cyclic or too deep") && error.contains("imported from"),
846        "{error}"
847    );
848}
849
850#[test]
851fn unbundleable_code_fails_before_writing() {
852    let server = Registry::new();
853    let dir = tempfile::tempdir().unwrap();
854    files(
855        dir.path(),
856        &[("dep.cjs", "module.exports = 1;"), ("addon.node", "binary")],
857    );
858    for (source, args, message) in [
859        ("import 'node:fs';", &[][..], "node:fs"),
860        ("import 'avril:std';", &[], "avril:std"),
861        ("import(['./dep.cjs'][0]);", &[], "computed import()"),
862        ("import(`./${'dep'}.cjs`);", &[], "computed import()"),
863        ("require('./dep.cjs');", &[], "unavailable in ES modules"),
864        (
865            "console.log(new URL('./dep.cjs', import.meta.url));",
866            &[],
867            "Asset references",
868        ),
869        (
870            "await null;",
871            &["--target", "es2019"],
872            "top-level await requires es2022",
873        ),
874        (
875            "for await (const x of []);",
876            &["--target", "es2021"],
877            "top-level await",
878        ),
879        (
880            "await using x = null;",
881            &["--target", "es2019"],
882            "top-level await",
883        ),
884    ] {
885        files(dir.path(), &[("main.js", source)]);
886        let mut all = vec!["main.js", "-o", "dist/app.js"];
887        all.extend(args);
888        let error = bundle(dir.path(), &server, &all).unwrap_err();
889        assert!(error.contains(message), "{source}: {error}");
890        assert!(error.contains("main.js"), "{source}: {error}");
891    }
892    for source in [
893        "const r = require; r('./dep.cjs');",
894        "require?.('./dep.cjs');",
895        "require(name);",
896        "require.resolve('./dep.cjs');",
897        "require('./dep.cjs', console.log('dropped'));",
898        "obj.method(require);",
899        "obj[require];",
900        "require('./addon.node');",
901    ] {
902        files(dir.path(), &[("main.cjs", source)]);
903        let error = bundle(dir.path(), &server, &["main.cjs", "-o", "dist/app.js"]).unwrap_err();
904        assert!(
905            error.contains("require") || error.contains("addon"),
906            "{source}: {error}"
907        );
908    }
909    files(
910        dir.path(),
911        &[
912            (
913                "tsconfig.json",
914                r#"{"compilerOptions": {"jsx": "preserve"}}"#,
915            ),
916            ("main.tsx", "export const view = <p />;"),
917        ],
918    );
919    let error = bundle(dir.path(), &server, &["main.tsx", "-o", "dist/app.js"]).unwrap_err();
920    assert!(error.contains("preserved JSX"), "{error}");
921    assert!(!dir.path().join("dist").exists());
922}
923
924#[cfg(unix)]
925#[test]
926fn outputs_never_replace_inputs_or_follow_links() {
927    use std::os::unix::fs::{PermissionsExt, symlink};
928    let server = Registry::new();
929    let dir = tempfile::tempdir().unwrap();
930    let main = "import x from './app.js.map';\nconsole.log(x);";
931    files(
932        dir.path(),
933        &[
934            ("src/main.js", main),
935            ("src/app.js.map", "export default 42;"),
936        ],
937    );
938    symlink(dir.path().join("src"), dir.path().join("alias")).unwrap();
939    symlink(dir.path().join("nowhere"), dir.path().join("dangling")).unwrap();
940    symlink(dir.path().join("src/main.js"), dir.path().join("link.js")).unwrap();
941    for (out, message) in [
942        ("src/main.js", "overwrite"),
943        ("alias/main.js", "overwrite"),
944        ("alias/../src/main.js", "overwrite"),
945        ("new/../src/main.js", "Invalid output path"),
946        ("src/app.js", "overwrite"),
947        ("link.js", "symlink"),
948        ("dangling/app.js", ""),
949    ] {
950        let result = bundle(
951            dir.path(),
952            &server,
953            &["src/main.js", "-o", out, "--sourcemap"],
954        );
955        assert!(
956            result.as_ref().unwrap_err().to_string().contains(message),
957            "{out}: {result:?}"
958        );
959    }
960    assert_eq!(
961        fs::read_to_string(dir.path().join("src/main.js")).unwrap(),
962        main
963    );
964    assert_eq!(
965        fs::read_to_string(dir.path().join("src/app.js.map")).unwrap(),
966        "export default 42;"
967    );
968    assert!(!dir.path().join("new").exists() && !dir.path().join("nowhere").exists());
969    // New files get the usual creation mode, replaced files keep theirs,
970    // and a failed build leaves the previous output alone.
971    fs::write(dir.path().join("baseline"), "").unwrap();
972    let mode = |path: &str| {
973        fs::metadata(dir.path().join(path))
974            .unwrap()
975            .permissions()
976            .mode()
977            & 0o777
978    };
979    bundle(dir.path(), &server, &["src/main.js", "-o", "dist/app.js"]).unwrap();
980    assert_eq!(mode("dist/app.js"), mode("baseline"));
981    fs::set_permissions(
982        dir.path().join("dist/app.js"),
983        fs::Permissions::from_mode(0o640),
984    )
985    .unwrap();
986    bundle(dir.path(), &server, &["src/main.js", "-o", "dist/app.js"]).unwrap();
987    assert_eq!(mode("dist/app.js"), 0o640);
988    let before = fs::read_to_string(dir.path().join("dist/app.js")).unwrap();
989    files(dir.path(), &[("src/main.js", "import './missing.js';")]);
990    bundle(dir.path(), &server, &["src/main.js", "-o", "dist/app.js"]).unwrap_err();
991    assert_eq!(
992        fs::read_to_string(dir.path().join("dist/app.js")).unwrap(),
993        before
994    );
995}
996
997fn snapshot(dir: &Path) -> Vec<(String, Vec<u8>)> {
998    listing(dir)
999        .into_iter()
1000        .map(|name| {
1001            let bytes = fs::read(dir.join(&name)).unwrap();
1002            (name, bytes)
1003        })
1004        .collect()
1005}
1006
1007#[test]
1008fn rebuilds_are_reproducible_from_locks_vendors_and_other_checkouts() {
1009    let server = Registry::new();
1010    server.publish_package(
1011        "pinned",
1012        "1.0.0",
1013        serde_json::json!({"type": "module"}),
1014        &[("index.js", "export default 'pinned';")],
1015    );
1016    // The project in `app/` uses a JSX module from a package outside its root.
1017    let sources = [
1018        (
1019            "app/avril.conf",
1020            "deps: ['npm:pinned@1.0.0'], compilerOptions: {jsx: 'react-jsxdev'}, imports: {'react/jsx-dev-runtime': './jsx.js'}",
1021        ),
1022        (
1023            "app/jsx.js",
1024            "export const jsxDEV = (type, props, key, isStatic, source) => source.fileName;",
1025        ),
1026        (
1027            "app/main.tsx",
1028            "import pinned from 'pinned';\nimport { dep } from '../outside/dep.tsx';\nconst lazy = await import('./lazy.ts');\nconsole.log(pinned, <p />, dep, lazy.value);",
1029        ),
1030        ("app/lazy.ts", "export const value: string = 'lazy';"),
1031        (
1032            "outside/avril.conf",
1033            "compilerOptions: {jsx: 'react-jsxdev'}, imports: {'react/jsx-dev-runtime': '../app/jsx.js'}",
1034        ),
1035        ("outside/dep.tsx", "export const dep = <i />;"),
1036    ];
1037    let fixture = tempfile::tempdir().unwrap();
1038    files(fixture.path(), &sources);
1039    let app = fixture.path().join("app");
1040    let args = ["main.tsx", "-o", "dist/app.js", "--sourcemap"];
1041    bundle(&app, &server, &args).unwrap();
1042    assert_eq!(
1043        execute(&app.join("dist"), "app.js"),
1044        "pinned main.tsx ../outside/dep.tsx lazy\n"
1045    );
1046    let first = snapshot(&app.join("dist"));
1047    let lock = fs::read(app.join("avril.lock")).unwrap();
1048    let mut offline = args.to_vec();
1049    offline.extend(["--offline", "--frozen"]);
1050    bundle(&app, &server, &offline).unwrap();
1051    assert_eq!(snapshot(&app.join("dist")), first);
1052    assert_eq!(fs::read(app.join("avril.lock")).unwrap(), lock);
1053    avril(&app, &server, "vendor", &["--out=vendored", "main.tsx"]).unwrap();
1054    let relocated = tempfile::tempdir().unwrap();
1055    files(relocated.path(), &sources);
1056    let moved = relocated.path().join("app");
1057    fs::copy(app.join("avril.lock"), moved.join("avril.lock")).unwrap();
1058    let vendored = format!("--vendor={}", app.join("vendored").display());
1059    let mut args = args.to_vec();
1060    args.extend([vendored.as_str(), "--offline"]);
1061    bundle(&moved, &server, &args).unwrap();
1062    assert_eq!(snapshot(&moved.join("dist")), first);
1063}
1064
1065#[test]
1066fn remote_graphs_use_one_consistent_source_snapshot() {
1067    let server = Registry::new();
1068    let http = http::Http::new();
1069    http.source("dep.js", "export const value = 'VERIFIED';");
1070    let dir = tempfile::tempdir().unwrap();
1071    let entry = dir.path().join("main.js");
1072    files(
1073        dir.path(),
1074        &[(
1075            "main.js",
1076            &format!(
1077                "import {{ value }} from '{}';\nconsole.log(value);",
1078                http.name("dep.js")
1079            ),
1080        )],
1081    );
1082    *http.hook.lock().unwrap() = Some(Box::new(move || {
1083        fs::write(
1084            entry,
1085            "import './never-existed.js';\nconsole.log('NEW-SOURCE');",
1086        )
1087        .unwrap();
1088    }));
1089    bundle(dir.path(), &server, &["main.js", "-o", "dist/app.js"]).unwrap();
1090    assert_eq!(execute(&dir.path().join("dist"), "app.js"), "VERIFIED\n");
1091}
1092
1093#[test]
1094fn library_generate_bundles_entries_in_memory() {
1095    use avril_runtime::authority::SourceAuthority;
1096    use avril_runtime::bundle;
1097    use avril_runtime::caps::{Capabilities, Category, Initial};
1098    use avril_runtime::modules::ModuleGraph;
1099
1100    let dir = tempfile::tempdir().unwrap();
1101    files(
1102        dir.path(),
1103        &[
1104            (
1105                "src/app.ts",
1106                "export { state } from './shared.ts'; export const app = 'app';",
1107            ),
1108            (
1109                "src/admin.ts",
1110                "export { state } from './shared.ts'; export const admin = 'admin'; export const lazy = () => import('./lazy.ts');",
1111            ),
1112            (
1113                "src/shared.ts",
1114                "export const state = { message: 'SHARED-MARKER' };",
1115            ),
1116            ("src/lazy.ts", "export const message = 'LAZY-MARKER';"),
1117        ],
1118    );
1119    let mut initial = Initial::new(true);
1120    initial
1121        .grant(
1122            Category::Read,
1123            false,
1124            dir.path().to_str().unwrap(),
1125            dir.path(),
1126        )
1127        .unwrap();
1128    let caps = Capabilities::new(initial, dir.path().to_owned());
1129    let graph = ModuleGraph::open_browser(
1130        registry::runtime_handle(),
1131        SourceAuthority::read(caps),
1132        avril_runtime::registry::Options {
1133            jsr_registry: url::Url::parse("https://example.invalid/").unwrap(),
1134            npm_registry: url::Url::parse("https://example.invalid/").unwrap(),
1135            cache: dir.path().join("cache"),
1136            lock: dir.path().join("avril.lock"),
1137            project_root: dir.path().to_owned(),
1138            offline: true,
1139            frozen: false,
1140            update: false,
1141            ephemeral_lock: false,
1142            vendor: None,
1143        },
1144    )
1145    .unwrap();
1146    let entries = vec![
1147        bundle::Entry {
1148            name: Some("app".to_owned()),
1149            import: dir.path().join("src/app.ts").to_string_lossy().into_owned(),
1150        },
1151        bundle::Entry {
1152            name: Some("admin".to_owned()),
1153            import: dir
1154                .path()
1155                .join("src/admin.ts")
1156                .to_string_lossy()
1157                .into_owned(),
1158        },
1159    ];
1160    let out = dir.path().join("dist");
1161    let generated = tokio::runtime::Builder::new_current_thread()
1162        .enable_all()
1163        .build()
1164        .unwrap()
1165        .block_on(bundle::generate(
1166            &graph,
1167            &entries,
1168            "[name].js",
1169            &out,
1170            &bundle::Options::default(),
1171        ))
1172        .unwrap();
1173    assert!(generated.warnings.is_empty());
1174    for name in ["app.ts", "admin.ts", "shared.ts", "lazy.ts"] {
1175        assert!(
1176            generated
1177                .modules
1178                .contains(dir.path().join("src").join(name).to_str().unwrap())
1179        );
1180    }
1181    let names: Vec<_> = generated
1182        .files
1183        .iter()
1184        .map(|(name, _)| name.clone())
1185        .collect();
1186    assert!(names.contains(&"app.js".to_owned()), "{names:?}");
1187    assert!(names.contains(&"admin.js".to_owned()), "{names:?}");
1188    // Each shared or lazy module is emitted exactly once.
1189    for marker in ["SHARED-MARKER", "LAZY-MARKER"] {
1190        let chunks: Vec<_> = generated
1191            .files
1192            .iter()
1193            .filter(|(name, code)| {
1194                name.ends_with(".js") && String::from_utf8_lossy(code).contains(marker)
1195            })
1196            .collect();
1197        assert_eq!(chunks.len(), 1, "{marker}: {names:?}");
1198    }
1199    // Generation writes no outputs: the caller publishes.
1200    assert!(!out.exists());
1201    assert!(!dir.path().join("avril.lock").exists());
1202    files(
1203        dir.path(),
1204        &[(
1205            "dist/check.js",
1206            "import { state as a, app } from './app.js'; import { state as b, admin, lazy } from './admin.js'; console.log(a === b, app, admin, (await lazy()).message);",
1207        )],
1208    );
1209    for (name, contents) in &generated.files {
1210        fs::write(out.join(name), contents).unwrap();
1211    }
1212    assert_eq!(execute(&out, "check.js"), "true app admin LAZY-MARKER\n");
1213}