@@ -3,16 +3,58 @@ import fsPromises, { readFile, writeFile, readdir } from 'node:fs/promises'
33import { existsSync } from 'node:fs'
44import { fileURLToPath , pathToFileURL } from 'node:url'
55import { basename , dirname , extname , resolve , join , relative } from 'node:path'
6- import { createRequire } from 'node:module'
6+ import nodeModule , { createRequire } from 'node:module'
77import { randomUUID as uuid , randomBytes } from 'node:crypto'
88import * as esbuild from 'esbuild'
99
1010const require = createRequire ( import . meta. url )
1111const resolveRequire = ( query ) => require . resolve ( query )
12- const cjsMockRegex = / \. e x o d u s - t e s t - m o c k \. c j s $ / u
12+ const cjsMockRegex = / \. e x o d u s - t e s t - m o c k \. c j s $ /
1313const cjsMockFallback = `throw new Error('Mocking loaded ESM modules in not possible in bundles')`
1414let resolveSrc , globLib
1515
16+ const packageJSONs = new Map ( )
17+
18+ function findPackageJSON ( file ) {
19+ if ( packageJSONs . has ( file ) ) return packageJSONs . get ( file )
20+
21+ assert . equal ( resolve ( file ) , file ) // must be absolute and not end with a '/'
22+
23+ if ( nodeModule . findPackageJSON ) {
24+ const res = nodeModule . findPackageJSON ( pathToFileURL ( file ) ) ?? null
25+ packageJSONs . set ( file , res )
26+ return res
27+ }
28+
29+ // does not go into /package.json if file is a dir
30+ for ( let dir = dirname ( file ) ; dir ; ) {
31+ const res = join ( dir , 'package.json' )
32+ if ( existsSync ( res ) ) {
33+ packageJSONs . set ( file , res )
34+ return res
35+ }
36+
37+ const parent = dirname ( dir )
38+ if ( ! parent || parent === dir ) break
39+ dir = parent
40+ }
41+
42+ packageJSONs . set ( file , null )
43+ return null
44+ }
45+
46+ const reactNativeMaps = new Map ( )
47+
48+ async function mapReactNative ( context ) {
49+ const pkg = findPackageJSON ( context )
50+ if ( ! pkg ) return [ null ]
51+ if ( reactNativeMaps . has ( pkg ) ) return reactNativeMaps . get ( pkg )
52+ const { browser, 'react-native' : rn } = JSON . parse ( await readFile ( pkg , 'utf8' ) )
53+ const res = [ ( rn ?? browser ) || null , dirname ( pkg ) ]
54+ reactNativeMaps . set ( pkg , res )
55+ return res
56+ }
57+
1658const emptyToUndefined = ( x ) => ( x . length > 0 ? x : undefined ) // optimize out define if there are none
1759const readSnapshots = async ( files , resolvers ) => {
1860 const snapshots = [ ]
@@ -435,14 +477,19 @@ export const build = async (...files) => {
435477 : JSON . stringify ( x , null , 1 ) . replaceAll ( / ^ * ( " .+ " ) ( , ? ) $ / gmu, ( _ , s , c ) => `${ wrap ( s ) } ${ c } ` )
436478 }
437479
438- const conditions = [ ]
480+ const conditions = [ ] // 'require' and 'import' are built-in
481+ const mainFields = [ 'module' , 'main' ]
439482 if ( process . env . EXODUS_TEST_PLATFORM === 'workerd' ) {
440483 conditions . push ( 'workerd' )
441484 } else if ( process . env . EXODUS_TEST_IS_BROWSER ) {
442485 // browsers, electron renderer, servo
443486 conditions . push ( 'browser' )
487+ mainFields . unshift ( 'browser' )
444488 } else if ( process . env . EXODUS_TEST_IS_BAREBONE ) {
445- conditions . push ( 'react-native' )
489+ conditions . push ( 'react-native' ) // does not set browser, see https://metrobundler.dev/docs/configuration/#unstable_conditionnames-experimental
490+ // FIXME: mainFields = react-native is unsupported by esbuild: https://github.com/evanw/esbuild/issues/4427
491+ // To not follow just browser here, we resolve that manually in onResolve plugin
492+ // mainFields.unshift('react-native', 'browser') // https://metrobundler.dev/docs/configuration/#resolvermainfields
446493 }
447494
448495 const config = {
@@ -456,7 +503,7 @@ export const build = async (...files) => {
456503 entryNames : filename ,
457504 platform : process . env . EXODUS_TEST_IS_BROWSER ? 'browser' : 'neutral' ,
458505 conditions,
459- mainFields : [ 'browser' , 'module' , 'main' ] , // FIXME: Removing 'browser' breaks some pkgs
506+ mainFields,
460507 define : {
461508 'process.browser' : stringify ( true ) ,
462509 'process.emitWarning' : 'undefined' ,
@@ -515,16 +562,57 @@ export const build = async (...files) => {
515562 plugins : [
516563 {
517564 name : 'exodus-test.bundle' ,
518- setup ( { onResolve, onLoad } ) {
519- onResolve ( { filter : / \. [ c m ] ? [ j t ] s x ? $ / } , ( args ) => {
520- if ( shouldInstallMocks && cjsMockRegex . test ( args . path ) ) {
521- return { path : args . path , namespace : 'file' }
565+ setup ( { onResolve, onLoad, resolve : esbuildResolve } ) {
566+ onResolve (
567+ { filter : process . env . EXODUS_TEST_IS_BAREBONE ? / ./ : cjsMockRegex , namespace : 'file' } ,
568+ async ( args ) => {
569+ let { path, ...opts } = args
570+ if ( shouldInstallMocks && cjsMockRegex . test ( path ) ) return { path, namespace : 'file' }
571+
572+ // This whole hack is needed because of https://github.com/evanw/esbuild/issues/4427
573+ if ( process . env . EXODUS_TEST_IS_BAREBONE ) {
574+ // Modules are mapped pre-resolve against importer
575+ if ( ! / ^ [ . / ] / u. test ( path ) ) {
576+ const [ map0 ] = await mapReactNative ( args . importer )
577+ if ( map0 && typeof map0 !== 'string' && Object . hasOwn ( map0 , path ) ) {
578+ if ( map0 [ path ] === false ) {
579+ // Unsupported, see https://github.com/evanw/esbuild/issues/4426
580+ // TODO
581+ } else if ( typeof map0 [ path ] === 'string' ) {
582+ path = map0 [ path ]
583+ }
584+ }
585+ }
586+
587+ const r = await esbuildResolve ( path , { ...opts , namespace : 'exodus-test.bundle' } )
588+
589+ // Errors can only default to usual resolution to support e.g. optional dynamic require()
590+ if ( ! r . path || r . errors . length > 0 || r . warnings . length > 0 || r . external ) return
591+
592+ // Resolved files are mapped post-resolve against their package
593+ const [ map , dir ] = await mapReactNative ( r . path )
594+ if ( map ) {
595+ if ( typeof map === 'string' ) {
596+ // Only maps the entry point, check if we are using the entry point
597+ // TODO: also check for dir import to a package.json?
598+ if ( ! path . includes ( '/' ) ) r . path = resolve ( dir , map )
599+ } else {
600+ for ( const [ key , value ] of Object . entries ( map ) ) {
601+ if ( ! value || typeof value !== 'string' ) continue // e.g. false
602+ if ( r . path !== resolve ( dir , key ) ) continue
603+ r . path = resolve ( dir , value )
604+ break
605+ }
606+ }
607+ }
608+
609+ return r
610+ }
522611 }
523- } )
612+ )
524613 onLoad ( { filter : / \. [ c m ] ? [ j t ] s x ? $ / , namespace : 'file' } , async ( args ) => {
525614 let filepath = args . path
526- // Resolve .native versions
527- // TODO: maybe follow package.json for this
615+ // Load .native versions where available (past onResolve)
528616 if ( process . env . EXODUS_TEST_IS_BAREBONE ) {
529617 const maybeNative = filepath . replace ( / ( \. [ c m ] ? [ j t ] s x ? ) $ / u, '.native$1' )
530618 if ( existsSync ( maybeNative ) ) filepath = maybeNative
0 commit comments