#[allow(dead_code)] #[path = "common/http.rs"] mod http; #[path = "common/registry.rs"] mod registry; use std::fs; use std::path::Path; use std::process::Command; use registry::{Registry, files, success}; fn avril(dir: &Path, server: &Registry, command: &str, args: &[&str]) -> Result { let output = server.command(dir, command).args(args).output().unwrap(); let stdout = String::from_utf8(output.stdout).unwrap(); let stderr = String::from_utf8(output.stderr).unwrap(); if output.status.success() { Ok(stdout) } else { assert!(!stderr.is_empty(), "failures explain themselves"); Err(stderr) } } fn bundle(dir: &Path, server: &Registry, args: &[&str]) -> Result { avril(dir, server, "bundle", args) } /// Runs a bundle's entry from a copy of its output directory alone, so it /// can only depend on what was emitted. fn execute(output: &Path, entry: &str) -> String { let copy = tempfile::tempdir().unwrap(); for file in fs::read_dir(output).unwrap() { let file = file.unwrap(); fs::copy(file.path(), copy.path().join(file.file_name())).unwrap(); } let output = Command::new(env!("CARGO_BIN_EXE_avril")) .current_dir(copy.path()) .args(["run", entry]) .output() .unwrap(); success(&output) } fn listing(dir: &Path) -> Vec { let mut names: Vec<_> = fs::read_dir(dir) .unwrap() .map(|entry| entry.unwrap().file_name().into_string().unwrap()) .collect(); names.sort(); names } #[test] fn options_are_validated_before_bundling() { let server = Registry::new(); let dir = tempfile::tempdir().unwrap(); files(dir.path(), &[("main.js", "console.log(1);")]); let help = bundle(dir.path(), &server, &["--help"]).unwrap(); for option in [ "-o, --out", "--outdir", "--minify", "--sourcemap", "--no-splitting", "--target", "--define", ] { assert!(help.contains(option), "{help}"); } for (args, message) in [ (&["main.js"][..], "-o "), ( &["main.js", "-o", "dist/app.js", "--target", "es5"], "Unsupported --target", ), ( &["main.js", "-o", "dist/app.js", "--define", "novalue"], "KEY=VALUE", ), ( &["main.js", "-o", "dist/app.js", "--define=9lives=1"], "KEY=VALUE", ), ( &["main.js", "-o", "dist/app.js", "--filter=x"], "not an option", ), (&["main.js", "-o", "dist/app.txt"], ".js or .mjs"), (&["main.js", "-o", "dist/out?query.js"], "filename"), (&["main.js", "-o", "dist/out#hash.js"], "filename"), (&["main.js", "-o", "dist/out%25.js"], "filename"), (&["main.js", "-o", "dist/out name.js"], "filename"), ] { let error = bundle(dir.path(), &server, args).unwrap_err(); assert!(error.contains(message), "{args:?}: {error}"); } assert!(!dir.path().join("dist").exists()); } #[test] fn multiple_entries_share_modules_and_keep_exports_and_maps() { let server = Registry::new(); let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ( "src/app.ts", "export { state } from './shared.ts'; export const app: string = 'app';", ), ( "src/admin.ts", "export { state } from './shared.ts'; export const admin: string = 'admin'; export const lazy = () => import('./lazy.ts');", ), ( "src/shared.ts", "export const state = { message: 'SHARED-MARKER' };", ), ( "src/lazy.ts", "export const message: string = 'LAZY-MARKER';", ), ("dist/keep.txt", "untouched"), ], ); bundle( dir.path(), &server, &[ "src/app.ts", "--outdir", "dist", "src/admin.ts", "--sourcemap", ], ) .unwrap(); let output = dir.path().join("dist"); let names = listing(&output); for name in ["app.js", "admin.js", "app.js.map", "admin.js.map"] { assert!(names.iter().any(|file| file == name), "{names:?}"); } for marker in ["SHARED-MARKER", "LAZY-MARKER"] { let chunks: Vec<_> = names .iter() .filter(|name| { name.ends_with(".js") && fs::read_to_string(output.join(name)) .unwrap() .contains(marker) }) .collect(); assert_eq!(chunks.len(), 1, "{marker}: {chunks:?}"); assert!( maps_back(&output, chunks[0], marker).ends_with(if marker == "SHARED-MARKER" { "src/shared.ts" } else { "src/lazy.ts" }) ); } files( &output, &[( "check.js", "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);", )], ); assert_eq!(execute(&output, "check.js"), "true app admin LAZY-MARKER\n"); assert_eq!( fs::read_to_string(output.join("keep.txt")).unwrap(), "untouched" ); } #[test] fn multiple_entry_options_and_names_are_validated() { let server = Registry::new(); let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ("a/main.ts", "export const a = 1;"), ("b/main.js", "export const b = 2;"), ("other.ts", "export const c = 3;"), ], ); for (args, message) in [ ( &["a/main.ts", "other.ts", "-o", "dist/app.js"][..], "use --outdir", ), ( &["a/main.ts", "--outdir", "dist", "-o", "dist/app.js"], "cannot be combined", ), ( &["a/main.ts", "other.ts", "--outdir=dist", "--no-splitting"], "--no-splitting requires exactly one", ), ( &["a/main.ts", "b/main.js", "--outdir=dist"], "Duplicate entry output filename: main.js", ), ( &["a/main.ts", "a/main.ts", "--outdir=dist"], "Duplicate entry output filename", ), (&["--outdir=dist"], "Usage:"), (&["a/main.ts", "--outdir"], "Expected a value"), ] { let error = bundle(dir.path(), &server, args).unwrap_err(); assert!(error.contains(message), "{args:?}: {error}"); assert!(!dir.path().join("dist").exists()); } bundle( dir.path(), &server, &["a/main.ts", "--outdir=dist", "--no-splitting"], ) .unwrap(); assert_eq!(listing(&dir.path().join("dist")), ["main.js"]); } #[test] fn entry_basenames_are_not_silently_rewritten() { let server = Registry::new(); let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ("+page.ts", "console.log('plus');"), ("_page.ts", "console.log('underscore');"), ("[page].ts", "console.log('brackets');"), ], ); let error = bundle( dir.path(), &server, &["+page.ts", "[page].ts", "--outdir=dist"], ) .unwrap_err(); assert!(error.contains("Unsupported output filename"), "{error}"); assert!(!dir.path().join("dist").exists()); bundle( dir.path(), &server, &["+page.ts", "_page.ts", "--outdir=dist"], ) .unwrap(); assert_eq!(listing(&dir.path().join("dist")), ["+page.js", "_page.js"]); assert_eq!(execute(&dir.path().join("dist"), "+page.js"), "plus\n"); assert_eq!( execute(&dir.path().join("dist"), "_page.js"), "underscore\n" ); } #[test] fn each_entry_uses_its_own_configuration() { let server = Registry::new(); let remote = http::Http::new(); remote.source("remote.ts", "export const remote: number = 42;"); let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ( "a/avril.conf", "compilerOptions: {jsx: 'react', jsxFactory: 'h'}", ), ( "a/app.tsx", "const h = (tag) => 'a:' + tag; export const app =
;", ), ( "b/avril.conf", "compilerOptions: {jsx: 'react', jsxFactory: 'j'}", ), ( "b/admin.tsx", "const j = (tag) => 'b:' + tag; export const admin =
;", ), ], ); bundle( dir.path(), &server, &[ "--outdir=dist", "--", "a/app.tsx", "b/admin.tsx", &remote.name("remote.ts"), ], ) .unwrap(); files( &dir.path().join("dist"), &[( "check.js", "import { app } from './app.js'; import { admin } from './admin.js'; import { remote } from './remote.js'; console.log(app, admin, remote);", )], ); assert_eq!( execute(&dir.path().join("dist"), "check.js"), "a:div b:div 42\n" ); } #[test] fn each_entry_gets_an_implicit_permission_grant() { let server = Registry::new(); let remote = http::Http::new(); remote.source("remote.ts", "export const remote: number = 42;"); let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ("a/app.ts", "export const app: string = 'app';"), ("b/admin.ts", "export const admin: string = 'admin';"), ], ); bundle( dir.path(), &server, &[ "-0", "--outdir=dist", "a/app.ts", "b/admin.ts", &remote.name("remote.ts"), ], ) .unwrap(); files( &dir.path().join("dist"), &[( "check.js", "import { app } from './app.js'; import { admin } from './admin.js'; import { remote } from './remote.js'; console.log(app, admin, remote);", )], ); assert_eq!( execute(&dir.path().join("dist"), "check.js"), "app admin 42\n" ); } #[test] fn multiple_entry_failures_leave_all_outputs_untouched() { let server = Registry::new(); let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ("app.ts", "console.log('app');"), ("admin.js", "console.log('admin');"), ], ); let original = fs::read(dir.path().join("admin.js")).unwrap(); let error = bundle(dir.path(), &server, &["app.ts", "admin.js", "--outdir=."]).unwrap_err(); assert!(error.contains("overwrite a bundle input"), "{error}"); assert!(!dir.path().join("app.js").exists()); assert_eq!(fs::read(dir.path().join("admin.js")).unwrap(), original); bundle( dir.path(), &server, &["app.ts", "admin.js", "--outdir=dist"], ) .unwrap(); let before = snapshot(&dir.path().join("dist")); files(dir.path(), &[("admin.js", "import './missing.js';")]); bundle( dir.path(), &server, &["app.ts", "admin.js", "--outdir=dist"], ) .unwrap_err(); assert_eq!(snapshot(&dir.path().join("dist")), before); #[cfg(unix)] { use std::os::unix::fs::symlink; files(dir.path(), &[("admin.js", "console.log('admin');")]); fs::remove_file(dir.path().join("dist/admin.js")).unwrap(); symlink( dir.path().join("admin.js"), dir.path().join("dist/admin.js"), ) .unwrap(); let before = snapshot(&dir.path().join("dist")); let error = bundle( dir.path(), &server, &["app.ts", "admin.js", "--outdir=dist"], ) .unwrap_err(); assert!(error.contains("symlink"), "{error}"); assert_eq!(snapshot(&dir.path().join("dist")), before); symlink(dir.path().join("missing"), dir.path().join("dangling")).unwrap(); bundle( dir.path(), &server, &["app.ts", "admin.js", "--outdir=dangling"], ) .unwrap_err(); assert!(!dir.path().join("missing").exists()); } } #[test] fn compiles_each_module_kind_and_links_them() { let server = Registry::new(); let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ( "avril.conf", "compilerOptions: {jsx: 'react', jsxFactory: 'h'}, imports: {helper: './helper.cjs'}", ), ( "main.tsx", r#"import { greet } from "./greet.ts"; import data from "./data.json"; import { answer } from "./native.cjs"; import { fromCts } from "./typed.cts"; import { a } from "./cycle-a.js"; import "./bindings.js"; const h = (tag: string, props: unknown, ...children: string[]) => `<${tag}>${children.join("")}`; const lazy = await import("./lazy.ts"); console.log({greet("web")}, data.answer, answer, fromCts, a, lazy.heavy); console.log(process.env.NODE_ENV, import.meta.url.endsWith("/app.js")); export const secret = () => process.env.AVRIL_SECRET; "#, ), ( "greet.ts", "export const greet = (name: string): string => `hi ${name}`;\nexport const unusedExport = 'TREE-SHAKEN';", ), ("data.json", r#"{"answer": 42}"#), ( "native.cjs", "const helper = require('helper');\nmodule.exports = { answer: helper(require('./data.json').answer) };", ), ("helper.cjs", "module.exports = (value) => value + 1;"), ( "typed.cts", "import dep = require('./dep.cjs');\nconst fromCts: string = dep;\nmodule.exports = { fromCts };", ), ("dep.cjs", "module.exports = 'cts';"), ( "cycle-a.js", "import { b } from './cycle-b.js';\nexport const a = 'a' + b;", ), ( "cycle-b.js", "import { a } from './cycle-a.js';\nexport const b = 'b';\nexport const usesA = () => a;", ), ("lazy.ts", "export const heavy: string = 'lazy';"), // Bindings that merely share host features' names are ordinary code. ( "bindings.js", "{ 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);", ), ], ); bundle( dir.path(), &server, &[ "main.tsx", "-o", "dist/app.js", "--define", "process.env.NODE_ENV=\"production\"", ], ) .unwrap(); assert_eq!( execute(&dir.path().join("dist"), "app.js"), "ok\n./x.png string\ntrue\ntrue undefined\nhi web 42 43 cts ab lazy\nproduction true\n" ); assert_eq!(listing(&dir.path().join("dist")).len(), 2); let entry = fs::read_to_string(dir.path().join("dist/app.js")).unwrap(); assert!(!entry.contains("TREE-SHAKEN"), "{entry}"); assert!(entry.contains("process.env.AVRIL_SECRET"), "{entry}"); assert!(!entry.contains(dir.path().to_str().unwrap()), "{entry}"); } #[test] fn splitting_minification_and_targets_preserve_behavior() { let server = Registry::new(); let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ( "main.js", "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));", ), ("data.json", r#"{"answer": 42}"#), ( "lazy.js", "export const heavy = 'lazy';\nexport async function* unused() {}", ), ], ); for (args, chunks, absent) in [ (&[][..], 2, &[][..]), (&["--no-splitting"], 1, &["import("][..]), (&["--minify", "--target", "es2019"], 2, &["?.", "??", "\n"]), (&["--target", "es2015"], 2, &["async ", "await "]), ] { let _ = fs::remove_dir_all(dir.path().join("dist")); let mut all = vec!["main.js", "-o", "dist/app.js"]; all.extend(args); bundle(dir.path(), &server, &all).unwrap(); let names = listing(&dir.path().join("dist")); assert_eq!(names.len(), chunks, "{args:?}: {names:?}"); for name in &names { let code = fs::read_to_string(dir.path().join("dist").join(name)).unwrap(); for text in absent { assert!(!code.trim_end().contains(text), "{args:?} {name}: {code}"); } } assert_eq!( execute(&dir.path().join("dist"), "app.js"), "42 lazy\n", "{args:?}" ); } } /// Checks that the string literal containing `marker` in an emitted chunk /// maps to its exact original line and column in the original contents, /// returning the source name. fn maps_back(dir: &Path, chunk: &str, marker: &str) -> String { let js = fs::read_to_string(dir.join(chunk)).unwrap(); assert!( js.ends_with(&format!("//# sourceMappingURL={chunk}.map")), "{js}" ); let prefix = &js[..js.find(marker).unwrap()]; let line = prefix.matches('\n').count() as u32; let column = prefix.rsplit('\n').next().unwrap().len() as u32; let map = fs::read_to_string(dir.join(format!("{chunk}.map"))).unwrap(); let map = oxc_sourcemap::SourceMap::from_json_string(&map).unwrap(); let token = map .lookup_token(&map.generate_lookup_table(), line, column) .unwrap(); let source = token.get_source_id().unwrap(); let original = map.get_source_content(source).unwrap(); let (line, text) = original .lines() .enumerate() .find(|(_, text)| text.contains(marker)) .unwrap(); assert_eq!( (token.get_src_line(), token.get_src_col()), (line as u32, text.find(marker).unwrap() as u32 - 1), "{chunk}: {marker}" ); let name = map.get_source(source).unwrap().to_owned(); if !name.starts_with("http:") { assert_eq!(original, fs::read_to_string(dir.join(&name)).unwrap()); } name } #[test] fn source_maps_lead_back_to_original_files() { let server = Registry::new(); server.publish_npm( "typeless", "1.0.0", &[ ( "package.json", &serde_json::json!({"name": "typeless", "version": "1.0.0", "main": "index.js"}) .to_string(), ), ( "index.js", "module.exports = function crash() {\n throw new Error('CJS-MARKER');\n};", ), ], ); let http = http::Http::new(); http.source( "remote.ts?v=1", "export const remote = (n: number): string => 'REMOTE-MARKER' + n;", ); http.redirect("alias", "/remote.ts?v=1"); let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ( "avril.conf", "deps: ['npm:typeless@1.0.0'], compilerOptions: {jsx: 'react', jsxFactory: 'h'}", ), ( "web/main.tsx", &format!( "import crash from 'typeless';\nimport {{ raw }} from './raw.js';\nimport {{ remote }} from '{}';\nconst h = (tag: string, ...rest: unknown[]) => tag;\nconst view = (n: number) =>

