diff --git a/config/scripts/build-mobile-web-app-bundle.mjs b/config/scripts/build-mobile-web-app-bundle.mjs
index 9fe78e907ba..52b9b3c8c13 100644
--- a/config/scripts/build-mobile-web-app-bundle.mjs
+++ b/config/scripts/build-mobile-web-app-bundle.mjs
@@ -477,7 +477,18 @@ export function resolveMobileWebPageRoutes(routeKeys, declared = MOBILE_WEB_PAGE
)
}
}
- return declared.map((route) => ({ pathname: route.pathname, grants: [...route.grants] }))
+ // Mapped member by member rather than spread: the manifest is `.strict()`, so a field this
+ // declaration grows and this map does not name is dropped in silence -- which is how
+ // `optionalGrants` would have reached a phone as a route that declared nothing optional.
+ // `optionalGrants` is omitted when the route declares none, because absent and empty are the same
+ // answer to a shell and a key written empty would be a manifest field with no reader.
+ return declared.map((route) => ({
+ pathname: route.pathname,
+ grants: [...route.grants],
+ ...(route.optionalGrants === undefined || route.optionalGrants.length === 0
+ ? {}
+ : { optionalGrants: [...route.optionalGrants] })
+ }))
}
/**
diff --git a/config/scripts/mobile-web-app-external-navigation-grant.test.mjs b/config/scripts/mobile-web-app-external-navigation-grant.test.mjs
new file mode 100644
index 00000000000..266979f6e56
--- /dev/null
+++ b/config/scripts/mobile-web-app-external-navigation-grant.test.mjs
@@ -0,0 +1,130 @@
+/**
+ * Which page routes render an HTML artifact, and the grant the preview needs from each of them.
+ *
+ * Natively the preview is its own WebView and `onShouldStartLoadWithRequest` hands every request to
+ * `openExternalLink`, so there is nothing to negotiate. In the page a tap inside the sealed frame
+ * becomes a top-frame navigation, and only the shell around the document can cancel it and open the
+ * URL. A shell built before that event drops the navigation in silence, so
+ * `use-html-preview-link-grant.web.ts` asks first (`init.grants.native`) and a route that declared
+ * nothing renders the artifact's links as text.
+ *
+ * `externalNavigation` has no call site of the shape `mobile-web-app-page-grant-call-sites.mjs`
+ * parses -- nothing is requested and nothing is answered, so there is no seam function to find --
+ * which is why it belongs here beside `haptics` and `screencastBinary` rather than as a row there.
+ * The screencast census's shape, against the third token that is neither a verb nor a notify.
+ *
+ * The lane is optional, so what this census pins is different from the required-lane ones: a route
+ * missing the grant is not a native screen, it is a preview with inert links. That makes the
+ * declaration easy to forget, and this is the only thing that would notice.
+ */
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { describe, expect, it } from 'vitest'
+import { mobileWebAppRouteClosure } from './build-mobile-web-app-bundle.mjs'
+import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
+import {
+ PAGE_ROUTE_MODULES,
+ pageRouteModulesCoverTheManifest
+} from './mobile-web-app-page-route-modules.mjs'
+import { MOBILE_WEB_PAGE_ROUTES } from './mobile-web-page-routes.mjs'
+
+const mobileDir = fileURLToPath(new URL('../../mobile/', import.meta.url))
+const describeClosure = mobileWebAppDependenciesPresent() ? describe : describe.skip
+
+/** The seam as the web build resolves it, and the native sibling the page must never reach. */
+const SEAM = 'src/components/use-html-preview-link-grant.web.ts'
+const NATIVE = 'src/components/use-html-preview-link-grant.ts'
+/** Where the grant token is declared, so this file reads it rather than spelling it again. */
+const GRANT_MODULE = 'src/mobile-web-shell/cancelled-navigation-target.ts'
+
+/** The token, parsed off its own declaration: a second spelling is one that can drift. */
+function externalNavigationGrantToken() {
+ const source = readFileSync(join(mobileDir, GRANT_MODULE), 'utf8')
+ const declared = /BRIDGE_EXTERNAL_NAVIGATION_GRANT = '([^']+)'/.exec(source)
+ if (declared === null) {
+ throw new Error(`${GRANT_MODULE} no longer declares the grant this census reads`)
+ }
+ return declared[1]
+}
+
+/** The modules that call the hook, which is the preview and whatever else grows one. */
+function linkGrantCallers(closure) {
+ return closure.local.filter((file) => {
+ if (!/\.tsx?$/.test(file) || file === SEAM || file === NATIVE) {
+ return false
+ }
+ return /\buseHtmlPreviewLinkGrant\s*\(/.test(readFileSync(join(mobileDir, file), 'utf8'))
+ })
+}
+
+/** Both lanes, because the token may be declared on either and the census is about the route
+ * holding it at all. Which lane is the product call ruling 37.3 gave the desktop. */
+function routesDeclaring(token) {
+ return MOBILE_WEB_PAGE_ROUTES.filter(
+ (route) => route.grants.includes(token) || (route.optionalGrants ?? []).includes(token)
+ ).map((route) => route.pathname)
+}
+
+describe('the grant token this census is written against', () => {
+ it('is the one the shell declares', () => {
+ expect(externalNavigationGrantToken()).toBe('externalNavigation')
+ })
+})
+
+describeClosure(
+ 'the routes that render an HTML artifact',
+ () => {
+ it('declares external navigation on exactly the routes whose closure asks for it', async () => {
+ const asking = []
+ for (const [route, mod] of PAGE_ROUTE_MODULES) {
+ const closure = await mobileWebAppRouteClosure(mod)
+ if (linkGrantCallers(closure).length > 0) {
+ asking.push(route)
+ }
+ }
+ // One route today, and the precondition an assertion about a derived set needs: an empty
+ // list is also what a walk that read nothing produces. The file preview route is deliberately
+ // not here -- it renders `MobileFilePreviewScreen`, which has no HTML preview in its closure.
+ expect(asking).toEqual(['/h/[hostId]/session/[worktreeId]'])
+ expect([...routesDeclaring(externalNavigationGrantToken())].sort()).toEqual(
+ [...asking].sort()
+ )
+ })
+
+ it('declares it on the optional lane, so a shell without it keeps the screen on the page', () => {
+ const session = MOBILE_WEB_PAGE_ROUTES.find(
+ (route) => route.pathname === '/h/[hostId]/session/[worktreeId]'
+ )
+ if (!session) {
+ throw new Error('the manifest lost the session route this census is written against')
+ }
+ const token = externalNavigationGrantToken()
+ expect(session.optionalGrants).toContain(token)
+ // The half that matters: on the required lane this one name would take the whole session
+ // screen native on every shell built before the cancelled-navigation event.
+ expect(session.grants).not.toContain(token)
+ })
+
+ it('reaches the seam through its web sibling, and the caller is the preview', async () => {
+ const closure = await mobileWebAppRouteClosure(
+ PAGE_ROUTE_MODULES.get('/h/[hostId]/session/[worktreeId]')
+ )
+ expect(closure.local).toContain(SEAM)
+ expect(closure.local).not.toContain(NATIVE)
+ expect(linkGrantCallers(closure)).toEqual(['src/components/MobileHtmlPreview.web.tsx'])
+ // The preview is mounted by the session's file reader rather than by a route of its own,
+ // which is why the grant has no route to be pinned against except this one.
+ expect(closure.local).toContain('src/session/MobileSessionFileReader.tsx')
+ // And the inerting pass is in the closure too: without it the hook's answer would change a
+ // sandbox token and leave the dead anchor ruling 37.2 forbids.
+ expect(closure.local).toContain('src/components/html-preview-inert-links.ts')
+ })
+
+ it('covers every declared page route, so a new one cannot be missed by this file', () => {
+ const { mapped, declared } = pageRouteModulesCoverTheManifest(MOBILE_WEB_PAGE_ROUTES)
+ expect(mapped).toEqual(declared)
+ })
+ },
+ 240_000
+)
diff --git a/config/scripts/mobile-web-app-html-preview-render.test.mjs b/config/scripts/mobile-web-app-html-preview-render.test.mjs
index 0a37414babd..deb47f5f8f0 100644
--- a/config/scripts/mobile-web-app-html-preview-render.test.mjs
+++ b/config/scripts/mobile-web-app-html-preview-render.test.mjs
@@ -23,7 +23,6 @@ import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import * as esbuild from 'esbuild'
-import { PNG } from 'pngjs'
import { chromium, webkit } from 'playwright-core'
import { lucideBarrelPlugin } from './build-mobile-web-app-bundle.mjs'
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
@@ -32,22 +31,15 @@ import {
readShellCsp,
readShellDocumentHeaders
} from './mobile-web-app-render-harness.mjs'
-import { createCspReportSink, reportedDirectives } from './mobile-web-app-preview-csp-reports.mjs'
-import { recordRequestsTo } from './mobile-web-app-preview-request-log.mjs'
+import { createCspReportSink } from './mobile-web-app-preview-csp-reports.mjs'
import { startArtifactAssetServer } from './mobile-web-app-preview-asset-server.mjs'
-import { watchImageEvidence } from './mobile-web-app-preview-image-evidence.mjs'
+import { openPreviewArm } from './mobile-web-app-preview-arm-driver.mjs'
import {
ARTIFACT_RGB,
ENTRY_SOURCE,
- artifact,
artifactScript
} from './mobile-web-app-preview-artifact-fixture.mjs'
-import {
- previewFrame,
- settleAfterMount,
- waitForLoadedFrame,
- waitForRecordedNavigation
-} from './mobile-web-app-preview-frame-readiness.mjs'
+import { waitForRecordedNavigation } from './mobile-web-app-preview-frame-readiness.mjs'
const mobileDir = fileURLToPath(new URL('../../mobile', import.meta.url))
@@ -75,8 +67,6 @@ let foreign = null
*/
let assetServer = null
-let nonceCounter = 0
-
const bundles = mobileWebAppDependenciesPresent()
const describeRender = bundles ? describe : describe.skip
@@ -128,11 +118,15 @@ beforeAll(async () => {
})
await new Promise((resolve) => foreign.listen(0, '127.0.0.1', resolve))
foreignOrigin = `http://127.0.0.1:${String(foreign.address().port)}`
+ // Onto the rig as well: the driver reads it from there, and the binding above is assigned after
+ // this module's top level has already built the object.
+ rig.foreignOrigin = foreignOrigin
await mkdir(join(mobileDir, '.tmp'), { recursive: true })
scratch = await mkdtemp(join(mobileDir, '.tmp', 'html-preview-render-'))
// Before any page exists, which is the point of it being a listener.
assetServer = await startArtifactAssetServer(scratch)
+ rig.assetServer = assetServer
outDir = join(scratch, 'bundle')
await mkdir(outDir, { recursive: true })
await esbuild.build({
@@ -215,268 +209,20 @@ afterAll(async () => {
}
})
-/**
- * Mounts the preview with one artifact and reports everything a case can assert on.
- *
- * `csp: null` is the control arm. The foreign origin's hit list is reset per open, so what it holds
- * is this artifact's doing.
- */
-async function open(
- browser,
- {
- extra = {},
- csp = 'shipped',
- sandbox,
- act,
- expectNavigation = null,
- frameReady = 'artifact',
- assets,
- reportReady = null,
- signal
- } = {}
-) {
- const origin = origins[csp === 'shipped' ? 'shipped' : csp === 'leaky' ? 'leaky' : 'none']
- nonceCounter += 1
- const nonce = `n${String(nonceCounter)}`
- // Read here and carried as a string: asked for at the abort it lost its race with teardown and
- // printed "browser unknown" in the CI log this diagnostic exists for.
- const browserVersion = browser.version()
- // An explicit context, so an arm that aborts mid-read can hand back everything it holds. The
- // arms share one browser per engine; only the context is theirs.
- // The asset listener's certificate is generated per run and trusted by nothing, which is what
- // this flag is for; the page's own origin is still plain http from the bundle server.
- const context = await browser.newContext({
- viewport: { width: 390, height: 844 },
- ignoreHTTPSErrors: true
- })
- const page = await context.newPage()
- // Subscribed before the first navigation, so a request made during load is in the log. Cheap
- // while an arm passes: it fills arrays, and only an abort asks them to speak.
- const requestLog = await recordRequestsTo(page, assetServer.origin)
- // Asked only when an arm has aborted, so the fresh-image probe and its wait cost a failing run
- // and never a passing one.
- const describeRequests = watchImageEvidence(page, assetServer.origin, requestLog, assetServer.saw)
- try {
- const navigations = []
- const popups = []
- let servedCsp = null
- page.on('response', (response) => {
- if (response.url().startsWith(`${origin}/preview`)) {
- servedCsp = response.headers()['content-security-policy'] ?? null
- }
- })
- page.on('popup', (popup) => {
- popups.push(popup.url())
- void popup.close().catch(() => {})
- })
- // The record is the page's own event, not the route handler's. Interception is per target and
- // attaches late on a Chrome that isolates the sandboxed frame, which is what left the CI log
- // saying `recorded []`; `page.on('request')` is one subscription over every frame the page has.
- // Armed after the rig's own `goto`, exactly where the route used to be registered: the initial
- // navigation is a main-frame navigation to this origin and would otherwise count as one the
- // artifact asked for.
- let recordingNavigations = false
- page.on('request', (request) => {
- if (!recordingNavigations || !request.isNavigationRequest()) {
- return
- }
- const url = request.url()
- if (!url.startsWith(foreignOrigin) && !url.startsWith(origin)) {
- return
- }
- navigations.push({
- url,
- foreign: url.startsWith(foreignOrigin),
- main: request.frame() === page.mainFrame()
- })
- })
- // The route stays for what only a route can do: refuse the navigation. Playwright is not the
- // shell, so a top-frame navigation is aborted here the way the shell's delegate would refuse
- // it, and a frame navigating itself is left alone -- aborting that would make "the frame stayed
- // on the artifact" true by the rig's own doing.
- const record = (route) => {
- const request = route.request()
- if (request.isNavigationRequest() && request.frame() === page.mainFrame()) {
- return void route.abort()
- }
- return void route.continue()
- }
- await page.route(`${foreignOrigin}/**`, record)
- // The shell page's violations, and only those: an artifact's own listener would have to run, and
- // the fence under test is that nothing in the artifact runs.
- await page.addInitScript(() => {
- // When this ran, in every frame it ran in. The collector below can only report what it was
- // present for, so its own moment is a reading rather than an assumption.
- window.__initAt = `${String(Math.round(performance.now()))} ${document.readyState}`
- window.__violations = []
- document.addEventListener('securitypolicyviolation', (event) => {
- window.__violations.push(`${event.violatedDirective} ${event.blockedURI || 'inline'}`)
- })
- })
- // The nonce in the document's own URL: the policy this response carries names a report endpoint
- // with the same nonce, which is how a report from a `srcdoc` frame with no URL of its own is
- // attributed to the arm that caused it.
- await page.goto(`${origin}/preview?n=${nonce}`, { waitUntil: 'load' })
- recordingNavigations = true
- // Registered after the page's own load, not before it: this handler aborts main-frame navigations
- // and the initial `goto` is one. `href="/"` and `href=""` inside an artifact resolve against the
- // embedder's base, so a tap on either asks to navigate the top frame to the shell's own document.
- // The rig has no shell, so what this pins is the request the shell is handed; refusing it is
- // `MobileWebShellDroppedNavigationTest`'s "refuses every navigation to the document that the shell
- // did not ask for" and its `checkNavigationVerdict` twin on iOS.
- await page.route(`${origin}/**`, record)
- // `sandbox` undefined is the product's own token, which is what every non-control case runs.
- await page.evaluate(
- ([html, override]) => window.__mount(html, override),
- [
- artifact({ links: foreignOrigin, assets: assets ?? foreignOrigin, extra, nonce }),
- sandbox ?? null
- ]
- )
- // Named in every diagnostic, because the log shows the case and not which of its arms spoke.
- const arm =
- `arm csp=${csp} sandbox=${sandbox ?? 'product'} frameReady=${frameReady} ` +
- `reportReady=${reportReady ?? 'none'} nonce=${nonce}`
- // One reader for the wait and for the reading: an arm that waits on one list and asserts on
- // another proves nothing about the list it asserts on.
- const readImageHits = () => assetServer.hitsFor(nonce)
- const artifactFrame = await waitForLoadedFrame(page, {
- frameReady,
- reportReady,
- signal,
- browserVersion,
- arm,
- sink: cspReports,
- nonce,
- readImageHits,
- describeRequests
- })
- // Sampled before the action as well as after: a case that taps a link is asking what the tap
- // produced, and by then the top frame is mid-navigation and the iframe has blanked to its own
- // background. So the precondition "there was a rendered artifact to tap" is this reading, and the
- // one below is only meaningful for a case that did nothing.
- const pixelBefore = await probePixel(page)
- const readToggles = async () =>
- await page
- .evaluate(() =>
- [...document.querySelectorAll('[role="tab"]')].map((one) => ({
- label: one.getAttribute('aria-label'),
- selected: one.getAttribute('aria-selected')
- }))
- )
- .catch(() => null)
- // Sampled before the action as well, because the toggle's whole claim is that it changes.
- const togglesBefore = await readToggles()
- let actError = null
- if (act) {
- // Recorded, never swallowed: a click that never landed and a click that produced no
- // navigation are the same empty counter, and only one of them is the product's doing.
- await act({ page, frame: previewFrame(page) }).catch((error) => {
- actError = String(error).split('\n')[0]
- })
- }
- // Every arm settles, acting or not: an artifact can start a navigation with no tap behind it --
- // `` is one -- and the arms that pin zero were reading their counters
- // while that was still in flight.
- await settleAfterMount(page, navigations, expectNavigation, signal, {
- frame: artifactFrame,
- browserVersion,
- arm,
- describeRequests
- })
- const result = {
- page,
- pixelBefore,
- pixel: await probePixel(page),
- declaredSandbox: await page.evaluate(() => window.__sandbox),
- // What the toolbar emits into the DOM, not what the component was handed: react-native-web
- // forwards `aria-*` and drops `accessibilityState` on the floor, so a selected state that reads
- // fine in the test renderer can reach a screen reader as nothing at all.
- togglesBefore,
- toggles: await readToggles(),
- // The attribute on the element the component actually rendered, not the constant it exports: a
- // literal in the JSX would leave the constant correct and the frame unsealed, which is what the
- // control run for this file did before this reading existed.
- mountedSandbox: await page
- .evaluate(() => document.querySelector('iframe')?.getAttribute('sandbox') ?? null)
- .catch(() => null),
- frameCount: page.frames().length - 1,
- // Reported so a pixel that read the page instead of the frame names the layout rather than
- // looking like a frame that refused to load.
- frameBox: await page
- .evaluate(() => {
- const frame = document.querySelector('iframe')
- if (!frame) {
- return null
- }
- const box = frame.getBoundingClientRect()
- return { x: box.x, y: box.y, width: box.width, height: box.height }
- })
- .catch(() => null),
- // Reported, never asserted on: a `srcdoc` frame's URL reads `about:srcdoc` here and empty on
- // CI's browser, so nothing may be decided by it.
- frameUrl: previewFrame(page)?.url() ?? null,
- // The element's own attributes, which is where "the artifact is parsed inside the frame rather
- // than fetched into it" actually lives.
- mountedSrcDoc: await page
- .evaluate(() => document.querySelector('iframe')?.getAttribute('srcdoc') ?? null)
- .catch(() => null),
- mountedSrc: await page
- .evaluate(() => document.querySelector('iframe')?.getAttribute('src') ?? null)
- .catch(() => null),
- inside: await (previewFrame(page)
- ?.evaluate(() => ({
- marker: document.getElementById('marker')?.textContent ?? null,
- title: document.title,
- ran: document.documentElement.dataset.ran === '1' ? 1 : 0,
- threw: document.documentElement.dataset.threw ?? null,
- // The two moments the late-listener question turns on: when the page's init script ran in
- // this frame, and when the artifact's own script did.
- initAt: window.__initAt ?? null,
- artifactAt: document.documentElement.dataset.artifactAt ?? null,
- // The frame's own list, not the embedder's: `securitypolicyviolation` does not cross frames,
- // and the page's init script installs the same collector in every one.
- violations: window.__violations ?? null
- }))
- .catch(() => null) ?? Promise.resolve(null)),
- // What this document was actually served, so "the shipped policy, plus a report endpoint and
- // nothing else" is asserted rather than intended.
- servedCsp,
- // Every refusal the browser reported for this arm, which is the evidence an in-frame listener
- // cannot be relied on to have collected.
- reported: reportedDirectives(cspReports, nonce),
- // Null on every arm that acted successfully, and on every arm that did not act at all.
- actError,
- topNavigations: navigations.filter((one) => one.main && one.foreign).length,
- ownOriginTopNavigations: navigations.filter((one) => one.main && !one.foreign).length,
- // What the frame asked for itself at the embedder's origin, which is a different escape from a
- // top-frame request and is refused by a different line of the policy.
- ownOriginFrameNavigations: navigations.filter((one) => !one.main && !one.foreign).length,
- popups: popups.length,
- // This arm's fetches only, by nonce: the paths, with the nonce stripped, so a case reads the
- // subresource rather than the bookkeeping.
- foreignHits: foreignHits
- .filter((one) => one.includes(`n=${nonce}`))
- .map((one) => one.split('?')[0]),
- // Same shape as `foreignHits` and read the same way: this arm's requests only, by nonce, as
- // paths. Absolute URLs go in, so the origin is stripped along with the query.
- secureHits: readImageHits(),
- // What each admitted request carried, this arm's only, so an absence is this artifact's.
- // Read off the header the listener received rather than off a request object handed to a
- // route: the header on the wire is what the shell's `Referrer-Policy` is about.
- secureReferers: assetServer.referersFor(nonce),
- violations: await page.evaluate(() => window.__violations),
- body: await page.evaluate(() => document.body.innerText)
- }
- return result
- } finally {
- // The context and not just the page: an arm whose wait aborted still owns one, and the case
- // after it runs on the same browser. On the happy path this is the close that always ran.
- await page.close().catch(() => {})
- await context.close().catch(() => {})
- }
+/** The rig's own state, handed to the driver: one object rather than a module of loose bindings. */
+const rig = {
+ origins,
+ foreignHits,
+ cspReports,
+ clip: FRAME_PROBE,
+ nonce: 0,
+ foreignOrigin: null,
+ assetServer: null
}
+/** Mounts the preview with one artifact and reports everything a case can assert on. */
+const open = async (browser, options) => await openPreviewArm(rig, browser, options)
+
for (const engine of ['chromium', 'webkit']) {
describeRender(
`the HTML preview's sealed frame on ${engine}`,
@@ -680,6 +426,217 @@ for (const engine of ['chromium', 'webkit']) {
expect(empty.topNavigations).toBe(0)
}, 180_000)
+ /**
+ * The hide path C8.1 exists for (ruling 37.2), against the same rig that measures the open one.
+ *
+ * A shell built before the cancelled-navigation event drops a tapped link in silence, so the
+ * page asks first and renders the artifact's links as text when the answer is no. The ruling
+ * names three readings and all three are taken: no underline, no pointer cursor, no anchor a
+ * tap does nothing on. The granted arm is each one's presence precondition -- without it,
+ * "no underline" is also what a frame that never rendered reports.
+ *
+ * Every verdict here is a reading the frame itself publishes: the anchors its document holds,
+ * the style the engine computed for one, whether focus lands on it, and whether the tap this
+ * arm made landed at all. None of them waits for a record that may never arrive.
+ *
+ * That is the round-1 fix, and it is why this case has two arms rather than three. It had a
+ * third that tapped the granted link and waited for the top-frame navigation through
+ * `expectNavigation: 'main-frame'`, and `waitForRecordedNavigation` has no bound but the
+ * case's own timeout: on CI's Chrome the click missed its 2 s actionability window under load,
+ * no navigation was ever recorded, and the arm sat in that wait for the whole 240 s
+ * (`Test timed out in 240000ms`, recorded `[]`, with the frame attached only at 38.9 s). Three
+ * arms sharing one budget is what made this case the one to find it.
+ *
+ * Nothing is lost by dropping it. The tap's outcome on a granted shell is the next case,
+ * `hands a user's tap on a link to the top frame, exactly once`, on these same counters from
+ * this same rig and with a budget of its own -- so the zero below still has a presence
+ * precondition, and it is the one this file uses elsewhere for exactly this reason.
+ */
+ it('renders an artifact link as text against a shell that cannot open one', async (ctx) => {
+ const hidden = await open(browser(), {
+ signal: ctx.signal,
+ grants: ['navigate', 'storage'],
+ act: async ({ frame }) => {
+ // The tap the next case makes on a granted shell. It is expected to produce nothing, so
+ // the arm takes the bounded settle rather than waiting for a record that is not coming.
+ await frame?.click('#toplink', { timeout: 2000 })
+ }
+ })
+ // The artifact is there and painted, so what follows is a hidden affordance on a complete
+ // screen rather than a frame that failed to load.
+ expect(hidden.grants).not.toContain('externalNavigation')
+ expect(hidden.pixelBefore).toBe(ARTIFACT_RGB)
+ expect(hidden.frameCount).toBe(1)
+ expect(hidden.inside?.marker).toBe('ARTIFACT_RENDERED')
+ // The toggle is still a toggle: this is the whole of "the screen that remains is complete".
+ expect(hidden.toggles?.map((one) => one.selected)).toEqual(['true', 'false'])
+ // No anchor left at all: the elements and their text survive, the links do not. The
+ // fragment link is in that count too -- inside this frame a fragment resolves against the
+ // embedder's base URL, so activating it navigates rather than scrolls (round 3).
+ expect(hidden.links?.linked).toBe(0)
+ expect(hidden.links?.anchors).toBe(5)
+ expect(hidden.links?.text).toBe('tap')
+ // No underline, as the browser resolves it, and not in the tab order either.
+ expect(hidden.links?.decoration).toBe('none')
+ expect(hidden.links?.focusable).toBe(false)
+ // And the tap does nothing, which is the behaviour the affordance was advertising. The
+ // click landing is asserted first, because a click that never reached its target and a tap
+ // that did nothing are the same three zeros and only one of them is the product's doing.
+ expect(hidden.actError).toBeNull()
+ expect(hidden.topNavigations).toBe(0)
+ expect(hidden.ownOriginTopNavigations).toBe(0)
+ expect(hidden.popups).toBe(0)
+ // The second fence: the browsing context cannot navigate the top frame either, so a link
+ // this pass somehow missed is refused by the sandbox as well.
+ expect(hidden.mountedSandbox).toBe('')
+
+ // Every reading above against the granted arm, which is the shipped screen. It does not tap,
+ // and that is not only about the wait: a tap costs the readings, because the top frame goes
+ // mid-navigation and the computed style of an element in a blanking frame reads as the
+ // initial value -- which is what this arm measured before it was split. The same split the
+ // `pixelBefore` sampling above exists for.
+ const shown = await open(browser(), { signal: ctx.signal })
+ expect(shown.grants).toContain('externalNavigation')
+ expect(shown.pixel).toBe(ARTIFACT_RGB)
+ expect(shown.links?.linked).toBe(5)
+ expect(shown.links?.anchors).toBe(5)
+ expect(shown.links?.text).toBe('tap')
+ expect(shown.links?.decoration).toBe('underline')
+ expect(shown.links?.focusable).toBe(true)
+ /**
+ * The pointer cursor, asserted only on the engine that reports one.
+ *
+ * Measured here: WebKit computes `cursor: auto` for an `` as well as for an anchor
+ * without one -- it resolves the link cursor at hit test rather than into the computed
+ * style -- so on that engine the reading cannot tell the two apart. Asserting "not pointer"
+ * on the hidden arm there would be a zero with no presence precondition behind it, so this
+ * pins the discrimination where it exists and pins the blindness where it does not. The
+ * underline, the missing anchor, the lost focusability and the tap that did nothing carry
+ * the case on WebKit.
+ */
+ if (engine === 'chromium') {
+ expect(shown.links?.cursor).toBe('pointer')
+ expect(hidden.links?.cursor).not.toBe('pointer')
+ } else {
+ expect(shown.links?.cursor).toBe(hidden.links?.cursor)
+ }
+ expect(shown.mountedSandbox).toBe('allow-top-navigation-by-user-activation')
+ }, 240_000)
+
+ /**
+ * What the hidden-link path must NOT change (C8.1 round 2).
+ *
+ * The pass parses the artifact and serialises it again, and a round trip is not free of the
+ * artifact's rendering by default. Three things were measured lossy and compensated in
+ * `html-preview-inert-links.ts`; this is the arm that reads what an engine actually did with
+ * the result, because the unit suite runs in happy-dom and happy-dom's parser does not drop
+ * the newline a browser drops or resolve a rendering mode at all.
+ *
+ * The granted arm is every reading's presence precondition: it takes the artifact untouched,
+ * so it is what the hidden arm has to match.
+ */
+ it('changes nothing an engine renders except that links are not links', async (ctx) => {
+ // A doctype with a public identifier and no system identifier, which is quirks. The bare
+ // name is not, so a pass that rewrote one as the other moves the whole artifact between
+ // layout modes -- which is what this arm exists to catch.
+ const quirks = ''
+ const shown = await open(browser(), { signal: ctx.signal, doctype: quirks })
+ const hidden = await open(browser(), {
+ signal: ctx.signal,
+ doctype: quirks,
+ grants: ['navigate', 'storage']
+ })
+ // Both painted, so every comparison below is between two rendered documents.
+ expect(shown.pixel).toBe(ARTIFACT_RGB)
+ expect(hidden.pixel).toBe(ARTIFACT_RGB)
+ expect(shown.grants).toContain('externalNavigation')
+ expect(hidden.grants).not.toContain('externalNavigation')
+
+ /**
+ * The rendering mode, pinned as the reading it is rather than the one it looks like.
+ *
+ * A quirks doctype does not put this frame in quirks mode, and nothing could: measured on
+ * both engines, a `srcdoc` document takes its mode from its embedder, and that doctype, the
+ * bare name and no doctype at all all read `CSS1Compat` inside the frame. So `compatMode`
+ * cannot tell a preserved doctype from a rewritten one here -- it is asserted equal across
+ * the arms, and pinned to the embedder's mode so that an engine which ever stopped
+ * inheriting reds this rather than going quietly green.
+ */
+ expect(shown.inside?.compatMode).toBe('CSS1Compat')
+ expect(hidden.inside?.compatMode).toBe(shown.inside?.compatMode)
+ // The reading that does discriminate: the doctype the frame's own document reports. Without
+ // the identifiers carried through, the hidden arm reports an empty public id here.
+ expect(shown.inside?.doctypePublicId).toBe('-//W3C//DTD HTML 4.01 Transitional//EN')
+ expect(hidden.inside?.doctypePublicId).toBe(shown.inside?.doctypePublicId)
+ expect(hidden.inside?.doctypeSystemId).toBe(shown.inside?.doctypeSystemId)
+ // Not vacuous: an arm handed the bare name reports no identifier on the same reading.
+ const bare = await open(browser(), { signal: ctx.signal, grants: ['navigate', 'storage'] })
+ expect(bare.inside?.doctypePublicId).toBe('')
+
+ // The blank line a preformatted block starts with, which the serialiser drops and the pass
+ // writes back. The fixture's block opens with one, so this is a presence either way.
+ expect(shown.inside?.preText).toBe('\nkept')
+ expect(hidden.inside?.preText).toBe(shown.inside?.preText)
+
+ // The fragment link, which the granted arm keeps and the hidden arm does not. Round 3
+ // measured why that is the right way round: a fragment is a frame navigation here, not a
+ // scroll, so there was no working affordance to preserve. The case below taps one.
+ expect(shown.inside?.fragmentHref).toBe('#fragtarget')
+ expect(hidden.inside?.fragmentHref).toBeNull()
+ expect(hidden.links?.linked).toBe(0)
+ expect(shown.links?.linked).toBe(5)
+ }, 240_000)
+
+ /**
+ * A tap on a table-of-contents link, which is not the scroll it looks like (round 3).
+ *
+ * The frame's document URL is `about:srcdoc` and its base URL is inherited from the embedder,
+ * so `#fragtarget` resolves against the shell's own URL: the destination differs from the
+ * document's by more than a fragment, which makes activating it a frame navigation and the
+ * shipped `frame-src \'none\'` refuses it. Nothing scrolls on either engine, and on Chromium
+ * the frame is replaced by an error page, so the artifact is gone.
+ *
+ * The granted arm is the presence precondition and it is also a bug: the pass does not run
+ * there, so the artifact keeps its fragment links and the same tap does the same damage. That
+ * has been true since the preview shipped and is not this change\'s to fix -- it is recorded
+ * in `followup-html-preview-fragment-links.md`. What it buys here is that the counters can
+ * see the navigation at all, so the hidden arm\'s silence is the missing href and not a rig
+ * that cannot watch.
+ */
+ it('taps a fragment link, which navigates this frame rather than scrolling it', async (ctx) => {
+ // Something to scroll, so "did not scroll" is a reading rather than a document that had
+ // nowhere to go.
+ const tall = { body: 'spacer
' }
+ const tapFragment = async ({ frame }) => {
+ await frame?.click('#fraglink', { timeout: 2000 })
+ }
+
+ const shown = await open(browser(), { signal: ctx.signal, extra: tall, act: tapFragment })
+ // The precondition the whole case rests on: the base URL is the embedder's, which is what
+ // makes a fragment resolve off-document here.
+ expect(shown.inside?.baseUri ?? shown.mountedSrcDoc).toBeTruthy()
+ // The navigation the shipped policy refused, which is what the hidden arm must not produce.
+ expect(shown.reported).toContain('frame-src')
+
+ const hidden = await open(browser(), {
+ signal: ctx.signal,
+ extra: tall,
+ grants: ['navigate', 'storage'],
+ act: tapFragment
+ })
+ // The tap landed on the element and produced nothing at all.
+ expect(hidden.actError).toBeNull()
+ expect(hidden.reported).not.toContain('frame-src')
+ // The artifact is still the frame's document, which is the damage this avoids.
+ expect(hidden.inside?.marker).toBe('ARTIFACT_RENDERED')
+ expect(hidden.inside?.fragmentHref).toBeNull()
+ // And it did not scroll either, because there is nothing left to activate.
+ expect(hidden.inside?.scrollY).toBe(0)
+ // Nothing went to the top frame or a new window on the way, either.
+ expect(hidden.topNavigations).toBe(0)
+ expect(hidden.popups).toBe(0)
+ }, 240_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,
@@ -867,11 +824,6 @@ describe('the HTML preview needs no policy change', () => {
})
/** 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')
diff --git a/config/scripts/mobile-web-app-page-grant-call-sites.mjs b/config/scripts/mobile-web-app-page-grant-call-sites.mjs
index bc3ba5dfb8a..0fe84c4f6c8 100644
--- a/config/scripts/mobile-web-app-page-grant-call-sites.mjs
+++ b/config/scripts/mobile-web-app-page-grant-call-sites.mjs
@@ -24,10 +24,16 @@ const importRow = (grants, specifier, why) => ({ kind: 'import', grants, specifi
* One row per grant the page can ask for through a call site of its own.
*
* `haptics` and `screencastBinary` have their own files (`mobile-web-app-haptics-seam.test.mjs`,
- * `mobile-web-app-screencast-lane-grant.test.mjs`) and the four audio grants have
- * `mobile-web-app-session-dictation-capture.test.mjs`, so those eight are not repeated here. The
+ * `mobile-web-app-screencast-lane-grant.test.mjs`), the four audio grants have
+ * `mobile-web-app-session-dictation-capture.test.mjs` and `externalNavigation` has
+ * `mobile-web-app-external-navigation-grant.test.mjs`, so those nine are not repeated here. The
* media three share one seam and one row: `useMediaPicker` is the only way in, and `canPickMedia`
* is `pick && read && release`, so a route reaching it needs all three or none of them.
+ *
+ * `externalNavigation` could not be a row here whatever it owned, and that is the rule rather than a
+ * detail: a row is a call site the walk can find, and the shell's cancelled-navigation behaviour has
+ * none. Nothing is requested and nothing is answered, so the only thing a closure holds is a read of
+ * `init.grants.native` -- which is what its own census walks for.
*/
export const PAGE_GRANT_CALL_SITES = [
callRow(
diff --git a/config/scripts/mobile-web-app-page-grant-call-sites.test.mjs b/config/scripts/mobile-web-app-page-grant-call-sites.test.mjs
index 2c1eec4dde0..8b345492838 100644
--- a/config/scripts/mobile-web-app-page-grant-call-sites.test.mjs
+++ b/config/scripts/mobile-web-app-page-grant-call-sites.test.mjs
@@ -1,10 +1,10 @@
/**
* The six grants that were pinned only by the list they were copied from (ruling 33.3).
*
- * `haptics`, `screencastBinary` and the four audio grants already have call-site censuses of their
- * own; these six did not, so removing any of them from a manifest entry reddened nothing. Each row
- * below gets its own named case, and each case's control is the same rule driven over the entry
- * that route would have had with the grant struck out.
+ * `haptics`, `screencastBinary`, `externalNavigation` and the four audio grants already have
+ * censuses of their own; these six did not, so removing any of them from a manifest entry reddened
+ * nothing. Each row below gets its own named case, and each case's control is the same rule driven
+ * over the entry that route would have had with the grant struck out.
*/
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
@@ -108,7 +108,12 @@ describe('the call-site reader', () => {
'native.media.read',
'native.media.release'
])
- for (const owned of ['haptics', 'screencastBinary', 'native.audio.start']) {
+ for (const owned of [
+ 'haptics',
+ 'screencastBinary',
+ 'externalNavigation',
+ 'native.audio.start'
+ ]) {
expect(grants).not.toContain(owned)
}
})
diff --git a/config/scripts/mobile-web-app-preview-arm-driver.mjs b/config/scripts/mobile-web-app-preview-arm-driver.mjs
new file mode 100644
index 00000000000..e82def32183
--- /dev/null
+++ b/config/scripts/mobile-web-app-preview-arm-driver.mjs
@@ -0,0 +1,221 @@
+/**
+ * One arm of the HTML-preview render rig: mount an artifact, act on it, and read what happened.
+ */
+import { recordRequestsTo } from './mobile-web-app-preview-request-log.mjs'
+import { watchImageEvidence } from './mobile-web-app-preview-image-evidence.mjs'
+import { artifact } from './mobile-web-app-preview-artifact-fixture.mjs'
+import {
+ previewFrame,
+ settleAfterMount,
+ waitForLoadedFrame
+} from './mobile-web-app-preview-frame-readiness.mjs'
+import {
+ probePreviewPixel,
+ readPreviewArm,
+ readPreviewToggles
+} from './mobile-web-app-preview-frame-readings.mjs'
+
+/**
+ * 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.
+ *
+ * Its own module, and the boundary `mobile-web-app-preview-frame-readings.mjs` already names: the
+ * rig file holds what each case claims, this holds how an arm is driven, and that one holds what a
+ * driven arm reports. The three grow for different reasons and were over the 600-line cap together.
+ *
+ * `rig` is everything the driver cannot build for itself -- the servers and origins the suite
+ * started once, the sink refusals are attributed through, the pixel clip, and the nonce counter it
+ * advances. Passed rather than imported, because a module-level copy of that state is a second one.
+ */
+export async function openPreviewArm(
+ rig,
+ browser,
+ {
+ extra = {},
+ csp = 'shipped',
+ sandbox,
+ act,
+ expectNavigation = null,
+ frameReady = 'artifact',
+ assets,
+ doctype,
+ reportReady = null,
+ /** What the shell told this page it may do. Defaults to the session route's own list, so an arm
+ * that does not mention it measures the shipped screen (C8.1). */
+ grants = null,
+ signal
+ } = {}
+) {
+ const { origins, foreignOrigin, foreignHits, assetServer, cspReports, clip } = rig
+ const origin = origins[csp === 'shipped' ? 'shipped' : csp === 'leaky' ? 'leaky' : 'none']
+ rig.nonce += 1
+ const nonce = `n${String(rig.nonce)}`
+ // Read here and carried as a string: asked for at the abort it lost its race with teardown and
+ // printed "browser unknown" in the CI log this diagnostic exists for.
+ const browserVersion = browser.version()
+ // An explicit context, so an arm that aborts mid-read can hand back everything it holds. The
+ // arms share one browser per engine; only the context is theirs.
+ // The asset listener's certificate is generated per run and trusted by nothing, which is what
+ // this flag is for; the page's own origin is still plain http from the bundle server.
+ const context = await browser.newContext({
+ viewport: { width: 390, height: 844 },
+ ignoreHTTPSErrors: true
+ })
+ const page = await context.newPage()
+ // Subscribed before the first navigation, so a request made during load is in the log. Cheap
+ // while an arm passes: it fills arrays, and only an abort asks them to speak.
+ const requestLog = await recordRequestsTo(page, assetServer.origin)
+ // Asked only when an arm has aborted, so the fresh-image probe and its wait cost a failing run
+ // and never a passing one.
+ const describeRequests = watchImageEvidence(page, assetServer.origin, requestLog, assetServer.saw)
+ try {
+ const navigations = []
+ const popups = []
+ let servedCsp = null
+ page.on('response', (response) => {
+ if (response.url().startsWith(`${origin}/preview`)) {
+ servedCsp = response.headers()['content-security-policy'] ?? null
+ }
+ })
+ page.on('popup', (popup) => {
+ popups.push(popup.url())
+ void popup.close().catch(() => {})
+ })
+ // The record is the page's own event, not the route handler's. Interception is per target and
+ // attaches late on a Chrome that isolates the sandboxed frame, which is what left the CI log
+ // saying `recorded []`; `page.on('request')` is one subscription over every frame the page has.
+ // Armed after the rig's own `goto`, exactly where the route used to be registered: the initial
+ // navigation is a main-frame navigation to this origin and would otherwise count as one the
+ // artifact asked for.
+ let recordingNavigations = false
+ page.on('request', (request) => {
+ if (!recordingNavigations || !request.isNavigationRequest()) {
+ return
+ }
+ const url = request.url()
+ if (!url.startsWith(foreignOrigin) && !url.startsWith(origin)) {
+ return
+ }
+ navigations.push({
+ url,
+ foreign: url.startsWith(foreignOrigin),
+ main: request.frame() === page.mainFrame()
+ })
+ })
+ // The route stays for what only a route can do: refuse the navigation. Playwright is not the
+ // shell, so a top-frame navigation is aborted here the way the shell's delegate would refuse
+ // it, and a frame navigating itself is left alone -- aborting that would make "the frame stayed
+ // on the artifact" true by the rig's own doing.
+ const record = (route) => {
+ const request = route.request()
+ if (request.isNavigationRequest() && request.frame() === page.mainFrame()) {
+ return void route.abort()
+ }
+ return void route.continue()
+ }
+ await page.route(`${foreignOrigin}/**`, record)
+ // The shell page's violations, and only those: an artifact's own listener would have to run, and
+ // the fence under test is that nothing in the artifact runs.
+ await page.addInitScript(() => {
+ // When this ran, in every frame it ran in. The collector below can only report what it was
+ // present for, so its own moment is a reading rather than an assumption.
+ window.__initAt = `${String(Math.round(performance.now()))} ${document.readyState}`
+ window.__violations = []
+ document.addEventListener('securitypolicyviolation', (event) => {
+ window.__violations.push(`${event.violatedDirective} ${event.blockedURI || 'inline'}`)
+ })
+ })
+ // The nonce in the document's own URL: the policy this response carries names a report endpoint
+ // with the same nonce, which is how a report from a `srcdoc` frame with no URL of its own is
+ // attributed to the arm that caused it.
+ await page.goto(`${origin}/preview?n=${nonce}`, { waitUntil: 'load' })
+ recordingNavigations = true
+ // Registered after the page's own load, not before it: this handler aborts main-frame navigations
+ // and the initial `goto` is one. `href="/"` and `href=""` inside an artifact resolve against the
+ // embedder's base, so a tap on either asks to navigate the top frame to the shell's own document.
+ // The rig has no shell, so what this pins is the request the shell is handed; refusing it is
+ // `MobileWebShellDroppedNavigationTest`'s "refuses every navigation to the document that the shell
+ // did not ask for" and its `checkNavigationVerdict` twin on iOS.
+ await page.route(`${origin}/**`, record)
+ // `sandbox` undefined is the product's own token, which is what every non-control case runs.
+ await page.evaluate(
+ ([html, override, granted]) => window.__mount(html, override, granted),
+ [
+ artifact({
+ links: foreignOrigin,
+ assets: assets ?? foreignOrigin,
+ extra,
+ nonce,
+ ...(doctype === undefined ? {} : { doctype })
+ }),
+ sandbox ?? null,
+ grants
+ ]
+ )
+ // Named in every diagnostic, because the log shows the case and not which of its arms spoke.
+ const arm =
+ `arm csp=${csp} sandbox=${sandbox ?? 'product'} frameReady=${frameReady} ` +
+ `reportReady=${reportReady ?? 'none'} nonce=${nonce}`
+ // One reader for the wait and for the reading: an arm that waits on one list and asserts on
+ // another proves nothing about the list it asserts on.
+ const readImageHits = () => assetServer.hitsFor(nonce)
+ const artifactFrame = await waitForLoadedFrame(page, {
+ frameReady,
+ reportReady,
+ signal,
+ browserVersion,
+ arm,
+ sink: cspReports,
+ nonce,
+ readImageHits,
+ describeRequests
+ })
+ // Sampled before the action as well as after: a case that taps a link is asking what the tap
+ // produced, and by then the top frame is mid-navigation and the iframe has blanked to its own
+ // background. So the precondition "there was a rendered artifact to tap" is this reading, and the
+ // one below is only meaningful for a case that did nothing.
+ const pixelBefore = await probePreviewPixel(page, clip)
+ // Sampled before the action as well, because the toggle's whole claim is that it changes.
+ const togglesBefore = await readPreviewToggles(page)
+ let actError = null
+ if (act) {
+ // Recorded, never swallowed: a click that never landed and a click that produced no
+ // navigation are the same empty counter, and only one of them is the product's doing.
+ await act({ page, frame: previewFrame(page) }).catch((error) => {
+ actError = String(error).split('\n')[0]
+ })
+ }
+ // Every arm settles, acting or not: an artifact can start a navigation with no tap behind it --
+ // `` is one -- and the arms that pin zero were reading their counters
+ // while that was still in flight.
+ await settleAfterMount(page, navigations, expectNavigation, signal, {
+ frame: artifactFrame,
+ browserVersion,
+ arm,
+ describeRequests
+ })
+ const result = await readPreviewArm({
+ page,
+ clip,
+ pixelBefore,
+ togglesBefore,
+ servedCsp,
+ actError,
+ navigations,
+ popups,
+ foreignHits,
+ readImageHits,
+ assetServer,
+ cspReports,
+ nonce
+ })
+ return result
+ } finally {
+ // The context and not just the page: an arm whose wait aborted still owns one, and the case
+ // after it runs on the same browser. On the happy path this is the close that always ran.
+ await page.close().catch(() => {})
+ await context.close().catch(() => {})
+ }
+}
diff --git a/config/scripts/mobile-web-app-preview-artifact-fixture.mjs b/config/scripts/mobile-web-app-preview-artifact-fixture.mjs
index d41da5be1e7..fc9723783f3 100644
--- a/config/scripts/mobile-web-app-preview-artifact-fixture.mjs
+++ b/config/scripts/mobile-web-app-preview-artifact-fixture.mjs
@@ -14,21 +14,45 @@ export const ARTIFACT_RGB = '0,128,255'
*
* 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.
+ *
+ * Wrapped in the page's own provider because the preview asks the shell what it may do
+ * (`use-html-preview-link-grant.web.ts` reads `init.grants.native`), and `usePageBridgeClient`
+ * throws outside one. The client is the two members that read is made of and nothing else: a fuller
+ * fake would be a second implementation of the bridge, and what an arm needs to vary is the grant
+ * list. `grants` defaults to carrying `externalNavigation`, which is what the session route
+ * declares, so an arm that does not mention it measures the shipped screen.
*/
export 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'
+import { RpcClientProvider } from '../transport/client-context.web'
+import {
+ MobileHtmlPreview,
+ MOBILE_HTML_PREVIEW_SANDBOX,
+ MOBILE_HTML_PREVIEW_SEALED_SANDBOX
+} from './MobileHtmlPreview'
window.__sandbox = MOBILE_HTML_PREVIEW_SANDBOX
-window.__mount = (html, sandboxOverride) => {
+window.__sealedSandbox = MOBILE_HTML_PREVIEW_SEALED_SANDBOX
+window.__mount = (html, sandboxOverride, grants) => {
const host = document.getElementById('root')
+ const native = grants ?? ['navigate', 'storage', 'externalNavigation']
+ window.__grants = native
+ const client = {
+ getShellSession: () => ({ grants: { native } }),
+ getState: () => 'connected',
+ onStateChange: () => () => {}
+ }
createRoot(host).render(
- createElement(MobileHtmlPreview, {
- html,
- renderSource: () => createElement(Text, null, 'SOURCE_TAB_RENDERED')
- })
+ createElement(
+ RpcClientProvider,
+ { client },
+ 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
@@ -78,13 +102,17 @@ window.__mount = (html, sandboxOverride) => {
* `extra.head` and `extra.body` let a case add a `` or a script without a second
* fixture, so the thing under test is the only difference between the arms.
*/
-export function artifact({ links, assets, extra = {}, nonce = 'n0' }) {
+export function artifact({ links, assets, extra = {}, nonce = 'n0', doctype = '' }) {
// 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}`
// Subresources move to `assets` and the links do not: a case about what the policy fetches should
// not also change which origin a tapped link navigates to.
- return `ARTIFACT
+ //
+ // `doctype` is a parameter for one case's sake (C8.1 round 2): the hidden-link path reparses and
+ // reserialises the artifact, and the doctype is what an engine reads its rendering mode from, so
+ // an arm has to be able to hand the frame one that is not the bare name.
+ return `${doctype}ARTIFACT