test(mobile): render-check the session route, and quiet the two things it found (OTA phase C, C7.7)

The render check mounts the registered route in a real browser on the built bundle, under the
header the shells send. It asserts the session screen paints rather than the Unmatched route, that
the Back control reaches the accessibility tree as a real `<button>` with its name, that the
route's own chunk arrives on a client-side navigation, that nothing it paints leaves the origin or
logs a policy violation, and that the three reads the screen makes carry the workspace the route
named — the precondition the rest needs, since a screen that mounted and asked for nothing would
paint the same chrome.

It also asserts, strictly, that the page and console errors are `[]`, which is what found both
fixes here. Measured on this tree before them: two console lines and one uncaught rejection on
every mount of the route, none of them visible natively.

- `use-mobile-session-markdown-actions.ts` registered `BackHandler.addEventListener` with no
  platform guard, and the effect re-registers whenever the dirty-draft list changes. React Native
  Web answers "BackHandler is not supported on web and should not be used." and hands back an inert
  subscription, so the guard was never armed on the page anyway. Gated on `Platform.OS`, as the
  right drawer, the bottom drawer and the file preview already are. There is no hardware back in a
  WebView; the shell owns the phone's, and the page's Back control is where the prompt lives.
- `use-mobile-session-diff-comments.ts` ran `void loadDiffComments()` in an effect with no catch.
  The loader returns on a *refused* `worktree.show` and nothing caught a *rejected* one, so a host
  that will not answer produced `Uncaught (in promise)` on every session mount. Caught at the
  effect rather than inside the loader, whose promise the golden recorder awaits; notes that did
  not arrive leave the ones on screen as they were, which is the module's own policy for a refusal.

