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 `ARTIFACT +${extra.head ?? ''} +

ARTIFACT_RENDERED

b
f
+ +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 `