mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 00:02:29 +00:00
* test(mobile): pin the browser pane's golden families The half pin for C6: 4 families, 15 goldens, every verdict the one C2's rule predicts. Measured per family with vitest `-t` over the full 787-golden corpus, with C1's 103 reproduced golden-for-golden as the control: 6 byte-identical, 9 result-absent-settlement. No composed `c6-page-closure.ts`: a composed table is pinned against a route and the browser is a pane, so C7's route is what composes this with C1's. The derivation census does not wait for that route. `mobileWebAppRoute- Closure` becomes one case of `mobileWebAppModuleClosure`, which takes any entries, so the pane's own closure can be read from the module. Two cases: the pane alone reaches exactly the pinned four, and the pane beside `app/h/_layout` adds exactly those four and no other, with the layout reproducing C1's 22 as the control for the difference. Closure at this base: 48 local modules alone, 34 beyond the layout, 30 under `src/browser` and four through the web siblings. The design said 23, all under `src/browser`; it was measured before C6.2 and C6.3 added those siblings, so the pin carries the re-measured number. `browser.screencast` has no golden at all, so this certifies the input path and says nothing about the frame path. Red first: with `browser.wheel` dropped from the table, both census cases fail naming the missing family; restored, the file's 10 cases pass and the parity suite reports "15 goldens in 4 families, 6 byte-identical". Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the frame budget against the shell's real frame Ruling 2's pin. `binaryEventEnvelopeBytes()` sizes the mobile view's device scale from a skeleton it builds itself, and until now its only check was another skeleton of the same shape in the same file: two copies of one assumption agreeing with each other. This measures the real thing. A frame with CDP's nine metadata fields and a real `Page.screencastFrame` timestamp, encoded by C6.1's `encodeBridgeScreencastFrame` and serialized by the real `BridgeHostSubscriptions`, posted through the host harness: 303 bytes besides the image, against a bound of 516. Held above is not enough on its own — 213 bytes of slack is room for the shell to grow the envelope by a field the page never hears about — so the bound is reconstructed exactly instead. Every byte of that slack is a number this frame prints narrower than a double can; adding those back gives 516 on the nose. The budget cases run a generated noise image at the budgeted scale, not a committed fixture: the worst case is the image JPEG compresses least, and a photograph sits a tenth of the way to it. 901,161 px at 0.545 bytes per pixel is 491,132 bytes, which the shell posts at 654,857 of the 655,360-byte cap. One envelope more and the shell drops it, which is ruling 1 read from the budget's side. Red first, two ways. Drop the metadata widening from the bound and three cases fail, the sharpest being the real shell answering the frame the page thought it could send with zero posts. Add a field to the shell's own envelope and the reconstruction fails at 516 against 548, where the existing suite stays green on all 14 — which is the drift this file exists for. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): record the measured frame bytes, correctinge7cd24ef10The previous commit message says the shell posts 654,857 bytes for a frame at the budgeted area. That number was not measured; I wrote it from the budget arithmetic instead of reading it off the harness. The measured value is 655,147, which is 213 under the cap rather than 503. Nothing in the assertions changes — they compare against the cap and the bound, never against a literal — but the figure now lives in the file where it was measured rather than only in a message that has it wrong. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): render the browser pane in a page and paint a real frame The only place C6's whole frame path runs. Every other check reads one half: the shell suites drive the host with no page, the page suites drive the hooks with no shell, and the parity pin certifies the input path from a recording. Ruling 4: no route is added. `bundleMobileWebApp` already takes an `appDir`, so this builds a one-route tree of its own and nothing under `mobile/app` moves. The shell double grows a screencast lane to serve it: it accepts a subscribe, posts `event.binary`, and prices each frame the way `BridgeHostSubscriptions` does, so an over-cap frame is dropped where the page can watch the stream survive it. Six cases: the pane subscribes with `wantsBinary` and paints the frame it is handed; a second frame flips the double buffer; an over-cap frame is dropped and the next one paints on the same subscription; the grant withheld produces the update-the-app copy and no subscribe at all; a tap issues one `browser.mouseClick` at the centre of the source viewport; and no request leaves the bundle's own origin. Measured. The frame the pane asks this viewport for is 390x698, which as noise is 201,924 base64 characters. The phone's mobile-mode frame is 780x1424 and encodes to 811,168, which is 124% of the cap and the reason the area budget exists; the over-cap case uses 2400x2160 at 3,761,580, 574% of it. The tap maps to (194, 356) against a 390x712 source, one device pixel off centre because the rendered width is 382.33 CSS pixels for 390 source pixels. One finding, recorded rather than fixed because it is not the pane's. The page files a CSP `script-src` violation on every load, on any route: Zod 4 feature-detects its compiled path with `new Function('')`, the shell's `script-src 'self'` blocks it, Zod catches the throw and takes the interpreted path. The page is correct and the report is filed anyway. One case names it so a second `eval` is visible, and every other case asserts no violation beyond it. Red first, twice, both by reverting behaviour C6.2 landed. Stub out the decode probe in `whenBrowserFrameDisplayable` and the flip case fails on two identical frame digests. Point `updateBrowserImageSource` at the host element instead of the surface child and the paint and flip cases both fail. The first case's comment is corrected by the first of those: it claimed a visible layer proved the decode-then-flip, and the frame still paints with the probe gone, so the flip case is what proves it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): sweep the worst-case JPEG cost instead of taking one point The reviewer is right, and it is worse than the report says. At 0.545 bytes per pixel, 90 of 143 viewports posted a frame over the cap, and 59 of the 111 the budget claims to fit were dropped outright by the real shell — 390x712 at scale 1.8 among them. The old number came from one 2400x2160 frame. A single large frame is the cheapest per pixel in the whole range, so a worst case measured there is not a worst case anywhere else. Swept 143 viewports, widths 320 to 1400 and heights 480 to 1600, each encoded by Chromium at the scale the real budget picks for it. Across the 111 the budget fits, the cost ranges 0.54470 to 0.55351 bytes per pixel. The constant is now 0.56: that maximum plus 0.00649, about 1.2%, for the encoder version it was not swept on. The docstring carries the sweep, the range, the margin and the date. 0.56 is a fixed point, not a guess. Raising the constant shrinks the budget, which lowers the scale, which moves the cost; 0.555, 0.56 and 0.565 all leave the same 31 viewports over the cap, and every one of those sits at the scale floor of 1, where the module already declines to go blurrier and C6 ruling 1's drop rule is the protection. The new test asserts both halves: nothing the budget fits goes over, and the largest viewport it cannot fit is dropped by the real shell. The sweep lives in `config/scripts` because it needs Chromium: the frames are CDP screencast frames, so Chromium's encoder is the oracle and a Node JPEG library would calibrate against the wrong bytes. It drives the real budget, the real scale function and the real `BridgeHostSubscriptions`, and runs in about 4 seconds. Ruling 2's block in `browser-screencast-budget-at-the-shell.test.ts` now says plainly what it measures. It feeds `noise(area * theConstant)`, a byte count the constant itself produced, so it can falsify the expansion and the drop rule but never the constant. It read as if it validated the worst case, and it did not. Red first: put 0.545 back and the sweep fails with 59 viewports, each naming its scale and reporting `null` — the real shell dropping the frame rather than posting it over the cap. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): turn Zod's JIT probe off for the page, before any module The page filed a CSP `script-src` violation on every load: Zod decides whether it may compile by constructing `new Function('')` and reading the throw as "no JIT here", the shell's `script-src 'self'` is exactly that throw, and the browser reports it before Zod catches it. Zod's own source gates the probe on `jitless` for this case. `z.config({ jitless: true })` at the entry does not work, and the reviewer's suggestion of putting it there was measured losing the race. `$ZodObject` reads `allowsEval` when a schema is constructed, not when one is parsed, so the first module-scope `z.object(...)` in the bundle fires the probe — and esbuild evaluates the chunk holding zod and its callers before the chunk holding any module of ours that imports zod. A Function-constructor trap in the page put the call under `new ZodObject` ahead of the entry's first statement. `globalConfig` is `globalThis.__zod_globalConfig`, which zod adopts with `??=` rather than replacing, so the banner can set the flag before any module runs. That is where it now lives, beside the `process` shim and under the same `MOBILE_WEB_APP_SHIMS` contract, which asserts it is applied. Nothing is lost: the compiled path was never reachable in a page under this policy. The render check's `newCsp()` filter is gone. It dropped violations by `blockedURI === 'eval'`, which would have hidden a real one, and every case now asserts zero. The first case walks load and first paint, which is where the second of the two reports fired. The dead `violations` array is deleted. Red first: blank the banner constant and four of the six cases fail, each naming a `blockedUri: 'eval'` the filter used to swallow. Finding, not fixed here and reported instead: the page bundles two copies of zod, mobile's 4.4.3 and the repo root's 4.5.4, because `src/shared/zod-salvage.ts` resolves upward. That is 808 KB of duplicate source. Aliasing `zod` to one copy in the builder fixes it and was measured working, but it changes which zod shared code runs in the shipped page, which is a call to make on its own rather than inside a CSP fix. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): give the shell double the whole canCarry rule and the acks The double reproduced one arm of `BridgeHostSubscriptions.canCarry`, the message cap, and silently carried anything the other two would have refused: a window already holding its maximum frames, and a window whose bytes the frame would push past the limit. It also ignored the page's `ack` frames, so its window never reopened — which was invisible only because no case streamed far enough to close it. Both arms are in now, and the `ack` arm consumes the page's acks exactly as the host does. The three caps are read out of `bridge-caps.ts` and `bridge-host-subscriptions.ts` rather than retyped, the same way the harness already reads the protocol version and the CSP, so a double carrying a stale number is not possible. The render check's own `640 * 1024` is gone with them. One case for it: thirty frames of about 200 KB, roughly 6 MB through a 4 MiB window, nothing over the message cap, so a drop can only come from the window. Every frame posts, nothing is dropped, and the page's ack seqs are read back to show the window stayed open because the page acked rather than because the double was generous. The file docstring said the double answers no RPC. It serves a screencast stream now, so it says that instead, and says what it still is not: it decides no domain behaviour. The dead `violations` array is gone, folded with the CSP commit. Red first: make the `ack` arm inert, as it was before this commit, and the case fails with `Set{'posted','dropped'}` against `Set{'posted'}`. A first attempt at that mutation left the byte subtraction in place and stayed green, which is the mutation being wrong rather than the case. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * ci(mobile): run the whole mobile-web-app family, not a list that goes stale The `mobile_web_app` job hand-listed ten files. The render check this chain added was not among them, so it would have skipped in CI — and it was not the first: three landed censuses were already unlisted, and their closure blocks only run with `ORCA_MOBILE_WEB_APP_DEPS_REQUIRED=1`, so they are green in the sharded `test` job whether or not they ever ran here. Nobody could see it. The list is now two vitest filename filters, `config/scripts/mobile-web- app-` and the one builder test outside that prefix. Quoted, because vitest matches a positional as a substring against the discovered files rather than expanding a glob: `mobile-web-app-*.test.mjs` finds nothing, and it fails by reporting no test files rather than by running fewer. Both forms were tried before this one was written. It runs 18 files and 205 cases, against 10 files before. With mobile dependencies absent, 111 of those 205 skip, which is the measure of what only this job runs. Per file, cases CI has never run: browser-pane-render 7 of 7 (this chain) source-control-external-links 9 of 9 source-control-keyboard 6 of 6 source-control-text-inputs 6 of 19 (C4.2) frame-budget-sweep 4 of 4 (this chain) route-manifest 2 of 17 Two more files the filter adds run fully in the sharded job already and change nothing here: `browser-pane-text-inputs` (C6.4 — it censuses a hand-written closure and never bundles, so unlike the report it was not skipping) and `external-link-seam`. The sweep is renamed into the family for the same reason. As `mobile-browser-frame-budget-sweep.test.ts` it matched neither the job's filter nor `pr-code-change-scope.mjs`'s `config/scripts/mobile-web-app-` prefix, so a change to it alone would not have run the job that runs it. It is also gated on the dependency check now: it needs no react-native-web, but it launches Chromium, and that flag is what tells the job with a browser from the one without. Unguarded it would have failed the sharded `test` job outright. That filter is a prefix match. `config/scripts/mobile-web-app-` and `mobile/src/` both fire this job, and `.github/workflows/pr.yml` is in GLOBAL_FORCE_PREFIXES, so this commit runs everything. Also: `postedFrame` in the ruling-2 pin and in the sweep both reached the binary lane through `?.`, so a subscribe that opened no stream read as zero posts — indistinguishable from a dropped frame, which is the verdict both files are about. They throw now. The render check's restated `640 * 1024` went with the window caps in c73b405f81; the cap is read from `bridge-caps.ts`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * ci(mobile): record what the mobile-web-app job costs to run The filter that replaced the hand list runs 18 files where the list ran 10, so the step's cost is now a function of what anyone names into the family rather than of what a reviewer remembered to add. Measured on this machine: 25-30s wall for the whole step, of which the frame-budget sweep is 2.5s. The sweep is the one part whose cost is a choice. It encodes 111 noise JPEGs in Chromium, one per viewport the budget fits, so adding rows to that set is a decision about this job's runtime and the comment says so where someone would make it. Found, not fixed, and reported for its own PR rather than folded here: the page bundles two copies of zod, mobile's 4.4.3 and the repo root's 4.5.4, reached through `src/shared/zod-salvage.ts`, which resolves upward while `mobile/src/` resolves to mobile's. That is 808 KB of duplicate source and two module instances in the shipped page. Aliasing `zod` in `mobileWebAppBuildOptions` fixes it and was measured working during this chain; it is reverted and stays reverted, because it changes which zod shared code runs in the page and that is not a call to make inside a CI commit. The CSP fix in71254ab3a9does not depend on it: `globalConfig` lives on `globalThis`, so the banner covers both copies. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): load the frame-budget sweep's mobile modules after the dependency guard vite transforms every file under mobile/ against mobile/tsconfig.json, which extends expo/tsconfig.base.json; the sharded test job installs no mobile dependencies, so the sweep's static imports failed the file at load before describe.skip ran. Type-only imports stay static; the values load in beforeAll behind mobileWebAppDependenciesPresent(). Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): certify the frame budget at the quality the pane ships Round 2: the sweep and the render check encoded fixtures at a retyped 0.72; both now read BROWSER_FRAME_QUALITY (the sweep from the module, the render check through the harness reader), so a quality change fails the certification instead of leaving it green. Every render case now asserts zero CSP violations; the sweep pins the 32 viewports left at scale 1; three references to a renamed file and a file that never existed are corrected; a shim count comment is made count-agnostic. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): assert the frame-budget sweep against the constant, not the measured maximum The margin above the measured 0.55351 is what an encoder drift is allowed to spend; pinning the measurement made a drift inside the margin fail a budget that still held. The number stays in the docstring as the sweep's record. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): move the sweep's measured-maximum note beside the assertion it explains 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 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 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/)
|
|
})
|
|
})
|
|
})
|