{{n}}

;\nconsole.log(view(1), remote(1), raw, crash, 'ENTRY-MARKER');\nconst late = await import('./late.ts');\nconsole.log(late.late);", http.name("alias") ), ), ("web/raw.js", "export const raw = () =>\n 'RAW-MARKER';\n"), ( "web/late.ts", "export const late: string =\n 'LATE-MARKER';", ), ], ); for minify in [false, true] { let mut args = vec!["web/main.tsx", "-o", "dist/app.js", "--sourcemap"]; if minify { args.push("--minify"); } bundle(dir.path(), &server, &args).unwrap(); let dist = dir.path().join("dist"); let late = listing(&dist) .into_iter() .find(|name| name.starts_with("late-") && name.ends_with(".js")) .unwrap(); assert_eq!( maps_back(&dist, "app.js", "ENTRY-MARKER"), "../web/main.tsx" ); assert_eq!(maps_back(&dist, &late, "LATE-MARKER"), "../web/late.ts"); assert_eq!(maps_back(&dist, "app.js", "RAW-MARKER"), "../web/raw.js"); assert_eq!( maps_back(&dist, "app.js", "REMOTE-MARKER"), http.name("alias") ); let typeless = maps_back(&dist, "app.js", "CJS-MARKER"); assert!( typeless.ends_with("/typeless/1.0.0/index.js") && !typeless.starts_with('/'), "{typeless}" ); fs::remove_dir_all(dist).unwrap(); } } #[test] fn npm_packages_resolve_their_browser_branches() { let server = Registry::new(); server.publish_package( "cond", "1.0.0", serde_json::json!({"type": "module", "exports": {".": {"avril": "./avril.js", "node": "./node.js", "browser": "./browser.js", "default": "./default.js"}}}), &[ ("avril.js", "export default 'avril';"), ("node.js", "export default 'node';"), ("browser.js", "export default 'browser';"), ("default.js", "export default 'default';"), ], ); server.publish_package( "legacy", "1.0.0", serde_json::json!({"type": "module", "main": "./node.js", "module": "./esm.js", "browser": "./web.js", "sideEffects": {"malformed": true}}), &[ ("node.js", "export default 'node';"), ("esm.js", "export default 'esm';"), ("web.js", "export default 'web';"), ], ); server.publish_package( "remap", "1.0.0", serde_json::json!({"type": "module", "browser": {"./server": "./general.js", "./server.js": "./shim.js", "./ignored.js": 42}}), &[ ( "index.js", "export { value } from './server.js';\nexport { default as ignored } from './ignored.js';", ), ("server.js", "export const value = 'server';"), ("shim.js", "export const value = 'shim';"), ("general.js", "export const value = 'extensionless';"), ("ignored.js", "export default 'kept';"), ], ); server.publish_npm( "swap", "1.0.0", &[ ( "package.json", &serde_json::json!({"name": "swap", "version": "1.0.0"}).to_string(), ), ("index.js", "module.exports = 'swapped';"), ], ); server.publish_npm( "actual", "1.0.0", &[ ( "package.json", &serde_json::json!({"name": "actual", "version": "1.0.0", "exports": {"./chosen": "./value.cjs"}}).to_string(), ), ("value.cjs", "module.exports = 'exact';"), ], ); server.publish_npm( "legacy-cjs", "1.0.0", &[ ( "package.json", &serde_json::json!({ "name": "legacy-cjs", "version": "1.0.0", "dependencies": {"swap": "1.0.0", "actual": "1.0.0"}, "imports": {"#dep": {"import": "./i.mjs", "require": "./r.cjs"}}, "browser": { "fs": false, "./nothing.js": false, "buffer": "swap", "dep/subpath": "./sub-shim.js", "original/sub": "actual/chosen", "alpha": "beta", "beta": "alpha", }, }).to_string(), ), ( "index.js", "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');", ), ("cyclic.js", "module.exports = require('alpha');"), ("nothing.js", "module.exports = 'excluded';"), ("sub-shim.js", "module.exports = 'full-subpath';"), ("i.mjs", "export default 'IMPORT';"), ("r.cjs", "module.exports = 'REQUIRE';"), ], ); server.publish_package( "effects", "1.0.0", serde_json::json!({"type": "module", "sideEffects": ["./{init,other}.js"]}), &[ ( "index.js", "import './init.js';\nexport const retained = globalThis.INIT;", ), ("init.js", "globalThis.INIT = 'retained';"), ], ); server.publish_package( "pure", "1.0.0", serde_json::json!({"type": "module", "sideEffects": ["*.css"]}), &[ ( "index.js", "export { used } from './used.js';\nexport { unused } from './unused.js';", ), ("used.js", "export const used = 'used';"), ( "unused.js", "globalThis.DROPPED = true;\nexport const unused = 1;", ), ], ); server.publish_package( "esm-shaped", "1.0.0", serde_json::json!({"type": "module"}), &[ ( "index.js", "if (typeof exports !== 'undefined') exports.value = 1;\nglobalThis.ESM_SHAPED = [this === undefined, typeof exports];", ), ], ); server.publish_npm( "dual", "1.0.0", &[ ( "package.json", &serde_json::json!({"name": "dual", "version": "1.0.0", "exports": {".": {"browser": {"require": "./browser.cjs"}, "require": "./node.cjs"}}}).to_string(), ), ("browser.cjs", "module.exports = 'browser-require';"), ("node.cjs", "module.exports = 'node';"), ], ); let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ( "avril.conf", "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'}}", ), // Local `require` maps through the owning config, then selects npm browser branches. ("shared/helper.cjs", "module.exports = require('aliased');"), ("sub/scoped.cjs", "module.exports = require('helper');"), ( "main.js", r#"import cond from 'cond'; import legacy from 'legacy'; import { value, ignored } from 'remap'; import pkg from 'legacy-cjs'; import excluded, { named } from 'legacy-cjs/nothing.js'; import { retained } from 'effects'; import { used as pure } from 'pure'; import scoped from './sub/scoped.cjs'; import 'esm-shaped'; console.log(JSON.stringify({ cond, legacy, value, ignored, retained, pure, dropped: globalThis.DROPPED, scoped, esmShaped: globalThis.ESM_SHAPED, fs: pkg.fs, swapped: pkg.swapped, sub: pkg.sub, exact: pkg.exact, kinds: await pkg.kinds, excluded: Object.keys(excluded), same: excluded === pkg.nothing, named: named === undefined, })); "#, ), ( "run.js", "import cond from 'cond';\nimport legacy from 'legacy';\nconsole.log(cond, legacy);", ), ("cyclic.js", "import 'legacy-cjs/cyclic.js';"), ], ); bundle(dir.path(), &server, &["main.js", "-o", "dist/app.js"]).unwrap(); assert_eq!( execute(&dir.path().join("dist"), "app.js"), concat!( r#"{"cond":"browser","legacy":"web","value":"shim","ignored":"kept","retained":"retained","pure":"used","scoped":"browser-require","esmShaped":[true,"undefined"],"#, r#""fs":[],"swapped":"swapped","sub":"full-subpath","exact":"exact","kinds":["REQUIRE","IMPORT"],"#, r#""excluded":[],"same":true,"named":true}"#, "\n" ) ); // Browser metadata never changes execution, malformed or not. assert_eq!( avril(dir.path(), &server, "run", &["run.js"]).unwrap(), "avril esm\n" ); let error = bundle(dir.path(), &server, &["cyclic.js", "-o", "dist/cyclic.js"]).unwrap_err(); assert!( error.contains("cyclic or too deep") && error.contains("imported from"), "{error}" ); } #[test] fn unbundleable_code_fails_before_writing() { let server = Registry::new(); let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[("dep.cjs", "module.exports = 1;"), ("addon.node", "binary")], ); for (source, args, message) in [ ("import 'node:fs';", &[][..], "node:fs"), ("import 'avril:std';", &[], "avril:std"), ("import(['./dep.cjs'][0]);", &[], "computed import()"), ("import(`./${'dep'}.cjs`);", &[], "computed import()"), ("require('./dep.cjs');", &[], "unavailable in ES modules"), ( "console.log(new URL('./dep.cjs', import.meta.url));", &[], "Asset references", ), ( "await null;", &["--target", "es2019"], "top-level await requires es2022", ), ( "for await (const x of []);", &["--target", "es2021"], "top-level await", ), ( "await using x = null;", &["--target", "es2019"], "top-level await", ), ] { files(dir.path(), &[("main.js", source)]); let mut all = vec!["main.js", "-o", "dist/app.js"]; all.extend(args); let error = bundle(dir.path(), &server, &all).unwrap_err(); assert!(error.contains(message), "{source}: {error}"); assert!(error.contains("main.js"), "{source}: {error}"); } for source in [ "const r = require; r('./dep.cjs');", "require?.('./dep.cjs');", "require(name);", "require.resolve('./dep.cjs');", "require('./dep.cjs', console.log('dropped'));", "obj.method(require);", "obj[require];", "require('./addon.node');", ] { files(dir.path(), &[("main.cjs", source)]); let error = bundle(dir.path(), &server, &["main.cjs", "-o", "dist/app.js"]).unwrap_err(); assert!( error.contains("require") || error.contains("addon"), "{source}: {error}" ); } files( dir.path(), &[ ( "tsconfig.json", r#"{"compilerOptions": {"jsx": "preserve"}}"#, ), ("main.tsx", "export const view =

