Files
orca/config/scripts/mobile-web-app-render-harness.mjs
T
Jinwoo Hong 11db2b9a7d feat(mobile): the device Back key reaches the page (#22308)
* feat(mobile): the page can claim the device Back key

The shell's page had no way to hear Android Back: every sheet inside it
early-returned on web, so the key popped the whole session route. Adds the
first negotiated shell-to-page frame kind alongside it.

- `back-claim`, page to shell, declared in `init.accepts`: the document is
  holding the key, or has let it go.
- `back`, shell to page, declared in `ready.accepts`: one press, dispatched to
  the newest consumer that takes it. A press nothing takes is handed back as a
  `navigate-back` rather than dropped.

Both are optional fields on frames the other side already reads, so an old
shell never hears a claim and an old page is never sent a press; each pops as
it does today. No protocol bump and no stream opcode.

`bridge-host.ts` was at its line cap, so the notify forwarder moves to
`bridge-host-notify.ts` unchanged.

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

* fix(mobile): Android Back closes the sheet on the page, not the screen

Inside the shell's page every sheet early-returned on web, so one press left
the session route with the sheet still open. The drawer, the right drawer and
the file-preview prompt now claim the key through one seam on both platforms:
`use-back-claim.ts` is the hardware key, `use-back-claim.web.ts` is a claim on
the shell's. All sixteen session sheets render through `MountedBottomDrawer`,
so the one claim there covers every one of them, and a census fails if a sheet
bypasses it.

`route-handoff.web.ts` claims while the page grew a stack of its own, and
hands the press back when it did not.

The shell takes the key off the navigator only while a claim is live: Android
gets a `hardwareBackPress` handler that returns the host's own answer, iOS
loses the stack's swipe-back. The claim is cleared on `document-started`, on a
remount, on a new `ready`, on anything that takes the generation off screen,
on the page's `close` and on dispose.

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

* test(config): a Back press closes a sheet on the real bundle

The unit suites reach both halves of the lane but never the two together on a
document a browser rendered. The render rig can now post a `back` frame, and
the drawer check opens the Filter sheet, reads the claim off the notify list,
sends one press and pins that the sheet closed with no `navigate-back` behind
it. Red without the drawer's claim: the claim never arrives.

Also fixes a fragility the rich-markdown rig caught. `MountedBottomDrawer` is
shared with the native app and mounts under no page provider in a bare tree,
where `usePageBridgeClient` threw; the seam now reads the bridge through
`usePageBridgeClientIfPresent` and claims nothing without one.

Session route closure 4207 -> 4209: `use-back-claim.web.ts` through the route
handoff, `bridge-page-back.ts` through the envelope.

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

* refactor(mobile): mirror the Back seam's latest values from an effect

Three `ref.current = …` writes sat in render, which React replays and
discards. Each moves into a dependency-list-free effect declared ahead of the
registration that reads it, the shape `use-mobile-web-shell-bridge.ts` already
uses for the same reason: the caller rebuilds the value every render, so there
is nothing to depend on, and `useRef` seeds the first mount. The registration
still keys on the claim alone, so a rebuilt handler re-registers nothing.

The web seam's test drops its two type assertions for a named fixture type.

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

* fix(mobile): the page says its Back claim again on every init

The claim was edge-triggered and the shell forgets on purpose: it drops the
claim answering every `ready`, and a host rebuilt under a live page — a client
swap through forceReconnect, which leaves the WebView mounted — starts with
none at all. A document still holding a sheet was then unknown to the shell,
and the next press popped the screen out from under it.

`init` is the shell saying it is here now, so the page answers each one with
the state rather than with a transition. Posted after the session has taken
the frame, so the gate reads that `init`'s own `accepts` and a shell that
never named the claim still hears nothing.

Nothing is said while nothing is held. Every `init` answering a `ready` comes
from a host that dropped the claim first, so it already holds false; the only
other one carries a rewritten route, where a stale true needs a `false` the
page posted to have never left, and a port that refused that frame refuses
this one too.

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

* fix(mobile): a rebuilt host keeps the session's Back claim

A host is rebuilt when the client under it changes, and the page document does
not move: the WebView stays mounted, the session id holds, and the page is
never told. The rebuilt host started with no claim and no `accepts`, so it
refused every press and the navigator popped the screen out from under an open
sheet. Two clients on the same generation leave the page nothing to refuse, so
nothing made it re-ask and re-assert.

What the page declared and what it is holding are facts about the session, the
way `sessionEstablished` already is. `createBridgeHostBack` takes them as a
seed, `readSessionBack()` hands them on, and the hook holds them stamped with
the session so a record left by one never seeds the next.

`dispose()` no longer reports the claim gone: a host retiring is not a
document ending, and that report was the thing taking the key off a live
sheet. Every reset path is unchanged and still has its own case — the page's
`ready`, its `close`, and the session's own store for `document-started`,
`remounted` and anything that takes the generation off screen.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 16:21:26 -04:00

762 lines
31 KiB
JavaScript

import { createServer } from 'node:http'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
export const projectDir = fileURLToPath(new URL('../..', import.meta.url))
/**
* 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 a 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 shipped policy, read from the Kotlin source so a test cannot drift from what the shell
* actually sends. Parsed rather than imported: the constant lives in a JVM module.
*/
export 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')
}
/**
* The other headers the shell puts on the document, read from the Kotlin source for the same reason
* the policy is. String literals only, so the policy itself -- assigned from a constant -- stays
* `readShellCsp`'s job and is not reported twice.
*
* Throws on an empty result rather than returning one: a rig that served no header would otherwise
* measure the browser's own default and call it the shell's guarantee.
*/
export async function readShellDocumentHeaders() {
const source = await readFile(
join(
projectDir,
'mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellResponseHeaders.kt'
),
'utf8'
)
const start = source.indexOf('if (path == "/")')
const end = source.indexOf('return headers', start)
if (start === -1 || end < start) {
throw new Error('could not find the shell document-header branch')
}
const headers = {}
for (const match of source.slice(start, end).matchAll(/headers\["([^"]+)"\] = "([^"]+)"/g)) {
headers[match[1]] = match[2]
}
if (Object.keys(headers).length === 0) {
throw new Error('could not parse the shell document headers')
}
return headers
}
/**
* The envelope version the page speaks, read from the contract rather than written down twice. A
* bumped `v` would otherwise reach a test as a 30s timeout naming nothing.
*/
export async function readBridgeProtocolVersion() {
// The module that declares it, which is the one both halves of the envelope import: the envelope
// re-exports the name, so a reader keyed on the re-export would answer for whichever file the
// last split left it in.
const source = await readFile(
join(projectDir, 'mobile/src/mobile-web-shell/bridge/bridge-frame-fields.ts'),
'utf8'
)
const match = /BRIDGE_PROTOCOL_VERSION = (\d+)/.exec(source)
if (!match) {
throw new Error('could not read BRIDGE_PROTOCOL_VERSION from bridge-frame-fields.ts')
}
return Number(match[1])
}
/**
* The bridge's window caps, read from the modules that define them.
*
* The shell double below has to price a frame the way `BridgeHostSubscriptions` does, and a double
* carrying its own copy of these numbers is a double that goes on passing after the real host's
* changed. `BRIDGE_MAX_UNACKED_BYTES` is written as a product, so the reader evaluates one.
*/
export async function readBridgeWindowCaps() {
const sources = await Promise.all(
[
'mobile/src/mobile-web-shell/bridge/bridge-caps.ts',
'mobile/src/mobile-web-shell/bridge-host-subscriptions.ts'
].map((path) => readFile(join(projectDir, path), 'utf8'))
)
const source = sources.join('\n')
const read = (name) => {
const match = new RegExp(`${name} = ([0-9*\\s]+)`).exec(source)
if (!match) {
throw new Error(`could not read ${name}`)
}
return match[1]
.split('*')
.map((part) => Number(part.trim()))
.reduce((product, factor) => product * factor, 1)
}
return {
maxMessageBytes: read('BRIDGE_MAX_MESSAGE_BYTES'),
maxUnackedFrames: read('BRIDGE_MAX_UNACKED_FRAMES'),
maxUnackedBytes: read('BRIDGE_MAX_UNACKED_BYTES')
}
}
/**
* The base64 one append of the clipboard-image upload carries, read from the leaf that defines it.
*
* The page's canvas resize is measured against this, so a check carrying its own copy is one that
* goes on passing after the upload path's chunk has moved. Written as a product, so the reader
* evaluates one the way the window caps above do.
*/
export async function readClipboardImageUploadChunkBase64Chars() {
const source = await readFile(
join(projectDir, 'mobile/src/session/mobile-clipboard-image-upload-chunk.ts'),
'utf8'
)
const match = /MOBILE_CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS = ([0-9*\s]+)/.exec(source)
if (!match) {
throw new Error('could not read MOBILE_CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS')
}
return match[1]
.split('*')
.map((part) => Number(part.trim()))
.reduce((product, factor) => product * factor, 1)
}
/**
* The JPEG quality the pane asks Chromium for, read from the module that sends it. A test that
* encoded its fixtures at a retyped quality would certify the budget at a number nothing ships.
*/
export async function readBrowserFrameQuality() {
const source = await readFile(
join(projectDir, 'mobile/src/browser/browser-screencast-request-parameters.ts'),
'utf8'
)
const match = /BROWSER_FRAME_QUALITY = (\d+)/.exec(source)
if (!match) {
throw new Error('could not read BROWSER_FRAME_QUALITY')
}
return Number(match[1]) / 100
}
/**
* The page's client-identity placeholder and the `init.accepts` name that unlocks it, read from
* the module that declares both. A rig carrying its own copy would go on passing after the real
* pair moved, which is the whole reason every other constant here is read rather than retyped.
*/
export async function readBridgePageClientIdentity() {
const source = await readFile(
join(projectDir, 'mobile/src/mobile-web-shell/bridge/bridge-page-client-identity.ts'),
'utf8'
)
const read = (name) => {
const match = new RegExp(`${name} = '([^']+)'`).exec(source)
if (!match) {
throw new Error(`could not read ${name} from bridge-page-client-identity.ts`)
}
return match[1]
}
return {
placeholder: read('BRIDGE_PAGE_CLIENT_ID'),
accept: read('BRIDGE_PAGE_CLIENT_IDENTITY_ACCEPT')
}
}
/** The grant the shell offers every page, read from the same source for the same reason. */
export async function readBridgeFaultGrant() {
const source = await readFile(
join(projectDir, 'mobile/src/mobile-web-shell/bridge/bridge-frame-fields.ts'),
'utf8'
)
const match = /BRIDGE_FAULT_GRANT = '([a-zA-Z]+)'/.exec(source)
if (!match) {
throw new Error('could not read BRIDGE_FAULT_GRANT from bridge-frame-fields.ts')
}
return match[1]
}
/** The name the page posts its first frame under, read where the page and the shell both read it. */
export async function readBridgePagePainted() {
const source = await readFile(
join(projectDir, 'mobile/src/mobile-web-shell/bridge/bridge-page-painted.ts'),
'utf8'
)
const match = /BRIDGE_PAGE_PAINTED = '([a-zA-Z]+)'/.exec(source)
if (!match) {
throw new Error('could not read BRIDGE_PAGE_PAINTED from bridge-page-painted.ts')
}
return match[1]
}
/**
* Every name a Back press can travel under, read where the product reads them: the claim and the
* frame this lane added, and the pop the page asks for when it has nothing to spend a press on.
* The third is what separates "the sheet closed" from "the whole screen went".
*/
export async function readBridgeBackNames() {
const lane = await readFile(
join(projectDir, 'mobile/src/mobile-web-shell/bridge/bridge-page-back.ts'),
'utf8'
)
const fields = await readFile(
join(projectDir, 'mobile/src/mobile-web-shell/bridge/bridge-frame-fields.ts'),
'utf8'
)
const claim = /BRIDGE_BACK_CLAIM_NOTIFY = '([a-z-]+)'/.exec(lane)
const frame = /BRIDGE_BACK_FRAME = '([a-z-]+)'/.exec(lane)
const navigateBack = /BRIDGE_NAVIGATE_BACK_NOTIFY = '([a-z-]+)'/.exec(fields)
if (!claim || !frame || !navigateBack) {
throw new Error('could not read the Back names the page and the shell exchange')
}
return { claim: claim[1], frame: frame[1], navigateBack: navigateBack[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`, answers the methods `replies` names, 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. `grants` and `pageRoutes` are what the shell would
* have negotiated, and every notify the page posts is kept whole in `__orcaRenderCheckNotifies`,
* because a control that handed something to the shell and one that did nothing look the same on
* the document.
*
* It answers RPC the way a refusing host does and serves a screencast stream the way the real
* `BridgeHostSubscriptions` does, including its whole `canCarry` rule and the page's acks. It is
* not the host: it decides no domain behaviour, and every reply a screen sees is one a check
* named.
*
* Serialized as a page init script, so it takes plain data and closes over nothing.
*/
export function installShellDouble({
version,
sessionId,
buildId,
route,
host,
storage,
faultGrant,
grants,
pageRoutes = null,
pageRouteGrants = null,
accepts = null,
backFrame = null,
replies,
streams = [],
windowCaps = null
}) {
// 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 = []
// Every grant-gated notify the page posted, whole and in order. A control that decided to hand
// something to the shell and a control that did nothing look identical on the document; this is
// the only thing that tells them apart.
globalThis.__orcaRenderCheckNotifies = []
// Every request the page issued, whole and in order, so a check can say which verb a gesture
// produced and with what geometry rather than only that something was sent.
globalThis.__orcaRenderCheckRequests = []
// The subscriptions the double accepted, with the `wantsBinary` each one asked for: the negative
// case is "the page did not ask", which no assertion on the frames can see.
globalThis.__orcaRenderCheckSubscribes = []
// Binary events this double refused to post because they exceeded the frame cap, which is the
// shell's drop rule reproduced where the page can watch it survive one.
globalThis.__orcaRenderCheckDroppedFrames = []
// Every ack seq the page posted, in order. Without this a stream that never acked and one that
// acked every frame look the same from the page's side.
globalThis.__orcaRenderCheckAcks = []
const openStreams = new Map()
// One Back press, on demand. The shell decides when the key goes to the page, so a check has no
// other way to make one happen: nothing the document does produces this frame.
globalThis.__orcaRenderCheckSendBack = () => {
if (backFrame === null) {
throw new Error('this shell double was not given the back frame name')
}
channel.onmessage?.({ data: JSON.stringify({ v: version, type: backFrame }) })
}
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 },
// The fault grant alone unless the caller named a set: every check needs that one,
// and a check that names none must not be handed an undefined list.
native: grants ?? [faultGrant]
},
...(pageRoutes === null ? {} : { pageRoutes }),
// Omitted when the caller names none, which is the older-shell case the page falls back
// on: an absent field is not an empty one, and the page reads the difference.
...(pageRouteGrants === null ? {} : { pageRouteGrants }),
// Omitted when a check names none, which is the shell that performs no swap and the
// state every other rig in this directory runs in.
...(accepts === null ? {} : { accepts }),
// Omitted for a shell too old to name one, which is the case the page has a panel for.
...(route === null ? {} : { route }),
...(host === null ? {} : { host }),
storage
})
return
}
if (frame.type === 'notify') {
globalThis.__orcaRenderCheckNotifies.push(frame)
if (frame.name === faultGrant) {
globalThis.__orcaRenderCheckFaults.push(frame.error.message)
}
return
}
// The result the caller named for this method, carried in the envelope a real host uses.
// Anything unnamed still takes the refusal below, so a screen only ever sees data a test
// asked for.
if (frame.type === 'subscribe' && streams.includes(frame.method)) {
globalThis.__orcaRenderCheckSubscribes.push({
id: frame.id,
method: frame.method,
params: frame.params,
wantsBinary: frame.wantsBinary === true
})
// Accepted by saying nothing, exactly as the real host does: a subscription is open until
// an `error` or an `end` closes it, and the first thing the page hears is an event.
openStreams.set(frame.id, { seq: 0, unacked: [], unackedBytes: 0 })
return
}
if (frame.type === 'ack') {
// The page's ack is what reopens the window, so a double that ignored it would drop
// frames the real host carries. Read exactly as `BridgeHostSubscriptions.ack` reads it.
const stream = openStreams.get(frame.id)
if (stream) {
let acked = 0
for (const pending of stream.unacked) {
if (pending.seq > frame.seq) {
break
}
stream.unackedBytes -= pending.bytes
acked += 1
}
stream.unacked.splice(0, acked)
globalThis.__orcaRenderCheckAcks.push(frame.seq)
}
return
}
if (frame.type === 'cancel') {
openStreams.delete(frame.id)
return
}
if (frame.type === 'request') {
globalThis.__orcaRenderCheckRequests.push({ method: frame.method, params: frame.params })
}
if (frame.type === 'request' && replies && Object.hasOwn(replies, frame.method)) {
answer({
v: version,
type: 'reply',
id: frame.id,
payload: { id: frame.id, ok: true, result: replies[frame.method] }
})
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
}
/**
* One screencast frame from the shell, priced the way `BridgeHostSubscriptions` prices it.
*
* All three arms of the host's `canCarry`, not just the size one: a frame over the message cap,
* a window already holding the most frames it may, and a window whose bytes this frame would
* push past the limit. Dropping is the behaviour under test — the event goes nowhere, the
* stream stays open, and the next frame paints — so a double that posted an uncarriable frame
* would prove the page decodes something no shell could have sent.
*
* The window only stays open because the page acks, which the `ack` arm above consumes. That is
* what makes a long stream a real test of both rather than of neither.
*/
globalThis.__orcaRenderCheckEmitBinary = (id, binary) => {
const stream = openStreams.get(id)
if (!stream) {
return 'no-stream'
}
const seq = stream.seq + 1
const json = JSON.stringify({ v: version, type: 'event', id, seq, binary })
const bytes = new TextEncoder().encode(json).length
const carries =
windowCaps === null ||
(bytes <= windowCaps.maxMessageBytes &&
stream.unacked.length < windowCaps.maxUnackedFrames &&
stream.unackedBytes + bytes <= windowCaps.maxUnackedBytes)
if (!carries) {
globalThis.__orcaRenderCheckDroppedFrames.push(binary.frameSeq)
return 'dropped'
}
stream.seq = seq
stream.unacked.push({ seq, bytes })
stream.unackedBytes += bytes
channel.onmessage?.({ data: json })
return 'posted'
}
/**
* One JSON stream event from the shell, on the same ledger the binary emitter uses.
*
* The host serves `session.tabs.subscribe` and `terminal.subscribe` as JSON events — the native
* client decodes the terminal's binary frames into `scrollback`/`data` payloads before the bridge
* ever sees them — so a check that drives a screen off a live stream needs this and not the
* binary arm. No window rule: these payloads are a check's own fixtures and are nowhere near the
* cap, and a drop here would read as the page ignoring an event it was never sent.
*
* It still owes the ledger its bytes. The `ack` arm subtracts what it finds on `unacked`, so a
* frame that took a slot without paying for it drove `unackedBytes` negative on the first ack and
* left the binary emitter's window admitting frames past the cap for the life of the stream.
*/
globalThis.__orcaRenderCheckEmitEvent = (id, payload) => {
const stream = openStreams.get(id)
if (!stream) {
return 'no-stream'
}
const seq = stream.seq + 1
const json = JSON.stringify({ v: version, type: 'event', id, seq, payload })
const bytes = new TextEncoder().encode(json).length
stream.seq = seq
stream.unacked.push({ seq, bytes })
stream.unackedBytes += bytes
channel.onmessage?.({ data: json })
return 'posted'
}
/** The window as the double holds it, so a check can read the ledger both emitters share. */
globalThis.__orcaRenderCheckWindow = (id) => {
const stream = openStreams.get(id)
return stream === undefined
? null
: { frames: stream.unacked.length, unackedBytes: stream.unackedBytes }
}
globalThis.orcaBridge = channel
}
/** How long a check waits for a mount's reads before it reports what the page did send. */
const RECORDED_REQUEST_MS = 30_000
/**
* The double's request log, once every method named is in it.
*
* A route issues its first reads from effects that run after the commit painting its chrome, so a
* snapshot taken where the awaited text lands is a race a loaded machine loses. The bound names
* what never arrived and what did.
*/
export async function waitForRecordedRequests(
page,
methods,
{ boundMs = RECORDED_REQUEST_MS } = {}
) {
const started = Date.now()
for (;;) {
const requests = await page.evaluate(() => globalThis.__orcaRenderCheckRequests ?? [])
const missing = methods.filter((method) => !requests.some((one) => one.method === method))
if (missing.length === 0) {
return requests
}
if (Date.now() - started > boundMs) {
throw new Error(
`[render-harness] the page never asked for ${missing.join(', ')} in ${String(boundMs)}ms; ` +
`it asked for ${JSON.stringify(requests.map((one) => one.method))}`
)
}
await page.waitForTimeout(25)
}
}
/**
* The page server the render checks run against: the built bundle, under the shell's own policy.
*
* `transformChunk` is how a check poisons one route chunk without building a second bundle.
* `cspHeader` may be a function of the request, and `handleRequest` lets a check answer a path of
* its own on this origin.
*/
export async function createBundleServer({
outDir,
cspHeader,
documentHeaders,
transformChunk,
handleRequest
}) {
const requestedPaths = []
const server = createServer((request, response) => {
const path = new URL(request.url, 'http://localhost').pathname
// Every path this origin was asked for, the browser's own fetches included. A favicon request
// is made by the browser process rather than the page, and Playwright's `page.on('request')`
// never reports one, so the server is the only place a check can see it.
requestedPaths.push(path)
// An endpoint of the check's own, answered before anything is looked for on disk: a policy's
// `report-uri` has to name a real server, and naming this one keeps it on the page's origin.
if (handleRequest?.(request, response, path)) {
return
}
// 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. Kept for the
// probe documents the checks compose themselves, which declare no icon; the page's own
// document does declare one, and answering 204 hides nothing from a check that reads the
// request rather than the response (`mobile-web-app-session-render.test.mjs`).
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) => {
const bytes = transformChunk ? transformChunk(path, real) : 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) {
// A function when the policy is per-document: the preview rig appends this document's own
// report endpoint, which carries the arm's nonce.
headers['content-security-policy'] =
typeof cspHeader === 'function' ? cspHeader(request) : cspHeader
}
// Whatever else the shell puts on the document, on the document only, exactly as the native
// handler does.
if (file === 'index.html' && documentHeaders) {
Object.assign(headers, documentHeaders)
}
response.writeHead(200, headers)
response.end(bytes)
},
() => {
response.writeHead(404)
response.end()
}
)
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
return { server, origin: `http://127.0.0.1:${String(server.address().port)}`, requestedPaths }
}
/**
* A handler of the page's own, installed before the bundle so the terminal meets a `window.onerror`
* that belongs to someone else.
*
* Reading `null` three times would pass on a terminal that assigned `null` over a real handler,
* which is the failure this seam exists to prevent. The sentinel is identity-checked in the page
* rather than marshalled out of it — a function does not survive `evaluate` — and it returns
* false so the browser still reports the error normally.
*/
export function installPageErrorSentinel() {
globalThis.__orcaSentinelCalls = []
const sentinel = (message) => {
globalThis.__orcaSentinelCalls.push(String(message))
return false
}
globalThis.__orcaSentinel = sentinel
window.onerror = sentinel
}
/**
* Every animation frame and timer, tagged with the mount that scheduled it.
*
* Installed before the bundle loads, so the document's own scheduling goes through it. Each
* schedule remembers the `#terminal-container` that was on the page at the time; a callback that
* runs once that element has left the document is a frame or timer of the first mount firing
* into the second, which is the whole finding. The element rather than a counter the test bumps,
* because React unmounts on its own schedule and a callback that runs while the first terminal is
* still up is not a leak. Every schedule is kept, not just the ones still owed, so the test can
* say that there was something to leak before it says that nothing did.
*/
export function installSchedulerRecorder() {
globalThis.__orcaScheduler = { watching: false, scheduled: [], leaked: [] }
const state = globalThis.__orcaScheduler
const wrap = (schedule, kind) =>
function (callback, ...rest) {
if (!state.watching || typeof callback !== 'function') {
return schedule(callback, ...rest)
}
// The line that called this, which is the script the work belongs to. Line 0 is the error's
// own header and line 1 is this wrapper.
const caller = ((new Error('scheduled').stack ?? '').split('\n')[2] ?? '').trim()
const container = document.getElementById('terminal-container')
// `fired` is what makes "owed" readable: a callback that has not run is still owed, whether
// it was cancelled or is merely waiting, and cancelling never sets it.
const entry = { kind, caller, owned: container !== null, fired: false }
state.scheduled.push(entry)
return schedule(
(...args) => {
entry.fired = true
if (container !== null && !container.isConnected) {
state.leaked.push(`${kind} from ${caller}`)
}
return callback(...args)
},
...rest
)
}
globalThis.requestAnimationFrame = wrap(
globalThis.requestAnimationFrame.bind(globalThis),
'frame'
)
globalThis.setTimeout = wrap(globalThis.setTimeout.bind(globalThis), 'timer')
globalThis.setInterval = wrap(globalThis.setInterval.bind(globalThis), 'interval')
}
/** Recorded before anything else runs, so a refusal during the page's own boot is counted. */
/**
* Every window and document listener the page holds, by target, type and phase.
*
* Identity, not a tally: `addEventListener` with a listener the target already holds is a no-op in
* the DOM, and `removeEventListener` with one it does not hold is too, so counting calls would
* report leaks a browser does not have. The set is the live listeners, which is what a snapshot
* before and after a mount can be compared on.
*/
export function installListenerRecorder() {
const live = new Map()
globalThis.__orcaListeners = {
snapshot: () =>
Object.fromEntries(
[...live.entries()]
.map(([key, listeners]) => [key, listeners.size])
.filter(([, n]) => n > 0)
)
}
const keyFor = (target, type, options) => {
const where = target === globalThis ? 'window' : target === document ? 'document' : null
if (where === null) {
return null
}
const capture = typeof options === 'object' && options !== null ? !!options.capture : !!options
return `${where} ${type}${capture ? ' capture' : ''}`
}
const add = EventTarget.prototype.addEventListener
const remove = EventTarget.prototype.removeEventListener
EventTarget.prototype.addEventListener = function (type, listener, options) {
const key = keyFor(this, type, options)
if (key !== null && listener) {
if (!live.has(key)) {
live.set(key, new Set())
}
live.get(key).add(listener)
}
return add.call(this, type, listener, options)
}
EventTarget.prototype.removeEventListener = function (type, listener, options) {
const key = keyFor(this, type, options)
if (key !== null && listener) {
live.get(key)?.delete(listener)
}
return remove.call(this, type, listener, options)
}
}
export function installCspViolationRecorder() {
globalThis.__orcaCspViolations = []
document.addEventListener('securitypolicyviolation', (event) => {
globalThis.__orcaCspViolations.push(
`${event.violatedDirective}: ${event.blockedURI || 'inline'} @ ${event.sourceFile ?? '?'}:${String(event.lineNumber ?? 0)}`
)
})
}
/**
* Every computed property of `html` and `body`, as one string each.
*
* The oracle for "the page mount styles only what it owns" is a page of the same application with
* no terminal on it, so the comparison is against another page rather than against a list of
* properties someone chose. A rule that escaped the host would have to move one of these.
*/
export async function readRootComputedStyles(page) {
return await page.evaluate(() => {
const read = (element) => {
const computed = getComputedStyle(element)
const entries = []
for (const property of computed) {
entries.push(`${property}: ${computed.getPropertyValue(property)}`)
}
return entries.join('\n')
}
return { body: read(document.body), html: read(document.documentElement) }
})
}
/**
* What the terminal's injected sheet matches, and how much of it there is.
*
* The rule count is the precondition for the empty list: a sheet that was never planted, or one
* the browser refused, would match nothing for a reason that has nothing to do with scoping.
*/
export async function terminalStyleReach(page) {
return await page.evaluate(() => {
const sheet = [...document.styleSheets].find(
(one) => one.ownerNode?.id === 'orca-terminal-document-style'
)
if (!sheet) {
return { rules: 0, outside: ['the terminal stylesheet is not in the head'] }
}
const host = document.querySelector('.orca-terminal-document-host')
const outside = []
for (const rule of sheet.cssRules) {
for (const element of document.querySelectorAll(rule.selectorText)) {
if (!host || !host.contains(element)) {
outside.push(`${rule.selectorText} matched ${element.tagName}`)
}
}
}
return { rules: sheet.cssRules.length, outside }
})
}