Files
orca/config/scripts/build-mobile-web-app-bundle.test.mjs
T
Jinwoo Hong 23207bfde2 feat(mobile): register the source-control and review page routes (OTA phase C, C4.4) (#21957)
* refactor(mobile): move the review route body onto a component and the handoff seam (OTA phase C, C4.4)

The review route file called `useMobileDiffReviewController` at its top level. A switch cannot
keep it there: hooks are unconditional, so the whole controller — its client subscriptions
included — would run behind the shell's page whenever the shell renders. As an element passed for
`fallback` it is created and not mounted, which is how the explorer switch already behaves.

`useRouter` becomes `useRouteHandoff` in the same move. It was the one raw expo-router router left
in the review closure (measured: the only other value import of one is the seam's own web sibling),
and inside the page the session screen `openSession` replaces to is native, so that target has to
be handed back to the app rather than posted into a document that does not render it.

The params are read in the component rather than handed down, so this is the route body and the
route file above it is free to become a switch.

`session-router-seam-census.test.ts` gains the module by name. Kept with `useRouter` the census
reds twice — `imports nothing from expo-router that can navigate` names
`MobileDiffReviewRouteScreen.tsx (useRouter)`, and the completeness case gains `useRouter` — which
is what forces the swap rather than leaving it to a reviewer.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): switch the source-control and review routes to the shell, still unregistered (OTA phase C, C4.4)

Both take the files switch's shape: `firstParam`/`firstReviewParam` on every param, `shellScreenRoute`
as the one predicate, `MobileWebShellScreen` keyed on `shellScreenRouteKey`, the native screen built
as an element and passed for `fallback`. Both gain a `.web.tsx` sibling for `index.web.tsx`'s reason —
the native file reaches OrcaMobileWebShellView, whose module throws at import in a browser, and the
route manifest imports every route.

Inert on its own. A switched route renders the shell only once `MOBILE_WEB_PAGE_ROUTES` lists it,
which is the next commit; until then the flag is the only thing that changes and it is off.

Query params are omitted rather than sent empty, and the whole record is omitted when none was
named: `tab=` is a lens named nothing and lands on `changes` through a different branch than an
absent one, and the same holds for `name`, `origin`, `scope`, `file` and `area`.

`pr` and `history` are deliberately not switched. Both are `Redirect`s into `source-control`, and a
redirect inside the page would leave the session bound to a pathname the page has left; left native
they replace into this route and its switch mounts the shell.

Three censuses red without their rows, measured on this tree:
- `mobile-web-app-web-overrides.test.mjs` `lists exactly the .web.* files on disk` names the two new
  siblings; `states a reason for every override` reds on a placeholder under 20 characters.
- `mobile-web-shell-flag-census.test.ts` `reaches the switched routes through that hook and no
  others` reds without the two `SWITCHED_ROUTES` names.
- `shell-screen-route-census.test.ts` `walks the route tree and finds them` reds without the two
  switch names.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): register the source-control and review page routes (OTA phase C, C4.4)

Two entries in `MOBILE_WEB_PAGE_ROUTES`, five grants each, with the reason for each grant read off
the screen that needs it. The two lists are equal on purpose: the hub's changed-file rows push
review and review replaces back, and a target declaring no more than its opener is a hop the
handoff keeps inside the document. Registering either alone would have put a native frame and a
second bridge session between a changed-file row and its diff.

`pageRouteGrants` is derived from this list, so the two rows are a consequence of the entries and
there is no second table to edit. `pr` and `history` stay native redirects and are never listed; the
derived target list at this tree is [files, files/preview, source-control, [p], accounts,
agent-history, review, session, tasks, web], with no `pr` or `history` row, because the census reads
call sites and both redirects name `source-control`.

Measured on this tree, not carried from the draft:
- The hop census goes 8 -> 16. The eight new rows are exactly `{/h/[hostId], agent-history,
  files/[worktreeId], files/preview} -> {source-control, review}`, each handed off for
  `native.clipboard.write` and the first four also for `externalLink`. `source-control <-> review`
  is absent in both directions, which a new case now asserts as grant-list equality rather than as
  the absence of a row — absent is also what an unregistered route looks like.
