import { mkdir, readFile, writeFile } from 'node:fs/promises' import { join, relative } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { withScratch, readAppBundle, readWrittenBundle, copyWrittenBundle } from './mobile-web-app/mobile-web-app-bundle-test-fixture.mjs' import { MOBILE_WEB_APP_NATIVE_PARITY_STYLE, MOBILE_WEB_APP_ROOT_RESET, bundleMobileWebApp, buildMobileWebAppBundle, entryStaticClosure, renameOutputsByContent, routeChunkNames } from './build-mobile-web-app-bundle.mjs' import { MOBILE_WEB_APP_ROUTE_ROOT, collectMobileWebAppRouteKeys } from './mobile-web-app/mobile-web-app-route-manifest.mjs' import { MOBILE_WEB_APP_BUNDLE_MAX_ENTRY_BYTES, MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES, MOBILE_WEB_APP_BUNDLE_ROUTE_SCRIPT_SPREAD, MOBILE_WEB_APP_BUNDLE_SCRIPT_MARGIN, MOBILE_WEB_APP_BUNDLE_SCRIPT_SWEEP, MOBILE_WEB_APP_SOURCE_DIRS, assertAssetCeilingFitsShell, mobileWebAppBundleMaxAssets, mobileWebAppBundleBudgetBytes, mobileWebAppBundleMaxChunks, readMobileWebBundleMaxAssets, verifyMobileWebAppBundle } from './verify-mobile-web-app-bundle.mjs' import { BINARY_SOURCE_EXTENSIONS, assertNoCarriageReturnsInSource } from './mobile-web-source-line-endings.mjs' import { hashedAsset, readDesktopVersion, readProtocolWindow, sha256Hex, writeMobileWebBundleTree } from './mobile-web-bundle-manifest.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/mobile-web-app-bundle-dependencies.mjs' const projectDir = fileURLToPath(new URL('../..', import.meta.url)) const appDir = join(projectDir, 'mobile', 'app') // Root shards lack mobile deps; real bundling tests run in mobile_web_app CI job. const bundles = mobileWebAppDependenciesPresent() const describeBundling = bundles ? describe : describe.skip const itBundling = bundles ? it : it.skip function allScriptSource({ script, chunks }) { return [script, ...chunks.map((chunk) => chunk.bytes)].map((bytes) => bytes.toString('utf8')) } 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 readAppBundle()) 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 readAppBundle() 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 readAppBundle() 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) } 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' ) // Fail dropped route exports at build time; otherwise phones show blank screens without logs. 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 readAppBundle() 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) expect(after.script.equals(before.script)).toBe(false) }) }, 240_000) // Different-depth/symlink checkouts change esbuild input-key hashes and cascade through importers; content naming must preserve identical build IDs. 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) 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 readAppBundle() // Embedded import names must match manifest SHA256 of final output bytes. 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('ships no haptic that reaches for the DOM', async () => { // expo-haptics web inserts/clicks/removes DOM controls, swallowing long presses; inspect bundled bytes to ensure our haptics.web shim excludes it. for (const source of allScriptSource(await readAppBundle())) { // Match DOM shim fingerprints; navigator.vibrate also appears harmlessly in RN-Web Vibration. expect(source).not.toContain('ariaHidden') expect(source).not.toContain('pointer: coarse') expect(source).not.toContain('setAttribute("switch"') } }, 120_000) it("ships react-native-web's hairline at one device pixel, whichever of its builds resolves", async () => { const sources = allScriptSource(await readAppBundle()) // Minified, so the assignment reads `.hairlineWidth=`; RNW's own value is the literal 1. const assignments = sources.flatMap( (source) => source.match(/\.hairlineWidth=[^;]{0,120}/g) ?? [] ) expect(assignments.length).toBeGreaterThan(0) for (const assignment of assignments) { expect(assignment).toContain('devicePixelRatio') } }, 120_000) it('embeds no absolute path from this checkout', async () => { // Check all chunks: absolute route-manifest paths can survive outside the entry. for (const source of allScriptSource(await readAppBundle())) { expect(source).not.toContain(projectDir) } }, 120_000) it('loads the entry as a module, so its route imports resolve', async () => { const { manifest, html } = await readWrittenBundle() expect(html).toContain('