Files
orca/config/scripts/mobile-web-app-handoff-grants-render.test.mjs
T
Jinwoo Hong e1c8df41e5 fix(mobile): declare externalLink on the two page routes that reach the protocol wall (OTA phase C follow-up) (#22113)
* test(mobile): hold every page route to the externalLink call site it reaches

The grant call-site census carried an exact allowance for the two routes
that reach the shared protocol wall's `openExternalLink` without
declaring `externalLink`. Removing it makes the census enforce the
declaration instead of recording the gap; it now names both routes.

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

* fix(mobile): declare externalLink on the worktree list and agent history

Both routes render the shared layout's `HostProtocolGate`, whose
`ProtocolBlockScreen` opens its Update Orca link through
`openExternalLink`, and neither declared the grant: the tap posted a
notify the shell refuses, with nothing on screen. The census measures one
call site in each closure, `src/components/ProtocolBlockScreen.tsx`.

Repinned by measurement, with the manifest change named: the route-list
pin, and the handed-off hop census, which goes 23 rows to 19. The four
rows that leave are these two routes into the explorer and its preview —
all four now declare the same four grants, so a tapped file stays in the
document instead of costing a native frame and a second bridge session. A
new case asserts that coverage, so the four absences are load-bearing.

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

297 lines
12 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 {
createBundleServer,
installShellDouble,
readBridgeFaultGrant,
readBridgeProtocolVersion,
readShellCsp
} from './mobile-web-app-render-harness.mjs'
/**
* The in-page hop the sidebar makes, in a browser, under the grants the session actually has.
*
* On a wide layout `app/h/_layout.tsx` renders the worktree list beside every `/h` route, and its
* header pushes `/h/<id>/tasks` through `useRouteHandoff`. Keeping that local runs the tasks page
* under the opener's grants, so its copy actions refuse with nothing on screen. The unit tests pin
* the decision; only a browser proves the control exists, is reachable at that viewport, and that
* the document does not move when the hop is handed over.
*/
const HOST_ROUTE = '/h/render-check-host'
const FILES_ROUTE = '/h/render-check-host/files/wt-1'
const HOST_PATTERN = '/h/[hostId]'
const TASKS_PATTERN = '/h/[hostId]/tasks'
/** The bundle key for the target route, so its chunk can be named rather than inferred. */
const TASKS_ROUTE_KEY = './h/[hostId]/tasks.tsx'
const FILES_PATTERN = '/h/[hostId]/files/[worktreeId]'
const SHELL_SESSION_ID = 'render-check-session'
const SHELL_BUILD_ID = 'render-check-build'
const SHELL_HOST = {
id: 'render-check-host',
name: 'Render Check Host',
endpoint: 'ws://render-check',
lastConnected: 1
}
/** The manifest's own pairs, as the shell would send them. */
const PAGE_ROUTE_GRANTS = [
{ pathname: HOST_PATTERN, grants: ['navigate', 'storage', 'externalLink', 'haptics'] },
{ pathname: FILES_PATTERN, grants: ['navigate', 'storage', 'externalLink', 'haptics'] },
{
pathname: TASKS_PATTERN,
grants: ['navigate', 'storage', 'externalLink', 'haptics', 'native.clipboard.write']
}
]
/** Wide enough for `app/h/_layout.tsx` to render the sidebar beside the route. */
const WIDE = { width: 1180, height: 820 }
const NARROW = { width: 390, height: 844 }
const bundles = mobileWebAppDependenciesPresent()
const describeRender = bundles ? describe : describe.skip
let scratch
let server
let browser
let origin
let cspHeader = null
let bridgeVersion = null
let faultGrant = null
/** The target route's own chunk file, resolved from the build rather than matched by name: the
* bundler hashes chunk names, so there is nothing in the URL to recognise a route by. */
let tasksChunk = null
beforeAll(async () => {
if (!bundles) {
return
}
cspHeader = await readShellCsp()
bridgeVersion = await readBridgeProtocolVersion()
faultGrant = await readBridgeFaultGrant()
scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-app-handoff-'))
const built = await buildMobileWebAppBundle({ outDir: join(scratch, 'bundle') })
tasksChunk = built.routeChunks[TASKS_ROUTE_KEY]
// The precondition the absence assertions below need: a chunk that cannot be named cannot be
// observed as missing, and every one of those cases would pass on a typo.
expect(tasksChunk, `no chunk for ${TASKS_ROUTE_KEY}`).toBeTruthy()
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 } : {}) })
}, 180_000)
afterAll(async () => {
await browser?.close()
server?.close()
if (scratch) {
await rm(scratch, { recursive: true, force: true })
}
})
/** Opens the worktree list at a viewport, under a named set of session grants. */
async function openHostRoute({
viewport,
grants,
pageRouteGrants = PAGE_ROUTE_GRANTS,
route = HOST_ROUTE,
awaitText = SHELL_HOST.name
}) {
const page = await browser.newPage({ viewport })
await page.addInitScript(installShellDouble, {
version: bridgeVersion,
sessionId: SHELL_SESSION_ID,
buildId: SHELL_BUILD_ID,
route: { pathname: route },
host: SHELL_HOST,
storage: {},
faultGrant,
grants,
pageRoutes: [HOST_PATTERN, FILES_PATTERN, TASKS_PATTERN],
pageRouteGrants
})
const errors = []
// Every script answer with its status, not only the ones that arrived. A 200-only list cannot
// show a chunk the navigation asked for and did not get, and the absence assertions below would
// read that failed request as a fetch that never happened.
const jsResponses = []
page.on('pageerror', (error) => errors.push(`${error.name}: ${error.message}`))
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 (!path.endsWith('.js')) {
return
}
jsResponses.push({ status: response.status(), path })
})
await page.goto(`${origin}/`, { waitUntil: 'load' })
await page.waitForFunction(() => document.documentElement.dataset.orcaWebEntry === 'mounted', {
timeout: 30_000,
polling: 250
})
await page.waitForFunction((needle) => document.body.innerText.includes(needle), awaitText, {
timeout: 30_000,
polling: 250
})
return { page, errors, jsResponses }
}
/** Every `navigate` notify the page posted, in order. */
function navigates(page) {
return page.evaluate(() =>
(globalThis.__orcaRenderCheckNotifies ?? []).filter((frame) => frame.name === 'navigate')
)
}
/**
* The wait for the hop to land, and the page's own account of why it did not.
*
* A navigation that never commits reads as a bare 30 s timeout. The CI failure this file first hit
* was a `TypeError` thrown inside React Navigation that blanked the document, and it was invisible
* because the error assertions run after a wait that never returns.
*/
async function waitForTasksRoute(page, opened, clickedAt) {
try {
await page.waitForFunction(() => location.pathname.endsWith('/tasks'), {
timeout: 30_000,
polling: 250
})
} catch (cause) {
const seen = await page.evaluate(() => ({
pathname: location.pathname,
text: document.body.innerText.slice(0, 300)
}))
throw new Error(
[
`the document never reached /tasks; pathname is ${seen.pathname}`,
`page errors: ${JSON.stringify(opened.errors)}`,
`navigate notifies: ${JSON.stringify(await navigates(page))}`,
`body text: ${JSON.stringify(seen.text)}`,
`js responses since the click: ${JSON.stringify(opened.jsResponses.slice(clickedAt))}`
].join('\n'),
{ cause }
)
}
}
describeRender('the sidebar hop to tasks, under the session it was opened with', () => {
it('hands the hop to the shell when the session cannot cover tasks', async () => {
const opened = await openHostRoute({
viewport: WIDE,
grants: [faultGrant, 'navigate', 'storage', 'haptics']
})
const { page, errors, jsResponses } = opened
// The header's own control, by the name a user reads; it is the sidebar's on a wide layout.
await page.getByLabel('Tasks').first().click()
await page.waitForTimeout(1_500)
expect(await navigates(page)).toEqual([
{ v: bridgeVersion, type: 'notify', name: 'navigate', href: `${HOST_ROUTE}/tasks` }
])
// Handed over, not taken: the document stayed on the worktree list, and the tasks route's own
// chunk was never requested — which is what says the page did not quietly render it under
// these grants. That chunk by name, not "no chunk arrived after the click": the opener's own
// chunk can still be in flight when the click lands, and counting it as new reds a case whose
// rule held. At any status, because a request answered 404 is still a page that asked.
expect(await page.evaluate(() => location.pathname)).toBe(HOST_ROUTE)
expect(jsResponses.filter(({ path }) => path.endsWith(tasksChunk))).toEqual([])
expect(errors).toEqual([])
await page.close()
}, 60_000)
it('keeps the hop in the document when the session covers tasks', async () => {
// The same tap, the same viewport, one more grant. Without this the case above would pass on a
// page that simply never navigates.
const opened = await openHostRoute({
viewport: WIDE,
grants: [
faultGrant,
'navigate',
'storage',
'externalLink',
'haptics',
'native.clipboard.write'
]
})
const { page, errors } = opened
const clickedAt = opened.jsResponses.length
await page.getByLabel('Tasks').first().click()
await waitForTasksRoute(page, opened, clickedAt)
expect(await navigates(page)).toEqual([])
expect(errors).toEqual([])
await page.close()
}, 60_000)
it('hands the hop over from the narrow header too, whose control C2.10 named', async () => {
// At this viewport `app/h/_layout.tsx` renders no sidebar, so the route's own header is the
// narrow toolbar. C2.10 gave its Tasks control the wide sibling's role and label, so the hop
// can be aimed at here rather than asserted absent, and the rule must hold on this branch as
// well: the control the phone actually presses is this one.
const opened = await openHostRoute({
viewport: NARROW,
grants: [faultGrant, 'navigate', 'storage', 'haptics']
})
const { page, errors, jsResponses } = opened
const tasks = page.getByLabel('Tasks')
// Exactly one: the narrow layout renders one toolbar, so this is the control, not a pick
// among siblings that could have hidden a wide header rendering here.
expect(await tasks.count()).toBe(1)
await tasks.click()
await page.waitForTimeout(1_500)
expect(await navigates(page)).toEqual([
{ v: bridgeVersion, type: 'notify', name: 'navigate', href: `${HOST_ROUTE}/tasks` }
])
expect(await page.evaluate(() => location.pathname)).toBe(HOST_ROUTE)
expect(jsResponses.filter(({ path }) => path.endsWith(tasksChunk))).toEqual([])
expect(errors).toEqual([])
await page.close()
}, 60_000)
it('keeps the old behaviour when the shell sent no pairs at all', async () => {
// An older shell: the page cannot tell covered from uncovered, and must not start handing
// every hop over on the strength of a field nobody sent.
const opened = await openHostRoute({
viewport: WIDE,
grants: [faultGrant, 'navigate', 'storage', 'haptics'],
pageRouteGrants: null
})
const { page, errors } = opened
const clickedAt = opened.jsResponses.length
await page.getByLabel('Tasks').first().click()
await waitForTasksRoute(page, opened, clickedAt)
expect(await navigates(page)).toEqual([])
expect(errors).toEqual([])
await page.close()
}, 60_000)
it('hands the sidebar hop over from a files route too, which is the general shape', async () => {
// The defect is not "the worktree list pushes tasks": on a wide layout the sidebar renders
// beside EVERY `/h` route, so the same hop exists from files, whose session carries
// `externalLink` but not `native.clipboard.write`. One opener proving it would leave the
// general case to inference.
const opened = await openHostRoute({
viewport: WIDE,
grants: [faultGrant, 'navigate', 'storage', 'externalLink', 'haptics'],
route: FILES_ROUTE,
awaitText: SHELL_HOST.name
})
const { page, errors, jsResponses } = opened
await page.getByLabel('Tasks').first().click()
await page.waitForTimeout(1_500)
expect(await navigates(page)).toEqual([
{ v: bridgeVersion, type: 'notify', name: 'navigate', href: `${HOST_ROUTE}/tasks` }
])
expect(await page.evaluate(() => location.pathname)).toBe(FILES_ROUTE)
expect(jsResponses.filter(({ path }) => path.endsWith(tasksChunk))).toEqual([])
expect(errors).toEqual([])
await page.close()
}, 60_000)
})