/**
* 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 { PNG } from 'pngjs'
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, reportedDirectives } from './mobile-web-app-preview-csp-reports.mjs'
import { recordRequestsTo } from './mobile-web-app-preview-request-log.mjs'
import { startArtifactAssetServer } from './mobile-web-app-preview-asset-server.mjs'
import { watchImageEvidence } from './mobile-web-app-preview-image-evidence.mjs'
import {
ARTIFACT_RGB,
ENTRY_SOURCE,
artifact,
artifactScript
} from './mobile-web-app-preview-artifact-fixture.mjs'
import {
previewFrame,
settleAfterMount,
waitForLoadedFrame,
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
let nonceCounter = 0
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)}`
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)
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 })
}
})
/**
* Mounts the preview with one artifact and reports everything a case can assert on.
*
* `csp: null` is the control arm. The foreign origin's hit list is reset per open, so what it holds
* is this artifact's doing.
*/
async function open(
browser,
{
extra = {},
csp = 'shipped',
sandbox,
act,
expectNavigation = null,
frameReady = 'artifact',
assets,
reportReady = null,
signal
} = {}
) {
const origin = origins[csp === 'shipped' ? 'shipped' : csp === 'leaky' ? 'leaky' : 'none']
nonceCounter += 1
const nonce = `n${String(nonceCounter)}`
// Read here and carried as a string: asked for at the abort it lost its race with teardown and
// printed "browser unknown" in the CI log this diagnostic exists for.
const browserVersion = browser.version()
// An explicit context, so an arm that aborts mid-read can hand back everything it holds. The
// arms share one browser per engine; only the context is theirs.
// The asset listener's certificate is generated per run and trusted by nothing, which is what
// this flag is for; the page's own origin is still plain http from the bundle server.
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
ignoreHTTPSErrors: true
})
const page = await context.newPage()
// Subscribed before the first navigation, so a request made during load is in the log. Cheap
// while an arm passes: it fills arrays, and only an abort asks them to speak.
const requestLog = await recordRequestsTo(page, assetServer.origin)
// Asked only when an arm has aborted, so the fresh-image probe and its wait cost a failing run
// and never a passing one.
const describeRequests = watchImageEvidence(page, assetServer.origin, requestLog, assetServer.saw)
try {
const navigations = []
const popups = []
let servedCsp = null
page.on('response', (response) => {
if (response.url().startsWith(`${origin}/preview`)) {
servedCsp = response.headers()['content-security-policy'] ?? null
}
})
page.on('popup', (popup) => {
popups.push(popup.url())
void popup.close().catch(() => {})
})
// The record is the page's own event, not the route handler's. Interception is per target and
// attaches late on a Chrome that isolates the sandboxed frame, which is what left the CI log
// saying `recorded []`; `page.on('request')` is one subscription over every frame the page has.
// Armed after the rig's own `goto`, exactly where the route used to be registered: the initial
// navigation is a main-frame navigation to this origin and would otherwise count as one the
// artifact asked for.
let recordingNavigations = false
page.on('request', (request) => {
if (!recordingNavigations || !request.isNavigationRequest()) {
return
}
const url = request.url()
if (!url.startsWith(foreignOrigin) && !url.startsWith(origin)) {
return
}
navigations.push({
url,
foreign: url.startsWith(foreignOrigin),
main: request.frame() === page.mainFrame()
})
})
// The route stays for what only a route can do: refuse the navigation. Playwright is not the
// shell, so a top-frame navigation is aborted here the way the shell's delegate would refuse
// it, and a frame navigating itself is left alone -- aborting that would make "the frame stayed
// on the artifact" true by the rig's own doing.
const record = (route) => {
const request = route.request()
if (request.isNavigationRequest() && request.frame() === page.mainFrame()) {
return void route.abort()
}
return void route.continue()
}
await page.route(`${foreignOrigin}/**`, record)
// The shell page's violations, and only those: an artifact's own listener would have to run, and
// the fence under test is that nothing in the artifact runs.
await page.addInitScript(() => {
// When this ran, in every frame it ran in. The collector below can only report what it was
// present for, so its own moment is a reading rather than an assumption.
window.__initAt = `${String(Math.round(performance.now()))} ${document.readyState}`
window.__violations = []
document.addEventListener('securitypolicyviolation', (event) => {
window.__violations.push(`${event.violatedDirective} ${event.blockedURI || 'inline'}`)
})
})
// The nonce in the document's own URL: the policy this response carries names a report endpoint
// with the same nonce, which is how a report from a `srcdoc` frame with no URL of its own is
// attributed to the arm that caused it.
await page.goto(`${origin}/preview?n=${nonce}`, { waitUntil: 'load' })
recordingNavigations = true
// Registered after the page's own load, not before it: this handler aborts main-frame navigations
// and the initial `goto` is one. `href="/"` and `href=""` inside an artifact resolve against the
// embedder's base, so a tap on either asks to navigate the top frame to the shell's own document.
// The rig has no shell, so what this pins is the request the shell is handed; refusing it is
// `MobileWebShellDroppedNavigationTest`'s "refuses every navigation to the document that the shell
// did not ask for" and its `checkNavigationVerdict` twin on iOS.
await page.route(`${origin}/**`, record)
// `sandbox` undefined is the product's own token, which is what every non-control case runs.
await page.evaluate(
([html, override]) => window.__mount(html, override),
[
artifact({ links: foreignOrigin, assets: assets ?? foreignOrigin, extra, nonce }),
sandbox ?? null
]
)
// Named in every diagnostic, because the log shows the case and not which of its arms spoke.
const arm =
`arm csp=${csp} sandbox=${sandbox ?? 'product'} frameReady=${frameReady} ` +
`reportReady=${reportReady ?? 'none'} nonce=${nonce}`
// One reader for the wait and for the reading: an arm that waits on one list and asserts on
// another proves nothing about the list it asserts on.
const readImageHits = () => assetServer.hitsFor(nonce)
const artifactFrame = await waitForLoadedFrame(page, {
frameReady,
reportReady,
signal,
browserVersion,
arm,
sink: cspReports,
nonce,
readImageHits,
describeRequests
})
// Sampled before the action as well as after: a case that taps a link is asking what the tap
// produced, and by then the top frame is mid-navigation and the iframe has blanked to its own
// background. So the precondition "there was a rendered artifact to tap" is this reading, and the
// one below is only meaningful for a case that did nothing.
const pixelBefore = await probePixel(page)
const readToggles = async () =>
await page
.evaluate(() =>
[...document.querySelectorAll('[role="tab"]')].map((one) => ({
label: one.getAttribute('aria-label'),
selected: one.getAttribute('aria-selected')
}))
)
.catch(() => null)
// Sampled before the action as well, because the toggle's whole claim is that it changes.
const togglesBefore = await readToggles()
let actError = null
if (act) {
// Recorded, never swallowed: a click that never landed and a click that produced no
// navigation are the same empty counter, and only one of them is the product's doing.
await act({ page, frame: previewFrame(page) }).catch((error) => {
actError = String(error).split('\n')[0]
})
}
// Every arm settles, acting or not: an artifact can start a navigation with no tap behind it --
// `` is one -- and the arms that pin zero were reading their counters
// while that was still in flight.
await settleAfterMount(page, navigations, expectNavigation, signal, {
frame: artifactFrame,
browserVersion,
arm,
describeRequests
})
const result = {
page,
pixelBefore,
pixel: await probePixel(page),
declaredSandbox: await page.evaluate(() => window.__sandbox),
// What the toolbar emits into the DOM, not what the component was handed: react-native-web
// forwards `aria-*` and drops `accessibilityState` on the floor, so a selected state that reads
// fine in the test renderer can reach a screen reader as nothing at all.
togglesBefore,
toggles: await readToggles(),
// The attribute on the element the component actually rendered, not the constant it exports: a
// literal in the JSX would leave the constant correct and the frame unsealed, which is what the
// control run for this file did before this reading existed.
mountedSandbox: await page
.evaluate(() => document.querySelector('iframe')?.getAttribute('sandbox') ?? null)
.catch(() => null),
frameCount: page.frames().length - 1,
// Reported so a pixel that read the page instead of the frame names the layout rather than
// looking like a frame that refused to load.
frameBox: await page
.evaluate(() => {
const frame = document.querySelector('iframe')
if (!frame) {
return null
}
const box = frame.getBoundingClientRect()
return { x: box.x, y: box.y, width: box.width, height: box.height }
})
.catch(() => null),
// Reported, never asserted on: a `srcdoc` frame's URL reads `about:srcdoc` here and empty on
// CI's browser, so nothing may be decided by it.
frameUrl: previewFrame(page)?.url() ?? null,
// The element's own attributes, which is where "the artifact is parsed inside the frame rather
// than fetched into it" actually lives.
mountedSrcDoc: await page
.evaluate(() => document.querySelector('iframe')?.getAttribute('srcdoc') ?? null)
.catch(() => null),
mountedSrc: await page
.evaluate(() => document.querySelector('iframe')?.getAttribute('src') ?? null)
.catch(() => null),
inside: await (previewFrame(page)
?.evaluate(() => ({
marker: document.getElementById('marker')?.textContent ?? null,
title: document.title,
ran: document.documentElement.dataset.ran === '1' ? 1 : 0,
threw: document.documentElement.dataset.threw ?? null,
// The two moments the late-listener question turns on: when the page's init script ran in
// this frame, and when the artifact's own script did.
initAt: window.__initAt ?? null,
artifactAt: document.documentElement.dataset.artifactAt ?? null,
// The frame's own list, not the embedder's: `securitypolicyviolation` does not cross frames,
// and the page's init script installs the same collector in every one.
violations: window.__violations ?? null
}))
.catch(() => null) ?? Promise.resolve(null)),
// What this document was actually served, so "the shipped policy, plus a report endpoint and
// nothing else" is asserted rather than intended.
servedCsp,
// Every refusal the browser reported for this arm, which is the evidence an in-frame listener
// cannot be relied on to have collected.
reported: reportedDirectives(cspReports, nonce),
// Null on every arm that acted successfully, and on every arm that did not act at all.
actError,
topNavigations: navigations.filter((one) => one.main && one.foreign).length,
ownOriginTopNavigations: navigations.filter((one) => one.main && !one.foreign).length,
// What the frame asked for itself at the embedder's origin, which is a different escape from a
// top-frame request and is refused by a different line of the policy.
ownOriginFrameNavigations: navigations.filter((one) => !one.main && !one.foreign).length,
popups: popups.length,
// This arm's fetches only, by nonce: the paths, with the nonce stripped, so a case reads the
// subresource rather than the bookkeeping.
foreignHits: foreignHits
.filter((one) => one.includes(`n=${nonce}`))
.map((one) => one.split('?')[0]),
// Same shape as `foreignHits` and read the same way: this arm's requests only, by nonce, as
// paths. Absolute URLs go in, so the origin is stripped along with the query.
secureHits: readImageHits(),
// What each admitted request carried, this arm's only, so an absence is this artifact's.
// Read off the header the listener received rather than off a request object handed to a
// route: the header on the wire is what the shell's `Referrer-Policy` is about.
secureReferers: assetServer.referersFor(nonce),
violations: await page.evaluate(() => window.__violations),
body: await page.evaluate(() => document.body.innerText)
}
return result
} finally {
// The context and not just the page: an arm whose wait aborted still owns one, and the case
// after it runs on the same browser. On the happy path this is the close that always ran.
await page.close().catch(() => {})
await context.close().catch(() => {})
}
}
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 `