- The Back census now walks six trees and finds 8 controls, both rules printing empty. The two new
  ones are `MobileSourceControlHeader.tsx:46 role=button label=Back to session` and
  `MobileDiffReviewHeader.tsx:48 role=button label=Back`, which is what C4.3 bought. The
  `ARRIVING_SCREENS` describe it wrote for this moment is removed: with the rows in
  `PAGE_SERVED_SCREENS` its trees are covered and its cases were a second reading of the same thing.
- Both closures reach the haptics seam, so `haptics` is declared by measurement: the seam census
  derives the reaching set and its two cases pass with the routes in its `ROUTE_MODULES` map.

Without the two manifest entries these red on this tree: `pins every hop the handoff must take away
from the page`, `keeps the hub and review local to each other`, `declares only routes the bundle has
a module for`, `reaches the built manifest`, `covers every page route and finds a control in each`,
and both haptics-seam cases.

`build-mobile-web-app-bundle.test.mjs` is split rather than fenced. The two pinned entries put it at
607 non-comment lines against the 600 cap, and the declaration block is a different concern from how
the bundle is built — it grows once per registered domain while that file does not. It moves whole
into `mobile-web-page-routes.test.mjs`, named for the module it is written against, so the next route
to register does not have to choose between a lint fence and a split it did not ask for.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): render-check the two page routes, and make the oversized stage-all readable (OTA phase C, C4.4)

The render check mounts both routes in a real browser, asserts each paints its own screen rather
than the Unmatched route with no console error and no page fault, and asserts each fetches its own
chunk on a client-side navigation. It also reads the shipped `img-src 'self' data:` out of the
Kotlin source it is served with, pins the Swift twin beside it, and asserts neither route leaves the
origin or logs a policy violation while it paints.

The avatar skip itself (ruling 3) is `PRCommentCard`: on web it renders its existing empty-avatar
`View` rather than letting one `<Image>` per comment attempt a fetch the policy refuses. Its branch
is pinned by a component test, which reds on the platform check being removed. The render check's
off-origin case is honest about being the negative half only — no comment card renders there,
because the PR chain behind it is not scripted, and the file says so.

The `useAnimatedScrollHandler` risk is answered by the two static facts rather than by a probe, and
they are recorded as assertions: the hook is deliberately outside the four `MAPPER_HOOKS` because it
is an event handler, and its updater's only effect is a write to `scrollOffsetY`, which
`RightDrawer.tsx` assigns in two places and reads in none. A later read reds that case the moment it
is added.

