Files
orca/config/scripts/mobile-web-app-render.test.mjs
T
Jinwoo Hong d043cfbbee feat(mobile): the page mounts on the shell's init, with the client injected and a fault boundary (OTA phase C, C1.1) (#21500)
* feat(mobile): the page mounts on the shell's init, with the client injected (OTA phase C, C1.1)

The Route A entry built no client and mounted the route tree immediately, so the
web provider minted its own: it read the page channel, built `BridgeRpcClient`
and fell back to a placeholder that rejected every call. A tree that mounts
before `init` reads synchronous getters against a client that knows no host, no
state and no build, and the first render it records is the wrong one.

The entry now owns the page's one client. It builds it from the channel at
module scope, mounts nothing until `onReady` fires, and stamps the session and
build ids `getShellSession()` returns on the document beside the mount state, so
a screenshot, the render check and a device console read the same three facts.
`client-context.web.tsx` takes that client by injection and serves it from
`acquire()` for every hostId, because the bridge protocol names no host; the
placeholder and its `BridgeTransportUnavailableError` are gone, along with the
entry that pointed at them in the unvalidated-port inventory.

A document with no channel is not inside the shell, so it says `unbridged` and
stops rather than waiting out a backoff nobody answers. The render check gains a
shell double that answers `ready` with `init`, reads the stamped session back off
the document, and proves the gate is real by opening the same route with no
double and finding an empty `#root`.

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

* feat(mobile): the page can tell the shell it faulted (OTA phase C, C1.1)

A page that throws where it renders has nowhere to report it: the shell
sees a document that loaded and a view that never painted, so it waits
on a blank page forever. This adds the one frame that says so.

`notify { name: 'fault' }` carries the capture an `error` frame already
carries, so both directions share one bound and one reader. It rides a
grant because `notify` is a closed list on both sides: a page served by
a newer desktop into an older shell would have the whole frame refused,
so the page asks `init.grants.native` first and stays quiet on a no.

The shell answers it as `document-load-failed`, which is what happened.
That reason drops the generation and downloads once, so a page broken
by bytes this host has since replaced recovers, and one broken by its
own code stops at the failure screen rather than a blank one.

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

* refactor(mobile): give the bridge's notifications and the host's errors their own modules

The fault report took both files over the 300-line cap, so each gives up
the group that was already separable. The page's one-way members move to
`bridge-client-notifications.ts`, which is also where the two policies
that split them can be stated: the two the native contract declares throw
before a session, and the fault report never throws at all. The host's
three error classes move to `bridge-host-errors.ts`, the mirror of the
page's own `bridge-client-errors.ts`.

No behaviour changes. The commit before this one is over the cap on its
own, which a forward-only history is the reason to say rather than hide.

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

* feat(mobile): one boundary under the page's root, reporting to the shell (OTA phase C, C1.1)

Nothing in `app/h/**` exports an `ErrorBoundary` and `ExpoRoot` provides
no global one, so a throw while a route renders — or a route module that
rejects once the manifest is lazy — unmounts the tree and leaves a blank
document. The shell sees a load that finished and waits on it forever.

The entry now wraps what it mounts on `init` in one boundary that posts
the throw over the bridge. Above `ExpoRoot`, not inside its wrapper: a
route that cannot be resolved throws where the router renders it, and a
boundary below the router never sees that.

It renders nothing and offers nothing to press. The generation is on disk
and was hash-checked before the view loaded it, so the same bytes throw
again and a retry here would only throw twice; recovery belongs to the
shell, which drops the generation on the report.

The render check now grants the fault and collects what the page posts
into the errors every case already asserts empty, because a throw the
boundary caught paints nothing and logs nothing a `pageerror` listener
would hear.

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

* fix(mobile): write the page-fault callback ref after the commit, not during render

React may replay or discard a render, so the write belongs in the commit phase. Layout,
not passive, and declared above the host's effect: a native frame can arrive between a
commit and a passive effect, and the host must already hold this render's callback.

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

* test(mobile): take the boundary test to C0.5's fake-client pair

