mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 09:27:29 +00:00
* 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
324 lines
12 KiB
JavaScript
324 lines
12 KiB
JavaScript
// 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)
|
||
})
|