The `oversized` stage-all refusal (ruling 2, made testable by ruling 5) was a silent no-op, and this
is the fix as well as the case. Measured on this tree before it: `git.bulkStage` with 12,000 paths
posts one 1,033,012-byte frame, the shell's reader drops it with `{ kind: 'refused', refusal:
'oversized' }`, and the page's promise never settles — `busyAction` never cleared and
`setActionError` was never called. Both new cases red by timing out at 15s against that path.

Refused at the page's own send boundary instead, under the shell reader's own predicate rather than
a second spelling of it: `isBridgeFrameWithinCap` is extracted from `parseBridgeMessage` and used by
both sides. `sendFrame` answers `sent` / `oversized` / `port-failed`, so `sendRequest` rejects with a
`BridgeRequestOversizedError` whose message is a sentence the panel puts on its error surface, and
the members whose contract is a boolean keep it. No delivery-unknown mark: the frame never left, so
nothing ran on the desktop and the smaller retry is safe to offer.

The case runs the real chain — bridge port pair, `useMobileGitRequests`, `runGitWorkflow` — with
only react-native and the haptics seam mocked, and asserts the message that lands is a sentence and
that the busy flag is raised and then cleared.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): drop the type assertions from the two new C4.4 test files (OTA phase C, C4.4)

The changed-code quality gate named five, all in the files the previous commit added, and a fence is
not the answer to any of them. A separate commit because a reported head does not move by amend.

- The comment fixture is a real `PRComment` rather than a cast: the type's six required fields are
  all this case needs, and the SAFETY disable that stood in for them was inert anyway — oxfmt had
  wrapped it onto three lines, and a wrapped `oxlint-disable-next-line` matches nothing.
- The image lookup goes through `findAll` on the host tag rather than `findAllByType`, which takes a
  component. Through `String`, because `node.type` is `ElementType` and React Native declares no
  intrinsic elements, so the compiler reads a bare tag comparison as unreachable.
- The runners hook takes its router from `useRouteHandoff` with expo-router mocked under it, which
  is how a `RouteHandoff` is obtained rather than asserted into existence. No target is pressed.
- The rejection and the diagnostic are read through narrowings instead of casts, which also drops
  an `expect.any` that only type-checked because of one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep a frame the page cannot serialize inside the send contract (OTA phase C, C4.4 round 1)

Round 1 finding 1. The oversized refusal moved `JSON.stringify` outside `sendFrame`'s `try`, so a
frame carrying a cycle, a `BigInt` or a throwing `toJSON` threw past the whole send path. Three
things followed, all measured here on a cyclic `params`:

- the caller was rejected with a bare `TypeError` from `JSON.stringify` instead of the
  `BridgeSendFailedError` every other undelivered frame raises;
- no `send-failed` diagnostic was raised, so nothing recorded that a frame had been lost;
- `sendRequest` opens the id before it posts and abandons it on the way out, and the throw skipped
  the abandon: 63 of the 64 in-flight slots were usable afterwards, against 64 on a client that sent
  no such frame. Sixty-four of them and every later request is refused with nothing to say why.

`posted()` carried the same escape into the members whose contract is a boolean, where a throw is
worse still: those callers are taps and teardowns with no catch on them.

Serialization goes back inside the `try`, with the oversized refusal kept in front of the post. The
docstring said the port arm's throw is never `JSON.stringify`'s, which was exactly the assumption
that broke; it now says why the call sits where it does.

The new file is the pin: the rejection's name, the diagnostic, nothing reaching the shell, and the
slot count with a no-cyclic-frame control beside it so the count cannot pass by the cap moving. Both
changed cases red on the serialization moving back out — `expected 'TypeError' to be
'BridgeSendFailedError'` and `expected 63 to be 64`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the page's frame cap to the reader's, at the boundary and by construction (OTA phase C, C4.4 round 1)

Round 1 finding 2. Nothing held the sender's predicate to the reader's. Replacing
`isBridgeFrameWithinCap(json)` with an inline `json.length > BRIDGE_MAX_MESSAGE_BYTES + 1` passed 85
of the 86 mobile-web-shell and source-control test files on this tree, and a frame at exactly cap+1
would then be posted and silently dropped — the hang the refusal exists to end, back for every frame
in that one-unit band.

Two rules, because either alone passes against the defect:

- The boundary. A frame of exactly the cap is posted, arrives at `parseBridgeMessage` and is
  accepted; a frame one byte over is refused with `BridgeRequestOversizedError`, posts nothing, and
  is the same string the reader answers `oversized` to. An off-by-one reds the second.
- The census. The client reaches the cap through the shared predicate and does not name
  `BRIDGE_MAX_MESSAGE_BYTES` at all, and the module that exports the predicate is the module that
  parses inbound frames. A private copy that is correct on the day it is written reds here.

The overhead the boundary frames are built from is itself checked rather than trusted: a frame asked
for at exactly the cap must serialize to exactly the cap, so the constant cannot rot behind an
envelope that grew a field.

Against the mutation both new rules red — `expected null to be 'BridgeRequestOversizedError'` and
the census failing to find the predicate — while the rest of the suite stays green, which is the
finding reproduced.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): count the outbound frame in the unit both shells count it in (OTA phase C, C4.4 round 1)

Round 1 finding 3. Read off both shells rather than assumed, and they agree: iOS gates the inbound
frame on `json.utf8.count` (`MobileWebShellView.swift`, through
`MobileWebShellBridge.acceptsByteCount`) and Android on `json.toByteArray(Charsets.UTF_8).size`
(`MobileWebShellView.kt`, through `acceptsMobileWebShellBridgeByteCount`), both against `640 * 1024`.
UTF-8 bytes on each platform.

The predicate was already right. `isBridgeFrameWithinCap` decides on `utf8ByteLength`, and the
`raw.length` clause in front of it is a cheap refusal in the safe direction, not a second rule: every
code unit encodes to at least one byte, so a string over the cap in units is over it in bytes too.

The diagnostic was not. It reported `json.length` — UTF-16 code units — in a field named `bytes`, so
a frame of CJK text read as a quarter of the cap at the moment it was refused by it. It now reports
`utf8ByteLength(json)`, and the type says which unit that is.

Pinned with a 250,000-character frame of three-byte characters, which is under the cap in code units
and over it in bytes, plus a source case reading the measuring expression out of each shell. Three
mutations, all red: dropping the byte clause from the predicate reds the refusal (`expected null to
be 'BridgeRequestOversizedError'`) and the diagnostic; reporting `json.length` again reds the
diagnostic alone (`expected 250094 to be greater than 655360`), which is the defect this commit
fixes, in the number it would have printed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(config): drop the render check's avatar assertion, which could not fail (OTA phase C, C4.4 round 1)

Round 1 finding 4. The case asserted that no avatar host was requested while both routes painted,
which reads as a proof of the web skip and is not one: no comment card renders on either page,
because the PR chain the file's own closing note names is not scripted. Reproduced here — deleting
the `Platform.OS !== 'web'` guard from `PRCommentCard` leaves the file at 5 passed.

Deleted rather than propped up. Giving the page a presence precondition means five hand-written
fixtures against five Zod schemas inside the shell double, which is exactly what the harness's
docstring says that double must not become. So the only proof of that branch is
`pr-comment-card-web-avatar.test.tsx`, which reds when the check is removed, and the render check now
says so in its header instead of implying otherwise.

What survives is a property of these two closures rather than of that component: not one request
leaves the origin while either route paints, and nothing either paints violates the policy. That one
can fail — planting a `fetch` to a provider host in a module both routes reach reds it twice, on the
console-error case and on the off-origin case, with the `connect-src 'self'` refusal in the output.

The CSP half is unchanged and was never in question: the served header is read from the Kotlin source
and the Swift twin is pinned beside it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-21 05:32:24 -04:00

682 lines
30 KiB
JavaScript

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
// `<label><input type="checkbox" switch>` to document.head, clicking it, and removing it —
// once per call. The file explorer calls triggerSelection on every row tap, and C1.9 already
// traced a swallowed long press on the worktree list to that stray click. `haptics.web.ts` is
// what keeps the whole shim out of the bundle, so this reads the bytes rather than the import.
for (const source of allScriptSource(await bundleMobileWebApp())) {
// The shim's own fingerprint, not `navigator.vibrate`: react-native-web's Vibration export
// calls that too, and it touches no DOM until something invokes it.
expect(source).not.toContain('ariaHidden')
expect(source).not.toContain('pointer: coarse')
expect(source).not.toContain('setAttribute("switch"')
}
}, 120_000)
it('embeds no absolute path from this checkout', async () => {
// Every chunk, not only the entry: the route manifest names each route by absolute path, and
// the chunk that import resolves to is where such a path would survive.
for (const source of allScriptSource(await bundleMobileWebApp())) {
expect(source).not.toContain(projectDir)
}
}, 120_000)
it('builds the same buildId twice', async () => {
const first = await withScratch((scratch) =>
buildMobileWebAppBundle({ outDir: join(scratch, 'a') })
)
const second = await withScratch((scratch) =>
buildMobileWebAppBundle({ outDir: join(scratch, 'b') })
)
expect(first.manifest.buildId).toBe(second.manifest.buildId)
}, 120_000)
it('loads the entry as a module, so its route imports resolve', async () => {
await withScratch(async (scratch) => {
const outDir = join(scratch, 'module-tag')
const { manifest } = await buildMobileWebAppBundle({ outDir })
const html = await readFile(join(outDir, 'index.html'), 'utf8')
// import() in a classic script is a syntax error, so the tag and the format are one fact.
expect(html).toContain('<script type="module" src="/assets/')
const entry = html.match(/src="\/(assets\/[^"]+)"/)?.[1]
expect(manifest.assets.map((asset) => asset.path)).toContain(entry)
})
}, 120_000)
it('carries the root reset, so the mounted tree has a height to be 1 of', async () => {
await withScratch(async (scratch) => {
const outDir = join(scratch, 'root-reset')
await buildMobileWebAppBundle({ outDir })
const html = await readFile(join(outDir, 'index.html'), 'utf8')
expect(html).toContain(MOBILE_WEB_APP_ROOT_RESET)
// Literals rather than substrings taken off the constant, which would read it back against
// itself and follow any rule dropped from it. Every rule, because the chain is only as
// definite as its weakest link: a height on #root alone resolves against a body that has
// none, and percent of auto is auto. Named one by one so a failure says which rule went.
for (const rule of [
'html,body{height:100%}',
'body{overflow:hidden}',
'#root{display:flex;height:100%;flex:1}'
]) {
expect(MOBILE_WEB_APP_ROOT_RESET, rule).toContain(rule)
}
// The id travels with the rules: it is what marks this block as the template's reset rather
// than something the page grew its own copy of.
expect(MOBILE_WEB_APP_ROOT_RESET).toContain('<style id="expo-reset">')
// In the document itself, not a linked asset: the CSP that allows it is the one already
// relaxed for react-native-web's runtime sheet.
expect(html).not.toContain('<link rel="stylesheet"')
})
}, 120_000)
it('writes the manifest shape the packaging contract reads', async () => {
const { manifest } = await withScratch((scratch) =>
buildMobileWebAppBundle({ outDir: join(scratch, 'c') })
)
expect(manifest.schemaVersion).toBe(1)
expect(manifest.entrypoint).toBe('index.html')
expect(manifest.assets.map((asset) => asset.path)).toContain('index.html')
expect(manifest.totalBytes).toBe(
manifest.assets.reduce((total, asset) => total + asset.byteLength, 0)
)
}, 120_000)
})
describe('the Phase C budget', () => {
it('sits below the contract per-asset ceiling, so growth trips a build not a phone', () => {
expect(MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES).toBeLessThan(MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES)
})
itBundling(
'is not already exceeded by the current bundle',
async () => {
const { manifest, chunkCount, entryStaticBytes, imageCount, routeKeys } = await withScratch(
(scratch) => buildMobileWebAppBundle({ outDir: join(scratch, 'd') })
)
expect(manifest.totalBytes).toBeLessThanOrEqual(MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES)
expect(manifest.assets.length).toBeLessThanOrEqual(
mobileWebAppBundleMaxAssets(routeKeys.length, imageCount)
)
expect(chunkCount).toBeLessThanOrEqual(mobileWebAppBundleMaxChunks(routeKeys.length))
expect(entryStaticBytes).toBeLessThanOrEqual(MOBILE_WEB_APP_BUNDLE_MAX_ENTRY_BYTES)
},
120_000
)
it('says which node may be statically imported, and does not promise a route may', async () => {
const source = await readFile(
join(projectDir, 'config', 'scripts', 'verify-mobile-web-app-bundle.mjs'),
'utf8'
)
// The bound reads like a per-route escape hatch and is not one: 5 of the 14 routes break it
// on their own. What keeps it survivable is that expo-router wants a synchronous export off
// layout nodes only, so the note has to name the layout and the export that drives it.
const doc = source.slice(
0,
source.indexOf('export const MOBILE_WEB_APP_BUNDLE_MAX_ENTRY_BYTES')
)
const note = doc.slice(doc.lastIndexOf('/**'))
expect(note).toContain('h/_layout.tsx')
expect(note).toContain('unstable_settings')
})
it('budgets what loads first well under what the whole page weighs', () => {
// The point of the split: the entry budget is the one a route must not grow, and it is a
// fraction of the total the bundle is still allowed to weigh.
expect(MOBILE_WEB_APP_BUNDLE_MAX_ENTRY_BYTES).toBeLessThan(
MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES
)
})
it('derives the chunk ceiling from the route count, not from a measured number', async () => {
// A chunk is emitted per distinct set of importers, so the count is not a function of the
// route count alone. Re-measured on this head, by copying the route tree and dropping routes
// from the end of the sorted key list -- both siblings of each, because deleting a .web.tsx
// alone leaves the native file for the builder to resolve and measures a different closure.
// The 14-route reading is the real tree and includes the one script the deferred mermaid
// artifact costs.
for (const [routes, measured] of [
[8, 32],
[10, 43],
[12, 61],
[14, 69]
]) {
expect(mobileWebAppBundleMaxChunks(routes), `${String(routes)} routes`).toBeGreaterThan(
measured
)
}
expect(mobileWebAppBundleMaxChunks(14)).toBe(72)
expect(mobileWebAppBundleMaxChunks(15) - mobileWebAppBundleMaxChunks(14)).toBe(4)
// Between four and nine more per route above, so the ceiling is a bound and not a fit -- and
// at 14 routes it is a close one. 69 measured against 72, with the last two routes having cost
// the 8 the ceiling grants for two: the next route that shares less than its neighbours fails
// here, which is what this is for.
expect(mobileWebAppBundleMaxChunks(14) - mobileWebAppBundleMaxChunks(12)).toBe(8)
})
it('refuses an engine chunked along its own lazy boundaries, and passes one artifact', () => {
// The two builds this ceiling has to tell apart, both measured at 14 routes.
//
// The page reaches mermaid through one pre-bundled artifact and the bundle emits 69 scripts
// (68 of them the page's own split, one the deferred engine). Importing the package instead
// emitted 172: mermaid lazily imports each of its own diagram types and esbuild splits along
// those boundaries, all of it inside the generation the phone has already downloaded. The
// route term is the only term precisely so that the second of those fails here -- a ceiling
// raised to admit 172 would have admitted any split at all.
const ROUTES = 14
const WITH_ONE_ARTIFACT = 69
const CHUNKED_ALONG_THE_ENGINE = 172
expect(WITH_ONE_ARTIFACT).toBeLessThanOrEqual(mobileWebAppBundleMaxChunks(ROUTES))
expect(CHUNKED_ALONG_THE_ENGINE).toBeGreaterThan(mobileWebAppBundleMaxChunks(ROUTES))
// And the assets that came with it: 215 against 112, of the 256 the shell will load.
expect(mobileWebAppBundleMaxAssets(ROUTES, 42)).toBeLessThan(CHUNKED_ALONG_THE_ENGINE + 42 + 1)
})
it('derives the asset ceiling so the chunk ceiling is always the one that trips first', () => {
// A bundle's assets are its chunks, its images and the document. Asserting one constant under
// another did not say that: with 42 images, 4 * 18 + 16 chunks plus 42 plus the document is
// 131 assets, over the flat 128 the ceiling used to be, so from 18 routes on the asset count
// failed first and named the wrong thing.
for (const routeCount of [14, 18, 24, 40]) {
for (const imageCount of [0, 42, 120]) {
const chunks = mobileWebAppBundleMaxChunks(routeCount)
expect(mobileWebAppBundleMaxAssets(routeCount, imageCount)).toBe(chunks + imageCount + 1)
// The ordering claim itself: a bundle at the chunk ceiling is exactly at the asset
// ceiling, so no bundle can pass the chunk check and fail the asset one.
expect(chunks + imageCount + 1).toBeLessThanOrEqual(
mobileWebAppBundleMaxAssets(routeCount, imageCount)
)
}
}
})
itBundling(
'keeps the derived ceiling under the map the phone actually holds',
async () => {
const { manifest, routeKeys, imageCount } = await withScratch((scratch) =>
buildMobileWebAppBundle({ outDir: join(scratch, 'e') })
)
const ceiling = mobileWebAppBundleMaxAssets(routeKeys.length, imageCount)
expect(manifest.assets.length).toBeLessThanOrEqual(ceiling)
// The native side refuses a manifest past this, so the derived ceiling has to stay inside it.
expect(ceiling).toBeLessThanOrEqual(MOBILE_WEB_BUNDLE_MAX_ASSETS)
// And the build is what has to say so: the guard runs on the counts this bundle measured.
const shellCeiling = await readMobileWebBundleMaxAssets()
expect(assertAssetCeilingFitsShell(routeKeys.length, imageCount, shellCeiling)).toBe(ceiling)
},
120_000
)
it('fails the build when the derived ceiling passes what the phone will accept', async () => {
// The shell hands back null for a manifest over its own ceiling, so a derived ceiling above
// that ships a green build no device can open. At the 42 images the tree carries, 4r + 16 +
// 42 + 1 crosses 256 at 50 routes, which Phase C reaches. A deferred engine kept to one
// artifact leaves that where it is; the 103-script version of it moved the crossing to 24.
expect(await readMobileWebBundleMaxAssets()).toBe(MOBILE_WEB_BUNDLE_MAX_ASSETS)
expect(assertAssetCeilingFitsShell(49, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toBe(255)
expect(() => assertAssetCeilingFitsShell(50, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toThrow(
/259 .*256/
)
})
})
describe('the verifier', () => {
itBundling(
'accepts a bundle it has just built',
async () => {
await withScratch(async (scratch) => {
const outDir = join(scratch, 'mobile-web-app')
await buildMobileWebAppBundle({ outDir })
await expect(verifyMobileWebAppBundle({ bundleDir: outDir })).resolves.toBeDefined()
})
},
240_000
)
itBundling(
"rejects a buildId the manifest's own asset list does not derive",
async () => {
await withScratch(async (scratch) => {
const outDir = join(scratch, 'mobile-web-app')
await buildMobileWebAppBundle({ outDir })
const manifestPath = join(outDir, 'manifest.json')
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
manifest.buildId = 'f'.repeat(64)
await writeFile(manifestPath, JSON.stringify(manifest), 'utf8')
await expect(verifyMobileWebAppBundle({ bundleDir: outDir })).rejects.toThrow(
'does not match its asset list'
)
})
},
240_000
)
itBundling(
'rejects a self-consistent bundle a fresh build does not reproduce',
async () => {
await withScratch(async (scratch) => {
const outDir = join(scratch, 'mobile-web-app')
const { manifest } = await buildMobileWebAppBundle({ outDir })
// What a stale out/ actually looks like: every digest agrees with its bytes and the
// buildId derives from the asset list, but the source has moved on. Only the two fresh
// builds the verifier runs can tell, which is the check this covers.
const assets = await Promise.all(
manifest.assets.map(async (asset) => ({
...asset,
bytes: await readFile(join(outDir, asset.path))
}))
)
const document = assets.find((asset) => asset.path === manifest.entrypoint)
document.bytes = Buffer.concat([document.bytes, Buffer.from('<!-- drift -->\n', 'utf8')])
document.sha256 = sha256Hex(document.bytes)
document.byteLength = document.bytes.byteLength
const [desktopVersion, protocolWindow] = await Promise.all([
readDesktopVersion(),
readProtocolWindow()
])
await writeMobileWebBundleTree({ outDir, written: assets, desktopVersion, protocolWindow })
await expect(verifyMobileWebAppBundle({ bundleDir: outDir })).rejects.toThrow('is stale')
})
},
240_000
)
})
describe('the CRLF guard', () => {
it('covers the three trees whose bytes reach the buildId', () => {
expect(MOBILE_WEB_APP_SOURCE_DIRS.map((dir) => dir.slice(projectDir.length))).toEqual([
join('mobile', 'web-entry'),
join('mobile', 'app'),
join('mobile', 'src')
])
})
it('fails on a CRLF source file', async () => {
await withScratch(async (scratch) => {
await writeFile(join(scratch, 'route.tsx'), 'export default null\r\n', 'utf8')
await expect(assertNoCarriageReturnsInSource(scratch)).rejects.toThrow('CRLF')
})
})
it('exempts the binary assets .gitattributes pins -text', async () => {
await withScratch(async (scratch) => {
await writeFile(join(scratch, 'icon.ttf'), Buffer.from([0x00, 0x0d, 0x0a]))
await writeFile(join(scratch, 'shot.png'), Buffer.from([0x0d]))
await expect(assertNoCarriageReturnsInSource(scratch)).resolves.toBeUndefined()
})
})
it('exempts the gitignored generated webview engine modules', async () => {
await withScratch(async (scratch) => {
await writeFile(join(scratch, 'engine.generated.ts'), 'export const X = "a\r\n"', 'utf8')
await expect(assertNoCarriageReturnsInSource(scratch)).resolves.toBeUndefined()
})
})
})
describe('naming an output by its bytes', () => {
it('refuses two outputs that name each other', () => {
const emitted = (text) => new TextEncoder().encode(text)
const metafile = {
outputs: {
'dist/a.js': { imports: [{ path: 'dist/b.js', kind: 'import-statement' }] },
'dist/b.js': { imports: [{ path: 'dist/a.js', kind: 'import-statement' }] }
}
}
// Neither name can be final before the other is, so a cycle has no content hash to reach.
// esbuild's splitting emits a DAG; this is the hard stop for the day it does not.
expect(() =>
renameOutputsByContent(metafile, [
{ path: 'dist/a.js', contents: emitted('import "/assets/b.js"') },
{ path: 'dist/b.js', contents: emitted('import "/assets/a.js"') }
])
).toThrow(/output cycle/)
})
it('refuses a route it cannot find an output for', async () => {
await withScratch(async (scratch) => {
const module = join(scratch, 'index.tsx')
await writeFile(module, 'export default function Route() { return null }\n')
// The metafile is the only thing that knows which chunk holds a route. Without this the
// route reaches the manifest naming a chunk of undefined, which the phone fetches as a 404.
expect(() =>
routeChunkNames({ outputs: {} }, [{ key: './index.tsx', module }], new Map())
).toThrow(/\.\/index\.tsx reached no output/)
})
})
})