mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
* refactor(mobile): take the files screens' router from the handoff seam Inside the shell's page a screen is one document standing in for one screen, so a target the page does not render has to be handed back to the app that does. `useRouteHandoff` is where that decision lives, and its web sibling is the only thing that makes it; both files screens held expo-router's own `useRouter`, so on the web the explorer's Back and the preview's Back would post nothing and a target outside the page would paint Unmatched over the page it is on. Natively this is the same object — `route-handoff.ts` is `useRouter()` — so no behaviour moves here, and `back()` stays expo-router's until the navigate-back verb lands and the seam starts wrapping it. A census rather than a behaviour test: neither screen's own tests can see the difference, because a push that is never handed off still works for a target inside the page. It walks this directory, refuses a value import of expo-router, and names the two screens that must hold a router so a walk that found nothing fails instead of passing empty. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): let the shell stand in for the two files routes Both route files take the index.tsx shape — flag, MobileWebShellScreen, native screen as fallback — and both gain the `.web.tsx` sibling that shape forces. Inert until the manifest lists these routes: the shell answers `native-route` for a route the bundle does not name, which is what `fallback` renders, and the flag is `__DEV__`-only besides. Listing them waits on C2.3 and C2.5. The sibling is not a precaution. The manifest defers every route behind `import()`, so a native-only route module is invisible until the page opens that route; the render check now opens both and, without the siblings, painted `expo-modules-core.requireNativeViewManager is not available on web` instead of the screen. That is also why the two cases render the route rather than asserting a file exists. The file path never becomes a path segment: only `hostId` and `worktreeId` are spelled into the pathname, encoded, and everything else — `relativePath`, `absolutePath`, `cwd`, `pathText` — is a param, which is how a `/`, a space or a `..` stays out of the segment vocabulary the bridge holds a route to. The preview render case proves the round trip on `docs/my notes/readme.md`. `mobileFilePreviewShellParams` drops a param the normalizer left `undefined` rather than sending it empty, because the page reads these back through useLocalSearchParams where `line: ''` and no `line` are different screens. Its test drives the normalizer rather than a hand-written literal: the literal omits the key entirely, so it held with the filter removed. The preview case also records what React Native Web says out loud — BackHandler is inert on web, so Android back inside the page skips the unsaved-draft prompt. Named in the assertion rather than filtered out, so closing it is a change to that line. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): ask about an unsaved draft in the screen, not through Alert React Native Web's `Alert` is `static alert() {}`. Inside the shell's page that made Back with an unsaved terminal-artifact draft a button that did nothing at all: no prompt, because the dialog is a no-op, and no navigation either, because the code took the branch that shows one. Silently, with nothing on the console. The prompt is now a row under the header. Not `ConfirmModal`, which every other confirm here uses: that is a `BottomDrawer`, and C1.9 has Reanimated's animated styles never reaching the DOM node on WKWebView, so on iOS in the page the drawer parks off-screen and Back would be dead a second way. This paints the same on every platform with no animation behind it. Hardware back is registered natively only. React Native Web's `BackHandler.addEventListener` logs "BackHandler is not supported on web and should not be used." and hands back an inert subscription, so the guard never armed there regardless; the render check asserted that console error on main and now asserts none. The degradation is real and stated rather than hidden: Android back inside the page pops the native stack without asking, and the page's own Back control is where the question lives. The decision moved to a hook so it is testable without a screen: the prompt also drops itself when the draft it was about is saved or reverted, which is a state `Alert` had no way to be in. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep expo-haptics' DOM shim out of the page expo-haptics has a web build, and with no `navigator.vibrate` — iOS Safari, which is the WebView the page runs in — it fakes a haptic by appending a hidden `<label><input type="checkbox" switch>` to `document.head`, clicking it, and removing it, once per call. C1.9 traced a long press that never fired on the worktree list to exactly that stray click, and the file explorer calls `triggerSelection` on every row tap, so C3 is the first domain to fire it per tap rather than per long press. `haptics.web.ts` answers the same five names with nothing. A phone holding the page is a phone whose native app is right there with the real haptics, and a missing tap feedback is worth less than a tap that does not register. The test reads the shipped bytes rather than the import, because that is the claim: with the override removed the bundle carries `ariaHidden` and `pointer: coarse`; with it, neither, nor the `setAttribute("switch"` that does the clicking. Not `navigator.vibrate` — react-native-web's own Vibration export calls that and touches no DOM until something invokes it, which cost this test one wrong red before it was narrowed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep the files routes native when the page could not be given one A file path is a param, so `/`, spaces and `..` all cross safely — but `BRIDGE_MAX_ROUTE_PARAM_CHARS` is 1024 and a Windows long path is not bounded by anything the user cannot exceed. The symptom is not the blank document the design predicted, and the correction matters: `bridge-host.ts` already parses the route against the page's own schema and drops it to `null` when it fails, so `init` arrives naming no screen and the page paints "Update Orca to open this workspace" — a wrong message about a fine app, over a native screen that works. Deciding before the switch instead leaves the route native, which is where every route starts. The schema is the predicate rather than a copy of its bounds, so the rule cannot drift from the half that matters, which is the half the page reads. The same call also refuses a `worktreeId` the segment rule will not route: `..` survives `encodeURIComponent`, which is the C1.8 class. The tests assert the schema really refuses each input before asserting the guard does, so neither case can pass by being impossible. This belongs in the shell beside the schema; it is in the files domain while the contract files are the C2 lane's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin what keeps a file path out of the route vocabulary Seven shapes, one case each rather than a representative: a plain path, a space, a dot segment, an already-encoded slash, a fragment, non-ASCII, and an absolute path. Each is checked in the two directions a path travels — the href the shell writes into the page's history, and the href the page would hand back — for both the pattern accepting it and the path coming back out of the query unchanged. The counterfactual is in the file: the same paths spelled as a segment are refused. Without that, the cases above would hold for a rule that was never doing any work. Mutating `stringifyRouteHref` to join its query by hand instead of through `URLSearchParams` fails three of them. Also fixes two new test files the tests-typecheck ratchet caught: the partial `react-native` mock needs a typed `addEventListener`, `act` will not take a callback that returns a value, and `findAllByType('Pressable')` does not typecheck against `ElementType` — the neighbouring files that do it are grandfathered, so the tag comparison goes through a helper instead. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): derive the discard prompt instead of clearing it in an effect Both changed-code gate findings, which the lane had not run until the last commit. React Doctor is right: the effect that cleared the prompt when the draft went away adjusted state after a prop changed, so a save landing while the prompt was up painted one frame still offering to discard nothing. The prompt is now `asking && hasUnsavedDraft`, which cannot be stale by construction, and the test that covers it passes unchanged. The hoisted mock's `as` on a string literal is gone too: the literal narrows on its own and the tests reassign it, so the holder is annotated instead. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): add the files routes to the hybrid shell flag census The census pins every file that reads `useMobileWebShellEnabled`, because a reader nobody listed is how a dark feature stops being dark. C3's two routes are deliberate entries: each has a native screen behind it as `fallback`, and each is inert until the manifest lists the route. Found by the full mobile suite rather than by the files subset this lane had been running per commit. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): serve the files explorer and preview from the page The last C3 commit: both routes join MOBILE_WEB_PAGE_ROUTES, and the shell starts rendering the page for them on a phone with the dev flag on. Grants are not the same for the two, and the difference is the point. Both take `navigate` (Back pops the native stack, and the explorer's rows open the preview beside it) and `storage` (the shared components the host layout renders above them). Only the preview takes `externalLink`: a Markdown preview renders links and `MobileMarkdown` opens them through the platform seam. The explorer does not, and measuring is what says so rather than reading. Every page route reaches `external-link.web.ts` — `/h/[hostId]` and agent-history included, both granted nothing for it — because the protocol wall in the shared host layout imports it. So closure membership is not the oracle for a grant; the question is whether the route's own screens call it, and only the preview's do. `MobileMarkdown` is in the preview closure and absent from the explorer's, which the census now asserts in both directions. Neither route writes a clipboard, so neither takes `native.clipboard.write`; the census pins that as the absence of both `ExpoClipboard.web.js` and the clipboard seam, with the tasks closure as the control that the probe can see one when there is one. The seam predicate moved into a module both censuses import rather than being restated per series: two spellings of one rule drift, and this one is a regex. Red-first: both manifest assertions failed on the new entries before they were updated, and routing `MobileMarkdown` around the seam fails the preview's census while leaving the explorer's passing, which is the asymmetry the grants encode. Closure sizes as the page ships them, extensionless so the `.web.tsx` is what is measured: explorer 3439 modules / 302 local / 10 under src/files, preview 3667 / 331 / 20. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): read the files route's ids as one value and key the shell on them Two round-1 findings, both reproduced before the fix. A repeated query key reaches `useLocalSearchParams` as an array, and the explorer read `hostId` and `worktreeId` bare. `String(['a','b'])` is `a,b`, so the template built `/h/host-a%2Chost-b/files/wt-1%2Cwt-2` — a single segment the bridge's rule accepts, and the shell would open a page for a host nobody has. Read through `firstParam` now, as the tasks and agent-history switches do. The preview already went through `singleParam` and is unchanged. Neither switch keyed `MobileWebShellScreen`, where `index.tsx`, `tasks.tsx` and agent-history all do. A host captures the grants its session opened with, so a screen reused across a route change keeps authorising frames under the grants of the route the page has left; only a remount drops that bridge. Both are keyed on the route pathname now, with agent-history's reason. The new route test is the agent-history one's shape. It caught both: the array case landed on no route at all, because `name` was an array too and the schema refuses a non-string param value, and the two lifecycle cases saw a prop update where a remount was owed. It also needs agent-history's `lucide-react-native` mock, since `firstParam` lives in the source-control barrel. `name` is now omitted when empty rather than sent as `name=`, matching the two switches beside it: an absent label lets the panel derive its own. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): confirm a discarded draft with the app's own modal Round-1 findings 3, 4, 5 and the minor one. **ConfirmModal, not the bespoke row.** The row existed because C1.9 had Reanimated's animated styles never reaching the DOM node on WKWebView, which left every BottomDrawer parked off-screen. C1.10 (`b7c06900e2`, an ancestor of this branch) fixed that with a dependency array on the mapper hooks, and the drawer render check now holds it on WebKit as well as Chromium. With the reason gone the row does not stand on its other merits: `Alert.alert` was modal on native before the page existed, and the row quietly changed that for phones too, so the app's own confirm is both the idiom and the closer behaviour. `MobileFilePreviewDiscardPrompt`, its test and its thirty style keys are gone; the hook's state machine and its tests are unchanged. **The encoding test claimed more than it pinned.** Hand-joining the query reds only three of the seven shapes; `docs/readme.md`, `../etc/passwd`, `docs/日本語.md` and `/logs/run.txt` are encoding-neutral in the query, whose pattern half is `[^#\s]*` and admits a slash, a dot segment and non-ASCII verbatim. Rather than narrow the claim in a comment, the split is now pinned by behaviour: each neutral shape must survive the query unencoded, each load-bearing one must not. Moving `docs/readme.md` between the lists fails it. **The manifest comment named one shared-layout opener and there are two.** The New Workspace source field, which the sidebar renders on a wide layout, opens a URL through the seam as well. Both are the shared layout's and every `/h` route reaches both, `/h/[hostId]` included with no `externalLink`, so the tablet tap is dead on all of them — recorded here as pre-existing rather than fixed, since the grants do not move. **Minor:** the dot-segment case in the guard test now asserts the schema refuses the route before asserting the guard returns null, as the length case does. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): stop every page drawer logging a BackHandler error when it opens Round-2 findings. **The registration belongs to the drawer, and that is where the guard went.** `mounted-bottom-drawer.tsx` armed `hardwareBackPress` whenever a drawer was visible and interactive, with no platform check, so the hook's claim to have dropped that console line held only while its prompt was closed — and every page drawer since C1 has logged it on open. Platform-gated at the drawer now; the hook's comment says so rather than claiming the credit. **Nothing had ever opened a modal in a browser.** The render check next door mounts both files routes and reads what they paint but taps nothing, so `ConfirmModal` inside the page — a BottomDrawer, so Reanimated, a portal and a gesture handler — was unproved. A new render file loads an editable terminal artifact through the harness's scripted reply, edits it, taps the page's Back, and asserts the prompt's title is up and no BackHandler line is on the console. Red first on exactly that line; the prompt itself painted, which is also the first proof on a browser that C1.10's fix carries a real drawer in the page. A second case answers Stay and checks the draft survives. Its own file rather than the render check's, which is at 482 of the 600-line cap; registered in pr.yml. **The encoding rule was stated wrong.** Two rules decide it and neither is about paths: the pattern's query half refuses whitespace and `#`, and `URLSearchParams` is form-urlencoded, so it reinterprets `&`, `+` and a valid `%XX`. `a+b.ts` reads back `a b.ts` and `a&b.ts` reads back `a`, so both are load-bearing; `a=b.ts` and `a%b.ts` are not, because only the first `=` splits the pair and a lone `%` begins no escape. A newline joins the load-bearing list as the refused shape rather than the altered one. **The web sibling read its params bare** where the native one uses `firstParam`. Not reachable — the page only arrives through `init.route`, whose params are already `Record<string, string>` — but the two files are meant to be one screen. The preview keys on the pathname alone, and the comment now says why that is enough: every caller in this tree pushes. Closures after this: explorer 3441 / 304 / 10, preview 3666 / 330 / 19. The explorer grew two modules because its web sibling now reaches `firstParam`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): give the explorer the grants the preview needs, and key on the route Bot findings, one of them a real gap. **Pullfrog is right, and my grant oracle was half a rule.** Grants resolve once, from the route the shell opened: `grantsForRoute` reads `session.routePathname` and `init.grants.native` carries the answer for that session. The explorer's rows push to the preview, and because the preview is a page route that push stays inside the same document — no second `init`. So a preview opened that way runs under the explorer's grants, and a Markdown link in it was refused by `notifyExternalLink` with nothing on screen to say why. "Does the route's own screen call it" was right for a route's own screens and wrong for the routes it reaches in-page, so the explorer now declares `externalLink` as a transitive grant, with the comment saying that rather than claiming it opens links. The census pins the pair as a superset; removing the grant reds it. **The seam regexes matched one quote style.** A double-quoted `react-native` specifier walked past both censuses unseen. Both styles now, with the predicate tested directly for the first time. **The discard request outlived its draft.** `asking` stayed set after a save or a revert, so the next edit re-showed the prompt with no Back request behind it. The request is now dropped when the draft it was about goes, adjusted during render rather than in an effect — the shape React Doctor named in the round-1 fold. Red first: save with the prompt up, edit again, prompt is back. **CodeRabbit's keying comment is a correctness point, not the question I answered.** The page learns its route exactly once, out of `init`, so a same-path param change — another file in the same worktree — left the shell mounted and the page still showing the file it was opened on. My comment claimed "the screen reloads the preview from the param either way", which is true only with the shell absent. Both switches key on the whole route now, params included; two tests cover the same-path case and both red on a pathname-only key. `build-mobile-web-app-bundle.test.mjs` hit 601 of its 600-line cap on the way, so the two manifest assertions now share one expected list instead of repeating it. Closures unchanged: explorer 3441 / 304 / 10, preview 3666 / 330 / 19. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the seam test import the module it is testing Round 3. **The blocker is mine and the reviewer's diagnosis is exact.** The seam predicate test imported an absolute path into this lane's worktree. On CI that module does not exist and it takes the whole `config/scripts` suite down; here it resolved to the same file by accident, so the test was green against a tree rather than against the checkout — which is why reverting the double-quote fix left it passing and the predicate untested. Relative now, and proved: reverting the fix in place reds both double-quoted cases, which is the first time this test has failed for the right reason. Every file this PR touches is grepped for `/Users/` and `orca-lanes`; none carries a path. **Three comments outlived the grant change.** The two lists became equal when the explorer took `externalLink`, so "longer than the explorer's" and "declared with different grants" were both false. Corrected to what is actually true: the lists are equal and the reasons are not — the preview has its own consumer in `MobileMarkdown`, the explorer has none and declares the grant because its rows push to the preview in-page. **The duplicated serializer is pinned rather than imported.** `shellRouteHref` lives in `page-bootstrap.ts` beside the page's RPC client and its document channel, so a native route file importing it would pull both into the app. The copy stays, and a test asserts the two agree on three routes; dropping the empty-search branch reds it. **Recorded, not fixed:** the sidebar `HostScreen` pushes to `/h/<id>/tasks` through the handoff, which is local, so on a tablet the tasks page runs without `native.clipboard.write` from any page route and its copy actions refuse silently. Pre-existing since C2.1 for the worktree list and agent history. Named in the explorer's manifest comment as the known remaining hop, with the fix being a handoff rule in its own PR. The equality pin needed `it.each<BridgeInitRoute>`: the inferred table is a union whose members carry `?: undefined`, which the ratchet caught. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
724 lines
31 KiB
JavaScript
724 lines
31 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,
|
|
resolveMobileWebPageRoutes,
|
|
routeChunkNames
|
|
} from './build-mobile-web-app-bundle.mjs'
|
|
import {
|
|
MOBILE_WEB_APP_ROUTE_ROOT,
|
|
ROUTE_SOURCE_LOADERS,
|
|
collectMobileWebAppRouteKeys,
|
|
collectMobileWebAppRoutes,
|
|
routePathnameFromKey
|
|
} 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 {
|
|
computeMobileWebBundleBuildId,
|
|
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 })
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Every page route this bundle declares, written out rather than read from the source that
|
|
* produces it: the point is to pin the list, and comparing the manifest to its own input would
|
|
* pass whatever that input became. Shared by the two assertions below, which is also what keeps
|
|
* this file under the 600-line cap.
|
|
*/
|
|
const EXPECTED_PAGE_ROUTES = [
|
|
{ pathname: '/h/[hostId]', grants: ['navigate', 'storage'] },
|
|
{ pathname: '/h/[hostId]/agent-history/[worktreeId]', grants: ['navigate', 'storage'] },
|
|
{
|
|
pathname: '/h/[hostId]/tasks',
|
|
grants: ['navigate', 'storage', 'externalLink', 'native.clipboard.write']
|
|
},
|
|
{ pathname: '/h/[hostId]/files/[worktreeId]', grants: ['navigate', 'storage', 'externalLink'] },
|
|
{
|
|
pathname: '/h/[hostId]/files/preview/[worktreeId]',
|
|
grants: ['navigate', 'storage', 'externalLink']
|
|
}
|
|
]
|
|
|
|
describe('the page routes the manifest declares', () => {
|
|
it('turns a route key into the URL pattern expo-router gives it', () => {
|
|
expect(routePathnameFromKey('./h/[hostId]/index.tsx')).toBe('/h/[hostId]')
|
|
expect(routePathnameFromKey('./h/[hostId]/tasks.tsx')).toBe('/h/[hostId]/tasks')
|
|
expect(routePathnameFromKey('./h/[hostId]/session/[worktreeId].tsx')).toBe(
|
|
'/h/[hostId]/session/[worktreeId]'
|
|
)
|
|
})
|
|
|
|
it('answers null for a layout, which is not a screen anyone navigates to', () => {
|
|
expect(routePathnameFromKey('./h/_layout.tsx')).toBeNull()
|
|
expect(routePathnameFromKey('./h/[hostId]/_layout.tsx')).toBeNull()
|
|
})
|
|
|
|
it('declares only routes the bundle has a module for', async () => {
|
|
const keys = await collectMobileWebAppRouteKeys(appDir)
|
|
expect(resolveMobileWebPageRoutes(keys)).toEqual(EXPECTED_PAGE_ROUTES)
|
|
})
|
|
|
|
it('fails the build on a declaration the bundle cannot render', () => {
|
|
// The mismatch reaches a phone as a route the shell opens the page for and the page then
|
|
// paints as Unmatched. This is the only place whoever wrote the declaration can see it.
|
|
expect(() =>
|
|
resolveMobileWebPageRoutes(
|
|
['./h/[hostId]/index.tsx'],
|
|
[{ pathname: '/h/[hostId]/gone', grants: [] }]
|
|
)
|
|
).toThrow('has no module in the bundle')
|
|
})
|
|
|
|
itBundling(
|
|
'reaches the built manifest, where the build id does not move for it',
|
|
async () => {
|
|
await withScratch(async (scratch) => {
|
|
const { manifest } = await buildMobileWebAppBundle({ outDir: join(scratch, 'bundle') })
|
|
expect(manifest.routes).toEqual(EXPECTED_PAGE_ROUTES)
|
|
// The routes are derived from the same tree the script is built from, so the assets
|
|
// already decide them and the id has no reason to carry them as well.
|
|
expect(manifest.buildId).toBe(computeMobileWebBundleBuildId(manifest.assets))
|
|
})
|
|
},
|
|
240_000
|
|
)
|
|
})
|
|
|
|
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 a different option, so removing one leaves the other five true. 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 combinatorial rather than
|
|
// one per route. Measured while building this: 8 routes emit 23 chunks, 10 emit 40, 12 emit
|
|
// 47, 14 emit 53 -- about 3 more per route at the top. The ceiling allows 4 and starts 16
|
|
// above zero, so the next few routes land under it instead of failing on a pinned number.
|
|
for (const [routes, measured] of [
|
|
[8, 23],
|
|
[10, 40],
|
|
[12, 47],
|
|
[14, 53]
|
|
]) {
|
|
expect(mobileWebAppBundleMaxChunks(routes), `${String(routes)} routes`).toBeGreaterThan(
|
|
measured
|
|
)
|
|
}
|
|
expect(mobileWebAppBundleMaxChunks(14)).toBe(72)
|
|
expect(mobileWebAppBundleMaxChunks(15) - mobileWebAppBundleMaxChunks(14)).toBe(4)
|
|
})
|
|
|
|
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.
|
|
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/)
|
|
})
|
|
})
|
|
})
|