Files
orca/config/scripts/mobile-web-app-session-render.test.mjs
T
Jinwoo-H 5fe8cfe1b3 fix(mobile): report the diff-notes rejection instead of catching it (OTA phase C, C7.7)
The `.catch` the previous commit added to `loadDiffComments` moved a golden, which is a finding
rather than something to record over: `matrix-session.diff-notes-worktree.show-1` certifies the
unhandled rejection as an effect of its loaded checkpoint, so the corpus says the app raises it
today and a fix is a re-record and a review event.

Reverted to `void loadDiffComments()`, with the defect written where a reader of that effect will
find it. `family-recordings.test.ts > session.diff-notes: reply partitions at worktree.show#1` is
green again; it was the one failure in an otherwise clean 8,699-test run.

The render check keeps the observation rather than losing it. Its error assertion is now the exact
list `['RenderCheckShellDouble: the render check answers no RPC']` instead of `[]`, so a second
error reds it and so does this one going away — which makes the file the place the fix is noticed
when someone lands it with the re-record.

The defect, for that PR: the loader returns on a *refused* `worktree.show` and nothing catches a
*rejected* one, so a host that will not answer raises an unhandled rejection on every session
mount. It is not a page fault — the shell's `fault` notify comes from the React boundary and
nothing reaches it — so the generation is not dropped and the screen works; the cost is a
document-level error on every mount.

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

327 lines
16 KiB
JavaScript

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, both invisible natively and both a console line rather than a crash.
* One is fixed in the commit beside it and one is reported rather than fixed:
*
* - **Fixed.** `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 never armed anyway.
* - **Reported.** `use-mobile-session-diff-comments.ts` runs `void loadDiffComments()` in an effect
* with no catch. The loader returns on a *refused* `worktree.show` and nothing catches a
* *rejected* one, so a host that will not answer raises an unhandled rejection on every session
* mount. `.catch` is the fix and it is one line, but the corpus certifies the rejection —
* `matrix-session.diff-notes-worktree.show-1` lists it as an effect of the loaded checkpoint — so
* fixing it is a golden re-record and a review event rather than something this lane lands.
*
* So the error assertion below is an exact list rather than `toEqual([])` or a filter: that one
* rejection and nothing else. A second error reds it, and so does the rejection going away, which
* is what makes this file the place the fix is noticed when it lands.
*
* **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'
/**
* The one error this page is expected to produce, named in full.
*
* `use-mobile-session-diff-comments.ts`'s uncaught `loadDiffComments()` against a double that
* answers no RPC. The category is the double's own, so this string is stable for this file and
* says which refusal reached the document rather than only that something did.
*/
const KNOWN_UNCAUGHT = 'RenderCheckShellDouble: the render check answers no RPC'
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)
// Exact, because this closure's defects are exactly console lines. The unguarded
// `BackHandler` put two here and is fixed; the uncaught diff-notes rejection is the one
// entry left and is a golden re-record away from going too.
expect(opened.errors).toEqual([KNOWN_UNCAUGHT])
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([])
// And nothing else beyond the one rejection above, so this case reads the whole account.
expect(opened.errors).toEqual([KNOWN_UNCAUGHT])
// 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`.
*
* **That the one uncaught rejection is harmless.** It is not reported as a page fault — the shell's
* `fault` notify is raised by the React boundary, and `__orcaRenderCheckFaults` is empty here — so
* the generation is not dropped and the screen keeps working. What it costs is a document-level
* error on every mount, which is a line in a crash report and a red herring in the device proof.
*/