`createBridgePortPair` is generic over the shell client now; the fake-client form this
test wants is `createFakeBridgePortPair`.

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

* feat(mobile): bound the wait for a page that never says a word (OTA phase C, C1.1)

A route module that throws while the bundle is evaluated takes the entry with it. The
document still commits and the WebView still reports it loaded, but no boundary mounts,
no fault is posted and no frame is ever sent, so the session sat in `ready` behind a
blank view forever.

The native view's finished load starts a clock; the page's first `ready` stops it;
expiry is `document-load-failed`, which deletes the generation and fetches once. Nothing
cancels the timer — a `ready` that lands first makes the expiry a no-op — so the runner
owns a clock and the reducer owns every decision.

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

* test(config): make a route chunk throw, so the render check proves the boundary reports

The check folded page faults into its errors but nothing ever produced one, so a boundary
that stopped reporting would have stayed green. The server now serves one real route
chunk with a throw in front of it: the module still links, so the failure is an
evaluation throw where the router renders, which is exactly what the boundary is for.

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

* fix(mobile): make the host enforce the grants it issued, and hear nothing before ready

`forwardNotify` acted on any frame that parsed, including a `fault` from a page that had
never asked for a session and therefore held no grant. Both refusals now go through one
rule the host shares with the frame it sends, so the list a page is told about and the
list it will be served cannot drift.

Inert while every page is offered `fault`; the ungranted arm is what C1.3 needs the
moment a grant belongs to a route rather than to the protocol.

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

* fix(mobile): start a new flow when the shell view remounts

A remount cleared `pageReady` but left the flow alone, so the wait the retired
document armed still matched. It expired onto the page that replaced it, took a
ready workspace to `document-load-failed`, and deleted the generation on the way.

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

* fix(mobile): say which page notification the bridge refused and why

A refused `notify` fell through to the line about a view outliving its host,
which is a different fault and names neither the notification nor the reason.
The two refusals now get a line each, so a page that was told nothing cannot
bury one reaching past what it was told.

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

* test(mobile): pin the ready deadline to the page's own retry ceiling

The margin was stated in a comment and asserted against itself, so changing
either number left the suite green.

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

* test(mobile): split the bridge frame suite along the modules the merge created

`bridge-rpc-client-frames.test.ts` reached 835 counted lines once C0.8 and C1.1
both added cases to it, over the 800 the lint allows. The split follows the two
modules those changes extracted, so each suite now names the module it covers.

`bridge client page faults` moves to `bridge-client-notifications.test.ts` (the
outbound notify surface) and `bridge client refusals and send failures` to
`bridge-client-inbound-frames.test.ts` (the reader, including the refused-event
release that cancels at the shell). The seven suites that exercise the client as
a whole stay put. The fake port all three drive moves to
`bridge-page-client-test-harness.ts` rather than being copied three times.

No case changed and none was dropped: 48 `it` cases before, 37 + 4 + 7 after,
and all nine `describe` bodies compare byte-identical to their originals.

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

* test(mobile): type the shared init fixture as the member a case reads

The harness exported `INIT` as `BridgeHostMessage`. While it was a module-local
const, control flow narrowed it to the `init` member at each use, so
`INIT.grants` read fine. An imported binding keeps its declared type instead, so
the same read lost `grants` to the union and the tests ratchet went red.

Declared as the init member, which is what every case already treats it as. No
cast: the object literal is checked against the narrower type directly.

`INIT` was the only exported fixture with this shape. `CONNECTION` is `as const`,
`GRANTS` is inferred, and nothing reads a member off an `eventFrame` result.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 20:45:03 -04:00

538 lines
23 KiB
JavaScript