;"), ], ); let error = bundle(dir.path(), &server, &["main.tsx", "-o", "dist/app.js"]).unwrap_err(); assert!(error.contains("preserved JSX"), "{error}"); assert!(!dir.path().join("dist").exists()); } #[cfg(unix)] #[test] fn outputs_never_replace_inputs_or_follow_links() { use std::os::unix::fs::{PermissionsExt, symlink}; let server = Registry::new(); let dir = tempfile::tempdir().unwrap(); let main = "import x from './app.js.map';\nconsole.log(x);"; files( dir.path(), &[ ("src/main.js", main), ("src/app.js.map", "export default 42;"), ], ); symlink(dir.path().join("src"), dir.path().join("alias")).unwrap(); symlink(dir.path().join("nowhere"), dir.path().join("dangling")).unwrap(); symlink(dir.path().join("src/main.js"), dir.path().join("link.js")).unwrap(); for (out, message) in [ ("src/main.js", "overwrite"), ("alias/main.js", "overwrite"), ("alias/../src/main.js", "overwrite"), ("new/../src/main.js", "Invalid output path"), ("src/app.js", "overwrite"), ("link.js", "symlink"), ("dangling/app.js", ""), ] { let result = bundle( dir.path(), &server, &["src/main.js", "-o", out, "--sourcemap"], ); assert!( result.as_ref().unwrap_err().to_string().contains(message), "{out}: {result:?}" ); } assert_eq!( fs::read_to_string(dir.path().join("src/main.js")).unwrap(), main ); assert_eq!( fs::read_to_string(dir.path().join("src/app.js.map")).unwrap(), "export default 42;" ); assert!(!dir.path().join("new").exists() && !dir.path().join("nowhere").exists()); // New files get the usual creation mode, replaced files keep theirs, // and a failed build leaves the previous output alone. fs::write(dir.path().join("baseline"), "").unwrap(); let mode = |path: &str| { fs::metadata(dir.path().join(path)) .unwrap() .permissions() .mode() & 0o777 }; bundle(dir.path(), &server, &["src/main.js", "-o", "dist/app.js"]).unwrap(); assert_eq!(mode("dist/app.js"), mode("baseline")); fs::set_permissions( dir.path().join("dist/app.js"), fs::Permissions::from_mode(0o640), ) .unwrap(); bundle(dir.path(), &server, &["src/main.js", "-o", "dist/app.js"]).unwrap(); assert_eq!(mode("dist/app.js"), 0o640); let before = fs::read_to_string(dir.path().join("dist/app.js")).unwrap(); files(dir.path(), &[("src/main.js", "import './missing.js';")]); bundle(dir.path(), &server, &["src/main.js", "-o", "dist/app.js"]).unwrap_err(); assert_eq!( fs::read_to_string(dir.path().join("dist/app.js")).unwrap(), before ); } fn snapshot(dir: &Path) -> Vec<(String, Vec)> { listing(dir) .into_iter() .map(|name| { let bytes = fs::read(dir.join(&name)).unwrap(); (name, bytes) }) .collect() } #[test] fn rebuilds_are_reproducible_from_locks_vendors_and_other_checkouts() { let server = Registry::new(); server.publish_package( "pinned", "1.0.0", serde_json::json!({"type": "module"}), &[("index.js", "export default 'pinned';")], ); // The project in `app/` uses a JSX module from a package outside its root. let sources = [ ( "app/avril.conf", "deps: ['npm:pinned@1.0.0'], compilerOptions: {jsx: 'react-jsxdev'}, imports: {'react/jsx-dev-runtime': './jsx.js'}", ), ( "app/jsx.js", "export const jsxDEV = (type, props, key, isStatic, source) => source.fileName;", ), ( "app/main.tsx", "import pinned from 'pinned';\nimport { dep } from '../outside/dep.tsx';\nconst lazy = await import('./lazy.ts');\nconsole.log(pinned,

, dep, lazy.value);", ), ("app/lazy.ts", "export const value: string = 'lazy';"), ( "outside/avril.conf", "compilerOptions: {jsx: 'react-jsxdev'}, imports: {'react/jsx-dev-runtime': '../app/jsx.js'}", ), ("outside/dep.tsx", "export const dep = ;"), ]; let fixture = tempfile::tempdir().unwrap(); files(fixture.path(), &sources); let app = fixture.path().join("app"); let args = ["main.tsx", "-o", "dist/app.js", "--sourcemap"]; bundle(&app, &server, &args).unwrap(); assert_eq!( execute(&app.join("dist"), "app.js"), "pinned main.tsx ../outside/dep.tsx lazy\n" ); let first = snapshot(&app.join("dist")); let lock = fs::read(app.join("avril.lock")).unwrap(); let mut offline = args.to_vec(); offline.extend(["--offline", "--frozen"]); bundle(&app, &server, &offline).unwrap(); assert_eq!(snapshot(&app.join("dist")), first); assert_eq!(fs::read(app.join("avril.lock")).unwrap(), lock); avril(&app, &server, "vendor", &["--out=vendored", "main.tsx"]).unwrap(); let relocated = tempfile::tempdir().unwrap(); files(relocated.path(), &sources); let moved = relocated.path().join("app"); fs::copy(app.join("avril.lock"), moved.join("avril.lock")).unwrap(); let vendored = format!("--vendor={}", app.join("vendored").display()); let mut args = args.to_vec(); args.extend([vendored.as_str(), "--offline"]); bundle(&moved, &server, &args).unwrap(); assert_eq!(snapshot(&moved.join("dist")), first); } #[test] fn remote_graphs_use_one_consistent_source_snapshot() { let server = Registry::new(); let http = http::Http::new(); http.source("dep.js", "export const value = 'VERIFIED';"); let dir = tempfile::tempdir().unwrap(); let entry = dir.path().join("main.js"); files( dir.path(), &[( "main.js", &format!( "import {{ value }} from '{}';\nconsole.log(value);", http.name("dep.js") ), )], ); *http.hook.lock().unwrap() = Some(Box::new(move || { fs::write( entry, "import './never-existed.js';\nconsole.log('NEW-SOURCE');", ) .unwrap(); })); bundle(dir.path(), &server, &["main.js", "-o", "dist/app.js"]).unwrap(); assert_eq!(execute(&dir.path().join("dist"), "app.js"), "VERIFIED\n"); } #[test] fn library_generate_bundles_entries_in_memory() { use avril_runtime::authority::SourceAuthority; use avril_runtime::bundle; use avril_runtime::caps::{Capabilities, Category, Initial}; use avril_runtime::modules::ModuleGraph; let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ( "src/app.ts", "export { state } from './shared.ts'; export const app = 'app';", ), ( "src/admin.ts", "export { state } from './shared.ts'; export const admin = 'admin'; export const lazy = () => import('./lazy.ts');", ), ( "src/shared.ts", "export const state = { message: 'SHARED-MARKER' };", ), ("src/lazy.ts", "export const message = 'LAZY-MARKER';"), ], ); let mut initial = Initial::new(true); initial .grant( Category::Read, false, dir.path().to_str().unwrap(), dir.path(), ) .unwrap(); let caps = Capabilities::new(initial, dir.path().to_owned()); let graph = ModuleGraph::open_browser( registry::runtime_handle(), SourceAuthority::read(caps), avril_runtime::registry::Options { jsr_registry: url::Url::parse("https://example.invalid/").unwrap(), npm_registry: url::Url::parse("https://example.invalid/").unwrap(), cache: dir.path().join("cache"), lock: dir.path().join("avril.lock"), project_root: dir.path().to_owned(), offline: true, frozen: false, update: false, ephemeral_lock: false, vendor: None, }, ) .unwrap(); let entries = vec![ bundle::Entry { name: Some("app".to_owned()), import: dir.path().join("src/app.ts").to_string_lossy().into_owned(), }, bundle::Entry { name: Some("admin".to_owned()), import: dir .path() .join("src/admin.ts") .to_string_lossy() .into_owned(), }, ]; let out = dir.path().join("dist"); let generated = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap() .block_on(bundle::generate( &graph, &entries, "[name].js", &out, &bundle::Options::default(), )) .unwrap(); assert!(generated.warnings.is_empty()); for name in ["app.ts", "admin.ts", "shared.ts", "lazy.ts"] { assert!( generated .modules .contains(dir.path().join("src").join(name).to_str().unwrap()) ); } let names: Vec<_> = generated .files .iter() .map(|(name, _)| name.clone()) .collect(); assert!(names.contains(&"app.js".to_owned()), "{names:?}"); assert!(names.contains(&"admin.js".to_owned()), "{names:?}"); // Each shared or lazy module is emitted exactly once. for marker in ["SHARED-MARKER", "LAZY-MARKER"] { let chunks: Vec<_> = generated .files .iter() .filter(|(name, code)| { name.ends_with(".js") && String::from_utf8_lossy(code).contains(marker) }) .collect(); assert_eq!(chunks.len(), 1, "{marker}: {names:?}"); } // Generation writes no outputs: the caller publishes. assert!(!out.exists()); assert!(!dir.path().join("avril.lock").exists()); files( dir.path(), &[( "dist/check.js", "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);", )], ); for (name, contents) in &generated.files { fs::write(out.join(name), contents).unwrap(); } assert_eq!(execute(&out, "check.js"), "true app admin LAZY-MARKER\n"); }