/** * The HTML preview's sealed frame, in a real browser under the shipped policy, on both engines. * * The frame holds an agent-produced artifact inside the page's own document, so every claim about * what it cannot do has to be measured rather than reasoned about — and every one of those claims is * an absence, which is also what a frame that never rendered reports. So each case runs against a * no-header control where the same artifact does the thing: the script runs, the remote subresources * are fetched, the navigation happens. Without those controls a preview that failed to load would * pass every assertion here. * * WebKit as well as Chromium, because the iOS shell is WKWebView and the two disagree: a `blob:` * frame that Chromium admits under `frame-src blob:` is refused in WebKit by the * `frame-ancestors 'none'` it inherits. `srcdoc` is what both admit under the policy that already * ships, which is why this costs no CSP change and why a case below pins `frame-src 'none'` as still * shipped. * * The paint oracle is a pixel rather than a read inside the frame: the frame is an opaque origin, and * WebKit refuses to evaluate in one, so reading its DOM would make the instrument engine-dependent. */ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { createServer } from 'node:http' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import * as esbuild from 'esbuild' import { chromium, webkit } from 'playwright-core' import { lucideBarrelPlugin } from './build-mobile-web-app-bundle.mjs' import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs' import { createBundleServer, readShellCsp, readShellDocumentHeaders } from './mobile-web-app-render-harness.mjs' import { createCspReportSink } from './mobile-web-app-preview-csp-reports.mjs' import { startArtifactAssetServer } from './mobile-web-app-preview-asset-server.mjs' import { openPreviewArm } from './mobile-web-app-preview-arm-driver.mjs' import { ARTIFACT_RGB, ENTRY_SOURCE, artifactScript } from './mobile-web-app-preview-artifact-fixture.mjs' import { waitForRecordedNavigation } from './mobile-web-app-preview-frame-readiness.mjs' const mobileDir = fileURLToPath(new URL('../../mobile', import.meta.url)) /** Where the preview sits once mounted, which is what the pixel oracle samples. */ const FRAME_PROBE = { x: 60, y: 200, width: 4, height: 4 } /** The page behind the frame, so a frame that painted nothing reads as this instead. */ const PAGE_RGB = '17,17,17' /** Where the artifact's links and subresources point, and the origin that counts what it asked for. */ let foreignOrigin = null const foreignHits = [] let foreign = null /** * The artifact's https asset origin: a real TLS listener rather than route interception. * * Interception could not measure it. Chrome 152 isolates the sandboxed `srcdoc` frame into its own * target, and the parser-inserted `` is the document's first fetch, issued before interception * attaches there: the request escaped to the network, the unresolvable host failed it, and the rig * recorded nothing while the frame's own resource timing showed the fetch. A listener already * accepting before the page exists cannot be raced that way -- the request arrives or it does not, * and either answer is the measurement. `img-src https:` matches on scheme, so `https://127.0.0.1` * exercises the same directive any other https host would. */ let assetServer = null const bundles = mobileWebAppDependenciesPresent() const describeRender = bundles ? describe : describe.skip let scratch = null let outDir = null let shippedCsp = null const browsers = {} /** * Two servers over one bundle rather than one server with a switch: the policy is a response header * the harness reads once per server, and a control arm that shared a server with the sealed arm * would be one race away from measuring the wrong header. */ let sealedServer = null let openServer = null /** * A third server, serving the shipped policy with a deliberately permissive `Referrer-Policy`. * It is the presence precondition for the referrer reading: Chromium sends no referrer from a * srcdoc frame's image whatever the header says, so without an arm that does send one, "no * `Referer`" there would pass on a rig that dropped the header entirely. */ let leakyServer = null const origins = {} let shippedDocumentHeaders = null /** Every refusal the sealed server's policy was told about, by the arm that caused it. */ const cspReports = createCspReportSink() beforeAll(async () => { shippedCsp = await readShellCsp() shippedDocumentHeaders = await readShellDocumentHeaders() if (!bundles) { return } foreignHits.length = 0 foreign = createServer((request, response) => { foreignHits.push(request.url) if (request.url.endsWith('.png')) { response.writeHead(200, { 'content-type': 'image/png' }) response.end( Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==', 'base64' ) ) return } response.writeHead(200, { 'content-type': 'text/html', 'access-control-allow-origin': '*' }) response.end('FOREIGN') }) await new Promise((resolve) => foreign.listen(0, '127.0.0.1', resolve)) foreignOrigin = `http://127.0.0.1:${String(foreign.address().port)}` // Onto the rig as well: the driver reads it from there, and the binding above is assigned after // this module's top level has already built the object. rig.foreignOrigin = foreignOrigin await mkdir(join(mobileDir, '.tmp'), { recursive: true }) scratch = await mkdtemp(join(mobileDir, '.tmp', 'html-preview-render-')) // Before any page exists, which is the point of it being a listener. assetServer = await startArtifactAssetServer(scratch) rig.assetServer = assetServer outDir = join(scratch, 'bundle') await mkdir(outDir, { recursive: true }) await esbuild.build({ absWorkingDir: mobileDir, stdin: { contents: ENTRY_SOURCE, resolveDir: join(mobileDir, 'src/components'), loader: 'tsx', sourcefile: 'html-preview-check.tsx' }, bundle: true, format: 'iife', outfile: join(outDir, 'html-preview-check.js'), target: ['es2022'], jsx: 'automatic', logLevel: 'silent', // The page's own icon shim, imported rather than copied: `lucide-react-native` imports a // `LucideProvider` its context module does not export, so the toolbar's icons do not link // without it. plugins: [lucideBarrelPlugin], nodePaths: [join(mobileDir, 'node_modules')], alias: { 'react-native': 'react-native-web' }, // The web sibling is what the page runs; naming the native file would measure the module that // needs `react-native-webview` to exist. `.web.jsx`/`.web.js` are in the list for the same reason // the real bundle has them: without them `react-native-svg`, which the toolbar's icons pull in, // resolves its Fabric components and fails on `codegenNativeComponent`. resolveExtensions: ['.web.tsx', '.web.ts', '.web.jsx', '.web.js', '.tsx', '.ts', '.jsx', '.js'], define: { __DEV__: 'false', 'process.env.NODE_ENV': '"production"' } }) await writeFile( join(outDir, 'index.html'), '' + `` + // A flex column at the viewport's height: the component's outermost `View` is `flex: 1`, and // in a plain block container that resolves to no height at all and the frame never paints. '
' + '' ) const sealed = await createBundleServer({ outDir, // Per document, because each arm's policy names an endpoint carrying that arm's nonce. cspHeader: (request) => cspReports.policyFor(shippedCsp, request), documentHeaders: shippedDocumentHeaders, handleRequest: (request, response, path) => cspReports.handleRequest(request, response, path) }) sealedServer = sealed.server origins.shipped = sealed.origin const bare = await createBundleServer({ outDir, cspHeader: null }) openServer = bare.server origins.none = bare.origin const leaky = await createBundleServer({ outDir, cspHeader: shippedCsp, documentHeaders: { 'Referrer-Policy': 'unsafe-url' } }) leakyServer = leaky.server origins.leaky = leaky.origin const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER browsers.chromium = await chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}) }) // No override for WebKit: there is no system WebKit for Playwright to borrow, so a runner without // the download skips rather than testing Chromium twice under another name. browsers.webkit = await webkit.launch({ headless: true }).catch(() => null) }, 300_000) afterAll(async () => { await browsers.chromium?.close() await browsers.webkit?.close() sealedServer?.close() openServer?.close() leakyServer?.close() foreign?.close() assetServer?.server.close() if (scratch) { // This run's directory only: `mobile/.tmp` is a shared ignored root and another suite may hold // one of its own. await rm(scratch, { recursive: true, force: true }) } }) /** The rig's own state, handed to the driver: one object rather than a module of loose bindings. */ const rig = { origins, foreignHits, cspReports, clip: FRAME_PROBE, nonce: 0, foreignOrigin: null, assetServer: null } /** Mounts the preview with one artifact and reports everything a case can assert on. */ const open = async (browser, options) => await openPreviewArm(rig, browser, options) for (const engine of ['chromium', 'webkit']) { describeRender( `the HTML preview's sealed frame on ${engine}`, () => { const browser = () => { const one = browsers[engine] if (!one) { throw new Error(`${engine} is not installed for playwright-core`) } return one } it('paints the artifact under the policy the shell already ships', async (ctx) => { const read = await open(browser(), { signal: ctx.signal }) expect(read.frameCount).toBe(1) // The artifact is the frame's own document, not something it went and fetched: `srcdoc` // carries it and there is no `src` at all. Read from the element rather than from the // frame's URL, which is `about:srcdoc` on one browser and empty on another. expect(read.mountedSrcDoc).toContain('ARTIFACT_RENDERED') expect(read.mountedSrc).toBeNull() // The rendered frame carries the constant, so the token case below is about the frame the // page mounts rather than about a string nothing reads. expect(read.mountedSandbox).toBe(read.declaredSandbox) expect(read.mountedSandbox).toBe('allow-top-navigation-by-user-activation') // The policy this document was served is the shell's own text plus the rig's report // endpoint, and nothing else: `report-uri` says where a refusal is sent and changes nothing // about what is enforced, so the arms below measure the shipped policy. const servedParts = (read.servedCsp ?? '').split('; report-uri ') expect(servedParts[0]).toBe(shippedCsp) expect(servedParts).toHaveLength(2) // The pixel, not a read inside the frame: the frame is an opaque origin. expect(read.pixel).toBe(ARTIFACT_RGB) // The shell page's own violations, which is all this can be: `securitypolicyviolation` does // not cross into a frame, so an empty list here says the embedder raised none -- not that the // frame raised none. What the frame's inherited policy did to the frame is measured where it // can be: the pixel above is its inline `