import { createServer } from 'node:http'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { chromium } from 'playwright-core'
import { fileURLToPath } from 'node:url'
import { buildMobileWebAppBundle } from './build-mobile-web-app-bundle.mjs'
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
const projectDir = fileURLToPath(new URL('../..', import.meta.url))
// Why a real browser: the route tree is handed to expo-router's own ExpoRoot through a synthesized
// RequireContext. Nothing short of mounting it proves that object is the shape ExpoRoot reads.
const HOST_ROUTE = '/h/render-check-host'
// What the double answers `ready` with. Asserted on the document, so a page that mounted against
// some other session, or against none, fails here rather than on a phone.
const SHELL_SESSION_ID = 'render-check-session'
const SHELL_BUILD_ID = 'render-check-build'
// The sharded `test` job does not install mobile dependencies, so the page cannot be built there.
// The CSP suite below needs none of them and still runs. pr.yml's mobile_web_app job runs both.
const bundles = mobileWebAppDependenciesPresent()
const describeRender = bundles ? describe : describe.skip
let scratch
let server
let browser
let origin
let routeChunks = {}
let cspHeader = null
let bridgeVersion = null
let faultGrant = null
/**
* Chunk paths the server answers with a module that throws on evaluation.
*
* The one way to reproduce the failure the boundary exists for: a route chunk that never arrives
* intact. Building a second bundle around a throwing route would test a synthetic tree; poisoning
* one file of the real bundle keeps everything else exactly what ships.
*/
const poisonedChunks = new Set()
const POISON_MESSAGE = 'render check poisoned this route chunk'
/**
* Both CSP constants are a list of quoted directives with `//` comments between them, and those
* comments quote directive text. Dropping comment lines first is what keeps a comment out of the
* header this test serves.
*/
export function parseCspDirectives(source, startMarker, endMarker) {
const start = source.indexOf(startMarker)
const end = source.indexOf(endMarker)
if (start === -1 || end < start) {
throw new Error(`could not find ${startMarker} .. ${endMarker}`)
}
const body = source
.slice(start, end)
.split('\n')
.filter((line) => !line.trimStart().startsWith('//'))
.join('\n')
const directives = [...body.matchAll(/"([^"]+)"/g)].map((match) => match[1])
if (directives.length < 10) {
throw new Error('could not parse the shell CSP')
}
return directives.join('; ')
}
/**
* The envelope version the page speaks, read from the contract rather than written down twice. A
* bumped `v` would otherwise reach this file as a 30s timeout naming nothing.
*/
async function readBridgeProtocolVersion() {
const source = await readFile(
join(projectDir, 'mobile/src/mobile-web-shell/bridge/bridge-envelope.ts'),
'utf8'
)
const match = /BRIDGE_PROTOCOL_VERSION = (\d+)/.exec(source)
if (!match) {
throw new Error('could not read BRIDGE_PROTOCOL_VERSION')
}
return Number(match[1])
}
/** The grant the shell offers every page, read from the same source for the same reason. */
async function readBridgeFaultGrant() {
const source = await readFile(
join(projectDir, 'mobile/src/mobile-web-shell/bridge/bridge-envelope.ts'),
'utf8'
)
const match = /BRIDGE_FAULT_GRANT = '([a-zA-Z]+)'/.exec(source)
if (!match) {
throw new Error('could not read BRIDGE_FAULT_GRANT')
}
return match[1]
}
/**
* The shell's half of the bridge, as the page's channel sees it.
*
* The entry mounts nothing until `init` lands, so a render check with no shell renders no route at
* all. This answers `ready` and refuses everything else: a real reply would make this file the
* place domain behaviour is decided, and every screen below already has a state for an RPC that
* failed. The one message that matters here is the one that lets the tree mount.
*/
function installShellDouble({ version, sessionId, buildId, faultGrant }) {
// Where the page's own fault reports land. Read back after the render, so a route that threw
// under the boundary names itself instead of timing out as a page that never mounted.
globalThis.__orcaRenderCheckFaults = []
const channel = {
postMessage: (json) => {
const frame = JSON.parse(json)
const answer = (message) => {
// A microtask, not a task: the page posts `ready` while its script is still running, and
// this keeps the answer behind it without moving a timer the page's backoff reads.
queueMicrotask(() => {
channel.onmessage?.({ data: JSON.stringify(message) })
})
}
if (frame.type === 'ready') {
answer({
v: version,
type: 'init',
sessionId,
buildId,
connection: {
state: 'connected',
reconnectAttempt: 0,
lastConnectedAt: 1,
lastInboundAt: 1,
generation: 0
},
grants: {
rpc: { maxPendingRequests: 64, maxSubscriptions: 32 },
native: [faultGrant]
}
})
return
}
if (frame.type === 'notify' && frame.name === faultGrant) {
globalThis.__orcaRenderCheckFaults.push(frame.error.message)
return
}
if (frame.type === 'request' || frame.type === 'subscribe') {
answer({
v: version,
type: 'error',
id: frame.id,
error: {
category: 'RenderCheckShellDouble',
message: 'the render check answers no RPC',
isRpcDeliveryUnknown: false
}
})
}
},
onmessage: null
}
globalThis.orcaBridge = channel
}
/**
* The shipped policy, read from the Kotlin source so this test cannot drift from what the shell
* actually sends. Parsed rather than imported: the constant lives in a JVM module.
*/
async function readShellCsp() {
const source = await readFile(
join(
projectDir,
'mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellCsp.kt'
),
'utf8'
)
return parseCspDirectives(source, 'listOf(', ').joinToString')
}
beforeAll(async () => {
cspHeader = await readShellCsp()
bridgeVersion = await readBridgeProtocolVersion()
faultGrant = await readBridgeFaultGrant()
if (!bundles) {
return
}
scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-app-render-'))
const built = await buildMobileWebAppBundle({ outDir: join(scratch, 'bundle') })
const { outDir } = built
routeChunks = built.routeChunks
server = createServer((request, response) => {
const path = new URL(request.url, 'http://localhost').pathname
// A browser asks for this on its own and the shell's WebView never does. The bundle carries
// no icon, so a 404 would put a console error in every check that runs against a full Chrome
// -- which is what CI resolves -- and none against the bundled headless shell.
if (path === '/favicon.ico') {
response.writeHead(204)
response.end()
return
}
// A route path serves the entrypoint and the page routes client-side. A path naming a file
// has to come out of the bundle or 404, the same as the shell's manifest map: answering it
// with the document instead would hide a publicPath the script cannot fetch from.
const namesAFile = path.slice(path.lastIndexOf('/')).includes('.')
const file = namesAFile ? path.slice(1) : 'index.html'
readFile(join(outDir, file)).then(
(real) => {
// The real bytes with a throw in front: the module still links, so the importer resolves
// every export it asked for and then evaluation throws. A body replaced outright fails at
// link instead, which is a different failure from the one the boundary is here for.
const bytes = poisonedChunks.has(path)
? `throw new Error(${JSON.stringify(POISON_MESSAGE)});\n${real.toString('utf8')}`
: real
const headers = {
'content-type': file.endsWith('.js') ? 'text/javascript' : 'text/html'
}
// The document carries the shell's real policy, so a directive the page violates fails
// here rather than on a phone. Assets carry none, exactly as the native handler does.
if (file === 'index.html' && cspHeader) {
headers['content-security-policy'] = cspHeader
}
response.writeHead(200, headers)
response.end(bytes)
},
() => {
response.writeHead(404)
response.end()
}
)
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
origin = `http://127.0.0.1:${String(server.address().port)}`
// CI runs this against the runner's Google Chrome rather than paying for a browser download,
// the same reason and the same override shape as the orcad browser-provider job.
const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER
browser = await chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}) })
}, 180_000)
afterAll(async () => {
await browser?.close()
server?.close()
if (scratch) {
await rm(scratch, { recursive: true, force: true })
}
})
// expo-router's Unmatched screen mounts cleanly and paints text, so "no errors, some html" stays
// green with every host route unreachable. Each route below names content only it can produce.
const UNMATCHED = 'Unmatched Route'
/**
* A page with every signal the checks below read: uncaught errors, console errors, and the script
* paths the browser actually fetched. The last one is how a client-side navigation proves it
* pulled the next route's chunk rather than painting out of what the entry already had.
*/
async function openPage({ shell = true } = {}) {
const page = await browser.newPage({ viewport: { width: 390, height: 844 } })
if (shell) {
// At document start, where the native shell installs the real channel: the entry reads it
// while its own script runs, so a channel added after `load` would already be too late.
await page.addInitScript(installShellDouble, {
version: bridgeVersion,
sessionId: SHELL_SESSION_ID,
buildId: SHELL_BUILD_ID,
faultGrant
})
}
const errors = []
const scripts = []
let reportUncaught = () => {}
// An uncaught error from the entry means nothing will ever mount. Racing it against the wait
// reports that error in a second instead of a 30s timeout that names nothing -- which is what a
// native-only route module, throwing at import before React runs, looks like from here.
// Resolved rather than rejected: this one settles during goto, before anything awaits it.
const uncaught = new Promise((resolve) => {
reportUncaught = resolve
})
page.on('pageerror', (error) => {
errors.push(`${error.name}: ${error.message}`)
reportUncaught(error)
})
page.on('console', (message) => {
if (message.type() === 'error') {
errors.push(`console.error: ${message.text()}`)
}
})
page.on('response', (response) => {
const path = new URL(response.url()).pathname
if (response.status() === 200 && path.endsWith('.js')) {
scripts.push(path)
}
})
return { page, errors, scripts, uncaught }
}
/**
* Wait for the entry to mount and then for the route's own content, polled rather than read once:
* the route manifest defers every screen behind `import()`, so the entry's `mounted` signal lands
* while the route's chunk is still being fetched and the body is briefly empty. Waiting for the
* string the caller is about to assert is what makes the check about the route and not the timing.
*/
async function waitForRoute({ page, errors, uncaught }, route, awaitText) {
const named = (cause, what) =>
new Error(`${route} ${what}: ${errors.join(' | ') || 'no page or console error'}`, { cause })
const race = async (wait) =>
Promise.race([
wait.then(
() => null,
(error) => error
),
uncaught
])
// The entry's own signal, not "#root has children": an error boundary or a half-painted tree
// also fills #root, and this only lands once expo-router's tree below the wrapper has committed.
// Polled on a timer rather than Playwright's default animation frames, which a page that never
// paints never delivers.
const cause = await race(
page.waitForFunction(() => document.documentElement.dataset.orcaWebEntry === 'mounted', {
timeout: 30_000,
polling: 250
})
)
if (cause) {
const state = await page.evaluate(
() => document.documentElement.dataset.orcaWebEntry ?? 'absent'
)
throw named(cause, `never mounted (entry ${state})`)
}
const paintCause = await race(
page.waitForFunction((needle) => document.body.innerText.includes(needle), awaitText, {
timeout: 30_000,
polling: 250
})
)
if (paintCause) {
throw named(paintCause, `mounted but never painted ${JSON.stringify(awaitText)}`)
}
// Folded into the errors the caller already asserts empty: a throw the boundary caught paints
// nothing and logs nothing a `pageerror` listener hears, so this is the only place it shows up.
for (const fault of await page.evaluate(() => globalThis.__orcaRenderCheckFaults ?? [])) {
errors.push(`page fault: ${fault}`)
}
}
async function render(route, awaitText) {
const opened = await openPage()
await opened.page.goto(`${origin}${route}`, { waitUntil: 'load' })
await waitForRoute(opened, route, awaitText)
const text = await opened.page.evaluate(() => document.body.innerText)
// What the page believes it is: read off the document rather than off the double, so a tree that
// mounted without a session, or against a session it invented, is not a passing render.
const session = await opened.page.evaluate(() => ({
sessionId: document.documentElement.dataset.orcaWebSessionId ?? null,
buildId: document.documentElement.dataset.orcaWebBuildId ?? null
}))
await opened.page.close()
// A CSP refusal reaches the page as a console error, so the caller's empty-errors assertion is
// also the policy assertion; name it here so a failure says which one broke.
return {
errors: opened.errors,
cspErrors: opened.errors.filter((entry) => entry.includes('Content Security Policy')),
text,
session
}
}
/** The entry's state and what it painted, for a page that is never going to mount. */
async function renderUnbridged(route) {
const { page, errors } = await openPage({ shell: false })
// Read straight after `load` and not polled: the entry decides this synchronously, inside the
// script `load` waits for, so a state that is not settled by now is never going to settle.
await page.goto(`${origin}${route}`, { waitUntil: 'load' })
const entry = await page.evaluate(() => document.documentElement.dataset.orcaWebEntry ?? 'absent')
const rootChildren = await page.evaluate(() => document.getElementById('root').childElementCount)
await page.close()
return { entry, errors, rootChildren }
}
describe('the shell policy this page is tested under', () => {
it('is the same on both platforms, so one render check covers both', async () => {
const swift = await readFile(
join(projectDir, 'mobile/modules/orca-mobile-web-shell/ios/MobileWebShellCsp.swift'),
'utf8'
)
expect(parseCspDirectives(swift, 'static let header = [', '].joined')).toBe(cspHeader)
})
it('reads directives from the source and not from the comments around them', () => {
const source = [
'static let header = [',
" // React Native Web needs \"style-src 'self' 'unsafe-inline'\" and nothing more.",
' "default-src \'none\'",',
' "script-src \'self\'",',
" \"style-src 'self' 'unsafe-inline'\",",
' "img-src \'self\'",',
' "connect-src \'self\'",',
' "worker-src \'none\'",',
' "frame-src \'none\'",',
' "child-src \'none\'",',
' "object-src \'none\'",',
' "base-uri \'none\'",',
' "form-action \'none\'",',
' "frame-ancestors \'none\'"',
'].joined'
].join('\n')
const parsed = parseCspDirectives(source, 'static let header = [', '].joined')
expect(parsed.split('; ')[0]).toBe("default-src 'none'")
expect(parsed.split('; ').filter((entry) => entry.includes('unsafe-inline'))).toEqual([
"style-src 'self' 'unsafe-inline'"
])
})
it('still refuses inline script, which is the directive that matters', () => {
expect(cspHeader).toContain("script-src 'self';")
expect(cspHeader).not.toContain("script-src 'self' 'unsafe-inline'")
})
})
describeRender('the page server this check runs against', () => {
it('404s a file path the bundle does not contain', async () => {
// Without this the document answers every path, and a publicPath the script cannot fetch
// from still renders, because the script is fetched from the one prefix that is served.
expect((await fetch(`${origin}/wrong-prefix/entry.js`)).status).toBe(404)
expect((await fetch(`${origin}/assets/not-a-real-hash.js`)).status).toBe(404)
})
it('answers the icon a browser asks for without an error', async () => {
expect((await fetch(`${origin}/favicon.ico`)).status).toBe(204)
})
it('still serves the document at every route depth', async () => {
for (const route of ['/', HOST_ROUTE, `${HOST_ROUTE}/tasks`]) {
const response = await fetch(`${origin}${route}`)
expect(response.status, route).toBe(200)
expect(await response.text(), route).toContain('<div id="root">')
}
})
})
describeRender('the Route A page in a real browser', () => {
it('mounts the worktree list route, not the unmatched screen', async () => {
const { errors, cspErrors, text, session } = await render(HOST_ROUTE, 'Host not found')
expect(cspErrors).toEqual([])
expect(errors).toEqual([])
// The tree that mounted is the one the shell handed a session to, and it says which.
expect(session).toEqual({ sessionId: SHELL_SESSION_ID, buildId: SHELL_BUILD_ID })
// app/h/[hostId]/index.tsx: expo-secure-store is {} on web, so loadHosts() finds no profile
// and the list paints its not-found state. Only that route's own component produces this
// string, and C1.4's host-store.web.ts is what replaces it with a real row.
expect(text).toContain('Host not found')
expect(text).not.toContain(UNMATCHED)
}, 60_000)
it('routes a nested dynamic segment through the same context', async () => {
const { errors, cspErrors, text, session } = await render(`${HOST_ROUTE}/tasks`, 'Tasks')
expect(cspErrors).toEqual([])
expect(errors).toEqual([])
expect(session.sessionId).toBe(SHELL_SESSION_ID)
// app/h/[hostId]/tasks.tsx paints its header and its GitHub filter row.
expect(text).toContain('Tasks')
expect(text).toContain('Issues')
expect(text).not.toContain(UNMATCHED)
}, 60_000)
it('renders the unmatched route rather than crashing on a path with no module', async () => {
const { errors, cspErrors, text } = await render(`${HOST_ROUTE}/not-a-route`, UNMATCHED)
expect(cspErrors).toEqual([])
expect(errors).toEqual([])
// Asserted positively so the two negatives above are known to discriminate.
expect(text).toContain(UNMATCHED)
}, 60_000)
it('mounts nothing at all when no shell answered, which is what makes the three above real', async () => {
const { entry, errors, rootChildren } = await renderUnbridged(HOST_ROUTE)
// Without this the checks above would pass against a page that ignores `init` entirely.
expect(entry).toBe('unbridged')
expect(rootChildren).toBe(0)
expect(errors).toEqual([])
}, 60_000)
it('tells the shell when a route chunk throws, rather than sitting on a blank page', async () => {
const chunk = routeChunks['./h/[hostId]/index.tsx']
expect(chunk, Object.keys(routeChunks).join(' ')).toBeTruthy()
poisonedChunks.add(`/assets/${chunk}`)
try {
const opened = await openPage()
await opened.page.goto(`${origin}${HOST_ROUTE}`, { waitUntil: 'load' })
const reported = await opened.page
.waitForFunction(
() => {
const faults = globalThis.__orcaRenderCheckFaults ?? []
return faults.length > 0 ? faults : null
},
{ timeout: 30_000, polling: 250 }
)
.then((handle) => handle.jsonValue())
// The message the poisoned module threw, carried across the bridge as the shell sees it. A
// boundary that caught the throw and reported something else would pass an "any fault" check.
expect(reported.join(' | ')).toContain(POISON_MESSAGE)
// And the screen never painted. The router's own shell commits before the deferred chunk
// rejects, so the entry does reach `mounted`; what the boundary takes away is everything
// below it, which is the difference between a reported failure and a blank page nobody hears.
const text = await opened.page.evaluate(() => document.body.innerText)
expect(text).not.toContain('Host not found')
expect(text).not.toContain(UNMATCHED)
await opened.page.close()
} finally {
poisonedChunks.delete(`/assets/${chunk}`)
}
}, 60_000)
it("fetches the next route's chunks on a client-side navigation", async () => {
const opened = await openPage()
const { page, errors, scripts } = opened
await page.goto(`${origin}${HOST_ROUTE}`, { waitUntil: 'load' })
await waitForRoute(opened, HOST_ROUTE, 'Host not found')
const loadedForFirstRoute = [...scripts]
// What the shell will do in C1.2: the document is fetched once and every later route is a
// history entry, so the tasks screen can only arrive as a chunk fetched now.
await page.evaluate((to) => {
history.pushState(null, '', to)
dispatchEvent(new PopStateEvent('popstate'))
}, `${HOST_ROUTE}/tasks`)
await waitForRoute(opened, `${HOST_ROUTE}/tasks`, 'Issues')
expect(new URL(page.url()).pathname).toBe(`${HOST_ROUTE}/tasks`)
const fetchedOnNavigation = scripts.filter((path) => !loadedForFirstRoute.includes(path))
// Not "some script arrived": the chunk the builder put the tasks route in, named by the
// builder rather than guessed from the bytes, which is the only thing that says the route
// came over the wire now and not out of what the first route had already loaded.
const tasksChunk = routeChunks['./h/[hostId]/tasks.tsx']
expect(tasksChunk, Object.keys(routeChunks).join(' ')).toBeTruthy()
expect(fetchedOnNavigation, scripts.join(' ')).toContain(`/assets/${tasksChunk}`)
expect(loadedForFirstRoute).not.toContain(`/assets/${tasksChunk}`)
const text = await page.evaluate(() => document.body.innerText)
expect(text).toContain('Tasks')
expect(text).not.toContain(UNMATCHED)
expect(errors).toEqual([])
await page.close()
}, 60_000)
})