Merge branch 'main' into ota-c7-5c-scope-threading

This commit is contained in:
Jinwoo Hong
2026-09-21 03:44:55 -04:00
committed by GitHub
36 changed files with 2048 additions and 162 deletions
@@ -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) => ({
@@ -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 `<meta refresh>` 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 `<!doctype html><html><head><title>ARTIFACT</title>
<style>html,body{margin:0;height:100%;background:rgb(${ARTIFACT_RGB})}
#bg{background-image:url("${foreignOrigin}/css-bg.png${tag}")}
@font-face{font-family:probe;src:url("${foreignOrigin}/probe.woff2${tag}")}
#fonted{font-family:probe}</style>${extra.head ?? ''}</head><body>
<h1 id="marker">ARTIFACT_RENDERED</h1><div id="bg">b</div><div id="fonted">f</div>
<img id="remote" src="${foreignOrigin}/img.png${tag}" />
<a id="toplink" href="${foreignOrigin}/tapped.html${tag}" target="_top">tap</a>
<a id="blanklink" href="${foreignOrigin}/blank.html${tag}" target="_blank">window</a>
<a id="rootlink" href="/" target="_top">root</a>
<a id="emptylink" href="" target="_top">empty</a>
<form id="topform" action="${foreignOrigin}/form.html" target="_top" method="get"><button id="submit">go</button></form>
${extra.body ?? ''}</body></html>`
}
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 = `<script>
window.__ran = 1;
document.title = 'SCRIPT_RAN';
document.getElementById('marker').textContent = 'SCRIPT_RAN';
fetch('${'${foreignOrigin}'}/fetched.json').catch(() => {});
try { window.top.location.href = '${'${foreignOrigin}'}/by-script.html' } catch (error) { window.__threw = error.name }
</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('<html><body>FOREIGN</body></html>')
})
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'),
'<!doctype html><html><head><meta charset="utf-8"></head>' +
`<body style="margin:0;background:rgb(${PAGE_RGB})">` +
// 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.
'<div id="root" style="display:flex;flex-direction:column;height:100vh"></div>' +
'<script src="/html-preview-check.js"></script></body></html>'
)
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 --
// `<meta http-equiv="refresh">` 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 `<style>` applying, and the counting server in the
// case below is its `img-src` and `font-src`.
expect(read.violations).toEqual([])
}, 120_000)
it('does not run the artifact, behind two fences either of which would hold', async (ctx) => {
const sealed = await open(browser(), { extra: { body: script() }, signal: ctx.signal })
expect(sealed.pixel).toBe(ARTIFACT_RGB)
expect(sealed.inside?.ran).toBe(0)
expect(sealed.inside?.title).toBe('ARTIFACT')
expect(sealed.inside?.marker).toBe('ARTIFACT_RENDERED')
// The oracle's presence precondition: grant the frame `allow-scripts` and drop the policy,
// and this very fixture runs. Without this arm, "did not run" is also what an artifact with
// no script in it reports.
const loose = await open(browser(), {
signal: ctx.signal,
extra: { body: script() },
csp: null,
sandbox: 'allow-scripts allow-top-navigation-by-user-activation',
// The oracle here is what the script did, and the marker element exists before it runs,
// so this arm waits for the script's own write instead.
frameReady: 'script'
})
expect(loose.pixel).toBe(ARTIFACT_RGB)
expect(loose.inside?.ran).toBe(1)
expect(loose.inside?.title).toBe('SCRIPT_RAN')
// Nothing refused it, which is what "no policy" looks like from inside the frame.
expect(loose.inside?.violations).toEqual([])
// The second fence, measured on its own: grant `allow-scripts` and keep the shipped policy,
// and the script still does not run, because a `srcdoc` frame inherits its embedder's
// `script-src 'self'` and the artifact's script is inline. So the seal does not rest on the
// sandbox attribute alone -- which is what makes the token list below a defence in depth
// rather than the only thing standing between the page and an agent's script.
const inherited = await open(browser(), {
signal: ctx.signal,
extra: { body: script() },
sandbox: 'allow-scripts allow-top-navigation-by-user-activation'
})
expect(inherited.pixel).toBe(ARTIFACT_RGB)
expect(inherited.inside?.ran).toBe(0)
expect(inherited.inside?.title).toBe('ARTIFACT')
// This arm's own precondition, and the thing CI showed a rig can get wrong: a frame that was
// never really widened refuses the script too, silently and with no event, and would pass
// every line above under a name that says the policy held. A violation raised inside the
// frame can only happen if the sandbox let the script start, so this is the reading that
// separates the two -- and it is the frame's own list, since the embedder's never sees it.
expect(String(inherited.inside?.violations)).toContain('script-src')
// The sealed arm is the contrast: no policy refused anything there, the sandbox simply never
// let the script begin.
expect(sealed.inside?.violations).toEqual([])
}, 180_000)
it('fetches nothing of the artifact that leaves the origin, and would if allowed', async (ctx) => {
const sealed = await open(browser(), { signal: ctx.signal })
expect(sealed.pixel).toBe(ARTIFACT_RGB)
expect(sealed.foreignHits).toEqual([])
// The control: with no policy the same three subresources are fetched, so the empty list
// above is the inherited `img-src` and `font-src` and not an artifact that never parsed.
const control = await open(browser(), { csp: null, signal: ctx.signal })
expect(control.pixel).toBe(ARTIFACT_RGB)
expect(control.foreignHits).toEqual(
expect.arrayContaining(['/img.png', '/css-bg.png', '/probe.woff2'])
)
}, 120_000)
it('asks to navigate the top frame to the shell itself, which the shell must refuse', async (ctx) => {
// `href="/"` resolves against the embedder's base, so this is a request to load the shell's
// own document -- one tap that would clear the bridge target, restart the load state and
// lose the page. The browser hands it up like any other, so refusing it is the shell's job
// and the native tests named above are where that is pinned; what this counts is that the
// request is real and reaches the shell at all.
const root = await open(browser(), {
signal: ctx.signal,
expectNavigation: 'main-frame',
act: async ({ frame }) => {
await frame?.click('#rootlink', { timeout: 2000 }).catch(() => {})
}
})
expect(root.pixelBefore).toBe(ARTIFACT_RGB)
expect(root.ownOriginTopNavigations).toBe(1)
expect(root.topNavigations).toBe(0)
// `href=""` is the same navigation spelled as "this document", and it resolves the same way.
const empty = await open(browser(), {
signal: ctx.signal,
expectNavigation: 'main-frame',
act: async ({ frame }) => {
await frame?.click('#emptylink', { timeout: 2000 }).catch(() => {})
}
})
expect(empty.pixelBefore).toBe(ARTIFACT_RGB)
expect(empty.ownOriginTopNavigations).toBe(1)
expect(empty.topNavigations).toBe(0)
}, 180_000)
it("hands a user's tap on a link to the top frame, exactly once", async (ctx) => {
const read = await open(browser(), {
signal: ctx.signal,
expectNavigation: 'main-frame',
act: async ({ frame }) => {
await frame?.click('#toplink', { timeout: 2000 }).catch(() => {})
}
})
expect(read.pixelBefore).toBe(ARTIFACT_RGB)
expect(read.topNavigations).toBe(1)
expect(read.ownOriginTopNavigations).toBe(0)
expect(read.popups).toBe(0)
}, 120_000)
it("cannot reach the shell through a meta refresh at the embedder's own URL", async (ctx) => {
// `content="0;url=/"` resolves against the embedder's base, so this is the artifact asking
// for the shell's own document with no tap behind it. The foreign meta-refresh arm below
// cannot say anything about that: its URL is off-origin, so its own-origin count is zero
// whatever the frame did.
const own = await open(browser(), {
signal: ctx.signal,
extra: { head: '<meta http-equiv="refresh" content="0;url=/">' }
})
// The frame is still showing the artifact, so what follows is about a refusal rather than
// about a frame that never rendered.
expect(own.pixelBefore).toBe(ARTIFACT_RGB)
// Zero against a counter that is not blind: the `href="/"` case above reads exactly 1 on this
// same reading, from this same rig.
expect(own.ownOriginTopNavigations).toBe(0)
expect(own.topNavigations).toBe(0)
// The other escape the same fixture could take: the frame fetching the shell's document for
// itself, which would put the session's own page inside the preview.
expect(own.ownOriginFrameNavigations).toBe(0)
// That zero's presence precondition: give the frame `allow-same-origin` and drop the policy
// and this very fixture navigates the frame to the embedder's `/`, so the reading is not
// blind.
const loose = await open(browser(), {
signal: ctx.signal,
csp: null,
sandbox: 'allow-scripts allow-same-origin allow-top-navigation',
extra: { head: '<meta http-equiv="refresh" content="0;url=/">' },
// This arm's frame leaves the artifact behind, which is the whole point of it, so the
// marker is not what says it is ready, and the navigation it makes is what it waits for.
frameReady: 'load',
expectNavigation: 'frame'
})
expect(loose.ownOriginFrameNavigations).toBe(1)
// Two fences, either of which would hold, each run with the other taken away -- the shape
// the script case above uses, rather than a claim in a comment.
//
// The token alone: no policy at all, and the navigation never starts, so nothing is served
// and nothing is reported.
const tokenOnly = await open(browser(), {
signal: ctx.signal,
csp: null,
extra: { head: '<meta http-equiv="refresh" content="0;url=/">' }
})
expect(tokenOnly.pixelBefore).toBe(ARTIFACT_RGB)
expect(tokenOnly.ownOriginFrameNavigations).toBe(0)
expect(tokenOnly.ownOriginTopNavigations).toBe(0)
expect(tokenOnly.violations).toEqual([])
// The policy alone: grant `allow-same-origin`, keep the shipped header, and the navigation
// does start -- and `frame-src 'none'` refuses it, which the embedder reports as its own
// violation because a parent's policy governs where its frame may go. The engines differ
// only in what is left behind: chromium swaps an error page into the frame, WebKit leaves
// the artifact showing. Neither is asserted; the request never reaching the server is.
const policyOnly = await open(browser(), {
signal: ctx.signal,
sandbox: 'allow-scripts allow-same-origin allow-top-navigation',
extra: { head: '<meta http-equiv="refresh" content="0;url=/">' },
frameReady: 'load'
})
expect(policyOnly.ownOriginFrameNavigations).toBe(0)
expect(policyOnly.ownOriginTopNavigations).toBe(0)
expect(policyOnly.violations.join(' ')).toContain('frame-src')
}, 180_000)
it('hands up nothing without a tap, and nothing for a form or a new window', async (ctx) => {
const meta = await open(browser(), {
signal: ctx.signal,
extra: { head: `<meta http-equiv="refresh" content="0;url=${foreignOrigin}/meta.html">` }
})
expect(meta.topNavigations).toBe(0)
expect(meta.ownOriginTopNavigations).toBe(0)
const form = await open(browser(), {
signal: ctx.signal,
act: async ({ frame }) => {
await frame?.click('#submit', { timeout: 2000 }).catch(() => {})
}
})
expect(form.pixelBefore).toBe(ARTIFACT_RGB)
expect(form.topNavigations).toBe(0)
const blank = await open(browser(), {
signal: ctx.signal,
act: async ({ frame }) => {
await frame?.click('#blanklink', { timeout: 2000 }).catch(() => {})
}
})
expect(blank.pixelBefore).toBe(ARTIFACT_RGB)
expect(blank.topNavigations).toBe(0)
expect(blank.popups).toBe(0)
}, 180_000)
it('keeps the Preview/Source toggle, and Source shows the source', async (ctx) => {
const read = await open(browser(), {
signal: ctx.signal,
act: async ({ page }) => {
await page.getByLabel('View HTML source').click({ timeout: 2000 })
}
})
// Both positions announce which one is showing, before and after the tap. Asserted on the
// DOM because that is where a screen reader reads it.
expect(read.togglesBefore).toEqual([
{ label: 'Preview rendered HTML', selected: 'true' },
{ label: 'View HTML source', selected: 'false' }
])
expect(read.toggles).toEqual([
{ label: 'Preview rendered HTML', selected: 'false' },
{ label: 'View HTML source', selected: 'true' }
])
expect(read.body).toContain('SOURCE_TAB_RENDERED')
// The frame went with the preview, which is why the toggle is not a control that lies.
expect(read.frameCount).toBe(0)
expect(read.pixel).toBe(PAGE_RGB)
}, 120_000)
},
600_000
)
}
/** The artifact's inline script, with the foreign origin the fixture is built against. */
function script() {
return ARTIFACT_SCRIPT.replaceAll('${foreignOrigin}', foreignOrigin)
}
describe('the HTML preview needs no policy change', () => {
it('runs under a policy that still forbids every nested frame by URL', async () => {
const directives = (await readShellCsp()).split('; ')
// A `srcdoc` frame has no URL for `frame-src` to match, so the sealed box costs nothing here.
// Pinned so a future relaxation is a decision rather than a side effect of this component.
expect(directives).toContain("frame-src 'none'")
expect(directives).toContain("child-src 'none'")
expect(directives).toContain("script-src 'self'")
expect(directives).toContain("frame-ancestors 'none'")
})
it('grants exactly one sandbox token, and neither of the two that would unseal the frame', async () => {
const source = await readFileText('mobile/src/components/MobileHtmlPreview.web.tsx')
const match = /MOBILE_HTML_PREVIEW_SANDBOX = '([^']*)'/.exec(source)
expect(match).not.toBeNull()
const tokens = (match?.[1] ?? '').split(' ').filter((one) => one.length > 0)
expect(tokens).toEqual(['allow-top-navigation-by-user-activation'])
// Named rather than left to the list comparison: these two are the sealing invariant, and a
// reader of a failure should see which one was granted.
expect(tokens).not.toContain('allow-scripts')
expect(tokens).not.toContain('allow-same-origin')
})
})
/**
* The mounted frame, once it holds the artifact.
*
* Found by its element, never by its URL. A `srcdoc` frame reports `about:srcdoc` on both engines
* here and an empty URL on CI's browser, and a poll that waited for the string spent every case's
* whole timeout there -- seven timeouts on one engine, after the same difference had already shown
* up as `expected '' to be 'about:srcdoc'`.
*
* Three things still settle at their own moments: React commits the mount, the element's `srcdoc`
* commits a document, and an override arm replaces that document with a second one. So readiness is
* the fixture's own marker inside the frame, which exists only once the artifact has parsed there.
*
* `frameReady` is which of those an arm is waiting for, because the marker is not always the right
* one. `'script'` waits for what the inline script writes: the marker element exists from parse
* time, so an arm whose oracle is "the script ran" would otherwise read `window.__ran` before it
* had. `'load'` is for the one arm whose artifact deliberately navigates the frame somewhere else,
* where no marker is ever coming.
*/
async function waitForLoadedFrame(page, frameReady = 'artifact', signal, browserVersion, arm) {
const element = await page.waitForSelector('iframe', { timeout: 0 })
const frame = await element.contentFrame()
if (!frame) {
return null
}
await frame.waitForLoadState('load').catch(() => {})
if (frameReady === 'script') {
await untilAborted(
frame.waitForFunction(() => window.__ran === 1, undefined, { timeout: 0 }),
signal,
async () =>
`the artifact's script never ran inside the frame: ${arm} | ${await describePreviewFrame(page, frame, browserVersion)}`
)
}
if (frameReady !== 'load') {
await untilAborted(
frame.waitForSelector('#marker', { state: 'attached', timeout: 0 }),
signal,
async () =>
`the artifact never parsed inside the frame: ${arm} | ${await describePreviewFrame(page, frame, browserVersion)}`
)
}
return frame
}
/**
* Where an arm's counters are read: after the thing it is about, whatever that thing is.
*
* `expectNavigation` names what the arm is waiting for, and an arm that expects one waits for the
* record itself rather than for a clock. An arm that expects none has nothing to await, so it takes
* the bounded path below.
*/
async function settleAfterMount(page, navigations, expectNavigation, signal, reading) {
if (expectNavigation === 'main-frame') {
return await waitForRecordedNavigation(page, navigations, (one) => one.main, signal, reading)
}
if (expectNavigation === 'frame') {
return await waitForRecordedNavigation(
page,
navigations,
(one) => !one.main && !one.foreign,
signal,
reading
)
}
return await settleWithoutNavigation(page)
}
/**
* The moment the arm's navigation exists, for an arm that expects one.
*
* No clock at all: the route handler above records a main-frame navigation as the browser dispatches
* it, so the oracles are read after the thing under test rather than after a wait, and the only
* bound is the case's own timeout through `ctx.signal`. An arm whose click missed its target prints
* what it did record and lets the case fail as the timeout it is.
*
* Measured, so it is not sold as more than it is: with this replaced by a no-op every arm still
* passes, because the reads that follow are each a round trip and the record lands during them. It is
* the load the CI runner was under that this is for, which is the same condition that produced the
* frame-commit race above.
*/
async function waitForRecordedNavigation(page, navigations, matches, signal, reading) {
// Sampled while waiting, for the same reason `untilAborted` samples: a reading taken at the abort
// can lose its race with vitest's teardown and never reach the log.
let latest = 'no reading was taken before the case ended'
let since = Date.now()
while (!navigations.some((one) => matches(one))) {
if (signal?.aborted) {
console.error(
`[html-preview-render] the arm produced no navigation of the kind it expects; recorded ${JSON.stringify(navigations)}: ${reading?.arm ?? 'arm unknown'} | ${latest}`
)
return
}
if (Date.now() - since > 5000) {
since = Date.now()
latest = await describePreviewFrame(page, reading?.frame, reading?.browserVersion).catch(
(error) => `the reading itself failed: ${String(error).split('\n')[0]}`
)
}
await page.waitForTimeout(10)
}
}
/**
* Where an absence is read, for the arms that expect no navigation at all.
*
* Nothing signals "the tap produced nothing", so this one is bounded rather than awaited. Two painted
* frames inside the page come first: by the second, a navigation the click started has been dispatched
* and would already be in the list the arms above read. The 200 ms after it is for the popup queue,
* which is a browser-process event with no in-page counterpart to await.
*
* What keeps these absences honest is not the length of that wait: the arms that read 1 on the same
* counters take the path above, so a counter that had stopped counting reds there.
*/
async function settleWithoutNavigation(page) {
await page.evaluate(
() =>
new Promise((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
})
)
await page.waitForTimeout(200)
}
/** One pixel of the frame's own fill, which is what says the artifact parsed and painted. */
async function probePixel(page) {
const png = PNG.sync.read(await page.screenshot({ clip: FRAME_PROBE }))
return `${png.data[0]},${png.data[1]},${png.data[2]}`
}
async function readFileText(relativePath) {
const { readFile } = await import('node:fs/promises')
return await readFile(join(mobileDir, '..', relativePath), 'utf8')
}
@@ -0,0 +1,116 @@
/**
* What the HTML preview's render rig can still read when a frame never becomes ready, and the bound
* it reads at.
*
* Separate from the test file because the file is at its line limit and because these two are one
* thing: a wait that ends only with the case, and the reading it prints when it does. The rig's
* claims stay in the test; this is the instrument that reports why one could not be made.
*/
/**
* A wait bounded by the case's own timeout and by nothing else.
*
* `ctx.signal` aborts when vitest times a case out, so no number in here races the one the case
* declares. On abort the rig prints what the frame reported -- the reading that tells a frame the
* policy refused from one that was merely slow, which is what CI's chromium timeouts could not say.
*
* The reading is sampled while waiting and printed from the last sample, never read at the abort:
* a reading taken after the case has timed out loses its race with vitest's teardown, which is how
* a first attempt at this printed nothing at all. Nothing is rethrown either -- a rejection raised
* after vitest has given up has nobody left to catch it, and an unhandled one fails a run whose
* every test passed.
*/
export async function untilAborted(wait, signal, describe) {
let latest = 'no reading was taken before the case ended'
let sampling = true
const sample = async () => {
// Once at the start and then every five seconds, so a case that ends early still has a reading to
// print. A wait that only ever prints "no reading was taken" tells nobody anything.
while (sampling) {
latest = await describe().catch(
(error) => `the reading itself failed: ${String(error).split('\n')[0]}`
)
await new Promise((resolve) => setTimeout(resolve, 5000))
}
}
void sample()
await Promise.race([
wait,
new Promise((resolve) => {
if (!signal) {
return
}
const report = () => {
console.error(`[html-preview-render] ${latest}`)
resolve()
}
if (signal.aborted) {
// Silent: the case was already over when this wait began, so it has nothing of its own to
// report and the wait that did time out has already printed its reading.
resolve()
return
}
signal.addEventListener('abort', report, { once: true })
})
]).catch(() => {})
sampling = false
}
/**
* Everything a frame that never became ready can still be asked, which is the whole diagnosis when
* the only oracle is a runner.
*
* Three readings, because each is blind where the others see. The element's own attributes come from
* the embedder and survive a frame that never parsed. `contentDocument` and `contentWindow` answer
* only for a frame granted `allow-same-origin`, and say `refused` for the opaque ones, which is
* itself the answer to "is this arm same-origin". And every Playwright frame is evaluated through
* CDP, which reaches an opaque frame whose own scripts are blocked, so `readyState` separates a
* document that never parsed from one that parsed and did nothing.
*
* The violations are read per frame rather than from the top. `securitypolicyviolation` does not
* cross frames, so the top document's array says nothing about what the frame refused -- and the
* page's init script installs the same collector in every frame, measured on both engines, so each
* frame has its own array to report.
*/
export async function describePreviewFrame(page, frame, browserVersion) {
const host = await page
.evaluate(() => {
const element = document.querySelector('iframe')
const reach = (read) => {
try {
return read() ?? null
} catch {
return 'refused'
}
}
return {
srcdocChars: element?.getAttribute('srcdoc')?.length ?? null,
sandbox: element?.getAttribute('sandbox') ?? null,
contentReadyState: reach(() => element?.contentDocument?.readyState),
contentHref: reach(() => element?.contentWindow?.location.href),
topViolations: window.__violations ?? null
}
})
.catch((error) => `page refused: ${String(error).split('\n')[0]}`)
const frames = []
for (const one of page.frames()) {
const reading = await one
.evaluate(() => ({
readyState: document.readyState,
bodyChars: document.body?.innerHTML.length ?? null,
marker: document.getElementById('marker') !== null,
ran: window.__ran ?? null,
violations: window.__violations ?? 'absent'
}))
.catch((error) => `evaluate refused: ${String(error).split('\n')[0]}`)
frames.push(
`${JSON.stringify(one.url())} name ${JSON.stringify(one.name())} ${JSON.stringify(reading)}`
)
}
return [
`browser ${browserVersion ?? 'unknown'}`,
`awaited frame url ${JSON.stringify(frame?.url() ?? null)}`,
`host ${JSON.stringify(host)}`,
`frames [${frames.join(' ;; ')}]`
].join(' | ')
}
@@ -16,3 +16,102 @@ internal fun mobileWebShellDropsNavigation(
if (!isForMainFrame || originHost == null) return true
return resolveMobileWebShellRequestPath(parts, originHost) != "/"
}
/**
* What the shell does with one navigation: the whole decision, so the "allow it" half and the
* "offer it to the opener" half cannot drift apart. Kept in step with the iOS copy.
*/
internal sealed interface MobileWebShellNavigationVerdict {
/** The served document loading itself, which is the only navigation this WebView performs. */
data object Allow : MobileWebShellNavigationVerdict
/** Refused, and the host is told nothing. Every navigation was this before the preview existed. */
data object Cancel : MobileWebShellNavigationVerdict
/** Refused, and the URL is handed to the host's opener, which decides what may open. */
data class CancelAndOffer(val url: String) : MobileWebShellNavigationVerdict
}
/**
* Longest URL the shell hands back to JS for a dropped navigation.
*
* The page's own bound is `BRIDGE_MAX_EXTERNAL_LINK_CHARS` (2048) and the filter that applies it is
* `readBridgeExternalLinkUrl`, in TypeScript. This is not a second copy of that rule: it is a cap on
* what crosses the native boundary at all, so an artifact cannot spend the bridge on a URL the
* opener will refuse anyway.
*/
internal const val MOBILE_WEB_SHELL_MAX_DROPPED_NAVIGATION_URL_CHARS = 4096
/** The URL a dropped navigation may be offered under, or null when nothing crosses. */
internal fun mobileWebShellOfferableUrl(url: String?): String? {
if (url == null || url.isEmpty()) return null
return if (url.length > MOBILE_WEB_SHELL_MAX_DROPPED_NAVIGATION_URL_CHARS) null else url
}
/**
* The whole decision.
*
* Three rules, in this order, and the order is the design.
*
* A navigation outside the main frame is the sealed preview frame loading itself. It is refused and
* never offered: forwarding it would let an artifact ask for a browser with no tap behind it.
*
* **The document URL loads only when the shell asked for it, and on this platform it never asks
* here.** `WebViewClient`'s own javadoc: "This callback is not called for all page navigations. In
* particular, this is not called for navigations which the app initiated with loadUrl(): this
* callback would not serve a purpose in this case, because the app already knows about the
* navigation." So the view passes `isShellLoad = false` always, and every navigation that reaches
* this callback naming the document is refused and never offered -- offering the shell's own URL to
* the opener would send the user out of the app instead of reloading it. The parameter stays in the
* signature because the rule is shared with iOS, where `WKWebView` does route the view's own load
* through the delegate and the flag is what tells it apart.
*
* Nothing here rests on the host reporting a gesture. Chromium's own documentation allows
* `hasGesture()` to be false for a request a human started, and a sandboxed subframe navigating the
* top frame reports no gesture at all -- measured on WebKit, where the same navigation arrives as
* `.other`. Under a gesture-shaped rule that is an allow and a shell reload: the reply proxy
* dropped, the load state restarted, the page's state gone.
*
* `isFromSubframe` is the iOS twin's second discriminator, where the initiating frame is readable.
* Chromium does not report it here, and with nothing ever allowed there is nothing for it to guard:
* the window a raised flag used to leave open -- a generation switch and a tap inside it -- is gone
* with the flag.
*
* What a device proof has to look at instead is the other side of that decision. The javadoc's
* exemption is what this rests on; a WebView that did route the view's own load through here would
* have that load refused, and the load state would sit at `loading` rather than allowing a document
* to be replaced. An HTTP redirect out of `loadUrl` is routed here by design, and the shell serves
* its document itself with no redirect.
*
* What is left for the gesture is the only thing an artifact may ask for: a foreign URL, refused
* and handed to the opener. A download naming the document is refused by the rule above instead,
* without an offer, because `<a href="/" download>` is the shell's own URL however it is dressed.
* `isDownload` is always false here and is carried so this reads as its iOS twin does; Chromium
* never offers a download through `shouldOverrideUrlLoading`, it goes to the `DownloadListener` the
* view installs as a no-op.
*
* Which URLs may actually open is not decided here -- `readBridgeExternalLinkUrl` owns the scheme
* list, in the half that ships over the air.
*/
internal fun mobileWebShellNavigationVerdict(
url: String?,
isForMainFrame: Boolean,
isFromSubframe: Boolean,
isDocumentUrl: Boolean,
isShellLoad: Boolean,
hasGesture: Boolean,
isDownload: Boolean
): MobileWebShellNavigationVerdict {
if (!isForMainFrame) return MobileWebShellNavigationVerdict.Cancel
if (isDocumentUrl) {
return if (isShellLoad && !isFromSubframe && !isDownload) {
MobileWebShellNavigationVerdict.Allow
} else {
MobileWebShellNavigationVerdict.Cancel
}
}
if (!hasGesture) return MobileWebShellNavigationVerdict.Cancel
val offered = mobileWebShellOfferableUrl(url) ?: return MobileWebShellNavigationVerdict.Cancel
return MobileWebShellNavigationVerdict.CancelAndOffer(offered)
}
@@ -44,6 +44,7 @@ internal class OrcaMobileWebShellView(
) : ExpoView(context, appContext) {
private val onLoadState by EventDispatcher<Map<String, Any>>()
private val onBridgeMessage by EventDispatcher<Map<String, Any>>()
private val onExternalNavigation by EventDispatcher<Map<String, Any>>()
private var generationDirectory = ""
private var sessionId = ""
@@ -354,12 +355,34 @@ internal class OrcaMobileWebShellView(
return refusedResponse()
}
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean =
mobileWebShellDropsNavigation(
requestParts(request.url),
served?.originHost,
request.isForMainFrame
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
// `hasGesture` decides only what may be offered to the opener. Nothing is allowed on the
// strength of it: Chromium is permitted to report false for a request a human started, and a
// subframe can navigate the top frame with no gesture at all.
val verdict = mobileWebShellNavigationVerdict(
url = request.url?.toString(),
isForMainFrame = request.isForMainFrame,
// Not reported here, unlike WKNavigationAction.sourceFrame on iOS.
isFromSubframe = false,
isDocumentUrl = !mobileWebShellDropsNavigation(
requestParts(request.url),
served?.originHost,
request.isForMainFrame
),
// Always false, and it is the platform that says so. WebViewClient's own javadoc: "This
// callback is not called for all page navigations. In particular, this is not called for
// navigations which the app initiated with loadUrl(): this callback would not serve a purpose
// in this case, because the app already knows about the navigation." So there is no own-load
// window here to keep a flag for, and nothing reaching this callback is the shell's own load.
isShellLoad = false,
hasGesture = request.hasGesture(),
isDownload = false
)
if (verdict is MobileWebShellNavigationVerdict.CancelAndOffer) {
onExternalNavigation(mapOf("url" to verdict.url))
}
return verdict !is MobileWebShellNavigationVerdict.Allow
}
override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
// The document that spoke is being replaced, so its proxy stops being somewhere to post: the
@@ -8,7 +8,7 @@ class OrcaMobileWebShellModule : Module() {
Name("OrcaMobileWebShell")
View(OrcaMobileWebShellView::class) {
Events("onLoadState", "onBridgeMessage")
Events("onLoadState", "onBridgeMessage", "onExternalNavigation")
Prop("generationDirectory") { view: OrcaMobileWebShellView, value: String ->
view.setGenerationDirectory(value)
@@ -0,0 +1,148 @@
package expo.modules.orcamobilewebshell
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
private const val FOREIGN = "https://example.com/artifact-link"
private const val DOCUMENT = "orca-mobile-web://sess-01JN_aZ9/"
private fun verdict(
url: String? = FOREIGN,
isForMainFrame: Boolean = true,
isFromSubframe: Boolean = false,
isDocumentUrl: Boolean = false,
isShellLoad: Boolean = false,
hasGesture: Boolean = true,
isDownload: Boolean = false
) = mobileWebShellNavigationVerdict(
url,
isForMainFrame,
isFromSubframe,
isDocumentUrl,
isShellLoad,
hasGesture,
isDownload
)
class MobileWebShellDroppedNavigationTest {
@Test
fun `cancels a foreign navigation a human started and offers it to the opener`() {
assertEquals(MobileWebShellNavigationVerdict.CancelAndOffer(FOREIGN), verdict())
}
@Test
fun `refuses every navigation to the document that the shell did not ask for`() {
// `href="/"` and `href=""` in an artifact resolve against the embedder's base, so both name the
// shell's own document. Refused whatever the host says about a gesture, and never offered:
// handing the shell's own URL to the opener would send the user out of the app.
assertEquals(
MobileWebShellNavigationVerdict.Cancel,
verdict(url = DOCUMENT, isDocumentUrl = true)
)
// The same navigation with no gesture reported, which is what a subframe's top navigation looks
// like on both engines. Chromium's own documentation allows hasGesture() to be false for a
// request a human started, so nothing here may rest on it.
assertEquals(
MobileWebShellNavigationVerdict.Cancel,
verdict(url = DOCUMENT, isDocumentUrl = true, hasGesture = false)
)
}
@Test
fun `allows the document only for the load the shell itself started`() {
assertEquals(
MobileWebShellNavigationVerdict.Allow,
verdict(url = DOCUMENT, isDocumentUrl = true, isShellLoad = true, hasGesture = false)
)
// Carried for the iOS twin, which can see the initiating frame: a subframe's navigation is not
// the shell's load even if it arrives while the flag is up.
assertEquals(
MobileWebShellNavigationVerdict.Cancel,
verdict(
url = DOCUMENT,
isFromSubframe = true,
isDocumentUrl = true,
isShellLoad = true,
hasGesture = false
)
)
}
@Test
fun `offers a foreign navigation a subframe started, which is the tap in the preview`() {
assertEquals(
MobileWebShellNavigationVerdict.CancelAndOffer(FOREIGN),
verdict(isFromSubframe = true)
)
}
@Test
fun `cancels a foreign navigation with no gesture and offers nothing`() {
// A top-page meta refresh or a redirect: refused, and never opened in a browser, because
// nothing a human did asked for it.
assertEquals(MobileWebShellNavigationVerdict.Cancel, verdict(hasGesture = false))
}
@Test
fun `cancels a download rather than allowing it, and offers a gesture-started one`() {
assertEquals(
MobileWebShellNavigationVerdict.Cancel,
verdict(
url = DOCUMENT,
isDocumentUrl = true,
isShellLoad = true,
hasGesture = false,
isDownload = true
)
)
assertEquals(
MobileWebShellNavigationVerdict.CancelAndOffer(FOREIGN),
verdict(isDownload = true)
)
}
@Test
fun `refuses a download that names the document, and offers it to nobody`() {
// `<a href="/" download>` is the shell's own URL however it is dressed, and the one thing that
// is never handed to the opener. Refused from either frame, gesture or not.
assertEquals(
MobileWebShellNavigationVerdict.Cancel,
verdict(url = DOCUMENT, isDocumentUrl = true, isDownload = true)
)
assertEquals(
MobileWebShellNavigationVerdict.Cancel,
verdict(url = DOCUMENT, isFromSubframe = true, isDocumentUrl = true, isDownload = true)
)
}
@Test
fun `offers nothing for a subframe, which is the sealed preview loading itself`() {
assertEquals(MobileWebShellNavigationVerdict.Cancel, verdict(isForMainFrame = false))
assertEquals(
MobileWebShellNavigationVerdict.Cancel,
verdict(isForMainFrame = false, hasGesture = false)
)
}
@Test
fun `offers nothing for an absent or empty url, and nothing past the crossing cap`() {
assertEquals(MobileWebShellNavigationVerdict.Cancel, verdict(url = null))
assertEquals(MobileWebShellNavigationVerdict.Cancel, verdict(url = ""))
val cap = MOBILE_WEB_SHELL_MAX_DROPPED_NAVIGATION_URL_CHARS
val atCap = "https://example.com/" + "a".repeat(cap - "https://example.com/".length)
assertEquals(cap, atCap.length)
assertEquals(atCap, mobileWebShellOfferableUrl(atCap))
assertNull(mobileWebShellOfferableUrl(atCap + "a"))
assertEquals(MobileWebShellNavigationVerdict.Cancel, verdict(url = atCap + "a"))
}
@Test
fun `says nothing about which schemes open, because TypeScript owns that list`() {
// A scheme the opener will refuse still crosses: one filter, in the half that updates.
assertEquals(
MobileWebShellNavigationVerdict.CancelAndOffer("javascript:alert(1)"),
verdict(url = "javascript:alert(1)")
)
}
}
@@ -29,6 +29,27 @@ final class MobileWebShellLoadStateMachine {
/// for a load the caller has already been told is `loading`.
private(set) var hasCommittedDocument = false
/// Whether the navigation in flight is the one the shell asked for.
///
/// Kept here rather than beside the `load` call because every way a document can end already runs
/// through this type: a commit, a failure, a renderer that died, a prop update that never loaded.
/// A flag in the view had to remember each of those separately, and missed two.
private(set) var isShellLoad = false
/// The view is about to load the document itself. The only thing that raises the flag.
func shellLoadStarted() {
isShellLoad = true
}
/// The one navigation the flag was raised for has been allowed, so the flag is spent.
///
/// Spent at the decision and not at the commit: WebKit can decide a second main-frame action
/// before the first one starts, and a flag still raised then would have allowed that one to
/// replace the document.
func shellLoadConsumed() {
isShellLoad = false
}
/// A new prop pair. Nothing else reopens a terminal state: a retry is a remount.
func reset() {
isTerminal = false
@@ -37,6 +58,7 @@ final class MobileWebShellLoadStateMachine {
}
func committed() {
isShellLoad = false
guard !isTerminal else { return }
hasCommittedDocument = true
}
@@ -44,6 +66,7 @@ final class MobileWebShellLoadStateMachine {
/// The committed document is gone: a new load, a failure, or a renderer that died.
func documentEnded() {
hasCommittedDocument = false
isShellLoad = false
}
func started() -> MobileWebShellLoadEmission? {
@@ -0,0 +1,84 @@
import Foundation
/// What the shell does with one navigation: the whole decision, so the "allow it" half and the
/// "offer it to the opener" half cannot drift apart.
enum MobileWebShellNavigationVerdict: Equatable {
/// The served document loading itself, which is the only navigation this WebView performs.
case allow
/// Refused, and the host is told nothing. Every navigation was this before the preview existed.
case cancel
/// Refused, and the URL is handed to the host's opener, which decides what may open.
case cancelAndOffer(String)
}
/// The rule for a navigation the shell is deciding about.
///
/// Framework-free on purpose, like `MobileWebShellOrigin`: `tests/MobileWebShellChecks.swift`
/// compiles this file with `swiftc` and checks it without a device or a simulator. Kept in step with
/// the Kotlin copy.
enum MobileWebShellNavigationPolicy {
/// Longest URL the shell hands back to JS for a cancelled navigation.
///
/// The page's own bound is `BRIDGE_MAX_EXTERNAL_LINK_CHARS` (2048) and the filter that applies it
/// is `readBridgeExternalLinkUrl`, in TypeScript. This is not a second copy of that rule: it is a
/// cap on what crosses the native boundary at all, so an artifact cannot spend the bridge on a URL
/// the opener will refuse anyway.
static let maxCancelledNavigationUrlCharacters = 4096
/// The whole decision.
///
/// Three rules, in this order, and the order is the design.
///
/// A navigation outside the main frame is the sealed preview frame loading itself. It is refused
/// and never offered: forwarding it would let an artifact ask for a browser with no tap behind it.
///
/// **The document URL loads only when the shell asked for it.** `isShellLoad` is a flag the view
/// raises around its own `webView.load` and drops at commit; nothing else can raise it. Every
/// other navigation that names the document is refused and never offered -- offering the shell's
/// own URL to the opener would send the user out of the app instead of reloading it.
///
/// The rule deliberately does not rest on the host reporting a gesture. Measured against a real
/// WKWebView, off-device: a sandboxed subframe navigating the top frame to the document URL
/// arrives as `.other` with no gesture at all, and under a gesture-shaped rule that is an allow
/// and a shell reload -- the bridge target cleared, the load state restarted, the page's state
/// gone. `isFromSubframe` is the second discriminator for the same reason: the same probe shows
/// the shell's own load arriving with source and target both the main frame, and a subframe's top
/// navigation arriving with the subframe as its source.
///
/// What is left for the gesture is the only thing an artifact may ask for: a foreign URL, which
/// is refused and handed to the opener. A download is not a document load, so it takes that path
/// too, which is what makes `<a download>` behave the way it does on the native screens -- but a
/// download that still names the document takes the rule above and is refused without an offer,
/// because `<a href="/" download>` is the shell's own URL however it is dressed.
///
/// Which URLs may actually open is not decided here -- `readBridgeExternalLinkUrl` owns the scheme
/// list, in the half that ships over the air.
static func verdict(
url: String?,
isMainFrame: Bool,
isFromSubframe: Bool,
isDocumentUrl: Bool,
isShellLoad: Bool,
hasGesture: Bool,
isDownload: Bool
) -> MobileWebShellNavigationVerdict {
guard isMainFrame else {
return .cancel
}
if isDocumentUrl {
return isShellLoad && !isFromSubframe && !isDownload ? .allow : .cancel
}
guard hasGesture, let offered = offerableUrl(url) else {
return .cancel
}
return .cancelAndOffer(offered)
}
/// The URL a cancelled navigation may be offered under, or nil when nothing crosses.
static func offerableUrl(_ url: String?) -> String? {
guard let url, !url.isEmpty, url.count <= maxCancelledNavigationUrlCharacters else {
return nil
}
return url
}
}
@@ -175,6 +175,7 @@ internal final class MobileWebShellBridgeMessageTooLargeException: GenericExcept
final class OrcaMobileWebShellView: ExpoView, WKNavigationDelegate, WKUIDelegate {
let onLoadState = EventDispatcher()
let onBridgeMessage = EventDispatcher()
let onExternalNavigation = EventDispatcher()
private let schemeHandler = MobileWebShellSchemeHandler()
private let bridgeReceiver = MobileWebShellBridgeReceiver()
@@ -421,6 +422,9 @@ final class OrcaMobileWebShellView: ExpoView, WKNavigationDelegate, WKUIDelegate
private func loadWhenIsolated() {
guard isolationReady, let url = pendingDocumentUrl else { return }
pendingDocumentUrl = nil
// The only thing that tells the load the shell asked for from one a document asked for. The
// state machine drops it again on every way a document can end.
loadState.shellLoadStarted()
webView.load(URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData))
}
@@ -457,13 +461,31 @@ final class OrcaMobileWebShellView: ExpoView, WKNavigationDelegate, WKUIDelegate
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
if #available(iOS 14.5, *), navigationAction.shouldPerformDownload {
decisionHandler(.cancel)
return
var isDownload = false
if #available(iOS 14.5, *) {
isDownload = navigationAction.shouldPerformDownload
}
let allowed = navigationAction.targetFrame?.isMainFrame == true &&
isDocumentUrl(navigationAction.request.url)
decisionHandler(allowed ? .allow : .cancel)
// `.linkActivated` is WebKit's own answer to "did a human start this". It decides only what may
// be offered to the opener; nothing is allowed on the strength of it, because a subframe can
// navigate the top frame with no gesture reported at all.
let verdict = MobileWebShellNavigationPolicy.verdict(
url: navigationAction.request.url?.absoluteString,
isMainFrame: navigationAction.targetFrame?.isMainFrame == true,
isFromSubframe: !navigationAction.sourceFrame.isMainFrame,
isDocumentUrl: isDocumentUrl(navigationAction.request.url),
isShellLoad: loadState.isShellLoad,
hasGesture: navigationAction.navigationType == .linkActivated,
isDownload: isDownload
)
if case let .cancelAndOffer(url) = verdict {
onExternalNavigation(["url": url])
}
if verdict == .allow {
// Spent here, before the decision is handed back: the next main-frame action gets no allow on
// the strength of a load that has already been given one.
loadState.shellLoadConsumed()
}
decisionHandler(verdict == .allow ? .allow : .cancel)
}
func webView(
@@ -5,7 +5,7 @@ public class OrcaMobileWebShellModule: Module {
Name("OrcaMobileWebShell")
View(OrcaMobileWebShellView.self) {
Events("onLoadState", "onBridgeMessage")
Events("onLoadState", "onBridgeMessage", "onExternalNavigation")
Prop("generationDirectory") { (view: OrcaMobileWebShellView, value: String) in
view.setGenerationDirectory(value)
@@ -6,6 +6,9 @@ import type { MobileWebShellLoadStatePayload } from './load-state'
/** One raw JSON envelope, exactly as the page posted it. Parsing is the caller's. */
export type MobileWebShellBridgeMessagePayload = { json: string }
/** The URL of a main-frame navigation the shell cancelled, as the document spelled it. */
export type MobileWebShellExternalNavigationPayload = { url: string }
export type OrcaMobileWebShellViewProps = ViewProps & {
/**
* Absolute path of an activated generation directory: `index.html`, `manifest.json`, and
@@ -29,6 +32,26 @@ export type OrcaMobileWebShellViewProps = ViewProps & {
* (`MobileWebShellBridge.maxMessageByteCount`); a refusal is silent and reaches no event.
*/
onBridgeMessage?: (event: NativeSyntheticEvent<MobileWebShellBridgeMessagePayload>) => void
/**
* A main-frame navigation a human started was cancelled, which is the user aiming the top frame
* somewhere else: a tap on a link inside the sealed HTML-preview frame, which the browser hands up
* as a top-frame request.
*
* **Only a gesture-started navigation away from the shell's own document is offered.** A top-page
* meta refresh, a redirect and anything the page does to its own path carry no gesture, so none of
* them reaches this. Neither does a tap naming the shell's own document: that is refused outright
* and never offered, because allowing it would reload the page out from under the session and
* offering it would send the user out of the app -- and that holds for `<a href="/" download>` too,
* which is the same URL in a download's clothing. A gesture-started download of anything else is
* offered, which is what makes `<a download>` behave as it does on the native screens.
*
* The URL is unfiltered by design — `readBridgeExternalLinkUrl` owns the scheme list and lives in
* the half that ships over the air — so a handler must run it through that before opening
* anything. Bounded natively at 4096 characters so an artifact cannot spend the boundary.
*/
onExternalNavigation?: (
event: NativeSyntheticEvent<MobileWebShellExternalNavigationPayload>
) => void
}
/** What a ref on the view carries. Expo puts the view's functions on the component prototype. */
@@ -8,6 +8,7 @@ import Foundation
// ios/MobileWebShellOrigin.swift ios/MobileWebShellGeneration.swift ios/MobileWebShellCsp.swift \
// ios/MobileWebShellLoadState.swift ios/MobileWebShellResponseHeaders.swift \
// ios/MobileWebShellBridge.swift ios/MobileWebShellAppliedProps.swift \
// ios/MobileWebShellNavigationPolicy.swift \
// tests/MobileWebShellChecks.swift && /tmp/mobile-web-shell-checks
@main struct MobileWebShellChecks {
static let session = "sess-01JN_aZ9"
@@ -234,6 +235,23 @@ import Foundation
precondition(MobileWebShellFailureReason.documentLoadFailed.rawValue == "document-load-failed")
precondition(MobileWebShellFailureReason.renderProcessGone.rawValue == "render-process-gone")
// The own-load flag's whole lifetime, which is what decides whether a navigation to the document
// may be allowed. Raised only by the view's own `load`, and dropped by anything that ends the
// document -- a commit, a failure, a dead renderer, a prop update that never loaded.
let ownLoad = MobileWebShellLoadStateMachine()
precondition(!ownLoad.isShellLoad)
ownLoad.shellLoadStarted()
precondition(ownLoad.isShellLoad)
ownLoad.committed()
precondition(!ownLoad.isShellLoad)
ownLoad.shellLoadStarted()
_ = ownLoad.failed(.documentLoadFailed)
precondition(!ownLoad.isShellLoad)
ownLoad.reset()
ownLoad.shellLoadStarted()
ownLoad.documentEnded()
precondition(!ownLoad.isShellLoad)
let progress = MobileWebShellLoadStateMachine()
precondition(progress.started()?.state == "loading")
precondition(progress.started() == nil)
@@ -507,6 +525,116 @@ import Foundation
precondition(gate.refusedCount == 2)
}
/// The whole navigation decision, which is one function so the allow half and the offer half
/// cannot drift. The rule is the frame and the gesture, not the scheme: TypeScript's
/// `readBridgeExternalLinkUrl` owns which URLs open, and a second scheme list here would be two
/// rules that drift.
static func checkNavigationVerdict() {
let foreign = "https://example.com/artifact-link"
let document = "orca-mobile-web://\(session)/"
func verdict(
_ url: String? = "https://example.com/artifact-link",
isMainFrame: Bool = true,
isFromSubframe: Bool = false,
isDocumentUrl: Bool = false,
isShellLoad: Bool = false,
hasGesture: Bool = true,
isDownload: Bool = false
) -> MobileWebShellNavigationVerdict {
MobileWebShellNavigationPolicy.verdict(
url: url,
isMainFrame: isMainFrame,
isFromSubframe: isFromSubframe,
isDocumentUrl: isDocumentUrl,
isShellLoad: isShellLoad,
hasGesture: hasGesture,
isDownload: isDownload
)
}
precondition(verdict() == .cancelAndOffer(foreign))
// The shell's own load, which is the only navigation to the document this view ever performs.
// Measured on WebKit: `webView.load` arrives with target and source both the main frame.
precondition(verdict(document, isDocumentUrl: true, isShellLoad: true, hasGesture: false) == .allow)
// Everything else that names the document is refused, whatever the host says about a gesture,
// and is never offered -- handing the shell's own URL to the opener would bounce the user out.
// The host is not trusted to report the gesture: measured on WebKit, a sandboxed subframe
// navigating the top frame to the document URL arrives with no gesture at all.
precondition(verdict(document, isDocumentUrl: true, hasGesture: false) == .cancel)
precondition(verdict(document, isDocumentUrl: true, hasGesture: true) == .cancel)
precondition(
verdict(document, isFromSubframe: true, isDocumentUrl: true, isShellLoad: true, hasGesture: false)
== .cancel
)
// The second discriminator, on its own: a load the shell did not start is refused even when the
// initiating frame is the main one, which is the page rewriting its own document away.
precondition(verdict(document, isDocumentUrl: true, isShellLoad: false, hasGesture: false) == .cancel)
// A top-page meta refresh or a redirect to somewhere else: refused, and never opened.
precondition(verdict(hasGesture: false) == .cancel)
// A tap inside the sealed preview is exactly a subframe-initiated foreign navigation, and that
// is the one thing the artifact is allowed to ask for.
precondition(verdict(isFromSubframe: true) == .cancelAndOffer(foreign))
// A download is not a document load, so it is refused there rather than allowed; started by a
// tap it reaches the opener, which is what makes `<a download>` behave as it does natively.
precondition(
verdict(document, isDocumentUrl: true, isShellLoad: true, hasGesture: false, isDownload: true)
== .cancel
)
precondition(verdict(isDownload: true) == .cancelAndOffer(foreign))
// `<a href="/" download>`: a download that still names the shell's own document, which is the
// one thing never handed to the opener. Refused from either frame, gesture or not.
precondition(verdict(document, isDocumentUrl: true, isDownload: true) == .cancel)
precondition(
verdict(document, isFromSubframe: true, isDocumentUrl: true, isDownload: true) == .cancel
)
// A subframe is the sealed preview loading itself, which is not the user leaving the app.
precondition(verdict(isMainFrame: false) == .cancel)
precondition(verdict(isMainFrame: false, hasGesture: false) == .cancel)
precondition(verdict(nil) == .cancel)
precondition(verdict("") == .cancel)
// The crossing cap, at it and one past it.
let cap = MobileWebShellNavigationPolicy.maxCancelledNavigationUrlCharacters
let prefix = "https://example.com/"
let atCap = prefix + String(repeating: "a", count: cap - prefix.count)
precondition(atCap.count == cap)
precondition(MobileWebShellNavigationPolicy.offerableUrl(atCap) == atCap)
precondition(MobileWebShellNavigationPolicy.offerableUrl(atCap + "a") == nil)
precondition(verdict(atCap + "a") == .cancel)
// A scheme the opener will refuse still crosses: one filter, in the half that updates.
precondition(verdict("javascript:alert(1)") == .cancelAndOffer("javascript:alert(1)"))
}
/// The own-load flag against the policy that reads it: one load allowed, and only one.
///
/// The flag and the rule are separate types, and the gap between them is where a second main-frame
/// action to the same URL before the first commits would have been allowed too. So the seam is
/// checked rather than each half on its own.
static func checkOwnLoadIsSpentOnce() {
let document = "orca-mobile-web://\(session)/"
func decide(_ machine: MobileWebShellLoadStateMachine) -> MobileWebShellNavigationVerdict {
MobileWebShellNavigationPolicy.verdict(
url: document,
isMainFrame: true,
isFromSubframe: false,
isDocumentUrl: true,
isShellLoad: machine.isShellLoad,
hasGesture: false,
isDownload: false
)
}
let machine = MobileWebShellLoadStateMachine()
machine.shellLoadStarted()
let first = decide(machine)
precondition(first == .allow)
// Spent by the allow itself, not by the commit that follows it: WebKit can decide a second action
// before the first one starts, and that one would have replaced the document.
machine.shellLoadConsumed()
precondition(decide(machine) == .cancel)
// And the endings still drop it, for a load that is allowed and then never commits.
machine.shellLoadStarted()
machine.documentEnded()
precondition(decide(machine) == .cancel)
}
static func main() {
checkSessionIds()
checkRequestResolution()
@@ -517,6 +645,8 @@ import Foundation
checkLoadStateMachine()
checkResponseHeaders()
checkNavigationErrors()
checkNavigationVerdict()
checkOwnLoadIsSpentOnce()
checkAppliedProps()
checkBridgeAcceptance()
checkBridgePostTarget()
+12 -1
View File
@@ -20,10 +20,18 @@ export function MobileHtmlPreview({ html, renderSource }: MobileHtmlPreviewProps
return (
<View style={styles.container}>
<View style={styles.toolbar}>
{/* A tab pair, not two buttons: which side is showing is carried by the active style, and a
style is announced to nobody. */}
<View style={styles.toolbar} accessibilityRole="tablist">
<Pressable
style={[styles.toggle, mode === 'preview' && styles.toggleActive]}
onPress={() => setMode('preview')}
accessibilityRole="tab"
// Both, because they reach different readers: `accessibilityState` is what the phone's
// screen reader takes, and react-native-web drops it entirely -- measured, the DOM carries
// no `aria-selected` without the line below.
accessibilityState={{ selected: mode === 'preview' }}
aria-selected={mode === 'preview'}
accessibilityLabel="Preview rendered HTML"
>
<Eye size={13} color={colors.textSecondary} strokeWidth={2.2} />
@@ -32,6 +40,9 @@ export function MobileHtmlPreview({ html, renderSource }: MobileHtmlPreviewProps
<Pressable
style={[styles.toggle, mode === 'source' && styles.toggleActive]}
onPress={() => setMode('source')}
accessibilityRole="tab"
accessibilityState={{ selected: mode === 'source' }}
aria-selected={mode === 'source'}
accessibilityLabel="View HTML source"
>
<Code size={13} color={colors.textSecondary} strokeWidth={2.2} />
+122 -26
View File
@@ -1,48 +1,144 @@
import { StyleSheet, Text, View } from 'react-native'
import { useState } from 'react'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { Code, Eye } from 'lucide-react-native'
import { colors, spacing, typography } from '../theme/mobile-theme'
// The native component's own prop type, so a change to it fails here rather than drifting.
import type { MobileHtmlPreviewProps } from './MobileHtmlPreview'
/**
* Web sibling: the labelled source, which is the half of this component the native one already
* renders on its own — `renderSource` is its Source tab, reached by the toggle above it.
* The `sandbox` the preview frame carries, and the whole of what makes it safe to render an
* agent-produced artifact inside the page's own document.
*
* The native preview renders the artifact inside a sandboxed `WebView` whose navigation is locked
* to the initial inline document, and `react-native-webview` is a native component with no browser
* counterpart: importing it runs a codegen lookup that throws, and the route manifest imports every
* route, so one such import takes the whole page down rather than one preview.
* No `allow-scripts`: a script in the artifact does not run. No `allow-same-origin`: the frame is an
* opaque origin, so it reaches no storage, no cookie and nothing the page holds — and it is not
* same-origin with the document the shell injects its bridge object into, which Chromium places in
* every same-origin frame. The shell refuses a non-main-frame bridge message by rule as well, so
* that fence is two deep.
*
* Rendering the HTML here instead is not a smaller change but a different one (ruling 8), and the
* difference is the sandbox: the page has no nested frame to put untrusted agent-produced HTML in —
* the shell's policy carries `frame-src 'none'` and `child-src 'none'` — so a browser renderer
* would need its own sanitiser and its own proof against hostile source. The toggle goes with the
* preview, because a control that can only be in one position is a control that lies.
* `allow-top-navigation-by-user-activation` is the one capability granted, and it is what keeps a
* link in the artifact working (ruling 29): a tap becomes a top-frame navigation the shell's
* navigation policy cancels and hands to its external-link opener. Measured on Chromium and WebKit:
* a user click produces exactly one top-frame navigation, while a `<meta http-equiv="refresh">`, a
* form submit and `target="_blank"` produce none, and with scripts deliberately enabled a
* script-initiated `window.top.location` throws `SecurityError`. Only a human's tap gets out.
*/
export function MobileHtmlPreview({ renderSource }: MobileHtmlPreviewProps) {
export const MOBILE_HTML_PREVIEW_SANDBOX = 'allow-top-navigation-by-user-activation'
/**
* Web sibling: the artifact rendered in a sealed frame, with the native component's Preview/Source
* toggle intact.
*
* `srcdoc` rather than a `blob:` URL, measured: `srcdoc` is admitted under the policy the shell
* already ships, because a `srcdoc` frame has no URL to match and inherits its embedder's policy
* instead, while a `blob:` frame is refused by `frame-src 'none'` on both engines and refused a
* second time in WebKit by the `frame-ancestors 'none'` it inherits. So this costs no CSP change at
* all, and the policy stays exactly what the Phase E native build carries.
*
* What the inherited policy then governs is everything the artifact tries to fetch: `img-src` bounds
* its images, `font-src 'none'` refuses a web font, `connect-src 'self'` its XHR, and
* `script-src 'self'` refuses its inline script even if the sandbox had allowed scripts. The native
* preview is a separate WebView process with no policy on its document, so it does load a remote
* image and does run a script; the page is deliberately stricter, because it has no second process
* to contain either.
*/
export function MobileHtmlPreview({ html, renderSource }: MobileHtmlPreviewProps) {
const [mode, setMode] = useState<'preview' | 'source'>('preview')
return (
<View style={styles.container}>
<View style={styles.label}>
<Text style={styles.labelText}>html source</Text>
{/* A tab pair, not two buttons: which side is showing is carried by the active style, and a
style is announced to nobody. */}
<View style={styles.toolbar} accessibilityRole="tablist">
<Pressable
style={[styles.toggle, mode === 'preview' && styles.toggleActive]}
onPress={() => setMode('preview')}
accessibilityRole="tab"
// Both, because they reach different readers: `accessibilityState` is what the phone's
// screen reader takes, and react-native-web drops it entirely -- measured, the DOM carries
// no `aria-selected` without the line below.
accessibilityState={{ selected: mode === 'preview' }}
aria-selected={mode === 'preview'}
accessibilityLabel="Preview rendered HTML"
>
<Eye size={13} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={styles.toggleText}>Preview</Text>
</Pressable>
<Pressable
style={[styles.toggle, mode === 'source' && styles.toggleActive]}
onPress={() => setMode('source')}
accessibilityRole="tab"
accessibilityState={{ selected: mode === 'source' }}
aria-selected={mode === 'source'}
accessibilityLabel="View HTML source"
>
<Code size={13} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={styles.toggleText}>Source</Text>
</Pressable>
</View>
<View style={styles.source}>{renderSource()}</View>
{mode === 'preview' ? <PreviewFrame html={html} /> : renderSource()}
</View>
)
}
// The native component's own frame, so the degradation sits where the preview sat.
/**
* The frame, as a DOM element react-native-web passes through untouched.
*
* Written as an `iframe` rather than through a react-native primitive because there is no primitive
* for it, and `srcdoc` is set as an attribute so React never has to be told the content is trusted:
* the browser parses it inside a frame that can run nothing.
*/
function PreviewFrame({ html }: { html: string }) {
return (
<View style={styles.frame}>
<iframe
title="HTML preview"
sandbox={MOBILE_HTML_PREVIEW_SANDBOX}
srcDoc={html}
style={IFRAME_STYLE}
// The artifact is untrusted, so nothing it navigates to may learn where it came from or
// reach back through `window.opener`. Belt and braces beside the sandbox, which already
// refuses `window.open`.
referrerPolicy="no-referrer"
/>
</View>
)
}
/** A DOM style, not a `StyleSheet` entry: this element is an `iframe` and not a react-native view. */
const IFRAME_STYLE = {
border: 'none',
width: '100%',
height: '100%',
// The artifact decides its own background; white is what the native preview shows behind one that
// sets none, and an unset background here would show the panel through it.
backgroundColor: '#ffffff'
} as const
const styles = StyleSheet.create({
container: { flex: 1 },
label: {
toolbar: {
flexDirection: 'row',
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
borderBottomWidth: 1,
borderBottomColor: colors.borderSubtle
},
labelText: {
color: colors.textSecondary,
fontSize: typography.metaSize,
fontFamily: typography.monoFamily
toggle: {
flexDirection: 'row',
alignItems: 'center',
gap: 5,
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: 6,
backgroundColor: colors.bgRaised
},
source: { flex: 1 }
toggleActive: {
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle
},
toggleText: { color: colors.textSecondary, fontSize: typography.metaSize },
// The native component's own frame, so the preview sits where the preview sat.
frame: { flex: 1, backgroundColor: '#ffffff' }
})
@@ -3,10 +3,14 @@
*
* Both native components put their surface inside a `WebView`, which has no browser counterpart:
* importing it runs a codegen lookup that throws, and the route manifest imports every route, so
* one such import takes the whole bundle down rather than one editor. Ruling 8 is that each gets
* the plain state it already degrades to and no second renderer, so what is pinned here is the
* degradation: the text is still there and still editable, the formatting toolbar and the rendered
* preview are not, and nothing reaches a WebView.
* one such import takes the whole bundle down rather than one editor.
*
* The two are no longer in the same state. Ruling 26 makes C7.6's fallbacks debt rather than done,
* and C7.10's PR A has already paid it for the HTML preview: it renders the artifact in a sealed
* `srcdoc` frame with the toggle intact, so what is pinned for it here is the frame's shape and the
* toggle's two positions. What a browser does with that frame is not a question this renderer can
* answer and is measured in `mobile-web-app-html-preview-render.test.mjs` instead. The rich Markdown
* editor is still the plain field, and its degradation is still what is pinned below.
*/
import { createElement, createRef } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
@@ -27,6 +31,11 @@ vi.mock('react-native', async () => {
({ children, ...props }, ref) => React.createElement('TextInput', { ...props, ref }, children)
)
return {
// The native preview's external-link opener reaches for this at module load, and a named import
// missing from a mocked module throws before any case runs.
Linking: { openURL: async () => true },
Pressable: host('Pressable'),
ScrollView: host('ScrollView'),
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
Text: host('Text'),
TextInput,
@@ -34,7 +43,25 @@ vi.mock('react-native', async () => {
}
})
import { MobileHtmlPreview } from './MobileHtmlPreview.web'
// The preview's toggle carries two icons, and `lucide-react-native` imports a `LucideProvider` its
// own context module does not export, so the real barrel does not load under vitest at all.
vi.mock('lucide-react-native', () => ({
Code: () => null,
Eye: () => null
}))
// Mocked so the native sibling can be rendered beside the web one for the toggle case below: the real
// import is the codegen lookup this whole file exists because of.
vi.mock('react-native-webview', async () => {
const React = await import('react')
return {
WebView: ({ children, ...props }: { children?: React.ReactNode }) =>
React.createElement('WebView', props, children)
}
})
import { MobileHtmlPreview, MOBILE_HTML_PREVIEW_SANDBOX } from './MobileHtmlPreview.web'
import { MobileHtmlPreview as PhoneHtmlPreview } from './MobileHtmlPreview'
import { MobileRichMarkdownEditor } from './MobileRichMarkdownEditor.web'
import type { MobileRichMarkdownEditorHandle } from './MobileRichMarkdownEditor'
@@ -156,34 +183,86 @@ describe('the rich markdown editor on the page', () => {
})
describe('the html preview on the page', () => {
it('renders the source the native component already falls back to', () => {
const renderSource = vi.fn(() => createElement('SourceView', null))
const renderer = render(createElement(MobileHtmlPreview, { html: '<h1>hi</h1>', renderSource }))
expect(findHosts(renderer, 'SourceView')).toHaveLength(1)
expect(renderSource).toHaveBeenCalledTimes(1)
})
const renderSourceMarker = () => createElement('SourceView', null)
it('renders no toggle, because there is no preview to flip to', () => {
// A control that can only be in one position is a control that lies: the artifact has no
// sandbox on the page, so the Preview half of the toggle goes with it.
it('renders the artifact in a frame that can run nothing, and keeps the toggle', () => {
const renderer = render(
createElement(MobileHtmlPreview, {
html: '<h1>hi</h1>',
renderSource: () => createElement('SourceView', null)
})
createElement(MobileHtmlPreview, { html: '<h1>hi</h1>', renderSource: renderSourceMarker })
)
expect(findHosts(renderer, 'Pressable')).toEqual([])
const frames = findHosts(renderer, 'iframe')
expect(frames).toHaveLength(1)
// The artifact reaches the frame as `srcDoc`, which the browser parses inside it. Neither
// `allow-scripts` nor `allow-same-origin`, which is the whole of what makes that safe.
expect(frames[0]?.props.srcDoc).toBe('<h1>hi</h1>')
expect(frames[0]?.props.sandbox).toBe(MOBILE_HTML_PREVIEW_SANDBOX)
expect(MOBILE_HTML_PREVIEW_SANDBOX.split(' ')).not.toContain('allow-scripts')
expect(MOBILE_HTML_PREVIEW_SANDBOX.split(' ')).not.toContain('allow-same-origin')
// Both positions of the toggle exist, which is what stops it being a control that lies.
expect(findHosts(renderer, 'Pressable')).toHaveLength(2)
})
it('never renders the html itself, which is the whole of ruling 8', () => {
it('shows the source when the toggle is flipped, and takes the frame away with it', () => {
const renderSource = vi.fn(renderSourceMarker)
const renderer = render(createElement(MobileHtmlPreview, { html: '<h1>hi</h1>', renderSource }))
expect(findHosts(renderer, 'SourceView')).toHaveLength(0)
const toSource = findHosts(renderer, 'Pressable').find(
(node) => node.props.accessibilityLabel === 'View HTML source'
)
expect(toSource).toBeDefined()
act(() => toSource?.props.onPress())
expect(findHosts(renderer, 'SourceView')).toHaveLength(1)
expect(renderSource).toHaveBeenCalled()
// The artifact is not parsed anywhere while Source is showing.
expect(findHosts(renderer, 'iframe')).toHaveLength(0)
})
// Both siblings, one case: the toggle is a pair of tabs and a reader has to be told which one is
// showing. The two toolbars are the same code in two files, so a change to one that does not reach
// the other reds here rather than reaching a phone as a toggle that announces nothing.
for (const [surface, Preview] of [
['the page', MobileHtmlPreview],
['a phone', PhoneHtmlPreview]
] as const) {
it(`says which side of the toggle is showing, on ${surface}`, () => {
const renderer = render(
createElement(Preview, { html: '<h1>hi</h1>', renderSource: renderSourceMarker })
)
const toggles = () => findHosts(renderer, 'Pressable')
expect(toggles()).toHaveLength(2)
expect(toggles().map((node) => node.props.accessibilityRole)).toEqual(['tab', 'tab'])
// The pair's own container, so the two tabs are a set rather than two loose ones.
expect(
findHosts(renderer, 'View').filter((node) => node.props.accessibilityRole === 'tablist')
).toHaveLength(1)
// The showing side, which is what a screen reader has no other way to learn: the active
// position is styling and styling is not announced.
expect(toggles().map((node) => node.props.accessibilityState?.selected)).toEqual([
true,
false
])
act(() => toggles()[1]?.props.onPress())
expect(toggles().map((node) => node.props.accessibilityState?.selected)).toEqual([
false,
true
])
})
}
it('never puts the artifact anywhere but the frame', () => {
const renderer = render(
createElement(MobileHtmlPreview, {
html: '<script>alert(1)</script>',
renderSource: () => createElement('SourceView', null)
renderSource: renderSourceMarker
})
)
// Agent-produced HTML, and the page has no frame to sandbox it in: the policy carries
// frame-src 'none' and child-src 'none'.
expect(JSON.stringify(renderer.toJSON())).not.toContain('alert(1)')
const tree = JSON.stringify(renderer.toJSON())
// Once, as the frame's `srcDoc`, and nowhere else: not as a child, not as `dangerouslySetInnerHTML`,
// not in a prop of the surrounding view.
expect(findHosts(renderer, 'iframe')[0]?.props.srcDoc).toBe('<script>alert(1)</script>')
expect(tree.split('alert(1)')).toHaveLength(2)
expect(tree).not.toContain('dangerouslySetInnerHTML')
})
})
@@ -18,6 +18,7 @@ import {
isDevelopmentBuild,
useMobileWebShellDroppedFrames
} from './mobile-web-shell-dev-facts'
import { cancelledShellNavigationTarget } from './cancelled-navigation-target'
import { playPageHaptic } from './page-haptics'
import { useMobileWebShellBridge } from './use-mobile-web-shell-bridge'
import type { MobileWebShellRuntime } from './mobile-web-shell-runtime'
@@ -164,6 +165,17 @@ export function MobileWebShellScreen({
// Declared before the bridge so the handler it is handed already belongs to this session: the
// media verbs hold staged files, and a registry born after the host would outlive the page.
const serveNativeVerb = useNativeDeviceVerbs(state.kind === 'ready' ? state.sessionId : null)
// Straight to the system handler, and the one opener the shell has: the page's `externalLink`
// notify and a cancelled top-frame navigation both arrive here already filtered. The only failure
// left is a device with nothing registered for the scheme -- a `mailto:` on a phone with no mail
// account. Reported rather than swallowed, because nothing crosses back for either path, and not
// rethrown, because both run on a native frame handler.
const openUrlForPage = (url: string) => {
void Linking.openURL(url).catch((error: unknown) => {
console.warn('[web-shell] could not open a URL for the page', { url, error })
})
}
const bridge = useMobileWebShellBridge({
hostId,
route,
@@ -211,11 +223,10 @@ export function MobileWebShellScreen({
// mail account. Reported rather than swallowed: nothing crosses back for a notify, so this is
// the one dead tap the verb does not rule out, and silence is what would hide it. Still not
// rethrown, because this runs on the native frame handler.
onExternalLink: (url: string) => {
void Linking.openURL(url).catch((error: unknown) => {
console.warn('[web-shell] could not open a URL for the page', { url, error })
})
},
// The same opener a cancelled top-frame navigation takes, hoisted above this call so both
// paths are one function: its body is the `Linking.openURL` and the warning this handler
// carried inline.
onExternalLink: openUrlForPage,
// The app's own haptics, reached through one mapping rather than a second copy of the
// `Platform.OS` split. Nothing crosses back and nothing can fail: each function already
// swallows its own rejection on the device.
@@ -277,6 +288,17 @@ export function MobileWebShellScreen({
sessionId={state.sessionId}
bridgeEnabled={bridge.bridgeEnabled}
onBridgeMessage={bridge.onBridgeMessage}
onExternalNavigation={(event) => {
const target = cancelledShellNavigationTarget(event.nativeEvent.url)
if (target === null) {
// Cancelled and not openable. Nothing naming the shell's own document reaches here:
// the shell refuses that without offering it, whatever asked. What lands here and is
// dropped is a URL outside the three allowed schemes. Silent, as every cancelled
// navigation was before this event existed.
return
}
openUrlForPage(target)
}}
onLoadState={(event) => {
const parsed = parseMobileWebShellLoadState(event.nativeEvent)
if (parsed?.state === 'failed') {
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { cancelledShellNavigationTarget } from './cancelled-navigation-target'
describe('cancelledShellNavigationTarget', () => {
it('opens the three schemes the page may already ask for', () => {
expect(cancelledShellNavigationTarget('https://example.com/a')).toBe('https://example.com/a')
expect(cancelledShellNavigationTarget('http://example.com/a')).toBe('http://example.com/a')
expect(cancelledShellNavigationTarget('mailto:someone@example.com')).toBe(
'mailto:someone@example.com'
)
})
it('opens the normalized href, not the string the artifact spelled', () => {
// The WHATWG parser strips tab, LF and CR from anywhere, so this is not the URL it looks like.
expect(cancelledShellNavigationTarget('ht\ntps://example.com/a')).toBe('https://example.com/a')
expect(cancelledShellNavigationTarget('https://example.com')).toBe('https://example.com/')
})
it('opens nothing for a scheme the grant does not cover', () => {
expect(cancelledShellNavigationTarget('javascript:alert(1)')).toBeNull()
expect(cancelledShellNavigationTarget('data:text/html,<b>x')).toBeNull()
expect(cancelledShellNavigationTarget('file:///etc/passwd')).toBeNull()
expect(cancelledShellNavigationTarget('orca-mobile-web://sess/')).toBeNull()
})
it('opens nothing for a relative target, which is a route rather than a link out', () => {
expect(cancelledShellNavigationTarget('/h/abc')).toBeNull()
expect(cancelledShellNavigationTarget('')).toBeNull()
})
it('opens nothing for a payload that is not a string', () => {
expect(cancelledShellNavigationTarget(undefined)).toBeNull()
expect(cancelledShellNavigationTarget(null)).toBeNull()
expect(cancelledShellNavigationTarget(42)).toBeNull()
})
})
@@ -0,0 +1,22 @@
import { readBridgeExternalLinkUrl } from './bridge/bridge-caps'
/**
* The URL to open for a main-frame navigation the shell cancelled, or null to open nothing.
*
* The shell cancels every navigation off its own document and now offers the URL back rather than
* dropping it in silence, because one of those is a user tapping a link inside the sealed
* HTML-preview frame: the browser hands a user-activated `target="_top"` navigation up to the top
* frame, and the shell's policy is the only thing that can act on it.
*
* The scheme list is `readBridgeExternalLinkUrl`'s and is not restated natively — the native side
* caps the string and says which frame it came from, nothing more, so the rule that decides what
* opens lives in the half that ships over the air. The normalized href is what opens, never the
* string the document spelled: the WHATWG parser strips tab, LF and CR from anywhere, so
* `ht\ntps://x` reaches this as something a device handler should not be given.
*
* A non-string reaches this only from a native payload that changed shape, which is a reason to
* open nothing rather than to throw on the native frame handler.
*/
export function cancelledShellNavigationTarget(url: unknown): string | null {
return typeof url === 'string' ? readBridgeExternalLinkUrl(url) : null
}
+1 -1
View File
@@ -119,7 +119,7 @@
},
{
"file": "src/components/MobileHtmlPreview.web.tsx",
"reason": "The native preview renders an agent-produced HTML artifact inside a sandboxed WebView with navigation locked to the initial inline document, and react-native-webview throws at import in a browser for the reason above. This one renders the labelled source, which is the component's own Source tab. Rendering the HTML instead is a different change rather than a smaller one: the page has no nested frame to sandbox untrusted source in, because the shell's policy carries frame-src 'none' and child-src 'none', so a browser renderer would need its own sanitiser and its own proof (ruling 8). The Preview/Source toggle goes with the preview, because a control that can only be in one position is a control that lies."
"reason": "The native preview renders an agent-produced HTML artifact inside a sandboxed WebView with navigation locked to the initial inline document, and react-native-webview throws at import in a browser for the reason above. This one renders the artifact in a sandboxed iframe with no allow-scripts and no allow-same-origin, keeping the Preview/Source toggle. srcdoc rather than a blob: URL and no CSP change at all: a srcdoc frame has no URL for frame-src to match and inherits its embedder's policy instead, so it is admitted under the shipped frame-src 'none' on Chromium and WebKit alike, while a blob: frame is refused by frame-src and refused again in WebKit by the frame-ancestors 'none' it inherits. The inherited policy is also what seals it -- script-src 'self' refuses the artifact's inline script, img-src bounds its images, font-src 'none' its fonts -- and allow-top-navigation-by-user-activation is the one capability granted, so a tapped link becomes a top-frame navigation the shell opens externally (ruling 29) while a meta refresh, a form submit, target=_blank and any script-initiated navigation produce none."
}
]
}
@@ -12,4 +12,17 @@ describe('migrateAgentYoloDefaults', () => {
expect(migrated.agentDefaultArgs?.droid).toBe('')
expect(migrated.agentDefaultEnv?.goose).toEqual({})
})
it('updates the previous Devin default for existing profiles', () => {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test only supplies the settings fields consumed by this migration.
const migrated = migrateAgentYoloDefaults({
agentYoloDefaultsMigrated: true,
agentDefaultArgs: { devin: '--permission-mode bypass' },
agentDefaultEnv: {}
} as never)
expect(migrated.agentDefaultArgs?.devin).toBe(
'--permission-mode bypass --respect-workspace-trust false'
)
})
})
@@ -126,6 +126,9 @@ export function migrateAgentYoloDefaults(
): Pick<GlobalSettings, 'agentDefaultArgs' | 'agentDefaultEnv' | 'agentYoloDefaultsMigrated'> {
const existingArgs = normalizeTuiAgentArgsRecord(settings?.agentDefaultArgs)
const existingEnv = normalizeTuiAgentEnvRecord(settings?.agentDefaultEnv)
if (existingArgs.devin === '--permission-mode bypass') {
existingArgs.devin = DEFAULT_TUI_AGENT_ARGS.devin
}
if (settings?.agentYoloDefaultsMigrated === true) {
// Keep newly added agents manual for profiles migrated by an older build.
// Missing keys otherwise fall through to the current (possibly yolo) defaults.
@@ -136,6 +136,8 @@ export function prepareLoadedProfileSettings(
const migratedAgentYoloDefaults = migrateAgentYoloDefaults(parsed.settings)
if (
parsed.settings?.agentYoloDefaultsMigrated !== true ||
parsed.settings?.agentDefaultArgs?.devin !==
migratedAgentYoloDefaults.agentDefaultArgs?.devin ||
hasUnsupportedTuiAgentArgs('opencode', parsed.settings?.agentDefaultArgs?.opencode) ||
hasUnsupportedTuiAgentArgs('kilo', parsed.settings?.agentDefaultArgs?.kilo)
) {
@@ -105,7 +105,8 @@ describe('CommitArea AI generation', () => {
const button = buttonByLabel(markup, 'Generate commit message with AI')
expect(hasDisabledAttribute(button)).toBe(false)
expect(button).toContain('title="ai commit msg"')
// Why: single Radix tooltip only — native title removed to avoid duplicate tooltips.
expect(button).not.toContain('title=')
})
it('disables AI generation when the textarea already has user text', () => {
@@ -116,7 +117,7 @@ describe('CommitArea AI generation', () => {
const button = buttonByLabel(markup, 'Generate commit message with AI')
expect(button).toContain('aria-disabled="true"')
expect(button).toContain('title="Clear the message to regenerate."')
expect(button).not.toContain('title=')
})
it('keeps AI generation discoverable when the configured agent needs attention', () => {
@@ -152,7 +153,7 @@ describe('CommitArea AI generation', () => {
const button = buttonByLabel(markup, 'Generate commit message with AI')
expect(hasDisabledAttribute(button)).toBe(false)
expect(button).toContain('title="Pick an agent in Settings -&gt; Git -&gt; Source Control AI."')
expect(button).not.toContain('title=')
})
it('turns the generating icon into a stop affordance', () => {
@@ -165,7 +166,7 @@ describe('CommitArea AI generation', () => {
})
const button = buttonByLabel(markup, 'Stop generating commit message')
expect(button).toContain('title="Stop generating"')
expect(button).not.toContain('title=')
expect(button).toContain('lucide-refresh-cw')
expect(button).toContain('lucide-square')
})
@@ -214,7 +215,6 @@ describe('CommitArea AI generation', () => {
expect(markup).not.toContain('aria-label="Commit message"')
expect(markup).not.toContain('aria-label="Generate commit message with AI"')
expect(markup).toContain('Nothing to commit')
expect(markup).toContain('aria-label="More commit and remote actions"')
})
})
@@ -197,6 +197,35 @@ describe('CommitArea', () => {
expect(markupWin).toContain('Enter')
})
it('renders no tooltip on enabled Stage All — the label already states the action', () => {
const props = baseProps()
const markup = renderCommitArea({
...props,
primaryAction: {
kind: 'stage',
disabled: false,
label: 'Stage All',
title: 'Stage all changes'
}
})
expect(firstButton(markup)).not.toContain('title=')
expect(markup).not.toContain('Stage all changes')
})
it('renders the disabled reason in the primary button tooltip', () => {
const props = baseProps()
const markup = renderCommitArea({
...props,
primaryAction: {
kind: 'commit',
disabled: true,
label: 'Commit',
title: 'Enter a commit message to commit'
}
})
expect(markup).toContain('Enter a commit message to commit')
})
it('only handles Cmd+Enter when focus is within the Source Control sidebar', () => {
setUserAgent('Macintosh')
const onPrimaryAction = vi.fn()
@@ -583,8 +612,8 @@ describe('CommitArea', () => {
expect(stageAllButton).not.toContain('disabled=""')
expect(stageAllButton).toContain('lucide-plus')
expect(stageAllButton).toContain('rounded-r-none')
expect(stageAllButton).not.toContain('title=')
expect(markup).toContain('aria-label="More commit and remote actions"')
expect(markup).toContain('Stage all changes')
expect(
(markup.match(/<button\b[\s\S]*?<\/button>/g) ?? []).some((button) =>
button.includes('Commit</button>')
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { shouldShowPrimaryTooltip } from './source-control-primary-action-tooltip'
import type { PrimaryActionKind } from './source-control-primary-action'
function action(kind: PrimaryActionKind, disabled: boolean) {
return { kind, disabled, label: kind, title: kind }
}
describe('shouldShowPrimaryTooltip', () => {
it('shows the tooltip when disabled — the title carries the blocking reason', () => {
for (const kind of ['stage', 'create_pr', 'create_pr_intent', 'commit'] as const) {
expect(shouldShowPrimaryTooltip(action(kind, true))).toBe(true)
}
})
it('hides pure repeats on enabled Stage All and Create PR', () => {
expect(shouldShowPrimaryTooltip(action('stage', false))).toBe(false)
expect(shouldShowPrimaryTooltip(action('create_pr', false))).toBe(false)
})
it('keeps the Create PR intent tooltip — it explains the prepare step the label omits', () => {
expect(shouldShowPrimaryTooltip(action('create_pr_intent', false))).toBe(true)
})
it('shows informative tooltips for commit and remote counts', () => {
for (const kind of ['commit', 'push', 'pull', 'sync', 'publish'] as const) {
expect(shouldShowPrimaryTooltip(action(kind, false))).toBe(true)
}
})
})
@@ -0,0 +1,57 @@
import React from 'react'
import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { getScreenSubmitModifierLabel } from '@/lib/screen-submit-shortcut'
import type { PrimaryAction } from './source-control-primary-action'
// Why: text primaries whose title merely repeats the label (enabled Stage All
// and Create PR) get no tooltip — pure noise. Tooltips stay only when they
// add info: disabled reasons, remote counts, the Commit shortcut, and the
// Create PR intent, whose label hides that the click also stages/commits/pushes.
export function shouldShowPrimaryTooltip(
primaryAction: Pick<PrimaryAction, 'kind' | 'disabled'>
): boolean {
if (primaryAction.disabled) {
return true
}
return (
primaryAction.kind === 'commit' ||
primaryAction.kind === 'create_pr_intent' ||
primaryAction.kind === 'push' ||
primaryAction.kind === 'pull' ||
primaryAction.kind === 'sync' ||
primaryAction.kind === 'publish'
)
}
// Why: both the commit-area split button and the header Create PR button share
// this show/hide rule, so the wrapper lives here instead of duplicating the
// Tooltip-or-plain-button branch (and the Button markup) at each call site.
export function PrimaryActionTooltip({
action,
side,
children
}: {
action: PrimaryAction
side: 'top' | 'bottom'
children: React.JSX.Element
}): React.JSX.Element {
if (!shouldShowPrimaryTooltip(action)) {
return children
}
return (
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent side={side} sideOffset={6} className="max-w-72">
{action.kind === 'commit' ? (
<span className="flex items-center gap-2">
<span>{action.title}</span>
<ShortcutKeyCombo keys={[getScreenSubmitModifierLabel(), 'Enter']} />
</span>
) : (
<span>{action.title}</span>
)}
</TooltipContent>
</Tooltip>
)
}
@@ -1,12 +1,11 @@
import React from 'react'
import { ChevronDown, Loader2 } from 'lucide-react'
import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
import { Button } from '@/components/ui/button'
import { DropdownMenu, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { getScreenSubmitModifierLabel } from '@/lib/screen-submit-shortcut'
import { cn } from '@/lib/utils'
import type { PrimaryAction } from '../../source-control-primary-action'
import { PrimaryActionTooltip } from '../../source-control-primary-action-tooltip'
export function CommitActionMenu({
showComposer,
@@ -15,7 +14,6 @@ export function CommitActionMenu({
showSpinner,
showChevronSpinner,
moreCommitAndRemoteActionsLabel,
moreActionsLabel,
dropdownMenuContent,
onPrimaryAction
}: {
@@ -28,7 +26,6 @@ export function CommitActionMenu({
showSpinner: boolean
showChevronSpinner: boolean
moreCommitAndRemoteActionsLabel: string
moreActionsLabel: string
dropdownMenuContent: React.ReactNode
onPrimaryAction: () => void
}): React.JSX.Element {
@@ -36,35 +33,25 @@ export function CommitActionMenu({
// Why: action + chevron form one split button so the edit → commit → push loop stays in a single vertical band.
<div className={cn('flex items-stretch gap-1', showComposer && 'mt-1')}>
<div className="flex flex-1 items-stretch">
{/* Why: match the Checks hosted-review buttons so action-button shape is consistent across Source Control and Checks. */}
<Tooltip>
<TooltipTrigger asChild>
<span className="flex flex-1">
<Button
type="button"
variant="outline"
size="xs"
disabled={primaryAction.disabled}
onClick={() => onPrimaryAction()}
className="w-full rounded-r-none px-3 text-[11px]"
title={primaryAction.title}
>
{showSpinner ? (
<Loader2 className="size-3.5 animate-spin" />
) : PrimaryIcon ? (
<PrimaryIcon className="size-3.5" aria-hidden="true" />
) : null}
{primaryAction.label}
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} className="flex max-w-72 items-center gap-2">
<span>{primaryAction.title}</span>
{primaryAction.kind === 'commit' ? (
<ShortcutKeyCombo keys={[getScreenSubmitModifierLabel(), 'Enter']} />
) : null}
</TooltipContent>
</Tooltip>
<PrimaryActionTooltip action={primaryAction} side="top">
<span className="flex flex-1">
<Button
type="button"
variant="outline"
size="xs"
disabled={primaryAction.disabled}
onClick={() => onPrimaryAction()}
className="w-full rounded-r-none px-3 text-[11px]"
>
{showSpinner ? (
<Loader2 className="size-3.5 animate-spin" />
) : PrimaryIcon ? (
<PrimaryIcon className="size-3.5" aria-hidden="true" />
) : null}
{primaryAction.label}
</Button>
</span>
</PrimaryActionTooltip>
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
@@ -80,7 +67,6 @@ export function CommitActionMenu({
primaryAction.disabled && 'opacity-50'
)}
aria-label={moreCommitAndRemoteActionsLabel}
title={moreActionsLabel}
>
{showChevronSpinner ? (
<Loader2 className="size-3.5 animate-spin" />
@@ -165,10 +165,6 @@ export function CommitArea({
'auto.components.right.sidebar.SourceControl.cc199ccc5f',
'More commit and remote actions'
)
const moreActionsLabel = translate(
'auto.components.right.sidebar.SourceControl.4d6e1fd7f3',
'More actions'
)
const dropdownMenuContent = (
<DropdownMenuContent align="end" className="min-w-[14rem]">
{dropdownItems.map((entry) =>
@@ -180,7 +176,6 @@ export function CommitArea({
<div className="block">
<DropdownMenuItem
disabled={entry.disabled}
title={entry.title}
variant={entry.variant}
className="w-full"
onSelect={(event) => {
@@ -235,7 +230,6 @@ export function CommitArea({
showSpinner={showSpinner}
showChevronSpinner={showChevronSpinner}
moreCommitAndRemoteActionsLabel={moreCommitAndRemoteActionsLabel}
moreActionsLabel={moreActionsLabel}
dropdownMenuContent={dropdownMenuContent}
onPrimaryAction={onPrimaryAction}
/>
@@ -56,10 +56,6 @@ export function CommitMessageComposer({
<button
type="button"
onClick={() => onCancelGenerate()}
title={translate(
'auto.components.right.sidebar.SourceControl.527e130b6f',
'Stop generating'
)}
aria-label={translate(
'auto.components.right.sidebar.SourceControl.ddc1fbd690',
'Stop generating commit message'
@@ -90,13 +86,6 @@ export function CommitMessageComposer({
}
onGenerate()
}}
title={
generateTooltip ??
translate(
'auto.components.right.sidebar.SourceControl.b16b8f0e4b',
'ai commit msg'
)
}
aria-label={translate(
'auto.components.right.sidebar.SourceControl.461575b9bc',
'Generate commit message with AI'
@@ -23,7 +23,6 @@ export function SourceControlHeaderIconButton({
size="icon-xs"
className="text-muted-foreground hover:text-foreground"
aria-label={label}
title={label}
onClick={onClick}
disabled={disabled}
>
@@ -6,8 +6,8 @@ import type { SourceControlViewMode } from '../../../../../../shared/ui-chrome-t
import type { HostedReviewInfo } from '../../../../../../shared/hosted-review'
import type { PrimaryAction } from '../../source-control-primary-action'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { PrimaryActionTooltip } from '../../source-control-primary-action-tooltip'
import { translate } from '@/i18n/i18n'
import type { WorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display'
import { HostedReviewHeaderLink, HostedReviewIcon } from '../review/hosted-review-header-chrome'
@@ -80,30 +80,24 @@ function CreatePrHeaderButton({
onClick: () => void
}): React.JSX.Element {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex shrink-0">
<Button
type="button"
size="xs"
disabled={action.disabled}
onClick={onClick}
className="h-6 shrink-0 px-2 text-[11px]"
title={action.title}
>
{isCreatePrIntentInFlight || isCreatingPr ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<GitPullRequestArrow className="size-3.5" aria-hidden="true" />
)}
{action.label}
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6} className="max-w-72">
{action.title}
</TooltipContent>
</Tooltip>
<PrimaryActionTooltip action={action} side="bottom">
<span className="inline-flex shrink-0">
<Button
type="button"
size="xs"
disabled={action.disabled}
onClick={onClick}
className="h-6 shrink-0 px-2 text-[11px]"
>
{isCreatePrIntentInFlight || isCreatingPr ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<GitPullRequestArrow className="size-3.5" aria-hidden="true" />
)}
{action.label}
</Button>
</span>
</PrimaryActionTooltip>
)
}
+1
View File
@@ -844,6 +844,7 @@
"424ee0e5bf": "error",
"48a003c1b1": "Staged Changes",
"522f44dce5": "Untracked Files",
"527e130b6f": "Stop generating",
"77afaa8152": "All",
"783a808870": "Close",
"7a09d7f9d2": "base",
@@ -252,12 +252,12 @@ describe('buildAgentStartupPlan', () => {
})
).toEqual({
agent: 'devin',
launchCommand: "devin '--permission-mode' 'bypass'",
launchCommand: "devin '--permission-mode' 'bypass' '--respect-workspace-trust' 'false'",
expectedProcess: 'devin',
followupPrompt: 'Trace the failing test',
launchConfig: {
agentCommand: "devin '--permission-mode' 'bypass'",
agentArgs: '--permission-mode bypass',
agentCommand: "devin '--permission-mode' 'bypass' '--respect-workspace-trust' 'false'",
agentArgs: '--permission-mode bypass --respect-workspace-trust false',
agentEnv: {}
}
})
+1 -1
View File
@@ -26,7 +26,7 @@ export const YOLO_TUI_AGENT_ARGS: Partial<Record<TuiAgent, string>> = {
hermes: '--yolo',
copilot: '--yolo',
grok: '--permission-mode bypassPermissions',
devin: '--permission-mode bypass',
devin: '--permission-mode bypass --respect-workspace-trust false',
ante: '--yolo',
trae: '--yolo',
droid: '--auto high'
+6 -4
View File
@@ -701,12 +701,12 @@ describe('tui agent startup plans', () => {
})
expect(plan).toEqual({
agent: 'devin',
launchCommand: "devin '--permission-mode' 'bypass'",
launchCommand: "devin '--permission-mode' 'bypass' '--respect-workspace-trust' 'false'",
expectedProcess: 'devin',
followupPrompt: 'fix the tests',
launchConfig: {
agentCommand: "devin '--permission-mode' 'bypass'",
agentArgs: '--permission-mode bypass',
agentCommand: "devin '--permission-mode' 'bypass' '--respect-workspace-trust' 'false'",
agentArgs: '--permission-mode bypass --respect-workspace-trust false',
agentEnv: {}
}
})
@@ -730,6 +730,8 @@ describe('tui agent startup plans', () => {
})
it('appends Devin default permission-mode bypass before stdin prompt delivery', () => {
expect(resolveTuiAgentLaunchArgs('devin', null)).toBe('--permission-mode bypass')
expect(resolveTuiAgentLaunchArgs('devin', null)).toBe(
'--permission-mode bypass --respect-workspace-trust false'
)
})
})