diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 983ace8c2e3..4eee03c5632 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -704,21 +704,23 @@ jobs: # The bundling tests skip themselves where mobile dependencies are absent, which is how they # stay green in the sharded `test` job. This is the job that installs them, so here a missing # install has to fail rather than skip everything the job exists to run. + # Why a prefix and not a file list: the list this replaces had gone stale twice without + # anyone noticing, because a census whose closure block skips without the env flag below is + # green in the sharded `test` job whether or not it ever runs here. The prefix is the same + # one `pr-code-change-scope.mjs` fires this job on, so naming a test into the family is all + # it takes to have it run. Quoted because these are vitest filename filters, matched as + # substrings against the discovered files, and the shell must not touch them. + # + # Cost: 18 files in 25-30s wall, of which the frame-budget sweep is 2.5s. That sweep encodes + # 111 noise JPEGs in Chromium, so it is the one step here whose cost grows with its viewport + # set; adding rows to that set is a decision about this job's runtime. - name: Builder, override census and render checks env: ORCA_MOBILE_WEB_APP_DEPS_REQUIRED: '1' run: | pnpm exec vitest run --config config/vitest.config.ts \ - config/scripts/build-mobile-web-app-bundle.test.mjs \ - config/scripts/mobile-web-app-web-overrides.test.mjs \ - config/scripts/mobile-web-app-render.test.mjs \ - config/scripts/mobile-web-app-drawer-render.test.mjs \ - config/scripts/mobile-web-app-agent-history-render.test.mjs \ - config/scripts/mobile-web-app-tasks-render.test.mjs \ - config/scripts/mobile-web-app-tasks-external-links.test.mjs \ - config/scripts/mobile-web-app-files-external-links.test.mjs \ - config/scripts/mobile-web-app-files-render.test.mjs \ - config/scripts/mobile-web-app-page-closure-families.test.mjs + 'config/scripts/mobile-web-app-' \ + 'config/scripts/build-mobile-web-app-bundle.test.mjs' cross-version-wire: name: cross-version wire compatibility diff --git a/config/scripts/build-mobile-web-app-bundle.mjs b/config/scripts/build-mobile-web-app-bundle.mjs index 4d394190e84..a5232639e88 100644 --- a/config/scripts/build-mobile-web-app-bundle.mjs +++ b/config/scripts/build-mobile-web-app-bundle.mjs @@ -54,6 +54,13 @@ export const MOBILE_WEB_APP_SHIMS = [ name: 'process-banner', appliesTo: (options) => options.banner?.js?.includes('globalThis.process ??=') === true }, + { + // Zod probes for a usable JIT with `new Function('')`, which the shell's CSP reports even + // though Zod catches the throw and runs interpreted. Turned off before any module, because a + // schema constructed at module scope reaches the probe before our own code can run. + name: 'zod-jitless-banner', + appliesTo: (options) => options.banner?.js?.includes('__zod_globalConfig') === true + }, { // lucide-react-native@1.14.0's barrel re-exports LucideProvider from a context.mjs that does // not export it. Metro's loose CJS interop tolerates it; esbuild's strict ESM does not. @@ -111,6 +118,27 @@ const PAGE_ASYNC_STORAGE_MODULE = join( 'page-async-storage.ts' ) +/** + * Zod's compiled path, off before any module runs. + * + * Zod decides whether it may compile by constructing `new Function('')` and reading the throw as + * "no JIT here". Under the shell's `script-src 'self'` that throw is exactly what happens, Zod + * catches it and takes the interpreted path — but the browser files a `securitypolicyviolation` + * report first, and it does so on every page load. Zod's own source gates the probe on `jitless` + * for this case, so nothing here is a workaround. + * + * In the banner rather than a module that calls `z.config`, because a module cannot win the race. + * `$ZodObject` reads `allowsEval` when a schema is *constructed*, not parsed, so the first + * module-scope `z.object(...)` in the bundle fires the probe — and esbuild evaluates the chunk + * holding zod and its callers before the chunk holding any module of ours that imports zod. An + * entry import placed first was measured losing that race; the banner runs before every module. + * + * `globalConfig` is `globalThis.__zod_globalConfig`, which zod adopts with `??=` rather than + * replacing, so setting the flag on it here is what zod itself reads. + */ +const ZOD_JITLESS_BANNER = + 'globalThis.__zod_globalConfig ??= {}; globalThis.__zod_globalConfig.jitless = true;' + const ROUTE_MANIFEST_PLUGIN_NAME = 'orca-route-manifest' const LUCIDE_PLUGIN_NAME = 'orca-lucide-barrel-provider' @@ -210,7 +238,7 @@ export function mobileWebAppBuildOptions(routes) { // script would resolve against the route instead. publicPath: '/assets', banner: { - js: "globalThis.process ??= { env: { NODE_ENV: 'production', EXPO_OS: 'web' }, platform: 'web', version: '', nextTick: (fn) => setTimeout(fn, 0) };" + js: `globalThis.process ??= { env: { NODE_ENV: 'production', EXPO_OS: 'web' }, platform: 'web', version: '', nextTick: (fn) => setTimeout(fn, 0) };${ZOD_JITLESS_BANNER}` }, define: { global: 'globalThis', @@ -341,26 +369,39 @@ const isScriptOutput = (path) => path.endsWith('.js') /** * Every source module one page route reaches, as the builder itself resolves them. * - * One definition of "what a page contains", read from `metafile.inputs` — the modules the route - * pulls in — rather than from `entryStaticClosure`, which walks emitted chunks and answers what a - * browser must download. Both entry points are needed: `app/h/_layout.tsx` wraps every route under - * it, and its imports are part of the page as surely as the route module's. + * Both entry points are needed: `app/h/_layout.tsx` wraps every route under it, and its imports are + * part of the page as surely as the route module's. + */ +export async function mobileWebAppRouteClosure(routeModule) { + return await mobileWebAppModuleClosure(['app/h/_layout', routeModule]) +} + +/** + * The same closure for any entry modules, which a route plus the layout is one case of. * - * `splitting: false` and a per-name output are required for a two-entry build; with the defaults + * One definition of "what a page contains", read from `metafile.inputs` — the modules the entries + * pull in — rather than from `entryStaticClosure`, which walks emitted chunks and answers what a + * browser must download. + * + * A component a route mounts rather than one the router registers — `MobileBrowserPane` is the + * first with a pin of its own — has a closure to certify and no route to name it by. Pass it alone + * to read what it reaches on its own, or beside `app/h/_layout` to read what it adds to a page. + * + * `splitting: false` and a per-name output are required for a multi-entry build; with the defaults * esbuild fails on two outputs claiming `dist/entry.js`. * * Note for anyone comparing this with a parity pin: `c1-page-closure.ts`, and the closures C2.6, * C5.2 and C3.2 generate, derive theirs by the C1.6 method inside the mobile suite. The two are * not the same computation, and a divergence between them is a finding rather than noise. */ -export async function mobileWebAppRouteClosure(routeModule) { +export async function mobileWebAppModuleClosure(entryModules) { const base = mobileWebAppBuildOptions(MOBILE_WEB_PAGE_ROUTES) const result = await esbuild.build({ ...base, // Extensionless, so `resolveExtensions` picks the same file the bundle ships: a route with a // `.web.tsx` sibling resolves to that one, and naming the `.tsx` path explicitly would measure // the native switch no browser ever loads. - entryPoints: ['app/h/_layout', routeModule.replace(/\.tsx?$/, '')], + entryPoints: entryModules.map((entry) => entry.replace(/\.tsx?$/, '')), splitting: false, entryNames: '[name]', plugins: base.plugins.filter((plugin) => plugin.name !== ROUTE_MANIFEST_PLUGIN_NAME), diff --git a/config/scripts/build-mobile-web-app-bundle.test.mjs b/config/scripts/build-mobile-web-app-bundle.test.mjs index fb70640924a..b32044ff5a5 100644 --- a/config/scripts/build-mobile-web-app-bundle.test.mjs +++ b/config/scripts/build-mobile-web-app-bundle.test.mjs @@ -374,8 +374,8 @@ describeBundling('the app bundle', () => { it('fails the named shim, not the whole build, when its option goes missing', async () => { const options = mobileWebAppBuildOptions(await collectMobileWebAppRoutes(appDir)) - // Each shim reads a different option, so removing one leaves the other five true. Without - // that, the list could name a shim the build stopped applying. + // Each shim reads an option of its own (two read `banner.js`), so stripping every option + // leaves none applying. Without that, the list could name a shim the build stopped applying. const stripped = { ...options, alias: {}, diff --git a/config/scripts/mobile-web-app-browser-pane-render.test.mjs b/config/scripts/mobile-web-app-browser-pane-render.test.mjs new file mode 100644 index 00000000000..64e7ab6d0d7 --- /dev/null +++ b/config/scripts/mobile-web-app-browser-pane-render.test.mjs @@ -0,0 +1,513 @@ +/** + * 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 } +/** 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 }) { + return page.evaluate( + ({ b64, frameSeq, width, height, 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: 1, + deviceWidth: source.deviceWidth, + deviceHeight: source.deviceHeight, + imageWidth: width, + imageHeight: height, + scrollOffsetX: 0, + scrollOffsetY: 0, + timestamp: 1_758_326_400.123456 + } + }) + }, + { b64, frameSeq, width, height, 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 } + ) + +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 second = await encodeNoiseJpeg(view.page, { ...FRAME, seed: 99 }) + expect(second).not.toBe(first) + await emitFrame(view.page, { b64: second, frameSeq: 2, ...FRAME }) + await view.page.waitForFunction( + (stale) => + [...document.querySelectorAll('*')].some( + (element) => + element.style?.backgroundImage?.startsWith('url("data:image/jpeg') && + element.style.backgroundImage.slice(-24) !== stale + ), + before[0].digest, + { timeout: 15_000 } + ) + + 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) + + // 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 view.page.waitForFunction( + (stale) => + [...document.querySelectorAll('*')].some( + (element) => + element.style?.backgroundImage?.startsWith('url("data:image/jpeg') && + element.style.backgroundImage.slice(-24) !== stale + ), + before[0].digest, + { timeout: 15_000 } + ) + + 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) +}) diff --git a/config/scripts/mobile-web-app-frame-budget-sweep.test.ts b/config/scripts/mobile-web-app-frame-budget-sweep.test.ts new file mode 100644 index 00000000000..4f7ab8e99ac --- /dev/null +++ b/config/scripts/mobile-web-app-frame-budget-sweep.test.ts @@ -0,0 +1,276 @@ +/** + * The mobile-view frame budget, held against Chromium's own JPEG encoder across the viewport range. + * + * `WORST_CASE_JPEG_BYTES_PER_PIXEL` is the one number the budget cannot derive, and every other + * check of it is circular: a case that encodes `noise(area * theConstant)` is measuring a byte + * count the constant just produced, so it agrees with the constant whatever the constant says. This + * encodes a real noise JPEG per viewport, at the scale the real budget picks, and posts it through + * the real `BridgeHostSubscriptions`. It is the only thing here that can falsify the number. + * + * Chromium rather than a Node encoder, because the frames are CDP screencast frames: the bytes the + * budget has to survive are the ones Chromium produces, not the ones another library would. + * + * In `config/scripts` rather than the mobile suite for that reason — this is where a browser is + * available — and it drives the mobile modules directly, so the budget, the scale and the host are + * all the real ones. + * + * Named into the `mobile-web-app-` family so two things hold without anyone remembering them: the + * `mobile_web_app` job's filter picks it up, and `pr-code-change-scope.mjs` fires that job when + * this file changes. Both key off that prefix. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { chromium, type Browser, type Page } from 'playwright-core' +import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs' +import type { BrowserScreencastFrame } from '../../mobile/src/transport/browser-screencast-protocol' + +/** + * The mobile modules load lazily, after the dependency check, never at the top of the file: vite + * transforms anything under `mobile/` against `mobile/tsconfig.json`, which extends + * `expo/tsconfig.base.json`, so a static import fails at load in the sharded `test` job before + * `describe.skip` gets a say. Type-only imports are erased and stay static. + */ +async function loadSweepModules() { + const [request, parameters, caps, fakes, harnessModule, protocol] = await Promise.all([ + import('../../mobile/src/browser/browser-screencast-request.web'), + import('../../mobile/src/browser/browser-screencast-request-parameters'), + import('../../mobile/src/mobile-web-shell/bridge/bridge-caps'), + import('../../mobile/src/mobile-web-shell/bridge-host-test-fakes'), + import('../../mobile/src/mobile-web-shell/bridge-host-test-harness'), + import('../../mobile/src/transport/browser-screencast-protocol') + ]) + return { + budgetedMobileViewDeviceScaleFactor: request.budgetedMobileViewDeviceScaleFactor, + mobileBrowserFrameAreaBudget: request.mobileBrowserFrameAreaBudget, + WORST_CASE_JPEG_BYTES_PER_PIXEL: request.WORST_CASE_JPEG_BYTES_PER_PIXEL, + MOBILE_VIEW_DEVICE_SCALE_FACTOR: parameters.MOBILE_VIEW_DEVICE_SCALE_FACTOR, + BROWSER_FRAME_QUALITY: parameters.BROWSER_FRAME_QUALITY, + BRIDGE_MAX_MESSAGE_BYTES: caps.BRIDGE_MAX_MESSAGE_BYTES, + utf8ByteLength: caps.utf8ByteLength, + clientFrame: fakes.clientFrame, + harness: harnessModule.harness, + ID: harnessModule.ID, + BrowserScreencastOpcode: protocol.BrowserScreencastOpcode + } +} + +let loaded: Awaited> | null = null + +function sweep() { + if (loaded === null) { + throw new Error('the sweep modules are not loaded') + } + return loaded +} + +/** The viewport range the pane is mounted in, phone through tablet, in CSS pixels. */ +const VIEWPORT_WIDTHS = [320, 360, 390, 393, 412, 430, 480, 600, 768, 834, 1024, 1280, 1400] +const VIEWPORT_HEIGHTS = [480, 640, 712, 720, 800, 896, 932, 1024, 1180, 1366, 1600] + +type Viewport = { width: number; height: number } + +const VIEWPORTS: Viewport[] = VIEWPORT_WIDTHS.flatMap((width) => + VIEWPORT_HEIGHTS.map((height) => ({ width, height })) +) + +let browser: Browser | null = null +let page: Page | null = null + +/** + * Skipped where the bundling tests skip, which is the sharded `test` job. + * + * Not because this needs react-native-web — it does not — but because that job has no browser to + * launch, and this is the flag that tells the two jobs apart. In the `mobile_web_app` job the + * required-env check turns a missing install into a failure, so it cannot skip there silently. + */ +const describeSweep = mobileWebAppDependenciesPresent() ? describe : describe.skip + +beforeAll(async () => { + if (!mobileWebAppDependenciesPresent()) { + return + } + loaded = await loadSweepModules() + const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER + browser = await chromium.launch({ + headless: true, + ...(executablePath ? { executablePath } : {}) + }) + page = await (await browser.newContext()).newPage() + await page.goto('about:blank') +}, 120_000) + +afterAll(async () => { + await browser?.close() +}) + +/** + * A noise JPEG at the quality the pane ships, encoded by Chromium, returned as the base64 the + * bridge carries. The quality is read, not retyped: at 90 every budgeted viewport posts over the cap. + */ +async function encodeNoiseJpeg(size: { width: number; height: number }, seed: number) { + if (page === null) { + throw new Error('the sweep has no page') + } + const quality = sweep().BROWSER_FRAME_QUALITY / 100 + return await page.evaluate( + ({ width, height, seed, quality }) => { + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + const context = canvas.getContext('2d') + if (context === null) { + throw new Error('no 2d context') + } + 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] ?? '' + }, + { ...size, seed, quality } + ) +} + +/** Base64 back to the byte length it stands for, which is what the shell is handed. */ +function base64ByteLength(b64: string): number { + const padding = b64.endsWith('==') ? 2 : b64.endsWith('=') ? 1 : 0 + return (b64.length / 4) * 3 - padding +} + +function screencastFrame(image: Uint8Array, frame: { width: number; height: number }) { + return { + opcode: sweep().BrowserScreencastOpcode.Frame, + seq: 1, + format: 'jpeg', + metadata: { + offsetTop: 0, + pageScaleFactor: 1, + deviceWidth: frame.width, + deviceHeight: frame.height, + imageWidth: frame.width, + imageHeight: frame.height, + scrollOffsetX: 0, + scrollOffsetY: 0, + timestamp: 1_758_326_400.123456 + }, + image + } satisfies BrowserScreencastFrame +} + +/** What the real host does with this frame: the bytes it posted, or null when it dropped it. */ +function postThroughShell( + image: Uint8Array, + frame: { width: number; height: number } +): number | null { + const bridge = sweep().harness({ ready: true }) + bridge.host.receive( + sweep().clientFrame({ + type: 'subscribe', + id: sweep().ID, + method: 'browser.screencast', + params: { worktree: 'id:w', page: 'p' }, + wantsBinary: true + }) + ) + const before = bridge.posted.length + // Same reason as the sibling pin: a subscribe that opened no binary lane would read here as a + // dropped frame, and this sweep's whole verdict is which frames were dropped. + const emitBinary = bridge.client.streams[0]?.emitBinary + if (emitBinary === null || emitBinary === undefined) { + throw new Error('the subscribe opened no binary stream') + } + emitBinary(screencastFrame(image, frame)) + if (bridge.posted.length === before) { + return null + } + return sweep().utf8ByteLength(bridge.posted.at(-1) ?? '') +} + +/** The device-pixel frame the budget asks this viewport for. */ +function budgetedFrame(viewport: Viewport) { + const scale = sweep().budgetedMobileViewDeviceScaleFactor(viewport) + return { + scale, + width: Math.round(viewport.width * scale), + height: Math.round(viewport.height * scale) + } +} + +/** + * The viewports the budget can actually fit, which are the ones it makes a promise about. + * + * Below a scale of one the module stops: asking for fewer device pixels than CSS pixels is a + * blurry frame rather than a working one, so a viewport too large for the cap keeps scale 1 and + * the frame that does not fit is C6 ruling 1's to drop. Split here so the promise and the + * exception are both asserted rather than averaged. + */ +const withinBudget = (viewport: Viewport) => budgetedFrame(viewport).scale > 1 + +describeSweep('the frame budget across the viewport range', () => { + it('keeps every viewport it budgets for inside one bridge message', async () => { + const overCap: string[] = [] + let worstBytesPerPixel = 0 + let bestBytesPerPixel = 1 + for (const viewport of VIEWPORTS.filter(withinBudget)) { + const frame = budgetedFrame(viewport) + const b64 = await encodeNoiseJpeg(frame, viewport.width * 7_919 + viewport.height) + const imageBytes = base64ByteLength(b64) + const bytesPerPixel = imageBytes / (frame.width * frame.height) + worstBytesPerPixel = Math.max(worstBytesPerPixel, bytesPerPixel) + bestBytesPerPixel = Math.min(bestBytesPerPixel, bytesPerPixel) + const posted = postThroughShell(new Uint8Array(imageBytes), frame) + if (posted === null || posted > sweep().BRIDGE_MAX_MESSAGE_BYTES) { + overCap.push( + `${viewport.width}x${viewport.height} at scale ${frame.scale}: ${String(posted)}` + ) + } + } + + expect(overCap).toEqual([]) + // And the constant is above every cost that sweep just measured. Against the constant, not the + // 0.55351 measured on 2026-09-20 that its docstring records: the margin above that is what an + // encoder drift may spend, and a drift inside it is not a budget failure. Without this the + // assertion above passes by the budget being merely generous. + expect(worstBytesPerPixel).toBeLessThanOrEqual(sweep().WORST_CASE_JPEG_BYTES_PER_PIXEL) + // The low end too, so a sweep that silently stopped encoding real images is visible: every + // frame here is noise, and noise never compresses to a tenth of a byte per pixel. + expect(bestBytesPerPixel).toBeGreaterThan(0.5) + }, 300_000) + + it('does not budget below one device pixel per CSS pixel, and the shell drops what will not fit', async () => { + // The exception the split above names. These are real: a 1400x1180 viewport posts 1.2 MB. + const tooLarge = VIEWPORTS.filter((viewport) => !withinBudget(viewport)) + // The 32 of the 143 the budget leaves at scale 1, a fixed number because the set is fixed. + expect(tooLarge.length).toBe(32) + + const largest = tooLarge.reduce((left, right) => + left.width * left.height > right.width * right.height ? left : right + ) + const frame = budgetedFrame(largest) + expect(frame.scale).toBe(1) + const b64 = await encodeNoiseJpeg(frame, 1) + expect(postThroughShell(new Uint8Array(base64ByteLength(b64)), frame)).toBeNull() + }, 120_000) + + it('never asks for more density than native, anywhere in the range', () => { + for (const viewport of VIEWPORTS) { + expect(budgetedFrame(viewport).scale).toBeLessThanOrEqual( + sweep().MOBILE_VIEW_DEVICE_SCALE_FACTOR + ) + } + }) + + it('sweeps a range wide enough to contain the phones the pane runs on', () => { + // The set is fixed, so this is what says it still covers the case the old constant missed. + expect(VIEWPORTS).toContainEqual({ width: 390, height: 712 }) + expect(VIEWPORTS).toContainEqual({ width: 393, height: 720 }) + expect(VIEWPORTS).toContainEqual({ width: 360, height: 640 }) + expect(VIEWPORTS.length).toBe(143) + expect(sweep().mobileBrowserFrameAreaBudget()).toBeGreaterThan(0) + }) +}) diff --git a/config/scripts/mobile-web-app-page-closure-families.test.mjs b/config/scripts/mobile-web-app-page-closure-families.test.mjs index c5f8b3e22ba..a800faec9b6 100644 --- a/config/scripts/mobile-web-app-page-closure-families.test.mjs +++ b/config/scripts/mobile-web-app-page-closure-families.test.mjs @@ -9,7 +9,10 @@ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' -import { mobileWebAppRouteClosure } from './build-mobile-web-app-bundle.mjs' +import { + mobileWebAppModuleClosure, + mobileWebAppRouteClosure +} from './build-mobile-web-app-bundle.mjs' import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs' import { pageClosureFamilies, pinnedFamilyNames } from './mobile-web-app-page-closure-families.mjs' @@ -26,6 +29,8 @@ const PIN_TABLES = [ 'mobile/src/test-support/bridged-parity/c2-work-item-closure-families.ts', 'mobile/src/test-support/bridged-parity/c2-task-source-closure-families.ts' ] +const C6_PIN_TABLE = 'mobile/src/test-support/bridged-parity/c6-browser-closure-families.ts' + const FILES_PIN_TABLES = [ C1_TABLE, 'mobile/src/test-support/bridged-parity/c3-explorer-closure-families.ts', @@ -127,3 +132,31 @@ describeClosure('the files page closures', () => { expect(previewFamilies).not.toContain('files.explorer-screen') }, 120_000) }) + +describeClosure('the browser pane closure', () => { + /** The pane is mounted by a route, not registered as one, so its closure is read from the module + * itself. C7 composes this half with the session route and censuses the route the usual way. */ + const PANE = 'src/browser/MobileBrowserPane' + + it('reaches exactly the golden families its pin table commits, on its own', async () => { + const closure = await mobileWebAppModuleClosure([PANE]) + await expectClosureFamilies(closure.local, [C6_PIN_TABLE]) + }, 60_000) + + it('adds exactly those families to a page, and no other', async () => { + // The pin is a half: alone it would also pass if the pane dragged in a family C1 already pins + // and the table happened to list it. This reads the difference the pane makes to the layout. + const scenarios = JSON.parse(read('mobile/rpc-foundation/pilot-scenarios.json')).scenarios + const [layout, withPane] = await Promise.all([ + mobileWebAppModuleClosure(['app/h/_layout']), + mobileWebAppModuleClosure(['app/h/_layout', PANE]) + ]) + const layoutFamilies = pageClosureFamilies(layout.local, scenarios) + const added = pageClosureFamilies(withPane.local, scenarios).filter( + (family) => !layoutFamilies.includes(family) + ) + expect(added).toEqual(pinnedFamilyNames(read(C6_PIN_TABLE)).sort()) + // And that the layout is the C1 control it is everywhere else, so the difference above is real. + expect(layoutFamilies).toEqual(pinnedFamilyNames(read(C1_TABLE)).sort()) + }, 120_000) +}) diff --git a/config/scripts/mobile-web-app-render-harness.mjs b/config/scripts/mobile-web-app-render-harness.mjs index b438fadd442..b0cef0720bf 100644 --- a/config/scripts/mobile-web-app-render-harness.mjs +++ b/config/scripts/mobile-web-app-render-harness.mjs @@ -59,6 +59,54 @@ export async function readBridgeProtocolVersion() { return Number(match[1]) } +/** + * The bridge's window caps, read from the modules that define them. + * + * The shell double below has to price a frame the way `BridgeHostSubscriptions` does, and a double + * carrying its own copy of these numbers is a double that goes on passing after the real host's + * changed. `BRIDGE_MAX_UNACKED_BYTES` is written as a product, so the reader evaluates one. + */ +export async function readBridgeWindowCaps() { + const sources = await Promise.all( + [ + 'mobile/src/mobile-web-shell/bridge/bridge-caps.ts', + 'mobile/src/mobile-web-shell/bridge-host-subscriptions.ts' + ].map((path) => readFile(join(projectDir, path), 'utf8')) + ) + const source = sources.join('\n') + const read = (name) => { + const match = new RegExp(`${name} = ([0-9*\\s]+)`).exec(source) + if (!match) { + throw new Error(`could not read ${name}`) + } + return match[1] + .split('*') + .map((part) => Number(part.trim())) + .reduce((product, factor) => product * factor, 1) + } + return { + maxMessageBytes: read('BRIDGE_MAX_MESSAGE_BYTES'), + maxUnackedFrames: read('BRIDGE_MAX_UNACKED_FRAMES'), + maxUnackedBytes: read('BRIDGE_MAX_UNACKED_BYTES') + } +} + +/** + * The JPEG quality the pane asks Chromium for, read from the module that sends it. A test that + * encoded its fixtures at a retyped quality would certify the budget at a number nothing ships. + */ +export async function readBrowserFrameQuality() { + const source = await readFile( + join(projectDir, 'mobile/src/browser/browser-screencast-request-parameters.ts'), + 'utf8' + ) + const match = /BROWSER_FRAME_QUALITY = (\d+)/.exec(source) + if (!match) { + throw new Error('could not read BROWSER_FRAME_QUALITY') + } + return Number(match[1]) / 100 +} + /** The grant the shell offers every page, read from the same source for the same reason. */ export async function readBridgeFaultGrant() { const source = await readFile( @@ -83,6 +131,11 @@ export async function readBridgeFaultGrant() { * because a control that handed something to the shell and one that did nothing look the same on * the document. * + * It answers RPC the way a refusing host does and serves a screencast stream the way the real + * `BridgeHostSubscriptions` does, including its whole `canCarry` rule and the page's acks. It is + * not the host: it decides no domain behaviour, and every reply a screen sees is one a check + * named. + * * Serialized as a page init script, so it takes plain data and closes over nothing. */ export function installShellDouble({ @@ -95,7 +148,9 @@ export function installShellDouble({ faultGrant, grants, pageRoutes = null, - replies + replies, + streams = [], + windowCaps = null }) { // Where the page's own fault reports land. Read back after the render, so a route that threw // under the boundary names itself instead of timing out as a page that never mounted. @@ -104,6 +159,19 @@ export function installShellDouble({ // something to the shell and a control that did nothing look identical on the document; this is // the only thing that tells them apart. globalThis.__orcaRenderCheckNotifies = [] + // Every request the page issued, whole and in order, so a check can say which verb a gesture + // produced and with what geometry rather than only that something was sent. + globalThis.__orcaRenderCheckRequests = [] + // The subscriptions the double accepted, with the `wantsBinary` each one asked for: the negative + // case is "the page did not ask", which no assertion on the frames can see. + globalThis.__orcaRenderCheckSubscribes = [] + // Binary events this double refused to post because they exceeded the frame cap, which is the + // shell's drop rule reproduced where the page can watch it survive one. + globalThis.__orcaRenderCheckDroppedFrames = [] + // Every ack seq the page posted, in order. Without this a stream that never acked and one that + // acked every frame look the same from the page's side. + globalThis.__orcaRenderCheckAcks = [] + const openStreams = new Map() const channel = { postMessage: (json) => { const frame = JSON.parse(json) @@ -151,6 +219,43 @@ export function installShellDouble({ // The result the caller named for this method, carried in the envelope a real host uses. // Anything unnamed still takes the refusal below, so a screen only ever sees data a test // asked for. + if (frame.type === 'subscribe' && streams.includes(frame.method)) { + globalThis.__orcaRenderCheckSubscribes.push({ + id: frame.id, + method: frame.method, + params: frame.params, + wantsBinary: frame.wantsBinary === true + }) + // Accepted by saying nothing, exactly as the real host does: a subscription is open until + // an `error` or an `end` closes it, and the first thing the page hears is an event. + openStreams.set(frame.id, { seq: 0, unacked: [], unackedBytes: 0 }) + return + } + if (frame.type === 'ack') { + // The page's ack is what reopens the window, so a double that ignored it would drop + // frames the real host carries. Read exactly as `BridgeHostSubscriptions.ack` reads it. + const stream = openStreams.get(frame.id) + if (stream) { + let acked = 0 + for (const pending of stream.unacked) { + if (pending.seq > frame.seq) { + break + } + stream.unackedBytes -= pending.bytes + acked += 1 + } + stream.unacked.splice(0, acked) + globalThis.__orcaRenderCheckAcks.push(frame.seq) + } + return + } + if (frame.type === 'cancel') { + openStreams.delete(frame.id) + return + } + if (frame.type === 'request') { + globalThis.__orcaRenderCheckRequests.push({ method: frame.method, params: frame.params }) + } if (frame.type === 'request' && replies && Object.hasOwn(replies, frame.method)) { answer({ v: version, @@ -175,6 +280,41 @@ export function installShellDouble({ }, onmessage: null } + /** + * One screencast frame from the shell, priced the way `BridgeHostSubscriptions` prices it. + * + * All three arms of the host's `canCarry`, not just the size one: a frame over the message cap, + * a window already holding the most frames it may, and a window whose bytes this frame would + * push past the limit. Dropping is the behaviour under test — the event goes nowhere, the + * stream stays open, and the next frame paints — so a double that posted an uncarriable frame + * would prove the page decodes something no shell could have sent. + * + * The window only stays open because the page acks, which the `ack` arm above consumes. That is + * what makes a long stream a real test of both rather than of neither. + */ + globalThis.__orcaRenderCheckEmitBinary = (id, binary) => { + const stream = openStreams.get(id) + if (!stream) { + return 'no-stream' + } + const seq = stream.seq + 1 + const json = JSON.stringify({ v: version, type: 'event', id, seq, binary }) + const bytes = new TextEncoder().encode(json).length + const carries = + windowCaps === null || + (bytes <= windowCaps.maxMessageBytes && + stream.unacked.length < windowCaps.maxUnackedFrames && + stream.unackedBytes + bytes <= windowCaps.maxUnackedBytes) + if (!carries) { + globalThis.__orcaRenderCheckDroppedFrames.push(binary.frameSeq) + return 'dropped' + } + stream.seq = seq + stream.unacked.push({ seq, bytes }) + stream.unackedBytes += bytes + channel.onmessage?.({ data: json }) + return 'posted' + } globalThis.orcaBridge = channel } diff --git a/mobile/src/browser/browser-screencast-budget-at-the-shell.test.ts b/mobile/src/browser/browser-screencast-budget-at-the-shell.test.ts new file mode 100644 index 00000000000..d83578e6547 --- /dev/null +++ b/mobile/src/browser/browser-screencast-budget-at-the-shell.test.ts @@ -0,0 +1,190 @@ +/** + * The page's frame budget, checked against the frame the shell really posts. + * + * `browser-screencast-request.web.ts` sizes the mobile view from `binaryEventEnvelopeBytes()`, a + * bound it derives from a skeleton it builds itself. Its own suite checks that bound against + * another skeleton of the same shape, which is two copies of one assumption agreeing. This is the + * case that makes it evidence: a real `event.binary` encoded by C6.1's encoder and serialized by + * the real `BridgeHostSubscriptions`, measured, and the bound held above it. + * + * C6 ruling 2's pin. The frames are generated noise at the budgeted scale rather than a fixture + * committed to the tree: the budget's worst case is the image JPEG compresses least, and a + * downloaded photograph would sit a tenth of the way to it and prove nothing. + * + * Measured at this base, so a later reader can tell drift from a rewrite: the real envelope costs + * 303 bytes against a bound of 516, and a frame at the budgeted area is an image the shell posts + * just under the 655,360-byte cap. The area itself moved when the worst case was swept properly, + * so the figures are derived here rather than written down. + */ +import { describe, expect, it } from 'vitest' +import { BRIDGE_MAX_MESSAGE_BYTES, utf8ByteLength } from '../mobile-web-shell/bridge/bridge-caps' +import { clientFrame } from '../mobile-web-shell/bridge-host-test-fakes' +import { harness, ID } from '../mobile-web-shell/bridge-host-test-harness' +import { + BrowserScreencastOpcode, + METADATA_KEYS, + type BrowserScreencastFrame +} from '../transport/browser-screencast-protocol' +import { + binaryEventEnvelopeBytes, + mobileBrowserFrameAreaBudget, + WORST_CASE_JPEG_BYTES_PER_PIXEL +} from './browser-screencast-request.web' + +/** What the bound spends per number: seventeen significant digits and the widest fixed notation + * JSON writes, `-0.0000012345678901234567`, and `Number.MAX_SAFE_INTEGER` for the two counters. */ +const WIDEST_JSON_DOUBLE_CHARS = 25 +const LARGEST_INTEGER_CHARS = JSON.stringify(Number.MAX_SAFE_INTEGER).length + +/** + * A frame's metadata as Chromium sends it: all nine fields, and a `timestamp` that is a real + * `Page.screencastFrame` value rather than a toy integer. + * + * The timestamp is the whole difference between a plausible envelope and the real one — epoch + * seconds with microseconds is sixteen characters where `1` is one — so a pin written with a small + * number would measure an envelope no frame ever has. + */ +const CDP_METADATA = { + offsetTop: 0, + pageScaleFactor: 1, + deviceWidth: 390, + deviceHeight: 712, + imageWidth: 780, + imageHeight: 1424, + scrollOffsetX: 0, + scrollOffsetY: 2048.5, + timestamp: 1_758_326_400.123456 +} + +/** Noise, which is what the worst case is: any structure at all is something JPEG would compress. + * Deterministic rather than seeded off the clock, so the measured bytes are the same every run. */ +function noise(byteLength: number): Uint8Array { + const bytes = new Uint8Array(byteLength) + let state = 0x9e37_79b9 + for (let index = 0; index < byteLength; index += 1) { + state = (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0 + bytes[index] = (state >>> 24) & 0xff + } + return bytes +} + +function screencastFrame(image: Uint8Array): BrowserScreencastFrame { + return { + opcode: BrowserScreencastOpcode.Frame, + seq: FRAME_SEQ, + format: 'jpeg', + metadata: CDP_METADATA, + image + } +} + +/** The screencast counter the frame below carries, and the event seq the shell gives its first + * event, both needed to reconstruct what the bound assumed about them. */ +const FRAME_SEQ = 4_096 +const FIRST_EVENT_SEQ = 1 + +/** The one frame the shell posted for this image, and what it cost besides the image. */ +function postedFrame(image: Uint8Array): { bytes: number; envelope: number; posts: number } { + const bridge = harness({ ready: true }) + bridge.host.receive( + clientFrame({ + type: 'subscribe', + id: ID, + method: 'browser.screencast', + params: { worktree: 'id:w', page: 'p' }, + wantsBinary: true + }) + ) + const before = bridge.posted.length + // Loudly, because the optional chain below would otherwise turn a subscribe that opened no + // binary lane into zero posts, which is what a dropped frame looks like. + const emitBinary = bridge.client.streams[0]?.emitBinary + if (emitBinary === null || emitBinary === undefined) { + throw new Error('the subscribe opened no binary stream') + } + emitBinary(screencastFrame(image)) + const json = bridge.posted.at(-1) ?? '' + const posts = bridge.posted.length - before + const event = posts === 0 ? null : bridge.last() + const b64 = event !== null && 'binary' in event ? event.binary.b64 : '' + return { bytes: utf8ByteLength(json), envelope: utf8ByteLength(json) - b64.length, posts } +} + +describe('the envelope bound against a real posted frame', () => { + it('holds above what the shell spends on everything but the image', () => { + const { envelope } = postedFrame(noise(64 * 1024)) + + expect(binaryEventEnvelopeBytes()).toBeGreaterThanOrEqual(envelope) + }) + + it('is the same cost whatever the image is, which is what makes it an envelope', () => { + // Base64 is ASCII and JSON escapes none of it, so the image contributes its characters and + // nothing else. This is the premise the shell prices an unencoded frame on. + expect(postedFrame(noise(1_024)).envelope).toBe(postedFrame(noise(256 * 1024)).envelope) + }) + + /** + * The bound, reconstructed from the real frame rather than merely held above it. + * + * Above-it alone is satisfied by 213 bytes of slack, which is room for the shell to grow the + * envelope by a field the page never hears about. The slack is not arbitrary: every byte of it + * is a value this frame prints narrower than a double can. Adding exactly those back is the + * whole difference, so an envelope field the bound does not know about fails here at one byte. + */ + it('is exactly what this frame costs once every number is widened to a double', () => { + const widen = (value: number): number => WIDEST_JSON_DOUBLE_CHARS - JSON.stringify(value).length + const widened = + postedFrame(noise(1_024)).envelope + + (LARGEST_INTEGER_CHARS - JSON.stringify(FIRST_EVENT_SEQ).length) + + (LARGEST_INTEGER_CHARS - JSON.stringify(FRAME_SEQ).length) + + METADATA_KEYS.reduce((total, key) => total + widen(CDP_METADATA[key]), 0) + + expect(binaryEventEnvelopeBytes()).toBe(widened) + }) + + it('covers every metadata field the protocol declares, not the ones this case sends', () => { + // If a tenth field is added, `METADATA_KEYS` grows, the bound grows with it, and the frame + // above keeps fitting. The guard is that this case sends all of them. + expect(Object.keys(CDP_METADATA).sort()).toEqual([...METADATA_KEYS].sort()) + }) +}) + +/** + * The arithmetic between the budget and the cap, and nothing about what a JPEG really costs. + * + * Every case here feeds `noise(area * WORST_CASE_JPEG_BYTES_PER_PIXEL)` — a byte count the constant + * itself produced — so they cannot falsify the constant, only the expansion and the drop rule + * around it. Said plainly because the earlier version of this block read as if it validated the + * worst case: it did not, and the constant it agreed with was wrong by enough to post a phone's + * frame over the cap. `config/scripts/mobile-web-app-frame-budget-sweep.test.ts` is what encodes + * real Chromium JPEGs across the viewport range and holds the constant to them. + */ +describe('a frame at exactly the budgeted area', () => { + /** The image the budget says the mobile view's worst case produces, to the byte. */ + const BUDGETED_IMAGE_BYTES = Math.floor( + mobileBrowserFrameAreaBudget() * WORST_CASE_JPEG_BYTES_PER_PIXEL + ) + + it('encodes under the frame cap and the shell posts it', () => { + const { bytes, posts } = postedFrame(noise(BUDGETED_IMAGE_BYTES)) + + expect(posts).toBe(1) + expect(bytes).toBeLessThanOrEqual(BRIDGE_MAX_MESSAGE_BYTES) + }) + + it('is tight: the budget spends nearly the whole cap', () => { + // A budget with room to spare is pixels the pane could have had. Within one base64 group plus + // the slack the envelope bound deliberately carries. + const { bytes } = postedFrame(noise(BUDGETED_IMAGE_BYTES)) + + expect(BRIDGE_MAX_MESSAGE_BYTES - bytes).toBeLessThan(binaryEventEnvelopeBytes()) + }) + + it('is a ceiling: an image past it is dropped by the shell, not sent over the cap', () => { + // C6 ruling 1 from the budget's side. The area is a worst case, so a real frame this size is + // the one the page could not predict, and the shell is what keeps it off the wire. + const { posts } = postedFrame(noise(BUDGETED_IMAGE_BYTES + binaryEventEnvelopeBytes())) + + expect(posts).toBe(0) + }) +}) diff --git a/mobile/src/browser/browser-screencast-request.web.ts b/mobile/src/browser/browser-screencast-request.web.ts index f9e34cd34c4..3531b7f9e0e 100644 --- a/mobile/src/browser/browser-screencast-request.web.ts +++ b/mobile/src/browser/browser-screencast-request.web.ts @@ -19,12 +19,21 @@ export type { /** * The bytes one pixel of this pane's JPEG costs at its worst. * - * Measured on this lane's fixtures at quality 72: uniform random noise, which is the image JPEG - * compresses least and the ceiling every real page sits under, encoded at 0.545 bytes per pixel. - * Photographic content measured near a tenth of that. The number is the worst case rather than a - * typical one because it is the one a budget has to survive. + * Uniform random noise at quality 72, which is the image JPEG compresses least and the ceiling + * every real page sits under; photographic content measures near a tenth of it. + * + * Swept 2026-09-20 over 143 viewports — widths 320 to 1400 and heights 480 to 1600 — each encoded + * by Chromium at the scale `budgetedMobileViewDeviceScaleFactor` picks for it. Across the 111 the + * budget fits, the measured cost ranged from 0.54470 to 0.55351 bytes per pixel. This is that + * maximum plus a margin of 0.00649, about 1.2%, for the encoder version it was not swept on. + * + * It was 0.545 before that sweep, taken from one 2400x2160 frame. A single large frame is the + * cheapest per pixel in the whole range, so the number it gave was under 90 of those 143 viewports + * and the budget it produced posted a frame over the cap on a phone. A worst case measured at one + * point is not a worst case; `mobile-web-app-frame-budget-sweep.test.ts` is what holds this one to + * the whole range, and re-running it is how this number is changed. */ -export const WORST_CASE_JPEG_BYTES_PER_PIXEL = 0.545 +export const WORST_CASE_JPEG_BYTES_PER_PIXEL = 0.56 /** Base64 carries three bytes in four characters, and a character is one UTF-8 byte here. */ export const BASE64_BYTES_PER_CHARACTER = 3 / 4 @@ -62,8 +71,9 @@ const NARROWEST_JSON_DOUBLE_CHARS = 1 * predict: the metadata object is a loose one, so a shell may send keys this list has never heard * of, and web view mode's frame is a letterboxed desktop viewport the page cannot size. * - * Pinning this against C6.1's real encoder belongs to C6.5, once the encoder and this are both on - * main; until then the bound is checked against a serialized envelope of the same shape. + * `browser-screencast-budget-at-the-shell.test.ts` is what makes this evidence rather than an + * assumption checked against a copy of itself: it reconstructs this number, to the byte, from a + * frame the real encoder produced and the real host serialized. */ export function binaryEventEnvelopeBytes(): number { const skeleton = JSON.stringify({ diff --git a/mobile/src/test-support/bridged-parity/c6-browser-closure-families.test.ts b/mobile/src/test-support/bridged-parity/c6-browser-closure-families.test.ts new file mode 100644 index 00000000000..a1621719a5d --- /dev/null +++ b/mobile/src/test-support/bridged-parity/c6-browser-closure-families.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { C1_PAGE_CLOSURE } from './c1-page-closure' +import { C2_PAGE_CLOSURE } from './c2-page-closure' +import { C3_PAGE_CLOSURE } from './c3-page-closure' +import { C5_PAGE_CLOSURE } from './c5-page-closure' +import { C6_BROWSER_CLOSURE_FAMILIES } from './c6-browser-closure-families' +import { pageClosureTotals } from './page-closure' + +describe('the C6 browser closure half', () => { + it('is the census the design named: 4 families, 15 goldens', () => { + const goldens = Object.values(C6_BROWSER_CLOSURE_FAMILIES).flatMap((family) => + Object.keys(family) + ) + expect({ + families: Object.keys(C6_BROWSER_CLOSURE_FAMILIES).length, + goldens: goldens.length + }).toEqual({ families: 4, goldens: 15 }) + expect(new Set(goldens).size).toBe(goldens.length) + }) + + /** The counts a per-id walk cannot see move: a table wrong the same way twice agrees with itself. */ + it('pins six byte-identical goldens and nine in one named class', () => { + expect(pageClosureTotals(C6_BROWSER_CLOSURE_FAMILIES)).toEqual({ + identical: 6, + 'result-absent-settlement': 9 + }) + }) + + /** + * Every family has a byte-identical golden, which is more than C1 or C2 could say of all of + * theirs: for these four the pin holds bytes and not only the name of a divergence. + */ + it('leaves no family excluded whole', () => { + for (const [family, goldens] of Object.entries(C6_BROWSER_CLOSURE_FAMILIES)) { + expect({ family, identical: Object.values(goldens).includes('identical') }).toEqual({ + family, + identical: true + }) + } + }) + + /** C2's rule over the new families, which predicted all fifteen; the table is generated, not + * hand-corrected, and this is what says so. */ + it('agrees with the C2 classification rule on every golden', () => { + for (const goldens of Object.values(C6_BROWSER_CLOSURE_FAMILIES)) { + for (const [id, verdict] of Object.entries(goldens)) { + expect({ id, verdict }).toEqual({ + id, + verdict: id.startsWith('matrix-') ? 'result-absent-settlement' : 'identical' + }) + } + } + }) + + /** + * No family here is pinned by another series, so a golden pinned twice is pinned once. + * + * The four are the pane's own and nothing else imports its call sites; `session.browser-tab-create` + * is recorded at the session screen and belongs to C7. + */ + it('shares no family with C1, C2, C3 or C5', () => { + const others = new Set([ + ...Object.keys(C1_PAGE_CLOSURE), + ...Object.keys(C2_PAGE_CLOSURE), + ...Object.keys(C3_PAGE_CLOSURE), + ...Object.keys(C5_PAGE_CLOSURE) + ]) + expect(Object.keys(C6_BROWSER_CLOSURE_FAMILIES).filter((family) => others.has(family))).toEqual( + [] + ) + }) +}) diff --git a/mobile/src/test-support/bridged-parity/c6-browser-closure-families.ts b/mobile/src/test-support/bridged-parity/c6-browser-closure-families.ts new file mode 100644 index 00000000000..c5618551c20 --- /dev/null +++ b/mobile/src/test-support/bridged-parity/c6-browser-closure-families.ts @@ -0,0 +1,56 @@ +import type { PageClosurePins } from './page-closure' + +/** + * The goldens recorded at a call site inside the browser pane's closure, and what each one did at + * the bridge. + * + * A half with no composed `c6-page-closure.ts` beside it, unlike every series before this one. A + * composed table is pinned against a route and C6 has none: the browser is a pane of the session + * screen, mounted from `MobileSessionActiveContent`, so C7 is what registers a route that reaches + * it. C7 spreads this table beside C1's. The derivation census does not wait for that route: it reads + * the pane's closure from the module itself, in `mobile-web-app-page-closure-families.test.mjs`. + * + * Derived from the value-import closure of `MobileBrowserPane` with `.web.*` resolution applied, + * measured through the builder's own options: 48 local modules on its own, and 34 beyond the shared + * layout, of which 30 are under `src/browser` and four are reached through its web siblings — + * `bridge-envelope.ts`, `bridge-error-capture.ts`, `browser-screencast-protocol.ts` and + * `rpc-response-shape.ts`, which the frame budget and the binary decoder pull in. The corpus records + * the pane at exactly two sites, `use-mobile-browser-request.ts` and + * `use-mobile-browser-commands.ts`, and the pane alone reaches all four families: the shared layout + * contributes none of them. + * + * **What 15 certified does not say.** `browser.screencast` has no golden at all. The corpus records + * the thirteen page commands and never the stream, so this pin certifies the input path — the taps, + * the wheel, the keyboard and the dialogs — byte for byte, and says nothing whatever about the + * frame path that C6.1 through C6.4 built. That is what the device proof has to carry. + * + * Every verdict is what C2's rule predicts: a `matrix-` golden is `result-absent-settlement`, for + * the reason the parity suite's own docstring gives, and every other replays byte-identically. All + * fifteen were measured per family with vitest `-t` over the full 787-golden corpus, with C1's 103 + * pins reproduced golden-for-golden as the control for the harness that measured them. + */ +export const C6_BROWSER_CLOSURE_FAMILIES: PageClosurePins = { + 'browser.dialog': { + 'browser-dialog-accepted': 'identical', + 'browser-dialog-dismissed': 'identical', + 'matrix-browser.dialog-browser.dialogaccept-1': 'result-absent-settlement' + }, + 'browser.keyboard': { + 'browser-keyboard-input': 'identical', + 'matrix-browser.keyboard-browser.keyboardinserttext-1': 'result-absent-settlement', + 'matrix-browser.keyboard-browser.keypress-1': 'result-absent-settlement' + }, + 'browser.pointer-click': { + 'browser-pointer-click-accepted': 'identical', + 'browser-pointer-click-fallback': 'identical', + 'matrix-browser.pointer-click-browser.mouseclick-1': 'result-absent-settlement', + 'matrix-browser.pointer-click-browser.mousedown-1': 'result-absent-settlement', + 'matrix-browser.pointer-click-browser.mousemove-1': 'result-absent-settlement', + 'matrix-browser.pointer-click-browser.mouseup-1': 'result-absent-settlement' + }, + 'browser.wheel': { + 'browser-wheel-scrolled': 'identical', + 'matrix-browser.wheel-browser.mousemove-1': 'result-absent-settlement', + 'matrix-browser.wheel-browser.mousewheel-1': 'result-absent-settlement' + } +} diff --git a/mobile/src/test-support/rpc-recording/rpc-recording-through-bridge.test.ts b/mobile/src/test-support/rpc-recording/rpc-recording-through-bridge.test.ts index cfa2dfc7b05..0ae92040e68 100644 --- a/mobile/src/test-support/rpc-recording/rpc-recording-through-bridge.test.ts +++ b/mobile/src/test-support/rpc-recording/rpc-recording-through-bridge.test.ts @@ -18,6 +18,7 @@ import { type BridgedParityEvidence } from '../bridged-parity/divergence-classes' import { C5_PAGE_CLOSURE } from '../bridged-parity/c5-page-closure' +import { C6_BROWSER_CLOSURE_FAMILIES } from '../bridged-parity/c6-browser-closure-families' import { C2_PAGE_CLOSURE } from '../bridged-parity/c2-page-closure' import { C1_PAGE_CLOSURE } from '../bridged-parity/c1-page-closure' import { C3_PAGE_CLOSURE } from '../bridged-parity/c3-page-closure' @@ -401,6 +402,23 @@ describe.skipIf(process.env[BRIDGED_PARITY_FLAG] === BRIDGED_PARITY_OFF)( ) }) + /** + * The browser pane's half, checked the same way and for the same reason the composed tables are. + * + * A half rather than a page closure because C6 registers no route — C7 composes this beside + * C1's — but a table nothing reads is not a pin, so the run is held to it here from the series + * that derived it rather than from the one that will inherit it. + */ + it('gives every golden the C6 browser closure records the verdict it is pinned to', () => { + process.stdout.write(readPageClosure('C6', C6_BROWSER_CLOSURE_FAMILIES, observed)) + expect({ closure: pageClosureDrift(C6_BROWSER_CLOSURE_FAMILIES, observed) }).toEqual({ + closure: [] + }) + expect(pageClosureRunTotals(C6_BROWSER_CLOSURE_FAMILIES, observed)).toEqual( + pageClosureTotals(C6_BROWSER_CLOSURE_FAMILIES) + ) + }) + it('gives every golden the C3 page closure records the verdict it is pinned to', () => { process.stdout.write(readPageClosure('C3', C3_PAGE_CLOSURE, observed)) // 28 families and 125 goldens, C1's 22 among them and inherited rather than re-derived, so