Files
orca/config/scripts/mobile-web-app-handoff-grants-render.test.mjs
Jinwoo Hong 5e5d731870 test(config): assert the target route's chunk was never fetched, not that no chunk arrived after the tap (#21922)
* test(config): name the chunk the handoff check says is absent

The three handed-off cases snapshotted the loaded scripts the moment the
page returned and then asserted nothing new arrived after the click. The
opener's own route chunk can still be in flight at that moment, so under
full-suite load it lands afterwards, counts as new, and reds a case whose
rule held: the notify was posted and the document had not moved.

They assert what the comment always claimed instead -- the target route's
own chunk was never fetched -- resolved from the build's route-to-chunk
map, since the bundler hashes chunk names and there is nothing in a URL
to recognise a route by. Being about one named chunk over the whole run
rather than a delta, it cannot be raced by an unrelated arrival.

An absence needs a presence precondition, so `beforeAll` fails if that
route has no chunk; without it a typo would pass all three.

Red-first control: with the coverage test in `route-handoff.web.ts`
forced to keep the hop local, the chunk assertion alone fails and names
the tasks chunk it fetched. Found by the C7.5b lane.

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

* test(config): judge the absent chunk on every status, not only 200

CodeRabbit: the three absence assertions filtered the 200-only list, so a
request for the tasks chunk answered 404 or 500 left no trace in it and
the assertion passed on a fetch that did happen -- the one shape where a
page that tried to render the target reads as a page that never asked.

They read `jsResponses` now, which carries every JavaScript response with
its status, and a request for that chunk fails the case whatever came
back. The 200-only list had no other reader, so it is gone rather than
left as a second collection to pick the wrong one from.

Re-proved on this form, with the coverage test forced to keep the hop
local: the chunk assertion alone reds and now prints the status beside the
path.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-21 03:02:32 -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', '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)
})