mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
Merge branch 'main' into ota-c7-5c-scope-threading
This commit is contained in:
@@ -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(' | ')
|
||||
}
|
||||
+99
@@ -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)
|
||||
}
|
||||
|
||||
+28
-5
@@ -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
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+148
@@ -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()
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 -> Git -> 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>')
|
||||
|
||||
+30
@@ -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>
|
||||
)
|
||||
}
|
||||
+20
-34
@@ -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}
|
||||
/>
|
||||
|
||||
-11
@@ -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
@@ -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: {}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user