/**
* 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 `