char/avril
(wip) why'd you have to go and make things so complicaaaaated?
git clone https://git.t4t.associates/char/avril
7325e8c
main
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 > { 14let output = server. command ( dir, command). args ( args). output (). unwrap (); 15let stdout =String :: from_utf8 ( output. stdout ). unwrap (); 16let stderr =String :: from_utf8 ( output. stderr ). unwrap (); 17if output. status . success () { 18Ok ( stdout) 19} else { 20assert! ( !stderr. is_empty (), "failures explain themselves" ); 21Err ( stderr) 22} 23} 24 25fn bundle ( dir : & Path , server : & Registry , args : & [ & str ]) ->Result < String , String > { 26avril ( 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 { 32let copy = tempfile:: tempdir (). unwrap (); 33for filein fs:: read_dir ( output). unwrap () { 34let file = file. unwrap (); 35 fs:: copy ( file. path (), copy. path (). join ( file. file_name ())). unwrap (); 36} 37let output =Command :: new ( env! ( "CARGO_BIN_EXE_avril" )) 38. current_dir ( copy. path ()) 39. args ([ "run" , entry]) 40. output () 41. unwrap (); 42success ( & output) 43} 44 45fn listing ( dir : & Path ) ->Vec < String > { 46let 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 () { 56let server =Registry :: new (); 57let dir = tempfile:: tempdir (). unwrap (); 58files ( dir. path (), & [( "main.js" , "console.log(1);" )]); 59let help =bundle ( dir. path (), & server, & [ "--help" ]). unwrap (); 60for optionin [ 61"-o, --out" , 62"--outdir" , 63"--minify" , 64"--sourcemap" , 65"--no-splitting" , 66"--target" , 67"--define" , 68] { 69assert! ( help. contains ( option), "{help}" ); 70} 71for ( 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] { 95let error =bundle ( dir. path (), & server, args). unwrap_err (); 96assert! ( error. contains ( message), "{args:?}: {error}" ); 97} 98assert! ( !dir. path (). join ( "dist" ). exists ()); 99} 100 101# [ test ] 102fn multiple_entries_share_modules_and_keep_exports_and_maps () { 103let server =Registry :: new (); 104let dir = tempfile:: tempdir (). unwrap (); 105files ( 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); 127bundle ( 128 dir. path (), 129& server, 130& [ 131"src/app.ts" , 132"--outdir" , 133"dist" , 134"src/admin.ts" , 135"--sourcemap" , 136], 137) 138. unwrap (); 139let output = dir. path (). join ( "dist" ); 140let names =listing ( & output); 141for namein [ "app.js" , "admin.js" , "app.js.map" , "admin.js.map" ] { 142assert! ( names. iter (). any ( |file| file == name), "{names:?}" ); 143} 144for markerin [ "SHARED-MARKER" , "LAZY-MARKER" ] { 145let 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 (); 154assert_eq! ( chunks. len (), 1 , "{marker}: {chunks:?}" ); 155assert! ( 156maps_back ( & output, chunks[ 0 ], marker). ends_with ( if marker =="SHARED-MARKER" { 157"src/shared.ts" 158} else { 159"src/lazy.ts" 160}) 161); 162} 163files ( 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); 170assert_eq! ( execute ( & output, "check.js" ), "true app admin LAZY-MARKER\n" ); 171assert_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 () { 179let server =Registry :: new (); 180let dir = tempfile:: tempdir (). unwrap (); 181files ( 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); 189for ( 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] { 213let error =bundle ( dir. path (), & server, args). unwrap_err (); 214assert! ( error. contains ( message), "{args:?}: {error}" ); 215assert! ( !dir. path (). join ( "dist" ). exists ()); 216} 217bundle ( 218 dir. path (), 219& server, 220& [ "a/main.ts" , "--outdir=dist" , "--no-splitting" ], 221) 222. unwrap (); 223assert_eq! ( listing ( & dir. path (). join ( "dist" )), [ "main.js" ]); 224} 225 226# [ test ] 227fn entry_basenames_are_not_silently_rewritten () { 228let server =Registry :: new (); 229let dir = tempfile:: tempdir (). unwrap (); 230files ( 231 dir. path (), 232& [ 233( "+page.ts" , "console.log('plus');" ), 234( "_page.ts" , "console.log('underscore');" ), 235( "[page].ts" , "console.log('brackets');" ), 236], 237); 238let error =bundle ( 239 dir. path (), 240& server, 241& [ "+page.ts" , "[page].ts" , "--outdir=dist" ], 242) 243. unwrap_err (); 244assert! ( error. contains ( "Unsupported output filename" ), "{error}" ); 245assert! ( !dir. path (). join ( "dist" ). exists ()); 246bundle ( 247 dir. path (), 248& server, 249& [ "+page.ts" , "_page.ts" , "--outdir=dist" ], 250) 251. unwrap (); 252assert_eq! ( listing ( & dir. path (). join ( "dist" )), [ "+page.js" , "_page.js" ]); 253assert_eq! ( execute ( & dir. path (). join ( "dist" ), "+page.js" ), "plus\n" ); 254assert_eq! ( 255execute ( & dir. path (). join ( "dist" ), "_page.js" ), 256"underscore\n" 257); 258} 259 260# [ test ] 261fn each_entry_uses_its_own_configuration () { 262let server =Registry :: new (); 263let remote = http:: Http :: new (); 264 remote. source ( "remote.ts" , "export const remote: number = 42;" ); 265let dir = tempfile:: tempdir (). unwrap (); 266files ( 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); 287bundle ( 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 (); 299files ( 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); 306assert_eq! ( 307execute ( & 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 () { 314let server =Registry :: new (); 315let remote = http:: Http :: new (); 316 remote. source ( "remote.ts" , "export const remote: number = 42;" ); 317let dir = tempfile:: tempdir (). unwrap (); 318files ( 319 dir. path (), 320& [ 321( "a/app.ts" , "export const app: string = 'app';" ), 322( "b/admin.ts" , "export const admin: string = 'admin';" ), 323], 324); 325bundle ( 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 (); 337files ( 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); 344assert_eq! ( 345execute ( & dir. path (). join ( "dist" ), "check.js" ), 346"app admin 42\n" 347); 348} 349 350# [ test ] 351fn multiple_entry_failures_leave_all_outputs_untouched () { 352let server =Registry :: new (); 353let dir = tempfile:: tempdir (). unwrap (); 354files ( 355 dir. path (), 356& [ 357( "app.ts" , "console.log('app');" ), 358( "admin.js" , "console.log('admin');" ), 359], 360); 361let original = fs:: read ( dir. path (). join ( "admin.js" )). unwrap (); 362let error =bundle ( dir. path (), & server, & [ "app.ts" , "admin.js" , "--outdir=." ]). unwrap_err (); 363assert! ( error. contains ( "overwrite a bundle input" ), "{error}" ); 364assert! ( !dir. path (). join ( "app.js" ). exists ()); 365assert_eq! ( fs:: read ( dir. path (). join ( "admin.js" )). unwrap (), original); 366bundle ( 367 dir. path (), 368& server, 369& [ "app.ts" , "admin.js" , "--outdir=dist" ], 370) 371. unwrap (); 372let before =snapshot ( & dir. path (). join ( "dist" )); 373files ( dir. path (), & [( "admin.js" , "import './missing.js';" )]); 374bundle ( 375 dir. path (), 376& server, 377& [ "app.ts" , "admin.js" , "--outdir=dist" ], 378) 379. unwrap_err (); 380assert_eq! ( snapshot ( & dir. path (). join ( "dist" )), before); 381# [ cfg ( unix )] 382{ 383use std:: os:: unix:: fs:: symlink; 384files ( dir. path (), & [( "admin.js" , "console.log('admin');" )]); 385 fs:: remove_file ( dir. path (). join ( "dist/admin.js" )). unwrap (); 386symlink ( 387 dir. path (). join ( "admin.js" ), 388 dir. path (). join ( "dist/admin.js" ), 389) 390. unwrap (); 391let before =snapshot ( & dir. path (). join ( "dist" )); 392let error =bundle ( 393 dir. path (), 394& server, 395& [ "app.ts" , "admin.js" , "--outdir=dist" ], 396) 397. unwrap_err (); 398assert! ( error. contains ( "symlink" ), "{error}" ); 399assert_eq! ( snapshot ( & dir. path (). join ( "dist" )), before); 400symlink ( dir. path (). join ( "missing" ), dir. path (). join ( "dangling" )). unwrap (); 401bundle ( 402 dir. path (), 403& server, 404& [ "app.ts" , "admin.js" , "--outdir=dangling" ], 405) 406. unwrap_err (); 407assert! ( !dir. path (). join ( "missing" ). exists ()); 408} 409} 410 411# [ test ] 412fn compiles_each_module_kind_and_links_them () { 413let server =Registry :: new (); 414let dir = tempfile:: tempdir (). unwrap (); 415files ( 416 dir. path (), 417& [ 418( 419"avril.conf" , 420"compilerOptions: {jsx: 'react', jsxFactory: 'h'}, imports: {helper: './helper.cjs'}" , 421), 422( 423"main.tsx" , 424r#"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); 468bundle ( 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 (); 480assert_eq! ( 481execute ( & 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); 484assert_eq! ( listing ( & dir. path (). join ( "dist" )). len (), 2 ); 485let entry = fs:: read_to_string ( dir. path (). join ( "dist/app.js" )). unwrap (); 486assert! ( !entry. contains ( "TREE-SHAKEN" ), "{entry}" ); 487assert! ( entry. contains ( "process.env.AVRIL_SECRET" ), "{entry}" ); 488assert! ( !entry. contains ( dir. path (). to_str (). unwrap ()), "{entry}" ); 489} 490 491# [ test ] 492fn splitting_minification_and_targets_preserve_behavior () { 493let server =Registry :: new (); 494let dir = tempfile:: tempdir (). unwrap (); 495files ( 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); 509for ( args, chunks, absent) in [ 510( & [][ ..], 2 , & [][ ..]), 511( & [ "--no-splitting" ], 1 , & [ "import(" ][ ..]), 512( & [ "--minify" , "--target" , "es2019" ], 2 , & [ "?." , "??" , "\n" ]), 513( & [ "--target" , "es2015" ], 2 , & [ "async " , "await " ]), 514] { 515let _ = fs:: remove_dir_all ( dir. path (). join ( "dist" )); 516let mut all =vec! [ "main.js" , "-o" , "dist/app.js" ]; 517 all. extend ( args); 518bundle ( dir. path (), & server, & all). unwrap (); 519let names =listing ( & dir. path (). join ( "dist" )); 520assert_eq! ( names. len (), chunks, "{args:?}: {names:?}" ); 521for namein & names{ 522let code = fs:: read_to_string ( dir. path (). join ( "dist" ). join ( name)). unwrap (); 523for textin absent{ 524assert! ( !code. trim_end (). contains ( text), "{args:?} {name}: {code}" ); 525} 526} 527assert_eq! ( 528execute ( & 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 { 539let js = fs:: read_to_string ( dir. join ( chunk)). unwrap (); 540assert! ( 541 js. ends_with ( & format! ( "//# sourceMappingURL={chunk}.map" )), 542"{js}" 543); 544let prefix =& js[ ..js. find ( marker). unwrap ()]; 545let line = prefix. matches ( '\n' ). count () as u32 ; 546let column = prefix. rsplit ( '\n' ). next (). unwrap (). len () as u32 ; 547let map = fs:: read_to_string ( dir. join ( format! ( "{chunk}.map" ))). unwrap (); 548let map = oxc_sourcemap:: SourceMap :: from_json_string ( & map). unwrap (); 549let token = map 550. lookup_token ( & map. generate_lookup_table (), line, column) 551. unwrap (); 552let source = token. get_source_id (). unwrap (); 553let original = map. get_source_content ( source). unwrap (); 554let ( line, text) = original 555. lines () 556. enumerate () 557. find ( |( _, text) | text. contains ( marker)) 558. unwrap (); 559assert_eq! ( 560( token. get_src_line (), token. get_src_col ()), 561( lineas u32 , text. find ( marker). unwrap () as u32 -1 ), 562"{chunk}: {marker}" 563); 564let name = map. get_source ( source). unwrap (). to_owned (); 565if !name. starts_with ( "http:" ) { 566assert_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 () { 573let 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); 589let 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" ); 595let dir = tempfile:: tempdir (). unwrap (); 596files ( 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); 617for minifyin [ false , true ] { 618let mut args =vec! [ "web/main.tsx" , "-o" , "dist/app.js" , "--sourcemap" ]; 619if minify{ 620 args. push ( "--minify" ); 621} 622bundle ( dir. path (), & server, & args). unwrap (); 623let dist = dir. path (). join ( "dist" ); 624let late =listing ( & dist) 625. into_iter () 626. find ( |name| name. starts_with ( "late-" ) && name. ends_with ( ".js" )) 627. unwrap (); 628assert_eq! ( 629maps_back ( & dist, "app.js" , "ENTRY-MARKER" ), 630"../web/main.tsx" 631); 632assert_eq! ( maps_back ( & dist, & late, "LATE-MARKER" ), "../web/late.ts" ); 633assert_eq! ( maps_back ( & dist, "app.js" , "RAW-MARKER" ), "../web/raw.js" ); 634assert_eq! ( 635maps_back ( & dist, "app.js" , "REMOTE-MARKER" ), 636 http. name ( "alias" ) 637); 638let typeless =maps_back ( & dist, "app.js" , "CJS-MARKER" ); 639assert! ( 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 () { 649let 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); 792let dir = tempfile:: tempdir (). unwrap (); 793files ( 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" , 805r#"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({ 815cond, legacy, value, ignored, retained, pure, dropped: globalThis.DROPPED, scoped, esmShaped: globalThis.ESM_SHAPED, 816fs: pkg.fs, swapped: pkg.swapped, sub: pkg.sub, exact: pkg.exact, kinds: await pkg.kinds, 817excluded: 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); 828bundle ( dir. path (), & server, & [ "main.js" , "-o" , "dist/app.js" ]). unwrap (); 829assert_eq! ( 830execute ( & dir. path (). join ( "dist" ), "app.js" ), 831concat! ( 832r#"{"cond":"browser","legacy":"web","value":"shim","ignored":"kept","retained":"retained","pure":"used","scoped":"browser-require","esmShaped":[true,"undefined"],"# , 833r#""fs":[],"swapped":"swapped","sub":"full-subpath","exact":"exact","kinds":["REQUIRE","IMPORT"],"# , 834r#""excluded":[],"same":true,"named":true}"# , 835"\n" 836) 837); 838// Browser metadata never changes execution, malformed or not. 839assert_eq! ( 840avril ( dir. path (), & server, "run" , & [ "run.js" ]). unwrap (), 841"avril esm\n" 842); 843let error =bundle ( dir. path (), & server, & [ "cyclic.js" , "-o" , "dist/cyclic.js" ]). unwrap_err (); 844assert! ( 845 error. contains ( "cyclic or too deep" ) && error. contains ( "imported from" ), 846"{error}" 847); 848} 849 850# [ test ] 851fn unbundleable_code_fails_before_writing () { 852let server =Registry :: new (); 853let dir = tempfile:: tempdir (). unwrap (); 854files ( 855 dir. path (), 856& [( "dep.cjs" , "module.exports = 1;" ), ( "addon.node" , "binary" )], 857); 858for ( 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] { 885files ( dir. path (), & [( "main.js" , source)]); 886let mut all =vec! [ "main.js" , "-o" , "dist/app.js" ]; 887 all. extend ( args); 888let error =bundle ( dir. path (), & server, & all). unwrap_err (); 889assert! ( error. contains ( message), "{source}: {error}" ); 890assert! ( error. contains ( "main.js" ), "{source}: {error}" ); 891} 892for sourcein [ 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] { 902files ( dir. path (), & [( "main.cjs" , source)]); 903let error =bundle ( dir. path (), & server, & [ "main.cjs" , "-o" , "dist/app.js" ]). unwrap_err (); 904assert! ( 905 error. contains ( "require" ) || error. contains ( "addon" ), 906"{source}: {error}" 907); 908} 909files ( 910 dir. path (), 911& [ 912( 913"tsconfig.json" , 914r#"{"compilerOptions": {"jsx": "preserve"}}"# , 915), 916( "main.tsx" , "export const view = <p />;" ), 917], 918); 919let error =bundle ( dir. path (), & server, & [ "main.tsx" , "-o" , "dist/app.js" ]). unwrap_err (); 920assert! ( error. contains ( "preserved JSX" ), "{error}" ); 921assert! ( !dir. path (). join ( "dist" ). exists ()); 922} 923 924# [ cfg ( unix )] 925# [ test ] 926fn outputs_never_replace_inputs_or_follow_links () { 927use std:: os:: unix:: fs::{ PermissionsExt , symlink}; 928let server =Registry :: new (); 929let dir = tempfile:: tempdir (). unwrap (); 930let main ="import x from './app.js.map';\nconsole.log(x);" ; 931files ( 932 dir. path (), 933& [ 934( "src/main.js" , main), 935( "src/app.js.map" , "export default 42;" ), 936], 937); 938symlink ( dir. path (). join ( "src" ), dir. path (). join ( "alias" )). unwrap (); 939symlink ( dir. path (). join ( "nowhere" ), dir. path (). join ( "dangling" )). unwrap (); 940symlink ( dir. path (). join ( "src/main.js" ), dir. path (). join ( "link.js" )). unwrap (); 941for ( 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] { 950let result =bundle ( 951 dir. path (), 952& server, 953& [ "src/main.js" , "-o" , out, "--sourcemap" ], 954); 955assert! ( 956 result. as_ref (). unwrap_err (). to_string (). contains ( message), 957"{out}: {result:?}" 958); 959} 960assert_eq! ( 961 fs:: read_to_string ( dir. path (). join ( "src/main.js" )). unwrap (), 962 main 963); 964assert_eq! ( 965 fs:: read_to_string ( dir. path (). join ( "src/app.js.map" )). unwrap (), 966"export default 42;" 967); 968assert! ( !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 (); 972let mode = |path : & str |{ 973 fs:: metadata ( dir. path (). join ( path)) 974. unwrap () 975. permissions () 976. mode () 977& 0o777 978}; 979bundle ( dir. path (), & server, & [ "src/main.js" , "-o" , "dist/app.js" ]). unwrap (); 980assert_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 (); 986bundle ( dir. path (), & server, & [ "src/main.js" , "-o" , "dist/app.js" ]). unwrap (); 987assert_eq! ( mode ( "dist/app.js" ), 0o640 ); 988let before = fs:: read_to_string ( dir. path (). join ( "dist/app.js" )). unwrap (); 989files ( dir. path (), & [( "src/main.js" , "import './missing.js';" )]); 990bundle ( dir. path (), & server, & [ "src/main.js" , "-o" , "dist/app.js" ]). unwrap_err (); 991assert_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 >)> { 998listing ( dir) 999. into_iter () 1000. map ( |name|{ 1001let 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 () { 1009let 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. 1017let 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]; 1037let fixture = tempfile:: tempdir (). unwrap (); 1038files ( fixture. path (), & sources); 1039let app = fixture. path (). join ( "app" ); 1040let args =[ "main.tsx" , "-o" , "dist/app.js" , "--sourcemap" ]; 1041bundle ( & app, & server, & args). unwrap (); 1042assert_eq! ( 1043execute ( & app. join ( "dist" ), "app.js" ), 1044"pinned main.tsx ../outside/dep.tsx lazy\n" 1045); 1046let first =snapshot ( & app. join ( "dist" )); 1047let lock = fs:: read ( app. join ( "avril.lock" )). unwrap (); 1048let mut offline = args. to_vec (); 1049 offline. extend ([ "--offline" , "--frozen" ]); 1050bundle ( & app, & server, & offline). unwrap (); 1051assert_eq! ( snapshot ( & app. join ( "dist" )), first); 1052assert_eq! ( fs:: read ( app. join ( "avril.lock" )). unwrap (), lock); 1053avril ( & app, & server, "vendor" , & [ "--out=vendored" , "main.tsx" ]). unwrap (); 1054let relocated = tempfile:: tempdir (). unwrap (); 1055files ( relocated. path (), & sources); 1056let moved = relocated. path (). join ( "app" ); 1057 fs:: copy ( app. join ( "avril.lock" ), moved. join ( "avril.lock" )). unwrap (); 1058let vendored =format! ( "--vendor={}" , app. join ( "vendored" ). display ()); 1059let mut args = args. to_vec (); 1060 args. extend ([ vendored. as_str (), "--offline" ]); 1061bundle ( & moved, & server, & args). unwrap (); 1062assert_eq! ( snapshot ( & moved. join ( "dist" )), first); 1063} 1064 1065# [ test ] 1066fn remote_graphs_use_one_consistent_source_snapshot () { 1067let server =Registry :: new (); 1068let http = http:: Http :: new (); 1069 http. source ( "dep.js" , "export const value = 'VERIFIED';" ); 1070let dir = tempfile:: tempdir (). unwrap (); 1071let entry = dir. path (). join ( "main.js" ); 1072files ( 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})); 1089bundle ( dir. path (), & server, & [ "main.js" , "-o" , "dist/app.js" ]). unwrap (); 1090assert_eq! ( execute ( & dir. path (). join ( "dist" ), "app.js" ), "VERIFIED\n" ); 1091} 1092 1093# [ test ] 1094fn library_generate_bundles_entries_in_memory () { 1095use avril_runtime:: authority:: SourceAuthority ; 1096use avril_runtime:: bundle; 1097use avril_runtime:: caps::{ Capabilities , Category , Initial }; 1098use avril_runtime:: modules:: ModuleGraph ; 1099 1100let dir = tempfile:: tempdir (). unwrap (); 1101files ( 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); 1119let mut initial =Initial :: new ( true ); 1120 initial 1121. grant ( 1122Category :: Read , 1123false , 1124 dir. path (). to_str (). unwrap (), 1125 dir. path (), 1126) 1127. unwrap (); 1128let caps =Capabilities :: new ( initial, dir. path (). to_owned ()); 1129let graph =ModuleGraph :: open_browser ( 1130 registry:: runtime_handle (), 1131SourceAuthority :: read ( caps), 1132 avril_runtime:: registry:: Options { 1133jsr_registry : url:: Url :: parse ( "https://example.invalid/" ). unwrap (), 1134npm_registry : url:: Url :: parse ( "https://example.invalid/" ). unwrap (), 1135cache : dir. path (). join ( "cache" ), 1136lock : dir. path (). join ( "avril.lock" ), 1137project_root : dir. path (). to_owned (), 1138offline : true , 1139frozen : false , 1140update : false , 1141ephemeral_lock : false , 1142vendor : None , 1143}, 1144) 1145. unwrap (); 1146let entries =vec! [ 1147 bundle:: Entry { 1148name : Some ( "app" . to_owned ()), 1149import : dir. path (). join ( "src/app.ts" ). to_string_lossy (). into_owned (), 1150}, 1151 bundle:: Entry { 1152name : Some ( "admin" . to_owned ()), 1153import : dir 1154. path () 1155. join ( "src/admin.ts" ) 1156. to_string_lossy () 1157. into_owned (), 1158}, 1159]; 1160let out = dir. path (). join ( "dist" ); 1161let 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 (); 1173assert! ( generated. warnings . is_empty ()); 1174for namein [ "app.ts" , "admin.ts" , "shared.ts" , "lazy.ts" ] { 1175assert! ( 1176 generated 1177. modules 1178. contains ( dir. path (). join ( "src" ). join ( name). to_str (). unwrap ()) 1179); 1180} 1181let names: Vec < _ > = generated 1182. files 1183. iter () 1184. map ( |( name, _) | name. clone ()) 1185. collect (); 1186assert! ( names. contains ( & "app.js" . to_owned ()), "{names:?}" ); 1187assert! ( names. contains ( & "admin.js" . to_owned ()), "{names:?}" ); 1188// Each shared or lazy module is emitted exactly once. 1189for markerin [ "SHARED-MARKER" , "LAZY-MARKER" ] { 1190let 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 (); 1197assert_eq! ( chunks. len (), 1 , "{marker}: {names:?}" ); 1198} 1199// Generation writes no outputs: the caller publishes. 1200assert! ( !out. exists ()); 1201assert! ( !dir. path (). join ( "avril.lock" ). exists ()); 1202files ( 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); 1209for ( name, contents) in & generated. files { 1210 fs:: write ( out. join ( name), contents). unwrap (); 1211} 1212assert_eq! ( execute ( & out, "check.js" ), "true app admin LAZY-MARKER\n" ); 1213}