mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
* fix(mobile): encode the host id in the tasks workspace-creation href (OTA phase C, C2.1)
`use-mobile-tasks-workspace-create-actions.tsx` built
`/h/${hostId}/session/...` with the host id interpolated raw — the C1.2 class.
A host id carrying `/`, `#`, `?` or whitespace reaches the wire as an href
`BRIDGE_ROUTE_HREF_PATTERN` refuses, the handoff falls through to the local
router, and expo-router's Unmatched paints over the page.
Deleted rather than patched: `hostNewWorktreeSessionRoute` already builds
this exact href with both segments encoded, and already has the test that
pins it. The screen now calls it.
The census that caught it stays: no module under `src/tasks` may interpolate
into `/h/${...}` without encoding, which is the rule rather than this one
line. Three refactor-parity hashes move with the statement change and are
recorded in that file the way every earlier movement is.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): route the tasks tree's external links through the seam (OTA phase C, C2.1)
Ten of the twelve call sites in the tasks page closure: the nine under
`src/tasks`, swapped by one export in the dependency barrel, and
`MobileMarkdown.tsx`, which imports react-native directly and is edited in
place.
Inside the shell's WebView react-native-web's `openURL` calls
`window.open(url, '_blank')`, which both shells refuse — iOS returns nil from
`createWebViewWith`, Android false from `onCreateWindow` — and resolves
regardless. Every one of these sites would have reported success into a tap
that opened nothing.
The barrel's `Linking` is typed `{ openURL: (url: string) => void }`, so a
`.catch` on it is a compile error rather than a handler for a rejection that
cannot arrive; the seam names its own failures. `MobileMarkdown`'s own
`.catch(() => {})` goes with the swap for the same reason.
No parity hash moved: the barrel and `MobileMarkdown` are outside the
refactor-parity family's source set.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): route the shared screens' external links through the seam, with a census (OTA phase C, C2.1)
The last two of the twelve call sites in the tasks page closure:
`ProtocolBlockScreen.tsx` and the `openExternalUrl` prop wiring at
`host-screen-overlays.tsx`.
Both are shared with native routes and with the already-live `/h/[hostId]`
page, so this changes that page too: its external links go from the measured
`window.open` no-op — which both shells refuse and which resolves anyway — to
a URL handed to the shell. Nothing changes on a phone, where the seam is
`Linking.openURL` unchanged.
The `openExternalUrl` prop chain is retyped `(url: string) => void` with it,
and `SmartWorkspaceSourceField`'s `.catch(() => {})` goes: the seam names its
own failures and never rejects, so that was a handler for a rejection that
cannot arrive.
The census is the rule rather than today's twelve sites: no module in the
tasks page closure may reach react-native's `Linking`, by name or through a
namespace import. It reads the closure from a new builder export —
`metafile.inputs` for `_layout` plus the route, which is one definition of
what a page contains — and checks which module the name comes from, not which
text a call site writes, since the tasks tree still calls `Linking.openURL`
and that `Linking` is now the barrel's seam-backed export. Confirmed to
discriminate: restoring one react-native import turns it red.
A second case pins that the seam is in the closure, so an empty offender list
cannot also mean a page that reaches no link code at all.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): write the tasks clipboard through the shell's verb (OTA phase C, C2.1)
The two `Clipboard.setStringAsync` sites in the tasks page closure move onto
a seam, `src/platform/clipboard.ts` with a `.web.ts` sibling, registered in
the overrides.
A hook rather than a function because the web form needs the page's bridge
client, which is React context. Native is `expo-clipboard` unchanged. Web
calls `native.clipboard.write` through `useNativeVerbs`, because
`expo-clipboard` on the web is `navigator.clipboard` and needs a secure
context: the iOS shell serves the page from a custom scheme and Android from
`https`, so that path would work on one platform and silently not on the
other, with nothing at the call site able to tell.
Both seams reject rather than return false, and both call sites already wrap
the write in a `catch` that puts the message on screen — so a write that did
not land says so instead of showing "Copied". A route that has not declared
`native.clipboard.write` is refused before a frame is sent and lands in that
same `catch`; the route declares it in the entry commit.
Two parity hashes move, the hook list and the statement hash, each by one
entry, and are recorded in that file. `semantics` holds, as do render and
style: no RPC call, method literal or JSX host signature changed.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): hand the tasks Back button to the shell (OTA phase C, C2.1)
The tasks header's `router.back()` reached expo-router through the dependency
barrel, and inside the page that moves nothing: the document holds the single
history entry the entry wrote with `replaceState`. The stack with somewhere
to go is the native one the shell pushed the page onto.
One line in the barrel, as with `Linking`: `useRouteHandoff` is router-shaped,
so every call site is unchanged. On a phone it is expo-router. Inside the page
it keeps a route the page renders and posts `navigate-back` for a Back the
document cannot serve — the C2.2 seam, which until now had no consumer.
No parity hash moved: the barrel is outside the refactor-parity source set,
and no call site changed.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): render mermaid as its own source box on the web (OTA phase C, C2.5)
`MermaidDiagram` is in the tasks page closure, reached through
`MobileMarkdown`, and it renders the diagram inside a sandboxed `WebView`.
`react-native-webview` is a native component with no browser counterpart:
importing it runs a codegen lookup that throws, and the route manifest imports
every route, so one such import takes the whole page down rather than one
diagram.
The web sibling renders the labelled source box the native component already
falls back to on a parse or render error, with that component's own styles, so
the degradation looks like a state the product already has rather than a
second design.
Not a browser renderer, and the reason is not reach: mermaid is a browser
library and the engine bundle is vendored. It is that the native path's safety
comes from the WebView it runs in — `buildHtml` escapes `</script>` and the
U+2028/U+2029 separators because diagram source is untrusted agent and PR
content — and a DOM path has no such sandbox, so it needs its own escaping and
its own proof. That is a change of its own, not a smaller version of this one.
Registered in the overrides, whose gate fails on an unlisted `.web.*` file.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): turn the tasks route on for the page (OTA phase C, C2.1)
The entry: `/h/[hostId]/tasks` joins `MOBILE_WEB_PAGE_ROUTES`, the route file
becomes the shell's flag switch in `index.tsx`'s shape, and a `.web.tsx`
sibling renders the screen directly, registered in the overrides.
The screen moves to `src/tasks/MobileTasksScreen.tsx` first, verbatim — body
byte-identical, imports rewritten to `./`. It has to: under the builder's
`resolveExtensions` a web sibling importing `./tasks` resolves back to
itself, which is why every other shell route's screen already lives in `src`.
The parity family follows the file rather than the path. `TASKS_ROUTE` leaves
`MOBILE_TASKS_SOURCE_FILES` — `SOURCE_PATTERN` already matches
`MobileTasks*.tsx`, so listing it too would double-count — and the execution
reader points at the new file. Measured rather than predicted: all six
refactor-parity cases pass unchanged. No hash moved, including the family
text and declaration list, because the new name sorts where the route path
sat.
The route declares `navigate`, `storage`, `externalLink` and
`native.clipboard.write`, which the grammar fold made expressible and
per-route scoping makes meaningful: it is granted those and not the rest of
what this shell implements.
The browser check covers what only a browser answers — every module in the
closure evaluating under React Native Web, `taskSource` surviving the
handshake into the page's own URL, and the route's chunk arriving on a
client-side navigation. It states plainly what it does not cover: the three
seams are reached from controls that need provider data the double does not
serve, so a case posting those frames directly would prove the transport and
read as a tap it never performed. Both new checks join the `mobile_web_app`
job.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(config): resolve a route closure the way the bundle ships it (OTA phase C, C2.1)
`mobileWebAppRouteClosure` took the route's explicit `.tsx` path as an entry
point, so esbuild used that file directly and `resolveExtensions` never ran.
For a route with a `.web.tsx` sibling that measured the native switch, which
no browser loads: the tasks closure came back carrying
`MobileWebShellScreen`, and with it a `Linking` import the census then
reported as an offender.
Extensionless now, so the closure is the one the page actually contains:
3775 modules, 428 local, with `external-link.web.ts` and `clipboard.web.ts`
in it and the shell screen out.
The route-manifest pins move with the tasks route joining
`MOBILE_WEB_PAGE_ROUTES`, in both the declaration check and the built
manifest.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): cover the clipboard seam, close two page escapes, share the mermaid props (OTA phase C, C2.1)
Four from round 1.
The clipboard seam shipped untested. Both halves have one now: the native
form rejects when `setStringAsync` answers false and resolves when it does
not, and the web form is driven through the real port pair — resolving on a
reply, rejecting when the shell says the pasteboard refused, and rejecting on
an ungranted route without putting a frame on the wire.
The tasks barrel still re-exported `expo-clipboard` with no consumer, which
kept `ExpoClipboard.web.js` — the `navigator.clipboard` path this series
exists to avoid — inside the page closure. Deleted, and asserted as the
module's absence from that closure rather than as a count of importers: a new
import puts the file back whoever writes it.
`ProtocolBlockScreen` reached expo-router's singleton for its way out to the
host list. A singleton is the one shape the handoff cannot intercept — it is
not a hook, so the page's bridge client is never consulted — and `/` is a
route the page does not carry, so inside the shell that replace rendered the
root route in the WebView instead of leaving it. Pre-existing and live via
`/h/[hostId]`; routed through the handoff now. Two suites' `expo-router`
mocks gain the hook the handoff reads.
`MermaidDiagram.web.tsx` redeclared its props; it imports the native
component's type, so drift fails tsc.
No parity hash moved: none of these files is in the refactor-parity source
set.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* style(config): use endsWith for the clipboard module check
The changed-code gate refuses a dollar-anchored regex where `String#endsWith`
says the same thing. No behaviour change.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): close the href census gap, read route params through firstParam (OTA phase C, C2.1)
Five from round 2, two of them real.
The raw-interpolation census inspected only the leading `${...}`, so
`` `/h/${encodeURIComponent(hostId)}/session/${worktreeId}` `` passed it — and
a worktree id carrying `/`, `#`, `?` or whitespace breaks the href exactly as
a host id does. It now refuses any hand-built `/h/...` template with any
interpolation left raw, whichever segment it is. Proved against exactly that
shape in a throwaway before the change, which the old rule admitted.
The tasks switch read `hostId` and `taskSource` as plain strings. expo-router
hands back an array for a repeated query key, so a duplicate `?hostId=` built
`/h/host-a%2Chost-b/tasks`; both go through `firstParam` now, as the
agent-history switch does. `index.tsx` is untouched, per the Phase D list.
Three in the render check's prose: the header claimed the browser proves the
three seams fire from a tap, which the file's own closing note denies; a
module count repeated a number the closure test already pins; and a `replies`
parameter was threaded through without ever being supplied.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
703 lines
30 KiB
JavaScript
703 lines
30 KiB
JavaScript
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join, relative } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
MOBILE_WEB_APP_ROOT_RESET,
|
|
MOBILE_WEB_APP_SHIMS,
|
|
bundleMobileWebApp,
|
|
buildMobileWebAppBundle,
|
|
entryStaticClosure,
|
|
mobileWebAppBuildOptions,
|
|
renameOutputsByContent,
|
|
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 })
|
|
}
|
|
}
|
|
|
|
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([
|
|
{ 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']
|
|
}
|
|
])
|
|
})
|
|
|
|
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([
|
|
{ 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']
|
|
}
|
|
])
|
|
// 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('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/)
|
|
})
|
|
})
|
|
})
|