diff --git a/config/scripts/build-mobile-web-app-bundle.mjs b/config/scripts/build-mobile-web-app-bundle.mjs index 30cceec9d53..9fe78e907ba 100644 --- a/config/scripts/build-mobile-web-app-bundle.mjs +++ b/config/scripts/build-mobile-web-app-bundle.mjs @@ -160,7 +160,11 @@ function routeManifestPlugin(manifestSource) { } } -const lucideBarrelPlugin = { +/** + * Exported so a component-level render check builds the icons the same way the page does, rather + * than carrying a second copy of this shim that could drift from it. + */ +export const lucideBarrelPlugin = { name: LUCIDE_PLUGIN_NAME, setup(build) { build.onLoad({ filter: /lucide-react-native[\\/].*[\\/]context\.mjs$/ }, async (args) => ({ diff --git a/config/scripts/mobile-web-app-html-preview-render.test.mjs b/config/scripts/mobile-web-app-html-preview-render.test.mjs new file mode 100644 index 00000000000..16943fd7f39 --- /dev/null +++ b/config/scripts/mobile-web-app-html-preview-render.test.mjs @@ -0,0 +1,849 @@ +/** + * 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 } 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 } from './mobile-web-app-render-harness.mjs' +import { describePreviewFrame, untilAborted } from './mobile-web-app-preview-frame-diagnosis.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 artifact fills itself with this, so one pixel says the frame parsed and painted. */ +const ARTIFACT_RGB = '0,128,255' +/** The page behind the frame, so a frame that painted nothing reads as this instead. */ +const PAGE_RGB = '17,17,17' + +/** + * The page under test: the real web sibling, mounted by react-native-web, with nothing else on it. + * + * The component is imported rather than reimplemented, and `resolveExtensions` puts `.web.tsx` first + * so this is the file the bundle ships. `renderSource` is a marker the Source case looks for. + */ +const ENTRY_SOURCE = ` +import { createElement } from 'react' +import { createRoot } from 'react-dom/client' +import { Text } from 'react-native' +import { MobileHtmlPreview, MOBILE_HTML_PREVIEW_SANDBOX } from './MobileHtmlPreview' + +window.__sandbox = MOBILE_HTML_PREVIEW_SANDBOX +window.__mount = (html, sandboxOverride) => { + const host = document.getElementById('root') + createRoot(host).render( + createElement(MobileHtmlPreview, { + html, + renderSource: () => createElement(Text, null, 'SOURCE_TAB_RENDERED') + }) + ) + // A control arm needs a frame the product would never build -- one with allow-scripts -- so that + // "the script did not run" can be told apart from "the fixture has no script". Built here rather + // than through a prop, because the product takes no such prop and must not grow one for a test. + // + // Awaited rather than read straight away: createRoot().render() commits on React's own schedule, + // and reading the element synchronously finds nothing. + if (sandboxOverride === null) { + return Promise.resolve() + } + return new Promise((resolve, reject) => { + // Twenty seconds for a commit that takes a frame or two here: the reads this rig makes all + // settle late on a loaded runner, which is the whole reason nothing below is timed. + const deadline = Date.now() + 20000 + const apply = () => { + const frame = host.querySelector('iframe') + if (frame) { + // A new element rather than the live one relaxed, because a live frame cannot be relaxed: + // sandbox flags are fixed on a browsing context when it is created, and Chrome 152 keeps the + // original ones through a srcdoc reassignment while still parsing the new document. An arm + // that ran on such a frame reports the sealed behaviour under a widened name and passes for + // the wrong reason, which is exactly what CI read while Chromium 147 here honoured the + // relaxation. The clone gets its own context from creation, the way the product does it: + // React sets the attribute before the element is inserted, and never afterwards. + const widened = frame.cloneNode(false) + widened.setAttribute('sandbox', sandboxOverride) + widened.srcdoc = html + // Resolved on the document the insertion commits, not on the insertion. + widened.addEventListener('load', () => resolve(), { once: true }) + frame.replaceWith(widened) + return + } + if (Date.now() > deadline) { + reject(new Error('the preview never mounted a frame to override')) + return + } + requestAnimationFrame(apply) + } + apply() + }) +} +` + +/** 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 + +/** + * One artifact, with every escape route a hostile one would try. + * + * `extra.head` and `extra.body` let a case add a `` or a script without a second + * fixture, so the thing under test is the only difference between the arms. + */ +function artifact(extra = {}, nonce = 'n0') { + // Every foreign URL carries this arm's nonce, because a closed page's requests can still land and + // a hit list shared across arms would report the previous one's fetches as this one's. + const tag = `?n=${nonce}` + return `
+tap
+window
+root
+empty
+
+${extra.body ?? ''}`
+}
+
+let nonceCounter = 0
+
+/** The inline script every arm carries, so "it did not run" is about the fence and not the fixture. */
+const ARTIFACT_SCRIPT = ``
+
+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
+const origins = {}
+
+beforeAll(async () => {
+ shippedCsp = await readShellCsp()
+ 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-'))
+ 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, cspHeader: shippedCsp })
+ sealedServer = sealed.server
+ origins.shipped = sealed.origin
+ const bare = await createBundleServer({ outDir, cspHeader: null })
+ openServer = bare.server
+ origins.none = bare.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()
+ foreign?.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',
+ signal
+ } = {}
+) {
+ const origin = csp === 'shipped' ? origins.shipped : origins.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()
+ const page = await browser.newPage({ viewport: { width: 390, height: 844 } })
+ const navigations = []
+ const popups = []
+ page.on('popup', (popup) => {
+ popups.push(popup.url())
+ void popup.close().catch(() => {})
+ })
+ // The shell's navigation delegate, stood in for: Playwright is not the shell, so a top-frame
+ // navigation is recorded with the frame that asked and aborted. That count is exactly what the
+ // shell's `onExternalNavigation` would be handed.
+ const record = (route) => {
+ const request = route.request()
+ if (request.isNavigationRequest()) {
+ const main = request.frame() === page.mainFrame()
+ navigations.push({
+ url: request.url(),
+ foreign: request.url().startsWith(foreignOrigin),
+ main
+ })
+ // A frame navigating itself is counted and then left alone: aborting it would make "the frame
+ // stayed on the artifact" true by the rig's own doing.
+ if (main) {
+ 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(() => {
+ window.__violations = []
+ document.addEventListener('securitypolicyviolation', (event) => {
+ window.__violations.push(`${event.violatedDirective} ${event.blockedURI || 'inline'}`)
+ })
+ })
+ await page.goto(`${origin}/preview`, { waitUntil: 'load' })
+ // 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(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} nonce=${nonce}`
+ const artifactFrame = await waitForLoadedFrame(page, frameReady, signal, browserVersion, arm)
+ const frames = () => page.frames().filter((frame) => frame !== page.mainFrame())
+ // 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()
+ if (act) {
+ await act({ page, frame: frames()[0] ?? null })
+ }
+ // 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
+ })
+ 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: frames().length,
+ // 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: frames()[0]?.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 (frames()[0]
+ ?.evaluate(() => ({
+ marker: document.getElementById('marker')?.textContent ?? null,
+ title: document.title,
+ ran: window.__ran ?? 0,
+ threw: window.__threw ?? 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)),
+ 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]),
+ violations: await page.evaluate(() => window.__violations),
+ body: await page.evaluate(() => document.body.innerText)
+ }
+ await page.close()
+ return result
+}
+
+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 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 `