/** * The browser pane, mounted in a real page and painted with a real frame. * * Every other C6 check reads one half: the shell suites drive `BridgeHostSubscriptions` with no * page, the page suites drive the hooks with no shell, and the parity pin certifies the input * path from a recording. This is the only place the whole frame path runs — the encoder's base64 * crossing the bridge, the page's decoder rebuilding the frame, the `.web.ts` layer writes * painting it, and the decode-then-flip that native gets for free from `Image.onLoad`. * * C6 ruling 4: no route is added for it. `bundleMobileWebApp` already takes an `appDir`, so the * check builds a one-route tree of its own, mounts the pane in it, and nothing under `mobile/app` * moves or is registered. * * The frames are JPEGs the page encodes from a noise canvas, for the reason the budget uses noise: * it is the image JPEG compresses least, so the over-cap case is over the cap for the reason a * real page would be rather than because the check inflated one. */ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { chromium } from 'playwright-core' import { buildMobileWebAppBundle } from './build-mobile-web-app-bundle.mjs' import { MOBILE_WEB_APP_ROUTE_ROOT } from './mobile-web-app-route-manifest.mjs' import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs' import { createBundleServer, installShellDouble, readBridgeFaultGrant, readBridgeProtocolVersion, readBridgeWindowCaps, readBrowserFrameQuality, readShellCsp } from './mobile-web-app-render-harness.mjs' const mobileDir = fileURLToPath(new URL('../../mobile', import.meta.url)) const HOST_ID = 'render-check-host' const ROUTE = { pathname: '/h' } const SHELL_HOST = { id: HOST_ID, name: 'Render Check Host', endpoint: 'ws://render-check', lastConnected: 1 } const VIEWPORT = { width: 390, height: 844 } /** The grant C6.1 named for the binary lane, and the one the negative case withholds. */ const BINARY_GRANT = 'screencastBinary' const SCREENCAST = 'browser.screencast' /** The frame's source viewport, which is what a tap is mapped back into. */ const SOURCE = { deviceWidth: 390, deviceHeight: 712 } /** * The frame the pane asks this viewport for, read off its own subscribe: `maxWidth` 390 by * `maxHeight` 698 in web view mode. * * Not the phone's mobile-mode frame, which is 780x1424 and, as noise, encodes to 811,168 base64 * characters — 124% of the cap, which is the measurement the area budget exists for. That one is * the over-cap case below rather than the frame that paints. */ const FRAME = { width: 390, height: 698 } /** * The page scale Chromium reports for a page with no ``. * * Measured on Chromium 1217, 2026-09-20 against the C6.6 `dialog.html` fixture: under a mobile * emulation the page lays out at Chromium's 980 px default and is scaled into the device width, so * `deviceWidth` stays the emulated width and `pageScaleFactor` carries the ratio. The browser's * input commands take page CSS pixels, so a tap sent in the frame's device space lands at that * fraction of the aim — 41% on the phone, which is how the proof found it. The case above is the * control a page with a viewport meta produces, where the scale is one and the mapping is exact. */ const NO_VIEWPORT_META_PAGE_SCALE = SOURCE.deviceWidth / 980 /** Noise at the largest layout the clamps admit, measured at 3,761,580 characters: 574% of the cap. */ const OVER_CAP_FRAME = { width: 2400, height: 2160 } /** * The scratch route: the pane and nothing else. * * The imports are relative and of a fixed depth, so this source carries no path from the machine * that generated it. `screencastSupported` is the desktop's answer, which this route stands in for * because the capability probe is the session screen's to make and C7's to prove. */ const ROUTE_SOURCE = ` import { useHostClient } from '../../../src/transport/client-context' import { MobileBrowserPane } from '../../../src/browser/MobileBrowserPane' const TAB = { type: 'browser', id: 'render-check-tab', title: 'Render check', browserWorkspaceId: 'render-check-workspace', browserPageId: 'render-check-page', url: 'https://example.test/', loading: false, canGoBack: false, canGoForward: false, isActive: true } export default function BrowserPaneRenderCheckRoute() { const { client } = useHostClient('${HOST_ID}') return ( {}} /> ) } ` const bundles = mobileWebAppDependenciesPresent() const describePane = bundles ? describe : describe.skip let scratch = null let server = null let origin = null let browser = null let cspHeader = null let bridgeVersion = null let faultGrant = null let windowCaps = null beforeAll(async () => { if (!bundles) { return } cspHeader = await readShellCsp() bridgeVersion = await readBridgeProtocolVersion() faultGrant = await readBridgeFaultGrant() windowCaps = await readBridgeWindowCaps() // Inside mobile/ rather than the system temp dir: the route resolves `react-native` and the // pane's own modules, and esbuild resolves a bare specifier from the importer upward. await mkdir(join(mobileDir, '.tmp'), { recursive: true }) scratch = await mkdtemp(join(mobileDir, '.tmp', 'browser-pane-render-')) const routeDir = join(scratch, MOBILE_WEB_APP_ROUTE_ROOT) await mkdir(routeDir, { recursive: true }) await writeFile(join(routeDir, 'index.tsx'), ROUTE_SOURCE) const { outDir } = await buildMobileWebAppBundle({ appDir: scratch, outDir: join(scratch, 'bundle'), pageRoutes: [{ pathname: ROUTE.pathname, grants: [BINARY_GRANT] }] }) const served = await createBundleServer({ 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 } : {}) }) }, 300_000) afterAll(async () => { await browser?.close() server?.close() if (scratch) { // This run's directory only. `mobile/.tmp` is a shared ignored root and another suite may be // holding one of its own. await rm(scratch, { recursive: true, force: true }) } }) /** One page with the shell double installed, its console and its requests watched. */ async function openPane({ grants }) { const context = await browser.newContext({ viewport: VIEWPORT }) const page = await context.newPage() const consoleErrors = [] const foreignRequests = [] page.on('console', (message) => { if (message.type() === 'error') { consoleErrors.push(message.text()) } }) page.on('pageerror', (error) => consoleErrors.push(error.message)) page.on('request', (request) => { if (!request.url().startsWith(origin) && !request.url().startsWith('data:')) { foreignRequests.push(request.url()) } }) await page.addInitScript(installShellDouble, { version: bridgeVersion, sessionId: 'render-check-session', buildId: 'render-check-build-id', route: ROUTE, host: SHELL_HOST, storage: {}, faultGrant, grants, pageRoutes: [ROUTE.pathname], replies: { 'browser.mouseClick': { ok: true } }, streams: [SCREENCAST], windowCaps }) // The page reports a CSP violation as a document event; the header is the shell's own. await page.addInitScript(() => { globalThis.__orcaRenderCheckCsp = [] document.addEventListener('securitypolicyviolation', (event) => { globalThis.__orcaRenderCheckCsp.push({ directive: event.violatedDirective, blockedUri: event.blockedURI }) }) }) await page.goto(`${origin}${ROUTE.pathname}`, { waitUntil: 'domcontentloaded' }) await page.waitForFunction(() => document.querySelector('#root')?.childElementCount > 0) return { page, context, consoleErrors, foreignRequests, csp: () => page.evaluate(() => globalThis.__orcaRenderCheckCsp) } } /** A JPEG of deterministic noise, encoded in the page, returned as the base64 the bridge carries. */ async function encodeNoiseJpeg(page, { width, height, seed }) { const quality = await readBrowserFrameQuality() return page.evaluate( ({ width, height, seed, quality }) => { const canvas = document.createElement('canvas') canvas.width = width canvas.height = height const context = canvas.getContext('2d') const image = context.createImageData(width, height) let state = seed >>> 0 for (let index = 0; index < image.data.length; index += 4) { state = (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0 image.data[index] = (state >>> 24) & 0xff image.data[index + 1] = (state >>> 16) & 0xff image.data[index + 2] = (state >>> 8) & 0xff image.data[index + 3] = 255 } context.putImageData(image, 0, 0) return canvas.toDataURL('image/jpeg', quality).split(',')[1] }, { width, height, seed, quality } ) } /** Hand the page one frame, and say what the double did with it. */ function emitFrame(page, { b64, frameSeq, width, height, pageScaleFactor = 1 }) { return page.evaluate( ({ b64, frameSeq, width, height, pageScaleFactor, source }) => { const subscription = globalThis.__orcaRenderCheckSubscribes.at(-1) if (!subscription) { return 'no-subscription' } return globalThis.__orcaRenderCheckEmitBinary(subscription.id, { b64, format: 'jpeg', frameSeq, metadata: { offsetTop: 0, pageScaleFactor, deviceWidth: source.deviceWidth, deviceHeight: source.deviceHeight, imageWidth: width, imageHeight: height, scrollOffsetX: 0, scrollOffsetY: 0, timestamp: 1_758_326_400.123456 } }) }, { b64, frameSeq, width, height, pageScaleFactor, source: SOURCE } ) } /** * The frame on screen, read off the DOM the way RN Web paints it. * * Selected by the inline `background-image` rather than by a testID, because that write is the * thing under test: `browser-frame-layer-paint.web.ts` puts the data URI on the element RN Web * gives `` a background on, and a handle added for this check could be on an element the * paint never touches. */ function readPaintedLayers(page) { return page.evaluate(() => { const painted = [...document.querySelectorAll('*')].filter((element) => element.style?.backgroundImage?.startsWith('url("data:image/jpeg') ) return painted.map((element) => { // The layer whose opacity the flip writes is the `` above the `` surface. let layer = element.parentElement while (layer && layer.style.opacity === '') { layer = layer.parentElement } return { uri: element.style.backgroundImage.length, digest: element.style.backgroundImage.slice(-24), opacity: layer?.style.opacity ?? null } }) }) } const waitForPaint = (page, count) => page.waitForFunction( (expected) => [...document.querySelectorAll('*')].filter((element) => element.style?.backgroundImage?.startsWith('url("data:image/jpeg') ).length >= expected, count, { timeout: 15_000 } ) /** Which of the pane's two layers is on screen, by its position among them. */ async function visibleLayerIndex(page) { const layers = await readPaintedLayers(page) return layers.findIndex((layer) => layer.opacity === '1') } /** * Waits for the page's own applied-frame signal: the double buffer's flip. * * `applyFrame` writes the next frame's URI onto the hidden layer as soon as the frame lands and * only flips the opacity once the decode resolves, so "some painted layer carries a new digest" is * true before the frame is on screen. Measured here on 2026-09-20: the write landed at 80.7 ms * after the emit and the flip at 85.7 ms, a 5 ms window in which a wait on the digest returns and * the visible layer is still the previous frame. That is what made this file fail once in CI with * the second frame's digest equal to the first's and no console errors. * * The flip is one opacity write, at `settleBrowserFrameLayer`, and it is the behaviour under test * rather than a proxy for it, so waiting on it can neither return early nor depend on how long a * decode takes. Asserting the exact layer, not merely a change, keeps a pane with nothing visible * from reading as a flip. */ async function waitForLayerFlip(page, staleIndex) { await expect .poll(() => visibleLayerIndex(page), { timeout: 15_000, interval: 25 }) .toBe(1 - staleIndex) } describePane('the browser pane in a page', () => { /** * Zero, which it was not until the Zod jitless flag moved into the bundler banner. * * Zod decided whether it could compile by constructing `new Function('')`, which the shell's * `script-src 'self'` reports even though Zod catches the throw — once on load and again on * first paint. This file filtered those out by `blockedURI === 'eval'` for one round, which * would also have hidden a real one, so the filter is gone and the cause is fixed instead. */ it('files no CSP violation at all, through load and first paint', async () => { const view = await openPane({ grants: [faultGrant, BINARY_GRANT] }) try { await view.page.waitForFunction(() => globalThis.__orcaRenderCheckSubscribes.length > 0) const b64 = await encodeNoiseJpeg(view.page, { ...FRAME, seed: 33 }) await emitFrame(view.page, { b64, frameSeq: 1, ...FRAME }) await waitForPaint(view.page, 1) expect(await view.csp()).toEqual([]) expect(view.consoleErrors).toEqual([]) } finally { await view.context.close() } }, 120_000) it('subscribes over the binary lane and paints the frame it is handed', async () => { const view = await openPane({ grants: [faultGrant, BINARY_GRANT] }) try { await view.page.waitForFunction(() => globalThis.__orcaRenderCheckSubscribes.length > 0) const subscribes = await view.page.evaluate(() => globalThis.__orcaRenderCheckSubscribes) expect(subscribes).toHaveLength(1) expect(subscribes[0]).toMatchObject({ method: SCREENCAST, wantsBinary: true }) const b64 = await encodeNoiseJpeg(view.page, { ...FRAME, seed: 1 }) expect(await emitFrame(view.page, { b64, frameSeq: 1, ...FRAME })).toBe('posted') await waitForPaint(view.page, 1) const layers = await readPaintedLayers(view.page) // Both layers, because a render repaints both from `renderedFrameSource`, and one visible. // This does not prove the decode-then-flip ran: with the probe removed entirely, the first // frame still paints and a layer is still visible, because the visible layer starts at 0 and // never needed to move. The flip is the next case's to prove. expect(layers.length).toBeGreaterThan(0) expect(layers.filter((layer) => layer.opacity === '1')).toHaveLength(1) expect(view.consoleErrors).toEqual([]) expect(await view.csp()).toEqual([]) expect(view.foreignRequests).toEqual([]) } finally { await view.context.close() } }, 120_000) it('flips the double buffer on the second frame', async () => { const view = await openPane({ grants: [faultGrant, BINARY_GRANT] }) try { await view.page.waitForFunction(() => globalThis.__orcaRenderCheckSubscribes.length > 0) const first = await encodeNoiseJpeg(view.page, { ...FRAME, seed: 7 }) await emitFrame(view.page, { b64: first, frameSeq: 1, ...FRAME }) await waitForPaint(view.page, 1) const before = await readPaintedLayers(view.page) const staleIndex = before.findIndex((layer) => layer.opacity === '1') const second = await encodeNoiseJpeg(view.page, { ...FRAME, seed: 99 }) expect(second).not.toBe(first) await emitFrame(view.page, { b64: second, frameSeq: 2, ...FRAME }) await waitForLayerFlip(view.page, staleIndex) const after = await readPaintedLayers(view.page) const visible = after.filter((layer) => layer.opacity === '1') expect(visible).toHaveLength(1) expect(visible[0].digest).not.toBe(before.find((l) => l.opacity === '1')?.digest) expect(view.consoleErrors).toEqual([]) expect(await view.csp()).toEqual([]) } finally { await view.context.close() } }, 120_000) it('drops an over-cap frame, keeps the stream, and paints the next one', async () => { const view = await openPane({ grants: [faultGrant, BINARY_GRANT] }) try { await view.page.waitForFunction(() => globalThis.__orcaRenderCheckSubscribes.length > 0) const small = await encodeNoiseJpeg(view.page, { ...FRAME, seed: 3 }) await emitFrame(view.page, { b64: small, frameSeq: 1, ...FRAME }) await waitForPaint(view.page, 1) const before = await readPaintedLayers(view.page) const staleIndex = before.findIndex((layer) => layer.opacity === '1') // Noise at the largest layout the clamps admit, which §1 measured at 574% of the cap. const huge = await encodeNoiseJpeg(view.page, { ...OVER_CAP_FRAME, seed: 5 }) expect(huge.length).toBeGreaterThan(windowCaps.maxMessageBytes) expect(await emitFrame(view.page, { b64: huge, frameSeq: 2, ...OVER_CAP_FRAME })).toBe( 'dropped' ) // The stream is still open: the next frame arrives on the same subscription and paints. const next = await encodeNoiseJpeg(view.page, { ...FRAME, seed: 11 }) expect(await emitFrame(view.page, { b64: next, frameSeq: 3, ...FRAME })).toBe('posted') await waitForLayerFlip(view.page, staleIndex) expect(await view.page.evaluate(() => globalThis.__orcaRenderCheckDroppedFrames)).toEqual([2]) expect(await view.page.evaluate(() => globalThis.__orcaRenderCheckSubscribes.length)).toBe(1) expect(await view.csp()).toEqual([]) expect(view.consoleErrors).toEqual([]) } finally { await view.context.close() } }, 120_000) it('asks for no binary lane at all when the shell withholds the grant', async () => { const view = await openPane({ grants: [faultGrant] }) try { await view.page.waitForFunction(() => document.body.innerText.includes('Update the Orca app to stream browser tabs here.') ) expect(await view.page.evaluate(() => globalThis.__orcaRenderCheckSubscribes)).toEqual([]) expect(view.consoleErrors).toEqual([]) expect(await view.csp()).toEqual([]) expect(view.foreignRequests).toEqual([]) } finally { await view.context.close() } }, 120_000) it('streams past the unacked window because the page acks, and drops nothing', async () => { // The two `canCarry` arms the size check hides. Thirty frames of about 200 KB is roughly 6 MB // through a 4 MiB window, so a page that did not ack, or a shell double that ignored the acks // it sent, starts dropping partway. Nothing here is over the message cap, so a drop can only // come from the window. const view = await openPane({ grants: [faultGrant, BINARY_GRANT] }) try { await view.page.waitForFunction(() => globalThis.__orcaRenderCheckSubscribes.length > 0) const b64 = await encodeNoiseJpeg(view.page, { ...FRAME, seed: 55 }) const cumulative = b64.length * 30 expect(cumulative).toBeGreaterThan(windowCaps.maxUnackedBytes) const outcomes = [] for (let frameSeq = 1; frameSeq <= 30; frameSeq += 1) { outcomes.push(await emitFrame(view.page, { b64, frameSeq, ...FRAME })) } expect(new Set(outcomes)).toEqual(new Set(['posted'])) expect(await view.page.evaluate(() => globalThis.__orcaRenderCheckDroppedFrames)).toEqual([]) // And the acks are real rather than the window merely being generous. const acks = await view.page.evaluate(() => globalThis.__orcaRenderCheckAcks) expect(acks.length).toBeGreaterThan(0) expect(await view.csp()).toEqual([]) expect(view.consoleErrors).toEqual([]) } finally { await view.context.close() } }, 120_000) it('issues one mouseClick with the geometry the native pane would send', async () => { const view = await openPane({ grants: [faultGrant, BINARY_GRANT] }) try { await view.page.waitForFunction(() => globalThis.__orcaRenderCheckSubscribes.length > 0) const b64 = await encodeNoiseJpeg(view.page, { ...FRAME, seed: 21 }) await emitFrame(view.page, { b64, frameSeq: 1, ...FRAME }) await waitForPaint(view.page, 1) // The centre of the rendered frame, which maps back to the centre of the source viewport // whatever the letterboxing did, so the expectation is exact rather than approximate. const box = await view.page.evaluate(() => { const painted = [...document.querySelectorAll('*')].find((element) => element.style?.backgroundImage?.startsWith('url("data:image/jpeg') ) const rect = painted.getBoundingClientRect() return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 } }) await view.page.mouse.click(box.x, box.y) await view.page.waitForFunction(() => globalThis.__orcaRenderCheckRequests.length > 0) const requests = await view.page.evaluate(() => globalThis.__orcaRenderCheckRequests) // One, not four: the double replies, so the pane never takes its move/down/up fallback. With // the refusal the double gives every other method, this is four requests instead. expect(requests).toHaveLength(1) expect(requests[0].method).toBe('browser.mouseClick') expect(requests[0].params).toMatchObject({ worktree: 'id:render-check-worktree', page: 'render-check-page', button: 'left', modifiers: [] }) // The centre of the rendered frame is the centre of the source viewport, to within the one // device pixel the rendered width's own fraction costs: the frame is 382.33 CSS px wide for // 390 source px, so the centre is not on a pixel boundary in either space. Wider than that // is a scale, an axis or a letterbox offset being wrong, which is what this is here for. expect(Math.abs(requests[0].params.x - SOURCE.deviceWidth / 2)).toBeLessThanOrEqual(1) expect(Math.abs(requests[0].params.y - SOURCE.deviceHeight / 2)).toBeLessThanOrEqual(1) expect(await view.csp()).toEqual([]) expect(view.consoleErrors).toEqual([]) } finally { await view.context.close() } }, 120_000) it('maps a tap through the page scale the frame was painted at', async () => { const view = await openPane({ grants: [faultGrant, BINARY_GRANT] }) try { await view.page.waitForFunction(() => globalThis.__orcaRenderCheckSubscribes.length > 0) const b64 = await encodeNoiseJpeg(view.page, { ...FRAME, seed: 22 }) await emitFrame(view.page, { b64, frameSeq: 1, ...FRAME, pageScaleFactor: NO_VIEWPORT_META_PAGE_SCALE }) await waitForPaint(view.page, 1) const box = await view.page.evaluate(() => { const painted = [...document.querySelectorAll('*')].find((element) => element.style?.backgroundImage?.startsWith('url("data:image/jpeg') ) const rect = painted.getBoundingClientRect() return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 } }) await view.page.mouse.click(box.x, box.y) await view.page.waitForFunction(() => globalThis.__orcaRenderCheckRequests.length > 0) const requests = await view.page.evaluate(() => globalThis.__orcaRenderCheckRequests) expect(requests[0].method).toBe('browser.mouseClick') // The centre of the frame is the centre of the layout Chromium scaled into it: 980 CSS px // wide, and 712 device px tall over the same scale. The tolerance is three CSS px because // one device px is 2.5 of them here, and the rendered width's own fraction costs one. expect(Math.abs(requests[0].params.x - 980 / 2)).toBeLessThanOrEqual(3) expect( Math.abs(requests[0].params.y - SOURCE.deviceHeight / 2 / NO_VIEWPORT_META_PAGE_SCALE) ).toBeLessThanOrEqual(3) // Unmapped, this is what the device proof recorded: the frame's own device space, on BODY. expect(requests[0].params.x).not.toBe(Math.round(SOURCE.deviceWidth / 2)) expect(await view.csp()).toEqual([]) expect(view.consoleErrors).toEqual([]) } finally { await view.context.close() } }, 120_000) })