Files
orca/config/scripts/mobile-web-app/shell/mobile-web-app-browser-pane-render.test.mjs
Neil 19043a3d76 refactor(mobile): organize pairing, session, RPC and host owners (#39846)
* refactor(mobile): give relay and encryption concrete owners

* refactor(mobile): organize session and review feature owners

* refactor(mobile): organize RPC and host ownership

* test(mobile): point render fixtures at concrete owners

* refactor(mobile): use explicit type-only owner imports
2026-11-08 07:19:05 -08:00

324 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Exercise entire browser-frame bridge: base64 encode/decode, web layer paint and native-equivalent decode-before-flip.
// Bundle a scratch single-route tree without registering production route (C6 ruling 4).
// JPEG noise resists compression, making over-cap frames realistic rather than artificially inflated.
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/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 '../preview/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 }
// Use pane-requested web viewport 390×698; noisy native 780×1424 is 811,168 base64 chars (124% cap), reserved for rejection case.
const FRAME = { width: 390, height: 698 }
// Without viewport meta, Chromium lays out at 980 CSS px and scales to phone width; input expects CSS pixels, not source-device pixels.
// Chromium 1217 measurement (2026-09-20) exposed ~41% targeting; viewport-meta control has scale one.
const NO_VIEWPORT_META_PAGE_SCALE = SOURCE.deviceWidth / 980
// Scratch pane-only route uses fixed-depth relative imports for reproducibility; substitute desktop screencastSupported answer, leaving capability probe to C7.
const ROUTE_SOURCE = `
import { useHostClient } from '../../../src/transport/connection/host-client-hooks'
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 (
<MobileBrowserPane
client={client}
worktreeId="render-check-worktree"
tab={TAB}
screencastSupported={true}
keyboardLift={0}
bottomInset={0}
onToast={() => {}}
/>
)
}
`
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()
// Keep scratch inside mobile/ so esbuild’s upward bare-import resolution finds react-native and pane deps.
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) {
// Remove this run’s scratch only; sibling suites share mobile/.tmp.
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 }
)
}
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 }
)
describePane('the browser pane in a page', () => {
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)
// Frame centre maps exactly to source viewport centre regardless of letterboxing.
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 request proves double’s click reply prevented move/down/up fallback (four requests).
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: []
})
// Allow one source-device pixel: fractional 382.33 CSS px width for 390 source px offsets centre; larger error means scale/axis/letterbox bug.
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')
// Scaled 980 CSS px layout maps from 712-device-px frame height; allow three CSS px since one device pixel is 2.5 CSS px plus fractional width.
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)
})