**The terminal is not painted here and the file says so at both ends.** A terminal on screen needs
the host protocol handshake, a tab snapshot, a terminal inventory and a `terminal.subscribe`
stream — five hand-written fixtures against five Zod schemas inside a transport double, which is
what the harness's docstring refuses to become. Scripting `status.get` alone was measured here:
the protocol gate reads it and the page paints "Update Orca on your computer" instead of the
screen. What the terminal does under the shipped header is
`mobile-web-app-terminal-render.test.mjs`, on the same component and the same build options.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-21 06:14:09 -04:00
parent 185914430d
commit c41dcdcabf
3 changed files with 322 additions and 2 deletions
@@ -0,0 +1,305 @@
import { mkdtemp, 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 { buildMobileWebAppBundle } from './build-mobile-web-app-bundle.mjs'
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
import { MOBILE_WEB_PAGE_ROUTES } from './mobile-web-page-routes.mjs'
import {
createBundleServer,
installShellDouble,
readBridgeFaultGrant,
readBridgeProtocolVersion,
readShellCsp
} from './mobile-web-app-render-harness.mjs'
/**
* The session route in a real browser, on the bundle the shell would serve, under its own header.
*
* What only a browser answers for this route: that every module in the largest closure of the
* series imports and evaluates under React Native Web, that the route paints the session screen
* rather than the Unmatched route, that its chunk arrives over the wire on a client-side
* navigation, and that nothing it paints leaves the origin or violates the policy. The unit tests
* cannot say any of it, because they mock react-native away — it is Flow source vitest will not
* parse.
*
* Two defects this file found and the commit beside it fixed, both invisible natively and both a
* console line rather than a crash:
*
* - `use-mobile-session-markdown-actions.ts` registered `BackHandler` with no platform guard, and
* the effect re-registers whenever the dirty-draft list changes. React Native Web answers with
* "BackHandler is not supported on web and should not be used." and an inert subscription: two
* lines on the console at mount, and a hardware-back guard that was never armed anyway.
* - `use-mobile-session-diff-comments.ts` ran `void loadDiffComments()` in an effect with no catch.
* The loader tolerates a *refused* `worktree.show` and nothing caught a *rejected* one, so a host
* that will not answer put an unhandled rejection on every session mount.
*
* Both are asserted as the absence of any page or console error below, which is why that assertion
* is the strict `toEqual([])` and not a filter.
*
* **The terminal is not painted here, and this file must not look as though it is.** Putting a
* terminal on screen needs the host protocol handshake, a tab snapshot, a terminal inventory and a
* `terminal.subscribe` stream, which is five hand-written fixtures against five Zod schemas inside
* a transport double — the thing the harness's own docstring says it must not become. What the
* terminal does under the shipped header, opening xterm with zero CSP violations and a byte-exact
* transcript, is `mobile-web-app-terminal-render.test.mjs`, which drives the same component on the
* same build options through a probe route. The rest of what this file does not claim is at the
* bottom.
*/
const HOST_ROUTE = '/h/render-check-host'
const WORKTREE = 'wt-1'
const SESSION_ROUTE = `${HOST_ROUTE}/session/${WORKTREE}`
const SESSION_PATTERN = '/h/[hostId]/session/[worktreeId]'
/** The patterns `init.pageRoutes` names, which is what the page matches a navigation against. */
const PAGE_ROUTE_PATTERNS = ['/h/[hostId]', SESSION_PATTERN]
const SHELL_SESSION_ID = 'session-render-session'
const SHELL_BUILD_ID = 'session-render-build'
const SHELL_HOST = {
id: 'render-check-host',
name: 'Render Check Host',
endpoint: 'ws://render-check',
lastConnected: 1
}
const UNMATCHED = 'Unmatched Route'
const SESSION_CHUNK_KEY = './h/[hostId]/session/[worktreeId].tsx'
/**
* Exactly what the route declares, read off the manifest rather than restated.
*
* The page's own seams are gated on these: a list written by hand here would let the route grow a
* grant this check never exercises, which is the case where a control renders and refuses.
*/
function sessionGrants() {
const declared = MOBILE_WEB_PAGE_ROUTES.find((route) => route.pathname === SESSION_PATTERN)
if (!declared) {
throw new Error(`${SESSION_PATTERN} is not registered`)
}
return declared.grants
}
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
beforeAll(async () => {
if (!bundles) {
return
}
cspHeader = await readShellCsp()
bridgeVersion = await readBridgeProtocolVersion()
faultGrant = await readBridgeFaultGrant()
scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-app-session-'))
const built = await buildMobileWebAppBundle({ outDir: join(scratch, 'bundle') })
routeChunks = built.routeChunks
const served = await createBundleServer({ outDir: built.outDir, cspHeader })
server = served.server
origin = served.origin
const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER
browser = await chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}) })
}, 240_000)
afterAll(async () => {
await browser?.close()
server?.close()
if (scratch) {
await rm(scratch, { recursive: true, force: true })
}
})
/** A page carrying every signal these cases read: uncaught errors, console errors, request paths. */
async function openPage(route) {
const page = await browser.newPage({ viewport: { width: 390, height: 844 } })
// 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,
route: { pathname: route },
host: SHELL_HOST,
storage: {},
faultGrant,
grants: [faultGrant, ...sessionGrants()],
pageRoutes: PAGE_ROUTE_PATTERNS,
replies: {}
})
const errors = []
const scripts = []
const requestedHosts = []
page.on('pageerror', (error) => errors.push(`${error.name}: ${error.message}`))
page.on('console', (message) => {
if (message.type() === 'error') {
errors.push(`console.error: ${message.text()}`)
}
})
// Every request, not only the ones that answered: a CSP refusal fails the request, and a check
// reading responses alone would read a blocked fetch as one that never happened.
page.on('request', (request) => requestedHosts.push(new URL(request.url()).host))
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, requestedHosts }
}
/**
* Wait for the entry to mount and then for the route's own content, polled rather than read once:
* every screen is deferred behind `import()`, so `mounted` lands while the chunk is still arriving.
*/
async function waitForRoute({ page, errors }, route, awaitText) {
const named = (what) =>
new Error(`${route} ${what}: ${errors.join(' | ') || 'no page or console error'}`)
try {
await page.waitForFunction(() => document.documentElement.dataset.orcaWebEntry === 'mounted', {
timeout: 60_000,
polling: 250
})
} catch {
throw named('never mounted')
}
try {
await page.waitForFunction((needle) => document.body.innerText.includes(needle), awaitText, {
timeout: 60_000,
polling: 250
})
} catch {
throw named(`mounted but never painted ${JSON.stringify(awaitText)}`)
}
// A route that threw under the page's own error boundary names itself here rather than timing
// out as a page that never mounted.
for (const fault of await page.evaluate(() => globalThis.__orcaRenderCheckFaults ?? [])) {
errors.push(`page fault: ${fault}`)
}
}
async function openRoute(route, awaitText) {
const opened = await openPage(route)
await opened.page.goto(`${origin}/`, { waitUntil: 'load' })
await waitForRoute(opened, route, awaitText)
return opened
}
/** The session header renders it, so the chrome is on screen before this reads the tree. */
const BACK_LABEL = 'Back to worktrees'
describeRender(
'the session route in a real browser',
() => {
it('mounts the session screen rather than the unmatched route, with nothing on the console', async () => {
const opened = await openRoute(SESSION_ROUTE, 'Terminal')
const text = await opened.page.evaluate(() => document.body.innerText)
// The command dock's own keys, which is the session screen and not a header that happens to
// say the word: no other page route renders an accessory bar.
for (const key of ['Esc', 'Tab', 'Ctrl+C', 'Ctrl+R']) {
expect(text).toContain(key)
}
expect(text).not.toContain(UNMATCHED)
// Strict, because two of this closure's defects were exactly a console line: the unguarded
// `BackHandler` and the uncaught `loadDiffComments` rejection.
expect(opened.errors).toEqual([])
await opened.page.close()
}, 120_000)
it('puts the Back control in the accessibility tree by name', async () => {
// Inside the shell there is no native chrome behind this control, so a bare Pressable is
// absent from the tree: a screen reader has nothing to announce and the device proof has
// nothing to find. The source census
// (`mobile/src/mobile-web-shell/page-served-back-control-a11y.test.ts`) holds the role and
// the wording; this is the half only a browser answers, that the two reach the rendered DOM.
const opened = await openRoute(SESSION_ROUTE, 'Terminal')
const control = await opened.page.evaluate((label) => {
const found = document.querySelector(`[aria-label="${label}"]`)
return found === null ? null : { role: found.getAttribute('role'), tag: found.tagName }
}, BACK_LABEL)
// A real `<button>`, which is what React Native Web emits for `accessibilityRole="button"`
// and what carries the name into the tree. Without the role it renders a `div` with the
// label and no role at all, so this is the assertion the role earns.
expect(control).toEqual({ role: 'button', tag: 'BUTTON' })
await opened.page.close()
}, 120_000)
it("fetches the session route's own chunk on a client-side navigation", async () => {
const opened = await openRoute(HOST_ROUTE, SHELL_HOST.name)
const loadedForFirstRoute = [...opened.scripts]
await opened.page.evaluate((to) => {
history.pushState(null, '', to)
dispatchEvent(new PopStateEvent('popstate'))
}, SESSION_ROUTE)
await waitForRoute(opened, SESSION_ROUTE, 'Terminal')
const chunk = routeChunks[SESSION_CHUNK_KEY]
expect(chunk, Object.keys(routeChunks).join(' ')).toBeTruthy()
// Named by the builder rather than guessed from the bytes: this is what says the route came
// over the wire now and not out of what the first route had already loaded.
expect(opened.scripts.filter((path) => !loadedForFirstRoute.includes(path))).toContain(
`/assets/${chunk}`
)
expect(loadedForFirstRoute).not.toContain(`/assets/${chunk}`)
await opened.page.close()
}, 120_000)
it('paints under the shipped policy without violating it or leaving the origin', async () => {
const opened = await openRoute(SESSION_ROUTE, 'Terminal')
// Chromium reports a refused subresource as a console error naming the directive, so
// anything this closure loaded that the policy blocked lands here.
expect(opened.errors.filter((entry) => entry.includes('Content Security Policy'))).toEqual([])
// Stronger than the line above and independent of it: not one request left the origin, so
// there is nothing for the policy to have refused. A font, a beacon or a provider image
// added anywhere in this closure reds this.
expect(opened.requestedHosts.filter((host) => host !== new URL(origin).host)).toEqual([])
await opened.page.close()
}, 120_000)
it('asks the desktop for the session it was opened on, so the page above is live', async () => {
// The precondition every assertion above needs: a screen that mounted and asked for nothing
// would paint the same chrome. The three reads are the header's live title, the tab snapshot
// and the terminal inventory, each carrying the workspace the route named.
const opened = await openRoute(SESSION_ROUTE, 'Terminal')
const requests = await opened.page.evaluate(() => globalThis.__orcaRenderCheckRequests ?? [])
for (const method of ['worktree.show', 'session.tabs.list', 'terminal.list']) {
expect(
requests.some((request) => request.method === method),
method
).toBe(true)
}
expect(JSON.stringify(requests)).toContain(WORKTREE)
await opened.page.close()
}, 120_000)
},
600_000
)
/**
* What this file does not claim, and where each is answered instead.
*
* **The terminal.** No terminal is attached here, for the reason in the header: the screen paints
* "Loading tabs" against a double that answers no RPC. `mobile-web-app-terminal-render.test.mjs`
* is the browser proof of the terminal itself — xterm opening under `script-src 'self'`, an
* escape-dense byte stream read back through the document's own selection path, zero CSP
* violations — and it drives the same component through a probe route because a session screen
* cannot reach one without the whole host protocol scripted.
*
* **The chat, the pickers and the clipboard.** Each is behind a control that only renders once the
* screen has provider data, which is the same wall C4's render check names. They are pinned by the
* source censuses beside them (`mobile-web-app-session-external-links.test.mjs`,
* `mobile-web-app-session-media-picker.test.mjs`) and by the host and port-pair suites.
*
* **The browser pane.** `mobile-web-app-browser-pane-render.test.mjs` drives it with real frames;
* what this route owes it is the `screencastBinary` grant, which
* `mobile-web-app-screencast-lane-grant.test.mjs` derives from this closure.
*
* **The storage refusals.** A page write needs a control to make it. The refusal's own chain is
* `mobile/src/session/mobile-structured-send-page-storage-refusal.test.ts` end to end over the
* real `page-async-storage`.
*/
@@ -60,7 +60,13 @@ export function useMobileSessionDiffComments(scope: MobileSessionDocumentReaders
)
useEffect(() => {
void loadDiffComments()
// Caught here rather than inside the loader, whose promise the golden recorder awaits. A
// refused read already returns above; a *rejected* one is the transport's, and the loader had
// no catch at all — inside the page that is an unhandled rejection on every mount against a
// host that will not answer `worktree.show`, measured in the render check. Notes that did not
// arrive leave the ones on screen as they were, which is this module's own policy for a
// refusal.
void loadDiffComments().catch(() => undefined)
}, [loadDiffComments])
const addDiffCommentForFile = useCallback(
@@ -1,5 +1,5 @@
import { useEffect, useCallback } from 'react'
import { BackHandler, Keyboard } from 'react-native'
import { BackHandler, Keyboard, Platform } from 'react-native'
import { useClipboardWriter } from '../platform/clipboard'
import { markdownTabSave } from './mobile-session-write-operations'
import { triggerSuccess, triggerError } from '../platform/haptics'
@@ -94,6 +94,15 @@ export function useMobileSessionMarkdownActions(scope: MobileSessionDiffComments
}, [getDirtyMarkdownDrafts, leaveSession])
useEffect(() => {
// Native only, as the drawers and the file preview already are: react-native-web's
// `BackHandler.addEventListener` logs "BackHandler is not supported on web and should not be
// used." and hands back an inert subscription, and this effect re-registers whenever the
// dirty-draft list changes — two lines on the console at mount, measured. There is no hardware
// back to intercept in a WebView; the shell owns the phone's, and the page's own Back control
// is where the unsaved-draft prompt lives.
if (Platform.OS === 'web') {
return
}
const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
requestLeaveSession()
return true