import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, relative } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { MOBILE_WEB_APP_ROOT_RESET, MOBILE_WEB_APP_SHIMS, bundleMobileWebApp, buildMobileWebAppBundle, entryStaticClosure, mobileWebAppBuildOptions, renameOutputsByContent, routeChunkNames } from './build-mobile-web-app-bundle.mjs' import { MOBILE_WEB_APP_ROUTE_ROOT, ROUTE_SOURCE_LOADERS, collectMobileWebAppRouteKeys, collectMobileWebAppRoutes } from './mobile-web-app-route-manifest.mjs' import { MOBILE_WEB_APP_BUNDLE_MAX_ENTRY_BYTES, MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES, MOBILE_WEB_APP_SOURCE_DIRS, assertAssetCeilingFitsShell, mobileWebAppBundleMaxAssets, mobileWebAppBundleMaxChunks, readMobileWebBundleMaxAssets, verifyMobileWebAppBundle } from './verify-mobile-web-app-bundle.mjs' import { BINARY_SOURCE_EXTENSIONS, assertNoCarriageReturnsInSource } from './verify-mobile-web-bundle.mjs' import { hashedAsset, readDesktopVersion, readProtocolWindow, sha256Hex, writeMobileWebBundleTree } from './build-mobile-web-bundle.mjs' import { MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES, MOBILE_WEB_BUNDLE_MAX_ASSETS } from '../../src/shared/mobile-web-bundle/manifest-contract.js' import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs' const projectDir = fileURLToPath(new URL('../..', import.meta.url)) const appDir = join(projectDir, 'mobile', 'app') // The sharded `test` job does not install mobile dependencies, so anything that runs esbuild over // the route tree is skipped there and run for real in pr.yml's mobile_web_app job. const bundles = mobileWebAppDependenciesPresent() const describeBundling = bundles ? describe : describe.skip const itBundling = bundles ? it : it.skip /** Every script the page loads. A route's code is in a chunk now, not in the entry. */ function allScriptSource({ script, chunks }) { return [script, ...chunks.map((chunk) => chunk.bytes)].map((bytes) => bytes.toString('utf8')) } async function withScratch(run) { const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-app-test-')) try { return await run(scratch) } finally { await rm(scratch, { recursive: true, force: true }) } } describe('the CRLF pin', () => { it('exempts the same extensions in .gitattributes as the CRLF scan skips', async () => { const attributes = await readFile(join(projectDir, '.gitattributes'), 'utf8') for (const tree of MOBILE_WEB_APP_SOURCE_DIRS) { const pattern = `/${relative(projectDir, tree).split('\\').join('/')}/**` for (const extension of BINARY_SOURCE_EXTENSIONS) { // Without the exemption the blanket `text eol=lf` pin above it rewrites the binary and // every asset hash with it. expect(attributes, `${pattern}/*${extension} is not exempt`).toContain( `${pattern}/*${extension} -text` ) } } }) }) describeBundling('the app bundle', () => { it('resolves react-native to react-native-web and leaves no require.context', async () => { const sources = allScriptSource(await bundleMobileWebApp()) for (const source of sources) { expect(source).not.toContain('require.context') } // react-native-web's touch responder is proof the alias resolved rather than the native stub. expect(sources.some((source) => source.includes('ResponderTouchHistoryStore'))).toBe(true) }, 120_000) it('cuts the routes into chunks the entry does not load', async () => { const { script, chunks, entryStaticBytes } = await bundleMobileWebApp() expect(chunks.length).toBeGreaterThan(1) // The entry's own bytes plus the chunks it imports statically, which is what the browser // parses before any route paints. Every route chunk is outside it. expect(entryStaticBytes).toBeGreaterThan(script.byteLength) const allBytes = script.byteLength + chunks.reduce((total, chunk) => total + chunk.bytes.byteLength, 0) expect(entryStaticBytes).toBeLessThan(allBytes) }, 120_000) it('names the chunk each route lands in', async () => { const { chunks, routeChunks, routeKeys } = await bundleMobileWebApp() expect(Object.keys(routeChunks).sort()).toEqual([...routeKeys].sort()) const emitted = new Set(chunks.map((chunk) => chunk.name)) for (const [key, name] of Object.entries(routeChunks)) { expect(emitted, key).toContain(name) } // One chunk per route, never the entry: that is what a client-side navigation fetches. expect(new Set(Object.values(routeChunks)).size).toBe(routeKeys.length) }, 120_000) it('counts only static imports into what loads before the first route', () => { const metafile = { outputs: { 'dist/entry.js': { bytes: 10, imports: [ { path: 'dist/shared.js', kind: 'import-statement' }, { path: 'dist/route.js', kind: 'dynamic-import' } ] }, 'dist/shared.js': { bytes: 20, imports: [{ path: 'dist/deep.js', kind: 'import-statement' }] }, 'dist/deep.js': { bytes: 30, imports: [] }, 'dist/route.js': { bytes: 40, imports: [] } } } expect([...entryStaticClosure(metafile, 'dist/entry.js')]).toEqual([ 'dist/entry.js', 'dist/shared.js', 'dist/deep.js' ]) }) it('does not walk a chunk cycle forever', () => { const metafile = { outputs: { 'dist/entry.js': { bytes: 1, imports: [{ path: 'dist/a.js', kind: 'import-statement' }] }, 'dist/a.js': { bytes: 1, imports: [{ path: 'dist/entry.js', kind: 'import-statement' }] } } } expect(entryStaticClosure(metafile, 'dist/entry.js').size).toBe(2) }) itBundling( 'refuses to build a route the lazy manifest would strip an export from', async () => { await withScratch(async (scratch) => { const directory = join(scratch, MOBILE_WEB_APP_ROUTE_ROOT) await mkdir(directory, { recursive: true }) await writeFile( join(directory, 'index.tsx'), 'export default function Route() { return null }\n' ) await expect(bundleMobileWebApp({ appDir: scratch })).resolves.toBeTruthy() await writeFile( join(directory, 'settings.tsx'), 'const anchor = { anchor: "index" }\nexport { anchor as unstable_settings }\nexport default function Route() { return null }\n' ) // The build is where this has to fail: the page it would otherwise emit mounts with the // export silently gone, which is a blank screen on a phone and nothing in any log. await expect(bundleMobileWebApp({ appDir: scratch })).rejects.toThrow( /settings\.tsx.*unstable_settings/s ) }) }, 240_000 ) itBundling( 'refuses a route whose star re-export it cannot read', async () => { await withScratch(async (scratch) => { const directory = join(scratch, MOBILE_WEB_APP_ROUTE_ROOT) await mkdir(directory, { recursive: true }) await writeFile(join(directory, 'boundary.ts'), 'export const value = 1\n') await writeFile( join(directory, 'index.tsx'), 'export * from "./boundary"\nexport default function Route() { return null }\n' ) await expect(bundleMobileWebApp({ appDir: scratch })).rejects.toThrow( /index\.tsx.*boundary/s ) }) }, 240_000 ) it('bundles every route module', async () => { const { routeKeys } = await bundleMobileWebApp() expect(routeKeys).toEqual(await collectMobileWebAppRouteKeys(appDir)) }, 120_000) it("bundles a route's .web.tsx sibling instead of the native file, changing the bytes", async () => { await withScratch(async (scratch) => { const directory = join(scratch, MOBILE_WEB_APP_ROUTE_ROOT) await mkdir(directory, { recursive: true }) const route = (marker) => `export default function Route() { return '${marker}' }\n` await writeFile(join(directory, 'index.tsx'), route('native-route-marker')) const before = await bundleMobileWebApp({ appDir: scratch }) const has = (bundle, marker) => allScriptSource(bundle).some((source) => source.includes(marker)) expect(has(before, 'native-route-marker')).toBe(true) await writeFile(join(directory, 'index.web.tsx'), route('web-route-marker')) const after = await bundleMobileWebApp({ appDir: scratch }) expect(has(after, 'web-route-marker')).toBe(true) expect(has(after, 'native-route-marker')).toBe(false) // Different script bytes means a different asset sha and so a different buildId. expect(after.script.equals(before.script)).toBe(false) }) }, 240_000) /** * The same route tree, bundled from two directories at different depths. esbuild's own `[hash]` * is computed over the metafile's input keys, which are paths relative to absWorkingDir, so two * checkouts of one commit -- at different depths, or one with mobile/node_modules as a symlink * and one with it as a directory -- name a byte-identical chunk differently. The rename * cascades through every importer into a different buildId, and every phone re-downloads a * bundle whose bytes did not change. */ async function bundleFromDepth(root, depth) { const nested = join(root, ...Array.from({ length: depth }, (_, index) => `d${String(index)}`)) const directory = join(nested, MOBILE_WEB_APP_ROUTE_ROOT) await mkdir(directory, { recursive: true }) // Two routes over one import, which is what makes esbuild emit a shared chunk to name. await writeFile(join(directory, 'shared.ts'), 'export const marker = "shared-marker"\n') for (const name of ['index.tsx', 'other.tsx']) { await writeFile( join(directory, name), `import { marker } from "./shared"\nexport default function Route() { return marker + "${name}" }\n` ) } return { appDir: nested, bundle: await bundleMobileWebApp({ appDir: nested }) } } it('names every output by its bytes, so another checkout path builds the same bundle', async () => { await withScratch(async (shallow) => { await withScratch(async (deep) => { const near = await bundleFromDepth(shallow, 1) const far = await bundleFromDepth(deep, 5) const names = ({ bundle }) => [...bundle.chunks, ...bundle.images].map((one) => one.name) expect(names(far)).toEqual(names(near)) expect(far.bundle.script.equals(near.bundle.script)).toBe(true) // The whole point: the manifest the phone compares is the same document. const buildIdFrom = async ({ appDir }) => withScratch(async (out) => { const { manifest } = await buildMobileWebAppBundle({ appDir, outDir: join(out, 'x'), // A synthetic tree: the real declarations name screens it does not have. pageRoutes: [] }) return manifest.buildId }) expect(await buildIdFrom(far)).toBe(await buildIdFrom(near)) }) }) }, 240_000) it("names an output the same way the manifest's own asset hash does", async () => { const { script, chunks } = await bundleMobileWebApp() // The name is embedded in the importer, so it cannot be recomputed later; this is what says // the name inside the bytes and the manifest's sha256 of those bytes are the same string. expect(hashedAsset(script, 'js').path).toBe(`assets/${sha256Hex(script)}.js`) for (const chunk of chunks) { expect(chunk.name).toBe(`${sha256Hex(chunk.bytes)}.js`) } }, 120_000) it('asks esbuild for the split the budgets assume', async () => { const options = mobileWebAppBuildOptions(await collectMobileWebAppRoutes(appDir)) // Each of these is load-bearing for a budget below: esm and splitting are what make a route a // chunk, and the metafile is the only thing that says which imports are static. expect(options.format).toBe('esm') expect(options.splitting).toBe(true) expect(options.chunkNames).toBe('[hash]') expect(options.metafile).toBe(true) }) it('reads a route source the same way the export guard does', async () => { const options = mobileWebAppBuildOptions(await collectMobileWebAppRoutes(appDir)) // The guard parses each route on its own, outside this build. Sharing the table is what stops // a loader the bundle relies on from being missing there and reported as a syntax error. for (const [extension, loader] of Object.entries(ROUTE_SOURCE_LOADERS)) { expect(options.loader[extension], extension).toBe(loader) } }) it('applies every shim it names', async () => { const options = mobileWebAppBuildOptions(await collectMobileWebAppRoutes(appDir)) for (const shim of MOBILE_WEB_APP_SHIMS) { expect(shim.appliesTo(options), `${shim.name} is named but not applied`).toBe(true) } }) it('fails the named shim, not the whole build, when its option goes missing', async () => { const options = mobileWebAppBuildOptions(await collectMobileWebAppRoutes(appDir)) // Each shim reads an option of its own (two read `banner.js`), so stripping every option // leaves none applying. Without that, the list could name a shim the build stopped applying. const stripped = { ...options, alias: {}, loader: {}, define: {}, banner: {}, plugins: [] } expect(MOBILE_WEB_APP_SHIMS.filter((shim) => shim.appliesTo(stripped))).toEqual([]) }) it('keeps the shims out of the shipped Phase A bootstrap builder', async () => { const shipped = await readFile( join(projectDir, 'config', 'scripts', 'build-mobile-web-bundle.mjs'), 'utf8' ) for (const { name } of MOBILE_WEB_APP_SHIMS) { expect(shipped, `the Phase A bootstrap builder mentions ${name}`).not.toContain(name) } expect(shipped).not.toContain('react-native-web') expect(shipped).not.toContain('lucide') }) it('ships no haptic that reaches for the DOM', async () => { // expo-haptics' web build fakes an iOS haptic by appending a hidden // `