mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
feat(mobile): the shell tells a page which optional capabilities it has (OTA phase D, C8.1) (#22141)
* fix(mobile): publish page-route pairs the strict host schema accepts (OTA phase D, C8.1) `routeViewOf` handed the manifest's own route entries to the host as `pageRouteGrants`. The phone reads a manifest route loosely, so an entry arrives carrying whatever field the desktop that wrote it knew about, and `BridgePageRouteGrantsSchema` is `.strict()`: one unread key refuses the pairs, `createBridgeHost` refuses the route with them, and the page gets no `init` at all rather than losing one field. Fixed before any route carries an optional grant (ruling 37.4), so the manifest field the next commits add costs an installed shell nothing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore: drop the closure and bundle probe scripts from the tree Scratch measurements for C8.1 (which route closures reach the HTML preview, and what the preview render rig costs to bundle with a client provider). They belong outside the repository and were swept in by the previous commit's `git add -A`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): a manifest route may declare optional grants (OTA phase D, C8.1) Design B of design-ota-c8-1.md, ruling 37. `MobileWebBundleRouteSchema` grows `optionalGrants` under the required lane's own grammar, with the 16-name ceiling applied over the union of the two lists rather than to each. Serving a route still reads `grants` alone, so a capability a screen cannot work without stays required and takes the route native; a session's granted list is `[...grants, ...optionalGrants]` narrowed to what this shell implements, from one helper that both `grantsForRoute` and the `pageRouteGrants` publish read. The ruling's compatibility rationale is corrected in place. `z.looseObject` passes unknown members through rather than dropping them (measured, zod 4.4.3), so a shell older than the field still receives the key; what it lacks is a policy that reads one. What makes the lane safe against such a shell is therefore the previous commit's publish fix, not the reader. BRIDGE_PROTOCOL_VERSION stays 1. No new notify, verb or frame field. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): name the shell's cancelled-navigation behaviour as a grant (OTA phase D, C8.1) `externalNavigation` joins `MOBILE_WEB_SHELL_GRANTS` beside `screencastBinary` and `haptics`, declared in `cancelled-navigation-target.ts` because that is the module holding the rule which acts on it. A third token that is neither a verb nor a notify: the page posts nothing to make a cancelled top-frame navigation happen, so this list is the only thing that can tell a page whether a tap inside the sealed HTML-preview frame escapes at all. A constant and not a platform read (ruling 37.1): both engines dispatch the event, `ios/MobileWebShellView.swift:481` and Android's `MobileWebShellView.kt:382`, so an app build carries the behaviour on both or on neither. The policy census grows the half that was only pinned by the verb table: the implemented set is that table plus exactly three non-verb tokens, each read off the module that declares it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the bundle builder carries a route's optional grants (OTA phase D, C8.1) `resolveMobileWebPageRoutes` maps each declaration member by member, so a field the declaration grows reaches a phone only once the map names it: until now `optionalGrants` would have been dropped in silence and every route would have declared nothing optional. Omitted when the route declares none, because absent and empty are the same answer to a shell. The declaration suite grows the rule rather than a row: the map carries the lane through and writes no key without one, and the lane is held to the manifest's own grammar and to the ceiling over the union of the two lists. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the HTML preview hides its links on a shell that cannot open one (OTA phase D, C8.1) The session route declares `externalNavigation` on the optional lane, and the preview asks for it before it renders an artifact's links as links. Ruling 37.2's three readings are what "hide" means here, and removing `href` is what delivers all three at once: `a:any-link` stops matching, so the UA stylesheet stops underlining, the element leaves the tab order, and there is no dead anchor a tap does nothing on. The text the author wrote stays where it was, the artifact paints, and the Preview/Source toggle is untouched. Done with the browser's own parser rather than over the source text: an `href` inside a comment or a `<template>` is text to a browser, and a pass that rewrote either would be editing the artifact instead of its links. The frame also loses `allow-top-navigation-by-user-activation` on that path, so a link the pass somehow missed is refused by the browsing context as well. One route, measured rather than assumed: the design said two, and the file preview route's closure does not reach the HTML preview at all - it renders `MobileFilePreviewScreen`. The new closure census derives that list from the hook's callers. The render rig grows the case on both engines and the readings it needs, and `mobile-web-app-preview-frame-readings.mjs` is split out of it at the readings/arms boundary, because the two were over the 600-line cap together. Two engine findings are recorded in the rig: an `<a>` with no `href` still answers `tabIndex` 0 on both, so focusability is asked by focusing; and WebKit computes `cursor: auto` for a real link, so that reading is pinned where it discriminates and its blindness pinned where it does not. The hop-coverage census now reads the effective set, because that is what the running rule compares. Inert today: the session route is the only declarer and an opener into every other route. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the preview's hidden link path where the unit suite can reach it (OTA phase D, C8.1) The mobile suite runs in a `node` environment whose resolver has no `.web` precedence, so `MobileHtmlPreview.web.tsx`'s import of the grant hook lands on the native sibling, which answers yes unconditionally. That is why the existing component suite still measured the granted frame without knowing a grant exists, and it means the hidden path had no coverage in the sharded `test` job, where the render rig is skipped for want of the bundler's dependencies. So the wiring gets its own file with the module replaced: that the component asks, and that both the frame's sandbox and the document it is handed follow the one answer. happy-dom rather than the suite default, because the inerting pass parses with the browser's own `DOMParser`. `String(node.type)` rather than a literal comparison: `node.type` is `ElementType`, which overlaps a real intrinsic tag and not the host strings these mocks render, so `=== 'Pressable'` is a no-overlap error under `tsconfig.test.json` and the tests-typecheck ratchet reds on it. Also replaces a `Reflect.get` the anti-slop gate refuses with an `in` check. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the session page closure at 4,362 for C8.1's three modules Measured on both sides with `mobileWebAppRouteClosure(SESSION_ROUTE)` at base `841d06a969` with all five postinstall generators run first, and the two `local` lists diffed rather than the total inferred: 4,359 -> 4,362 modules, 1,017 -> 1,020 local. All three are local source modules and none is vendored: the page's read of `init.grants.native`, the pass that turns an artifact's links back into text without the grant, and the module declaring the token beside the rule that acts on it - reached both by that hook and by `page-route-policy.ts`. The `bridge-caps.ts` it imports was already in this closure, and the hook's native sibling is replaced rather than joined. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): allowlist the preview's grant sibling among the .web.* overrides `mobile-web-app-web-overrides.test.mjs` pins the allowlist against the `.web.*` files on disk, so a new web sibling reds it until the file says why the page needs one. Red before: `expected [ …(36) ] to deeply equal [ …(37) ]`, naming `src/components/use-html-preview-link-grant.web.ts`. The preview's own entry is corrected with it: its reason said `allow-top-navigation-by-user-activation` is granted, and that token is now conditional on the shell answering that it can open such a navigation. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the hidden-link render case waits on the frame's own reading (round 1) CI's chromium arm timed out at the full 240 s on this case alone while the WebKit sibling passed in 1.5 s and it passed 26/26 locally. The cause is the third arm: it tapped the granted link and waited through `expectNavigation: 'main-frame'`, and `waitForRecordedNavigation` has no bound but the case's own timeout. Under CI load the click missed its 2 s actionability window, no navigation was ever recorded, and the arm sat in that wait until vitest gave up - `recorded []`, with the frame attached only at 38.9 s. Three arms sharing one budget is what made this the case to find it. The arm is dropped rather than its wait lengthened or retried. Every verdict left 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 now whether the tap this arm made landed at all - `actError` is asserted null, so a click that never reached its target is no longer the same three zeros as a tap that did nothing. Nothing is lost. The tap's outcome on a granted shell is the next case, on these same counters from this same rig and with a budget of its own, which is the presence precondition this file already uses elsewhere for the same reason. The WebKit sibling's discriminating reads are untouched. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the inert-link pass changes nothing an engine renders but the links (round 2) Round 2's ruling: the hidden-link path may change nothing about the artifact's rendering except that links are not links. A parse and a reserialise is not free of that by default, and all four findings reproduced on Chromium 147 and WebKit 26.4. A same-document fragment link is kept. It starts no navigation at all, so it goes on working inside the sealed frame whatever the shell can do, and taking it away would be degradation over a capability it never needed - an artifact's own table of contents is the case. Its `target` still goes, because a fragment aimed at another frame is a navigation rather than a scroll, and `href=""` is not a fragment: it resolves to the frame's own URL. Links inside `template.content` are reached, recursively. `<template shadowrootmode>` is a declarative shadow root the frame's parser attaches and renders, and `querySelectorAll` does not walk into template content, so those links arrived live inside a sandbox that refuses their navigation - the dead anchor ruling 37.2 forbids. Measured: `parseFromString` attaches no such root on either engine or in happy-dom, so the pass can reach them. The leading newline of a `pre`, `listing` or `textarea` is written back. A parser drops one after the start tag and the serialiser is specified to put it back; measured, neither engine's does, so a round trip lost a blank line from every such block. The doctype is carried whole, and the reason is corrected from the one the finding gave. It cannot move this frame between layout modes: a `srcdoc` document takes its mode from its embedder, and measured, a quirks doctype, the bare name and no doctype at all all read `CSS1Compat` inside the frame. What rewriting it does is change the document the author wrote for no reason, with `document.doctype` observable beside a Source tab showing the original. The render case pins `compatMode` as the blind reading it is and reads the frame's own doctype identifiers as the one that discriminates. Option B was not available: the frame has no `allow-scripts` and inherits `script-src 'self'`, so nothing runs inside it and there is no injection to carry the work. Also drops a vacuous half of the affordance test. `renderSource()` is called with no argument, so the markup a Source view shows is the caller's own closure and asserting it equals the fixture passed whatever the component did. What the component decides is whether the rewritten frame stays mounted underneath, and that is what is read now. `mobile-web-app-preview-arm-driver.mjs` is split out of the render rig at the boundary the readings module already names - the rig holds what each case claims, the driver how an arm is driven, the readings what it reports - since the three were over the 600-line cap together. No max-lines disable or bump. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): a fragment link is a frame navigation in this preview, so it is inerted too (round 3) pullfrog is right, reproduced on both engines before believing it. Round 2 kept `#`-prefixed hrefs on the theory that they are same-document scrolls. In this frame they are not: the document's URL is `about:srcdoc` while its base URL is inherited from the embedder, so `#section` resolves against the shell's own URL and 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. Measured under the shipped policy, one tap, with something to scroll: Chromium 147 scrollY 0, frame becomes chrome-error://chromewebdata/, artifact gone, embedder reports frame-src <origin>/preview WebKit 26.4 scrollY 0, frame stays about:srcdoc and intact, same report So the destruction is Chromium-only but the absence of a scroll is not: there was no working affordance to carve out for, and the carve-out left a live link that destroys the preview - worse than the inert text it was meant to avoid. Both sandbox values behave the same, so this is the base URL and the policy rather than the sandbox. The same tap does the same thing on the granted path, where this pass does not run, so an artifact's internal links have never worked in the preview. That is not this change's to fix; it is recorded in `followup-html-preview-fragment-links.md`, and the render case reads the granted arm's violation as its presence precondition so the behaviour is pinned rather than merely known. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -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] })
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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 --
|
||||
// `<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,
|
||||
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 `<a href>` 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 = '<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">'
|
||||
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: '<div style="height:1600px">spacer</div>' }
|
||||
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')
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 --
|
||||
// `<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,
|
||||
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(() => {})
|
||||
}
|
||||
}
|
||||
@@ -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 `<meta refresh>` 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 = '<!doctype html>' }) {
|
||||
// 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 `<!doctype html><html><head><title>ARTIFACT</title>
|
||||
//
|
||||
// `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}<html><head><title>ARTIFACT</title>
|
||||
<style>html,body{margin:0;height:100%;background:rgb(${ARTIFACT_RGB})}
|
||||
#bg{background-image:url("${assets}/css-bg.png${tag}")}
|
||||
@font-face{font-family:probe;src:url("${assets}/probe.woff2${tag}")}
|
||||
@@ -95,6 +123,10 @@ export function artifact({ links, assets, extra = {}, nonce = 'n0' }) {
|
||||
<a id="blanklink" href="${links}/blank.html${tag}" target="_blank">window</a>
|
||||
<a id="rootlink" href="/" target="_top">root</a>
|
||||
<a id="emptylink" href="" target="_top">empty</a>
|
||||
<a id="fraglink" href="#fragtarget">contents</a><h2 id="fragtarget">F</h2>
|
||||
<pre id="pre">
|
||||
|
||||
kept</pre>
|
||||
<form id="topform" action="${links}/form.html" target="_top" method="get"><button id="submit">go</button></form>
|
||||
${extra.body ?? ''}</body></html>`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Everything one arm of the HTML-preview render rig can be asserted on, read off a settled page.
|
||||
*
|
||||
* Split from the rig for the reason `mobile-web-app-preview-frame-readiness.mjs` and
|
||||
* `mobile-web-app-preview-csp-reports.mjs` were: the rig file holds how an arm is driven and what
|
||||
* each case claims, and this holds what a driven arm reports. They grow for different reasons -- a
|
||||
* case is added when a fence is added, a reading when a fence becomes measurable in a new way -- and
|
||||
* together they were over the 600-line cap the moment C8.1's link readings landed.
|
||||
*
|
||||
* Every reading is taken after the arm has settled, and every one that reaches inside the frame
|
||||
* falls back to null: the frame is an opaque origin, so an engine that refuses to evaluate in one
|
||||
* reports absence rather than failing the arm. A case that depends on such a reading therefore pins
|
||||
* a presence precondition beside it.
|
||||
*/
|
||||
import { PNG } from 'pngjs'
|
||||
import { reportedDirectives } from './mobile-web-app-preview-csp-reports.mjs'
|
||||
import { previewFrame } from './mobile-web-app-preview-frame-readiness.mjs'
|
||||
|
||||
/**
|
||||
* One RGB triple from the page, clipped to where the frame sits.
|
||||
*
|
||||
* A pixel rather than a read inside the frame, because the frame is an opaque origin and the paint
|
||||
* is the one claim that must hold on every engine.
|
||||
*/
|
||||
export async function probePreviewPixel(page, clip) {
|
||||
const png = PNG.sync.read(await page.screenshot({ clip }))
|
||||
return `${png.data[0]},${png.data[1]},${png.data[2]}`
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export async function readPreviewToggles(page) {
|
||||
return await page
|
||||
.evaluate(() =>
|
||||
[...document.querySelectorAll('[role="tab"]')].map((one) => ({
|
||||
label: one.getAttribute('aria-label'),
|
||||
selected: one.getAttribute('aria-selected')
|
||||
}))
|
||||
)
|
||||
.catch(() => null)
|
||||
}
|
||||
|
||||
/** The element's own attributes and geometry, which is where "the artifact is parsed inside the
|
||||
* frame rather than fetched into it" actually lives. Read from the element the component rendered
|
||||
* and not from 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 rig did before this reading existed. */
|
||||
async function readFrameElement(page) {
|
||||
return await page
|
||||
.evaluate(() => {
|
||||
const frame = document.querySelector('iframe')
|
||||
if (!frame) {
|
||||
return { sandbox: null, srcDoc: null, src: null, box: null }
|
||||
}
|
||||
const box = frame.getBoundingClientRect()
|
||||
return {
|
||||
sandbox: frame.getAttribute('sandbox'),
|
||||
srcDoc: frame.getAttribute('srcdoc'),
|
||||
src: frame.getAttribute('src'),
|
||||
// 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.
|
||||
box: { x: box.x, y: box.y, width: box.width, height: box.height }
|
||||
}
|
||||
})
|
||||
.catch(() => ({ sandbox: null, srcDoc: null, src: null, box: null }))
|
||||
}
|
||||
|
||||
/** The frame's own document: what the artifact rendered, whether its script ran, and the refusals
|
||||
* the frame itself was told about. `securitypolicyviolation` does not cross frames, so the page's
|
||||
* init script installs the same collector in every one. */
|
||||
async function readInsideFrame(page) {
|
||||
return await (previewFrame(page)
|
||||
?.evaluate(() => ({
|
||||
marker: document.getElementById('marker')?.textContent ?? null,
|
||||
title: document.title,
|
||||
// The two the hidden-link path could change without touching a link (C8.1 round 2). The
|
||||
// rendering mode the engine resolved from the doctype it was handed, and the text a
|
||||
// preformatted block holds -- both read off the frame's own document, because the claim is
|
||||
// about what the engine parsed rather than about the string the page built.
|
||||
compatMode: document.compatMode,
|
||||
// The doctype the frame's own document reports, which is the reading that discriminates:
|
||||
// `compatMode` cannot, because a `srcdoc` document takes its mode from its embedder.
|
||||
doctypePublicId: document.doctype?.publicId ?? null,
|
||||
doctypeSystemId: document.doctype?.systemId ?? null,
|
||||
preText: document.getElementById('pre')?.textContent ?? null,
|
||||
fragmentHref: document.getElementById('fraglink')?.getAttribute('href') ?? null,
|
||||
// Where the frame is aimed and where it has scrolled to, which is how a tap on a fragment is
|
||||
// told apart from a same-document scroll: inside this frame the base URL is the embedder's,
|
||||
// so a fragment resolves off-document and activating it navigates rather than scrolls.
|
||||
baseUri: document.baseURI,
|
||||
scrollY: Math.round(window.scrollY),
|
||||
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,
|
||||
violations: window.__violations ?? null
|
||||
}))
|
||||
.catch(() => null) ?? Promise.resolve(null))
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the artifact's links are links, as a browser resolves them (C8.1, ruling 37.2).
|
||||
*
|
||||
* Three readings rather than one, because the ruling names three: no underline, no pointer cursor,
|
||||
* no anchor a tap does nothing on. The first two are the UA stylesheet's `a:any-link` rules
|
||||
* resolving, which is a computed style and cannot be read off the markup; the third is the markup.
|
||||
* `#toplink` is the fixture's own link and the one every tapping arm taps.
|
||||
*
|
||||
* `focusable` is asked by focusing, not by reading `tabIndex`: measured on Chromium 152 and WebKit,
|
||||
* an `<a>` with no `href` still answers `tabIndex` 0, so that property says nothing about the tab
|
||||
* order. It is the last reading taken, because it is the one that changes the document.
|
||||
*/
|
||||
async function readFrameLinks(page) {
|
||||
return await (previewFrame(page)
|
||||
?.evaluate(() => {
|
||||
const first = document.getElementById('toplink')
|
||||
const style = first === null ? null : getComputedStyle(first)
|
||||
const reading = {
|
||||
linked: document.querySelectorAll('a[href], area[href]').length,
|
||||
// Every anchor the fixture wrote, link or not: the text has to still be there, because
|
||||
// hiding an affordance is not deleting what the author wrote.
|
||||
anchors: document.querySelectorAll('a').length,
|
||||
text: first?.textContent ?? null,
|
||||
decoration: style?.textDecorationLine ?? null,
|
||||
cursor: style?.cursor ?? null,
|
||||
focusable: null
|
||||
}
|
||||
if (first !== null) {
|
||||
first.focus()
|
||||
reading.focusable = document.activeElement === first
|
||||
}
|
||||
return reading
|
||||
})
|
||||
.catch(() => null) ?? Promise.resolve(null))
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole reading for one arm.
|
||||
*
|
||||
* `arm` carries what the rig already knows and cannot read back off the page: the counters it
|
||||
* subscribed for, the policy the response actually carried, and the error an action reported.
|
||||
*/
|
||||
export async function readPreviewArm({
|
||||
page,
|
||||
clip,
|
||||
pixelBefore,
|
||||
togglesBefore,
|
||||
servedCsp,
|
||||
actError,
|
||||
navigations,
|
||||
popups,
|
||||
foreignHits,
|
||||
readImageHits,
|
||||
assetServer,
|
||||
cspReports,
|
||||
nonce
|
||||
}) {
|
||||
const element = await readFrameElement(page)
|
||||
return {
|
||||
page,
|
||||
pixelBefore,
|
||||
pixel: await probePreviewPixel(page, clip),
|
||||
declaredSandbox: await page.evaluate(() => window.__sandbox),
|
||||
togglesBefore,
|
||||
toggles: await readPreviewToggles(page),
|
||||
mountedSandbox: element.sandbox,
|
||||
mountedSrcDoc: element.srcDoc,
|
||||
mountedSrc: element.src,
|
||||
frameBox: element.box,
|
||||
frameCount: page.frames().length - 1,
|
||||
// Reported, never asserted on: a `srcdoc` frame's URL reads `about:srcdoc` on one engine and
|
||||
// empty on CI's browser, so nothing may be decided by it.
|
||||
frameUrl: previewFrame(page)?.url() ?? null,
|
||||
// What the shell told the page, read back off the document so an arm cannot assert against a
|
||||
// grant list it only believes it passed.
|
||||
grants: await page.evaluate(() => window.__grants ?? null),
|
||||
links: await readFrameLinks(page),
|
||||
inside: await readInsideFrame(page),
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -301,8 +301,31 @@ const MERMAID_PACKAGE = 'node_modules/mermaid/'
|
||||
*
|
||||
* modules 4359 -> 4360 (+1)
|
||||
* local modules 1017 -> 1018 (+1)
|
||||
*
|
||||
* C8.1 then gave the HTML preview a capability to ask about, and three local modules join. Both
|
||||
* sides measured with `mobileWebAppRouteClosure(SESSION_ROUTE)` at base `841d06a969` with all five
|
||||
* postinstall generators run first, and the two `local` lists diffed rather than the total inferred:
|
||||
*
|
||||
* modules 4359 -> 4362 (+3)
|
||||
* local modules 1017 -> 1020 (+3)
|
||||
*
|
||||
* Named, and all three local: `src/components/use-html-preview-link-grant.web.ts`, the page's read
|
||||
* of `init.grants.native`; `src/components/html-preview-inert-links.ts`, the pass that turns the
|
||||
* artifact's links back into text without it; and
|
||||
* `src/mobile-web-shell/cancelled-navigation-target.ts`, which declares the grant token beside the
|
||||
* rule that acts on it and is reached both by that hook and by `page-route-policy.ts`. The
|
||||
* `bridge-caps.ts` it imports was already in this closure, and the hook's native sibling is
|
||||
* replaced rather than joined. Nothing vendored: three source modules, no package.
|
||||
*
|
||||
* The merge of the two is measured rather than summed, which is what this reading keeps having to
|
||||
* do. It agrees with the arithmetic this once, and only because the two additions are disjoint:
|
||||
* main's one module is the option catalog and this branch's three are the preview's, so neither
|
||||
* side counts the other's. Measured on the merged head with all five generators run first:
|
||||
*
|
||||
* modules 4360 -> 4363 (+3, and 4359 -> 4363 from the shared base)
|
||||
* local modules 1018 -> 1021 (+3)
|
||||
*/
|
||||
const SESSION_ROUTE_MODULES = 4360
|
||||
const SESSION_ROUTE_MODULES = 4363
|
||||
|
||||
/** What the page enters this route through once the route is a switch with a `.web.tsx` sibling. */
|
||||
const ROUTE_ENTRY = [
|
||||
|
||||
@@ -20,6 +20,19 @@ import { MOBILE_WEB_PAGE_ROUTES } from './mobile-web-page-routes.mjs'
|
||||
* made every declared route reachable and the filter inert.
|
||||
*/
|
||||
|
||||
/**
|
||||
* One route's effective grants: both lanes, which is what a session is actually granted.
|
||||
*
|
||||
* `page-route-policy.ts` builds a session's list from `[...grants, ...optionalGrants]` and publishes
|
||||
* that same list as the route's pair, and `route-handoff.web.ts` compares a target's pair against
|
||||
* what the opener holds. So a census that read the required lane alone would judge a hop covered
|
||||
* that the running rule hands off -- and the other way round once an optional grant is the only
|
||||
* difference between two routes.
|
||||
*/
|
||||
function effectiveGrants(route) {
|
||||
return [...route.grants, ...(route.optionalGrants ?? [])]
|
||||
}
|
||||
|
||||
/** Whether a concrete pattern from the source names the same route as a manifest pattern. */
|
||||
function sameRoute(pushed, declared) {
|
||||
const a = pushed.split('/')
|
||||
@@ -108,7 +121,8 @@ describe('in-page hops between page routes', () => {
|
||||
if (!reachable) {
|
||||
continue
|
||||
}
|
||||
const covered = target.grants.every((grant) => opener.grants.includes(grant))
|
||||
const held = effectiveGrants(opener)
|
||||
const covered = effectiveGrants(target).every((grant) => held.includes(grant))
|
||||
if (!covered) {
|
||||
handedOff.push(`${opener.pathname} -> ${target.pathname}`)
|
||||
}
|
||||
@@ -131,8 +145,12 @@ describe('in-page hops between page routes', () => {
|
||||
if (!explorer || !preview) {
|
||||
throw new Error('the manifest lost a route this census is written against')
|
||||
}
|
||||
expect(preview.grants.length, 'the preview declares something to inherit').toBeGreaterThan(0)
|
||||
expect(preview.grants.filter((grant) => !explorer.grants.includes(grant))).toEqual([])
|
||||
const held = effectiveGrants(explorer)
|
||||
expect(
|
||||
effectiveGrants(preview).length,
|
||||
'the preview declares something to inherit'
|
||||
).toBeGreaterThan(0)
|
||||
expect(effectiveGrants(preview).filter((grant) => !held.includes(grant))).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the file hops local from the two routes whose rows open them', () => {
|
||||
@@ -144,7 +162,7 @@ describe('in-page hops between page routes', () => {
|
||||
if (!route) {
|
||||
throw new Error(`${pathname} is not registered`)
|
||||
}
|
||||
return route.grants
|
||||
return effectiveGrants(route)
|
||||
}
|
||||
const explorer = grantsOf('/h/[hostId]/files/[worktreeId]')
|
||||
const preview = grantsOf('/h/[hostId]/files/preview/[worktreeId]')
|
||||
@@ -173,20 +191,26 @@ describe('in-page hops between page routes', () => {
|
||||
if (!session) {
|
||||
throw new Error('the manifest lost the session route this census is written against')
|
||||
}
|
||||
const held = effectiveGrants(session)
|
||||
const uncovered = MOBILE_WEB_PAGE_ROUTES.filter(
|
||||
(target) => target.pathname !== session.pathname
|
||||
)
|
||||
.filter((target) => target.grants.some((grant) => !session.grants.includes(grant)))
|
||||
.filter((target) => effectiveGrants(target).some((grant) => !held.includes(grant)))
|
||||
.map((target) => target.pathname)
|
||||
expect(uncovered).toEqual([])
|
||||
// And the superset is strict, so the line above is not two equal lists.
|
||||
expect(session.grants.length).toBeGreaterThan(
|
||||
expect(held.length).toBeGreaterThan(
|
||||
Math.max(
|
||||
...MOBILE_WEB_PAGE_ROUTES.map((route) => route.grants.length).filter(
|
||||
(length) => length !== session.grants.length
|
||||
...MOBILE_WEB_PAGE_ROUTES.map((route) => effectiveGrants(route).length).filter(
|
||||
(length) => length !== held.length
|
||||
)
|
||||
)
|
||||
)
|
||||
// The optional lane is inside that superset rather than beside it: the session route is the one
|
||||
// route that declares `externalNavigation`, and it is an opener into every other, so the lane
|
||||
// costs no handoff today. A route that grew an optional grant the session lacks would add a row
|
||||
// to the list above, which is the change this census exists to surface before a device does.
|
||||
expect(held).toContain('externalNavigation')
|
||||
})
|
||||
|
||||
it('keeps the hub and review local to each other, in both directions', () => {
|
||||
@@ -198,7 +222,7 @@ describe('in-page hops between page routes', () => {
|
||||
if (!route) {
|
||||
throw new Error(`${pathname} is not registered`)
|
||||
}
|
||||
return [...route.grants].sort()
|
||||
return [...effectiveGrants(route)].sort()
|
||||
}
|
||||
const hub = grantsOf('/h/[hostId]/source-control/[worktreeId]')
|
||||
expect(hub.length).toBeGreaterThan(0)
|
||||
|
||||
@@ -17,6 +17,12 @@
|
||||
* row is in all five closures and calls the seam, so a route without the grant is a page whose taps
|
||||
* stop buzzing. mobile-web-app-haptics-seam.test.mjs derives that list from the closures and fails
|
||||
* on a route that imports the seam and declares nothing.
|
||||
*
|
||||
* `optionalGrants` names what a screen is better with and complete without (ruling 37). A shell that
|
||||
* implements fewer than an entry's `grants` renders the native screen; a shell that implements fewer
|
||||
* than its `optionalGrants` renders the page and the page hides that one affordance. So the two
|
||||
* lanes are a product decision about the screen: a capability the screen cannot be shown without
|
||||
* goes above, and one an author can point at a complete screen without goes below.
|
||||
*/
|
||||
export const MOBILE_WEB_PAGE_ROUTES = [
|
||||
// The worktree list. `navigate` because every row opens a session screen that is still native.
|
||||
@@ -127,6 +133,15 @@ export const MOBILE_WEB_PAGE_ROUTES = [
|
||||
// none — a route granted three records with the screen free to lock, and a lock mid-processing
|
||||
// suspends the app and loses the transcript. Ruling 4's degradation is retired with them: the
|
||||
// page no longer falls back to the vendored module's denied microphone.
|
||||
//
|
||||
// `externalNavigation` is the one optional grant in this list, and it is C8.1's. The HTML preview
|
||||
// renders an agent's artifact in a sealed frame, and a tap on a link inside it becomes a top-frame
|
||||
// navigation only the shell can cancel and open. Without the grant the preview renders the
|
||||
// artifact with its links as text: the document paints, the Preview/Source toggle works, and
|
||||
// nothing offers a tap that does nothing (ruling 37.2). Required would have taken this whole
|
||||
// screen native on every shell built before the cancelled-navigation event, which is the trade the
|
||||
// optional lane exists to avoid. `mobile-web-app-external-navigation-grant.test.mjs` derives the
|
||||
// route list from the closure that calls the hook.
|
||||
{
|
||||
pathname: '/h/[hostId]/session/[worktreeId]',
|
||||
grants: [
|
||||
@@ -143,6 +158,7 @@ export const MOBILE_WEB_PAGE_ROUTES = [
|
||||
'native.audio.start',
|
||||
'native.audio.read',
|
||||
'native.audio.stop'
|
||||
]
|
||||
],
|
||||
optionalGrants: ['externalNavigation']
|
||||
}
|
||||
]
|
||||
|
||||
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { MobileWebBundleRouteSchema } from '../../src/shared/mobile-web-bundle/manifest-contract.ts'
|
||||
import {
|
||||
buildMobileWebAppBundle,
|
||||
resolveMobileWebPageRoutes
|
||||
@@ -87,7 +88,10 @@ const EXPECTED_PAGE_ROUTES = [
|
||||
'native.audio.start',
|
||||
'native.audio.read',
|
||||
'native.audio.stop'
|
||||
]
|
||||
],
|
||||
// The one optional grant in the list (C8.1): the HTML preview's links, hidden rather than dead
|
||||
// against a shell that cannot open one.
|
||||
optionalGrants: ['externalNavigation']
|
||||
}
|
||||
]
|
||||
|
||||
@@ -110,6 +114,47 @@ describe('the page routes the manifest declares', () => {
|
||||
expect(resolveMobileWebPageRoutes(keys)).toEqual(EXPECTED_PAGE_ROUTES)
|
||||
})
|
||||
|
||||
/**
|
||||
* The optional lane through the builder, which drops what it does not name.
|
||||
*
|
||||
* `resolveMobileWebPageRoutes` maps each declaration member by member, so a field the declaration
|
||||
* grows reaches a phone only once this map names it. Driven on an input of its own rather than on
|
||||
* the real list, so the case stays a rule about the map whatever the declarations become.
|
||||
*/
|
||||
it('carries an optional grant list through, and writes no key for a route without one', () => {
|
||||
expect(
|
||||
resolveMobileWebPageRoutes(
|
||||
['./h/[hostId]/index.tsx', './h/[hostId]/tasks.tsx'],
|
||||
[
|
||||
{
|
||||
pathname: '/h/[hostId]',
|
||||
grants: ['navigate'],
|
||||
optionalGrants: ['externalNavigation']
|
||||
},
|
||||
{ pathname: '/h/[hostId]/tasks', grants: ['navigate'], optionalGrants: [] }
|
||||
]
|
||||
)
|
||||
).toEqual([
|
||||
{ pathname: '/h/[hostId]', grants: ['navigate'], optionalGrants: ['externalNavigation'] },
|
||||
{ pathname: '/h/[hostId]/tasks', grants: ['navigate'] }
|
||||
])
|
||||
})
|
||||
|
||||
it('holds the optional lane to the manifest grammar and the ceiling over the union', () => {
|
||||
// The declaration is checked against `MobileWebBundleRouteSchema` when the manifest is written,
|
||||
// so this is that schema's rule read from the builder's side: a name the required lane refuses
|
||||
// is refused here, and the two lists are bounded together rather than one at a time.
|
||||
const withOptional = (optionalGrants, grants = []) =>
|
||||
MobileWebBundleRouteSchema.safeParse({ pathname: '/h/[hostId]', grants, optionalGrants })
|
||||
.success
|
||||
expect(withOptional(['externalNavigation'])).toBe(true)
|
||||
expect(withOptional(['native.externalNavigation'])).toBe(false)
|
||||
const names = (count, prefix) =>
|
||||
Array.from({ length: count }, (_value, index) => `${prefix}${String(index)}`)
|
||||
expect(withOptional(names(8, 'opt'), names(8, 'req'))).toBe(true)
|
||||
expect(withOptional(names(9, 'opt'), names(8, 'req'))).toBe(false)
|
||||
})
|
||||
|
||||
it('fails the build on a declaration the bundle cannot render', () => {
|
||||
// The mismatch reaches a phone as a route the shell opens the page for and the page then
|
||||
// paints as Unmatched. This is the only place whoever wrote the declaration can see it.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState } from 'react'
|
||||
import { useMemo, 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'
|
||||
import { htmlPreviewWithInertLinks } from './html-preview-inert-links'
|
||||
import { useHtmlPreviewLinkGrant } from './use-html-preview-link-grant'
|
||||
// The native component's own prop type, so a change to it fails here rather than drifting.
|
||||
import type { MobileHtmlPreviewProps } from './MobileHtmlPreview'
|
||||
|
||||
@@ -21,9 +23,24 @@ import type { MobileHtmlPreviewProps } from './MobileHtmlPreview'
|
||||
* 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.
|
||||
*
|
||||
* And only if the shell on the other side of that tap has somewhere to send it. A shell built
|
||||
* before the cancelled-navigation event drops the navigation in silence, so `externalNavigation` is
|
||||
* asked for first (C8.1, ruling 37.2) and without it the artifact renders with its links as text.
|
||||
* Hidden and not degraded: the document paints, the toggle works, the Source tab is untouched.
|
||||
*/
|
||||
export const MOBILE_HTML_PREVIEW_SANDBOX = 'allow-top-navigation-by-user-activation'
|
||||
|
||||
/**
|
||||
* The same frame against a shell that cannot open what a tap would aim at.
|
||||
*
|
||||
* The token buys nothing there: the shell cancels the navigation and drops it, so a tap would do
|
||||
* nothing and the page would be offering an affordance it cannot honour. Dropping it is the second
|
||||
* of the two fences the inert-link pass sets -- see `html-preview-inert-links.ts` for why neither
|
||||
* stands in for the other. Everything else about the frame is unchanged, opaque origin and all.
|
||||
*/
|
||||
export const MOBILE_HTML_PREVIEW_SEALED_SANDBOX = ''
|
||||
|
||||
/**
|
||||
* Web sibling: the artifact rendered in a sealed frame, with the native component's Preview/Source
|
||||
* toggle intact.
|
||||
@@ -53,6 +70,14 @@ export const MOBILE_HTML_PREVIEW_SANDBOX = 'allow-top-navigation-by-user-activat
|
||||
*/
|
||||
export function MobileHtmlPreview({ html, renderSource }: MobileHtmlPreviewProps) {
|
||||
const [mode, setMode] = useState<'preview' | 'source'>('preview')
|
||||
// The shell's answer for this session, asked once: the page mounts after `init` and a session's
|
||||
// grants do not change for the life of the document.
|
||||
const linksOpen = useHtmlPreviewLinkGrant()
|
||||
// Only the path that rewrites pays for a parse, and only when the artifact changes.
|
||||
const rendered = useMemo(
|
||||
() => (linksOpen ? html : htmlPreviewWithInertLinks(html)),
|
||||
[html, linksOpen]
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -85,7 +110,10 @@ export function MobileHtmlPreview({ html, renderSource }: MobileHtmlPreviewProps
|
||||
<Text style={styles.toggleText}>Source</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
{mode === 'preview' ? <PreviewFrame html={html} /> : renderSource()}
|
||||
{/* The Source tab shows what the author wrote, never the rewrite: the rewrite is a rendering
|
||||
decision about this shell, and a reader who flipped to Source to read the markup would
|
||||
otherwise be shown markup that was never in the artifact. */}
|
||||
{mode === 'preview' ? <PreviewFrame html={rendered} linksOpen={linksOpen} /> : renderSource()}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -97,12 +125,12 @@ export function MobileHtmlPreview({ html, renderSource }: MobileHtmlPreviewProps
|
||||
* 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 }) {
|
||||
function PreviewFrame({ html, linksOpen }: { html: string; linksOpen: boolean }) {
|
||||
return (
|
||||
<View style={styles.frame}>
|
||||
<iframe
|
||||
title="HTML preview"
|
||||
sandbox={MOBILE_HTML_PREVIEW_SANDBOX}
|
||||
sandbox={linksOpen ? MOBILE_HTML_PREVIEW_SANDBOX : MOBILE_HTML_PREVIEW_SEALED_SANDBOX}
|
||||
srcDoc={html}
|
||||
style={IFRAME_STYLE}
|
||||
// The artifact is untrusted, so nothing it navigates to may learn where it came from or
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
// @vitest-environment happy-dom
|
||||
/**
|
||||
* The link-inerting pass, read as the specification ruling 37.2 wrote: no underline, no pointer, no
|
||||
* dead anchor.
|
||||
*
|
||||
* All three are one property of the document -- whether the element is a link at all -- so this file
|
||||
* measures that property and `config/scripts/mobile-web-app-html-preview-render.test.mjs` measures
|
||||
* what a real browser then paints and does with it on both engines. Neither reading substitutes for
|
||||
* the other: happy-dom has no UA stylesheet and no cursor, and the render rig cannot say which
|
||||
* attribute went.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { htmlPreviewWithInertLinks } from './html-preview-inert-links'
|
||||
|
||||
/** The rewritten document, re-parsed, so every case reads a tree rather than a string. */
|
||||
function inert(html: string): Document {
|
||||
return new DOMParser().parseFromString(htmlPreviewWithInertLinks(html), 'text/html')
|
||||
}
|
||||
|
||||
const ARTIFACT =
|
||||
'<!doctype html><html><head><title>A</title></head><body>' +
|
||||
'<h1 id="marker">text</h1>' +
|
||||
'<a id="top" href="https://example.com/a" target="_top">tap</a>' +
|
||||
'<a id="blank" href="https://example.com/b" target="_blank">window</a>' +
|
||||
'<a id="root" href="/">root</a>' +
|
||||
'<a id="empty" href="">empty</a>' +
|
||||
'<a id="frag" href="#target" target="_top">contents</a>' +
|
||||
'<h2 id="target">T</h2>' +
|
||||
'<a id="named" name="anchor">named</a>' +
|
||||
'<img id="mapped" src="x.png" usemap="#m" />' +
|
||||
'<map name="m"><area id="area" href="https://example.com/c" shape="rect" coords="0,0,1,1" /></map>' +
|
||||
'<svg viewBox="0 0 1 1"><a id="svglink" href="https://example.com/d"><rect /></a></svg>' +
|
||||
'</body></html>'
|
||||
|
||||
describe('an artifact rendered for a shell that cannot open a link', () => {
|
||||
it('leaves no element a browser would treat as a link', () => {
|
||||
// The whole of "no underline, no pointer, no dead anchor": all three follow from `a:any-link`
|
||||
// not matching, and `href` is what it matches on.
|
||||
expect(inert(ARTIFACT).querySelectorAll('a[href], area[href]')).toHaveLength(0)
|
||||
})
|
||||
|
||||
/**
|
||||
* The fragment link goes too, and round 3 is why: in this frame a fragment is not a scroll.
|
||||
*
|
||||
* The document's URL is `about:srcdoc` while its base URL is inherited from the embedder, so
|
||||
* `#target` resolves against the shell's own URL and the destination differs from the document's
|
||||
* by more than a fragment -- which makes activating it a frame navigation. Measured on Chromium
|
||||
* 147 and WebKit 26.4 under the shipped policy: nothing scrolls, the embedder reports
|
||||
* `frame-src`, and on Chromium the frame is replaced by an error page and the artifact is gone.
|
||||
*
|
||||
* So there was no working affordance to carve out for. The render rig taps one and reads what
|
||||
* the engines do; this is the attribute that decides it.
|
||||
*/
|
||||
it('inerts a fragment link too, because a fragment is not a scroll in this frame', () => {
|
||||
const doc = inert(ARTIFACT)
|
||||
expect(doc.getElementById('frag')?.hasAttribute('href')).toBe(false)
|
||||
expect(doc.getElementById('frag')?.hasAttribute('target')).toBe(false)
|
||||
// The text and the target it named both stay, as everywhere else: hidden, not deleted.
|
||||
expect(doc.getElementById('frag')?.textContent).toBe('contents')
|
||||
expect(doc.getElementById('target')?.textContent).toBe('T')
|
||||
// And the empty href, which resolves to the frame's own URL rather than to a fragment at all.
|
||||
expect(doc.getElementById('empty')?.hasAttribute('href')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the text, the headings and the images the author wrote', () => {
|
||||
const doc = inert(ARTIFACT)
|
||||
expect(doc.getElementById('marker')?.textContent).toBe('text')
|
||||
expect(doc.getElementById('top')?.textContent).toBe('tap')
|
||||
expect(doc.getElementById('blank')?.textContent).toBe('window')
|
||||
expect(doc.getElementById('mapped')?.getAttribute('src')).toBe('x.png')
|
||||
expect(doc.title).toBe('A')
|
||||
// The elements are still there and still in order: this is a hidden affordance, not a deletion.
|
||||
expect([...doc.querySelectorAll('a')].map((one) => one.id)).toEqual([
|
||||
'top',
|
||||
'blank',
|
||||
'root',
|
||||
'empty',
|
||||
'frag',
|
||||
'named',
|
||||
'svglink'
|
||||
])
|
||||
})
|
||||
|
||||
it('drops the target with the href, so no non-link carries link markup', () => {
|
||||
const doc = inert(ARTIFACT)
|
||||
expect(doc.getElementById('top')?.hasAttribute('target')).toBe(false)
|
||||
expect(doc.getElementById('blank')?.hasAttribute('target')).toBe(false)
|
||||
})
|
||||
|
||||
it('reaches an image map and an SVG link, which a pass over `a[href]` alone would not', () => {
|
||||
const doc = inert(ARTIFACT)
|
||||
expect(doc.getElementById('area')?.hasAttribute('href')).toBe(false)
|
||||
// Still an area with its shape: the map is intact, it just goes nowhere.
|
||||
expect(doc.getElementById('area')?.getAttribute('shape')).toBe('rect')
|
||||
expect(doc.getElementById('svglink')?.hasAttribute('href')).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves an anchor that was never a link alone, which is most of what a document has', () => {
|
||||
// `<a name>` has no href to begin with, so nothing here should have changed about it.
|
||||
expect(inert(ARTIFACT).getElementById('named')?.getAttribute('name')).toBe('anchor')
|
||||
})
|
||||
|
||||
/**
|
||||
* The doctype, whole, because rewriting it is a change this pass has no reason to make.
|
||||
*
|
||||
* Not because it moves the frame between layout modes: measured, it cannot. A `srcdoc` document
|
||||
* takes its rendering mode from its embedder whatever its own doctype says, and on Chromium 147
|
||||
* and WebKit 26.4 a quirks doctype, the bare name and no doctype at all all read `CSS1Compat`
|
||||
* inside the frame. The reading that does discriminate is the doctype the frame's own document
|
||||
* reports, and that is the render rig's case; this is the string it is handed.
|
||||
*/
|
||||
it('keeps the doctype whole, identifiers and all', () => {
|
||||
const doctypeOf = (html: string) => htmlPreviewWithInertLinks(html).split('>')[0] ?? ''
|
||||
expect(doctypeOf(ARTIFACT).toLowerCase()).toBe('<!doctype html')
|
||||
expect(
|
||||
doctypeOf('<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"><html><body>x')
|
||||
).toBe('<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"')
|
||||
expect(
|
||||
htmlPreviewWithInertLinks(
|
||||
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"><html><body>x'
|
||||
)
|
||||
).toContain(
|
||||
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">'
|
||||
)
|
||||
// And an artifact that shipped without one still gets none, rather than being handed a mode it
|
||||
// was not written for.
|
||||
expect(htmlPreviewWithInertLinks('<html><body>x</body></html>').toLowerCase()).not.toContain(
|
||||
'<!doctype'
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* The blank line the next parse will eat, written back before it does.
|
||||
*
|
||||
* A parser drops one newline after a `pre`, `listing` or `textarea` start tag and the serialiser
|
||||
* is specified to put it back; measured, neither engine's does, so a round trip loses a blank
|
||||
* line from every such block. This reads the string the pass produces, because happy-dom's own
|
||||
* parser does not drop that newline and a round-trip assertion here would measure happy-dom
|
||||
* rather than a browser. What an engine renders is the render rig's case.
|
||||
*/
|
||||
it('writes back the leading newline the serialiser owes each preformatted block', () => {
|
||||
const out = htmlPreviewWithInertLinks(
|
||||
'<!doctype html><html><body><pre id="p">\n\nkept</pre>' +
|
||||
'<listing>\n\nalso</listing><textarea>\n\nfield</textarea>' +
|
||||
'<pre id="q">no newline</pre></body></html>'
|
||||
)
|
||||
expect(out).toContain('<pre id="p">\n\n\nkept</pre>')
|
||||
expect(out).toContain('<listing>\n\n\nalso</listing>')
|
||||
expect(out).toContain('<textarea>\n\n\nfield</textarea>')
|
||||
// Only a block that starts with one gets one: this is a compensation, not a prefix.
|
||||
expect(out).toContain('<pre id="q">no newline</pre>')
|
||||
})
|
||||
|
||||
it('does not run or fetch what the artifact carries, because nothing here has a context', () => {
|
||||
// The parse is inert by definition (`parseFromString` builds no browsing context), and this is
|
||||
// the reading that says the pass did not change that: the script survives as markup, unrun.
|
||||
const withScript =
|
||||
'<!doctype html><html><body><script>window.__ran = 1</script><a href="/x">a</a></body></html>'
|
||||
const out = htmlPreviewWithInertLinks(withScript)
|
||||
expect(out).toContain('window.__ran = 1')
|
||||
expect('__ran' in globalThis).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves an href inside a comment where a regex pass would have found it', () => {
|
||||
// The reason this is a parser and not a pattern: a comment is text to a browser, and a pass
|
||||
// that rewrote it would be editing the artifact rather than its links.
|
||||
const doc = inert(
|
||||
'<!doctype html><html><body><!-- <a href="https://example.com/x">c</a> -->' +
|
||||
'<a id="real" href="https://example.com/z">r</a></body></html>'
|
||||
)
|
||||
expect(doc.body.innerHTML).toContain('href="https://example.com/x"')
|
||||
// The control: a link that is rendered did lose its href.
|
||||
expect(doc.getElementById('real')?.hasAttribute('href')).toBe(false)
|
||||
})
|
||||
|
||||
/**
|
||||
* A template is not always inert markup, and that is what this case is about.
|
||||
*
|
||||
* `<template shadowrootmode>` is a declarative shadow root: the frame's parser attaches it and
|
||||
* renders what is inside. Measured, `parseFromString` attaches no such root (Chromium 147,
|
||||
* WebKit 26.4 and happy-dom all leave the template standing), so the links are reachable here --
|
||||
* and `querySelectorAll` does not walk into `template.content`, so a pass over the document alone
|
||||
* hands the frame live links inside a sandbox that refuses their navigation, which is the dead
|
||||
* anchor ruling 37.2 forbids.
|
||||
*/
|
||||
it('reaches a link inside a declarative shadow root, and inside one nested in it', () => {
|
||||
const doc = inert(
|
||||
'<!doctype html><html><body><div id="host">' +
|
||||
'<template shadowrootmode="open">' +
|
||||
'<a id="shadow" href="https://example.com/s" target="_top">s</a>' +
|
||||
'<div><template shadowrootmode="open">' +
|
||||
'<a id="deep" href="https://example.com/d">d</a>' +
|
||||
'<a id="deepfrag" href="#inside">f</a>' +
|
||||
'</template></div>' +
|
||||
'</template></div></body></html>'
|
||||
)
|
||||
const outer = doc.querySelector('template')?.content
|
||||
expect(outer?.getElementById('shadow')?.hasAttribute('href')).toBe(false)
|
||||
expect(outer?.getElementById('shadow')?.hasAttribute('target')).toBe(false)
|
||||
// The text is still there, as everywhere else: hidden, not deleted.
|
||||
expect(outer?.getElementById('shadow')?.textContent).toBe('s')
|
||||
const inner = outer?.querySelector('template')?.content
|
||||
expect(inner?.getElementById('deep')?.hasAttribute('href')).toBe(false)
|
||||
// Including the fragment one, at every depth.
|
||||
expect(inner?.getElementById('deepfrag')?.hasAttribute('href')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* The artifact with its links turned back into text, for a shell that cannot open one.
|
||||
*
|
||||
* Ruling 37.2 is the specification: no underline, no pointer, and no dead anchor a tap does nothing
|
||||
* on. Removing `href` is what delivers all three at once, because it is what the HTML definition of
|
||||
* a link turns on -- `a:any-link` stops matching, so the UA stylesheet stops underlining and stops
|
||||
* setting the pointer cursor, the element leaves the tab order, and activating it does nothing
|
||||
* because there is nothing to activate. The text the author wrote stays exactly where it was, which
|
||||
* is what makes this a hidden affordance rather than a degraded screen.
|
||||
*
|
||||
* Round 2's ruling is the tighter one this file is written against: this path may change nothing
|
||||
* about the artifact's rendering except that links are not links. A parse and a reserialise is not
|
||||
* free of that by default, so the three places it was measured to be lossy are compensated below
|
||||
* and pinned in two suites -- the shapes in `html-preview-inert-links.test.ts`, and what an engine
|
||||
* then renders in `config/scripts/mobile-web-app-html-preview-render.test.mjs`.
|
||||
*
|
||||
* A `#`-prefixed link is not an exception to that, and round 3 measured why. Inside this frame a
|
||||
* fragment is not a scroll: the document's URL is `about:srcdoc` while its base URL is inherited
|
||||
* from the embedder, so `#section` resolves against the shell's own URL and the destination differs
|
||||
* from the document's URL by more than a fragment -- which makes activating it a frame navigation
|
||||
* rather than a same-document one. Measured on Chromium 147 and WebKit 26.4 under the shipped
|
||||
* policy: the tap scrolls nothing (`scrollY` stays 0), the embedder reports
|
||||
* `frame-src http://<origin>/preview`, and on Chromium the frame is replaced by
|
||||
* `chrome-error://chromewebdata/` -- the artifact is gone. So there is no working affordance to
|
||||
* preserve, and keeping the href would have left a live link that destroys the preview, which is
|
||||
* worse than the inert text it was carved out to avoid.
|
||||
*
|
||||
* The same tap does the same thing on the granted path, where this pass does not run at all. That
|
||||
* is a bug the preview has always had and it is not this one's to fix; it is recorded in
|
||||
* `followup-html-preview-fragment-links.md`.
|
||||
*
|
||||
* Done with the browser's own parser rather than over the string, and this is the one mechanism
|
||||
* available. The frame has no `allow-scripts` and inherits `script-src 'self'`, so nothing runs
|
||||
* inside it and there is no injection to do the work there; a regex over the source would have to
|
||||
* decide what is an attribute inside an artifact written by an agent, and the two answers that
|
||||
* matter -- an `href` inside a comment or a `<template>`, and an `href` the pass failed to see --
|
||||
* are both wrong in a way nothing downstream could notice. `parseFromString` builds a document with
|
||||
* no browsing context: no script runs, no subresource is fetched, nothing is laid out.
|
||||
*
|
||||
* Belt and braces with the sandbox: the frame also loses
|
||||
* `allow-top-navigation-by-user-activation` on this path, so a link this pass somehow missed is
|
||||
* refused by the browsing context as well. Neither fence is the other's excuse -- the sandbox alone
|
||||
* would leave the dead anchor the ruling forbids, and this alone would leave a document whose
|
||||
* context could still navigate the top frame.
|
||||
*/
|
||||
|
||||
/** `Node.TEXT_NODE`, named here so this module reads one global fewer. */
|
||||
const TEXT_NODE = 3
|
||||
|
||||
/** `<area>` as well as `<a>`, because an image map is a link with a shape instead of a box, and
|
||||
* this namespace as well as the plain attribute, because that is how an `<a>` inside inline SVG
|
||||
* spells its target. */
|
||||
const XLINK_NAMESPACE = 'http://www.w3.org/1999/xlink'
|
||||
|
||||
/**
|
||||
* The doctype as it was written, identifiers and all.
|
||||
*
|
||||
* Not for the reason it looks like, and the difference is measured rather than reasoned. The
|
||||
* identifiers are what a parser reads the rendering mode from in general -- a standalone document
|
||||
* with `<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">` is quirks and the bare
|
||||
* name is not -- but this frame is a `srcdoc`, and a `srcdoc` document takes its mode from its
|
||||
* embedder whatever its own doctype says. Measured on Chromium 147 and WebKit 26.4: that doctype,
|
||||
* the bare name, and no doctype at all all read `CSS1Compat` inside the frame. So rewriting one as
|
||||
* the other cannot move this artifact between layout modes.
|
||||
*
|
||||
* What it does do is rewrite the document the author wrote, for no reason: `document.doctype` is
|
||||
* observable, the Source tab shows the original beside it, and this pass exists to change links and
|
||||
* nothing else. That is the whole argument for carrying the identifiers through.
|
||||
*
|
||||
* The quote character is chosen rather than fixed: the tokenizer admits a single-quoted identifier,
|
||||
* whose text may then contain a double quote that would end the string early here.
|
||||
*/
|
||||
function serializeDoctype(doctype: DocumentType | null): string {
|
||||
if (doctype === null) {
|
||||
return ''
|
||||
}
|
||||
const quoted = (value: string) => (value.includes('"') ? `'${value}'` : `"${value}"`)
|
||||
if (doctype.publicId !== '' && doctype.systemId !== '') {
|
||||
return `<!DOCTYPE ${doctype.name} PUBLIC ${quoted(doctype.publicId)} ${quoted(doctype.systemId)}>`
|
||||
}
|
||||
if (doctype.publicId !== '') {
|
||||
return `<!DOCTYPE ${doctype.name} PUBLIC ${quoted(doctype.publicId)}>`
|
||||
}
|
||||
if (doctype.systemId !== '') {
|
||||
return `<!DOCTYPE ${doctype.name} SYSTEM ${quoted(doctype.systemId)}>`
|
||||
}
|
||||
return `<!DOCTYPE ${doctype.name}>`
|
||||
}
|
||||
|
||||
/**
|
||||
* The newline the next parse will eat, put back before it does.
|
||||
*
|
||||
* A parser drops one `U+000A` immediately after a `pre`, `listing` or `textarea` start tag, and the
|
||||
* HTML serialiser is specified to put it back. Measured, neither engine's serialiser does
|
||||
* (Chromium 147 and WebKit 26.4 both write `<pre>\nfoo</pre>` for a text node of `"\nfoo"`, which
|
||||
* reparses as `"foo"`), so a round trip through them loses one blank line from every such block.
|
||||
* This writes the extra newline the serialiser owes, and the same measurement says the doubled one
|
||||
* survives the reparse exactly.
|
||||
*
|
||||
* The rendered text is the oracle, and it lives in the render rig: this module's own suite runs in
|
||||
* happy-dom, whose parser does not drop the newline in the first place, so a round-trip assertion
|
||||
* there would measure that parser rather than a browser's.
|
||||
*/
|
||||
function restoreLeadingNewlines(root: Document | DocumentFragment): void {
|
||||
for (const block of root.querySelectorAll('pre, listing, textarea')) {
|
||||
const first = block.firstChild
|
||||
if (first?.nodeType === TEXT_NODE && first.nodeValue?.startsWith('\n') === true) {
|
||||
first.nodeValue = `\n${first.nodeValue}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every link in one tree, and then every tree a `<template>` holds.
|
||||
*
|
||||
* `querySelectorAll` does not walk into `template.content`, which is its own fragment, and a
|
||||
* template is not always inert markup: `<template shadowrootmode>` is a declarative shadow root, and
|
||||
* the frame's parser attaches it on parse and renders what is inside. Measured, `parseFromString`
|
||||
* does not attach one (Chromium 147, WebKit 26.4 and happy-dom all leave the template standing), so
|
||||
* this pass can reach the links there -- and must, or they arrive in the frame as live links inside
|
||||
* a sandbox that refuses their navigation, which is exactly the dead anchor ruling 37.2 forbids.
|
||||
*
|
||||
* Recursive rather than one flat query, because a template may hold a template.
|
||||
*/
|
||||
function inertLinksIn(root: Document | DocumentFragment): void {
|
||||
for (const link of root.querySelectorAll('a, area')) {
|
||||
link.removeAttribute('href')
|
||||
link.removeAttributeNS(XLINK_NAMESPACE, 'href')
|
||||
link.removeAttribute('target')
|
||||
}
|
||||
restoreLeadingNewlines(root)
|
||||
for (const template of root.querySelectorAll('template')) {
|
||||
inertLinksIn(template.content)
|
||||
}
|
||||
}
|
||||
|
||||
export function htmlPreviewWithInertLinks(html: string): string {
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html')
|
||||
inertLinksIn(doc)
|
||||
return `${serializeDoctype(doc.doctype)}${doc.documentElement.outerHTML}`
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// @vitest-environment happy-dom
|
||||
/**
|
||||
* What the preview mounts for each answer the shell gives, read off the element it renders.
|
||||
*
|
||||
* Its own file because of how the grant resolves here: this suite runs in a `node` environment whose
|
||||
* resolver has no `.web` precedence, so `MobileHtmlPreview.web.tsx`'s import of
|
||||
* `./use-html-preview-link-grant` lands on the native sibling, which answers yes unconditionally.
|
||||
* That is why `mobile-webview-editor-web-fallbacks.test.tsx` still measures the granted frame
|
||||
* without knowing a grant exists, and why the hidden path needs the module replaced to be reached
|
||||
* at all.
|
||||
*
|
||||
* The browser half is `config/scripts/mobile-web-app-html-preview-render.test.mjs`, which resolves
|
||||
* the real web sibling and measures what an engine paints and does. This file is the wiring between
|
||||
* the two: that the component asks, and that both the frame's sandbox and the document it is handed
|
||||
* follow the answer. It runs in the sharded `test` job, where the render rig is skipped for want of
|
||||
* the bundler's dependencies.
|
||||
*
|
||||
* happy-dom rather than the suite's `node` default, because the inerting pass the hidden arm takes
|
||||
* parses with the browser's own `DOMParser` and there is none in `node`.
|
||||
*/
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const shell = { opensLinks: true }
|
||||
|
||||
vi.mock('./use-html-preview-link-grant', () => ({
|
||||
useHtmlPreviewLinkGrant: () => shell.opensLinks
|
||||
}))
|
||||
|
||||
vi.mock('react-native', async () => {
|
||||
const React = await import('react')
|
||||
return {
|
||||
View: ({ children, ...props }: { children?: React.ReactNode }) =>
|
||||
React.createElement('View', props, children),
|
||||
Text: ({ children, ...props }: { children?: React.ReactNode }) =>
|
||||
React.createElement('Text', props, children),
|
||||
Pressable: ({ children, ...props }: { children?: React.ReactNode }) =>
|
||||
React.createElement('Pressable', props, children),
|
||||
StyleSheet: { create: (styles: unknown) => styles }
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
Code: () => null,
|
||||
Eye: () => null
|
||||
}))
|
||||
|
||||
const { MobileHtmlPreview, MOBILE_HTML_PREVIEW_SANDBOX, MOBILE_HTML_PREVIEW_SEALED_SANDBOX } =
|
||||
await import('./MobileHtmlPreview.web')
|
||||
|
||||
const ARTIFACT =
|
||||
'<!doctype html><html><body><a id="x" href="https://example.com/a">tap</a></body></html>'
|
||||
|
||||
const renderers: ReactTestRenderer[] = []
|
||||
|
||||
/** By tag name through `String`, not a literal comparison: `node.type` is `ElementType`, which
|
||||
* overlaps a real intrinsic tag and not the host strings these mocks render, so `=== 'Pressable'`
|
||||
* is a comparison `tsconfig.test.json` rejects as having no overlap. */
|
||||
function findHosts(renderer: ReactTestRenderer, tag: string) {
|
||||
return renderer.root.findAll((node) => String(node.type) === tag)
|
||||
}
|
||||
|
||||
/** Mounted inside `act`, which is what `IS_REACT_ACT_ENVIRONMENT` makes mandatory: a `create`
|
||||
* outside one commits nothing and the renderer reads as unmounted. */
|
||||
function mount(renderSource: () => React.ReactNode): ReactTestRenderer {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
act(() => {
|
||||
renderer = create(createElement(MobileHtmlPreview, { html: ARTIFACT, renderSource }))
|
||||
})
|
||||
if (renderer === null) {
|
||||
throw new Error('nothing mounted')
|
||||
}
|
||||
renderers.push(renderer)
|
||||
return renderer
|
||||
}
|
||||
|
||||
function frameOf(opensLinks: boolean) {
|
||||
shell.opensLinks = opensLinks
|
||||
const frames = findHosts(
|
||||
mount(() => createElement('SourceView', null)),
|
||||
'iframe'
|
||||
)
|
||||
expect(frames).toHaveLength(1)
|
||||
return frames[0]?.props
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
shell.opensLinks = true
|
||||
for (const renderer of renderers.splice(0)) {
|
||||
act(() => renderer.unmount())
|
||||
}
|
||||
})
|
||||
|
||||
describe('the frame the preview mounts for each answer the shell gives', () => {
|
||||
it('hands the artifact over untouched when the shell can open a link', () => {
|
||||
const frame = frameOf(true)
|
||||
expect(frame?.srcDoc).toBe(ARTIFACT)
|
||||
expect(frame?.sandbox).toBe(MOBILE_HTML_PREVIEW_SANDBOX)
|
||||
})
|
||||
|
||||
it('hands over an artifact with no link, in a frame that cannot navigate, when it cannot', () => {
|
||||
const frame = frameOf(false)
|
||||
// Both fences follow the one answer, which is the wiring this file exists for.
|
||||
expect(frame?.sandbox).toBe(MOBILE_HTML_PREVIEW_SEALED_SANDBOX)
|
||||
expect(frame?.srcDoc).not.toContain('href')
|
||||
// The author's text is still there, so this is a hidden affordance and not a deletion.
|
||||
expect(frame?.srcDoc).toContain('tap')
|
||||
expect(frame?.srcDoc).toContain('id="x"')
|
||||
})
|
||||
|
||||
it('seals the frame with a sandbox that grants nothing, rather than dropping the attribute', () => {
|
||||
// An `iframe` with no `sandbox` attribute at all is a frame with every capability. Empty is the
|
||||
// maximally restrictive value, and it must not become undefined by way of a falsy check.
|
||||
expect(MOBILE_HTML_PREVIEW_SEALED_SANDBOX).toBe('')
|
||||
expect(frameOf(false)?.sandbox).toBe('')
|
||||
expect(MOBILE_HTML_PREVIEW_SANDBOX.split(' ')).toContain(
|
||||
'allow-top-navigation-by-user-activation'
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Source takes the frame away on either answer, which is the whole of what this can claim.
|
||||
*
|
||||
* The `html` a Source view shows is not the component's to get wrong: `renderSource` is called
|
||||
* with no argument, so what it renders is the caller's own closure over the artifact
|
||||
* (`MobileSessionFileReader` passes `() => renderSourceText(doc.content)`). Asserting that the
|
||||
* rendered markup equals the artifact would be asserting that this file's own closure returned
|
||||
* what this file put in it, which passes whatever the component does. What the component decides
|
||||
* is whether the rewritten frame is still mounted underneath, and that is what is read here.
|
||||
*/
|
||||
it('takes the frame away when Source is showing, on either answer', () => {
|
||||
for (const opensLinks of [true, false]) {
|
||||
shell.opensLinks = opensLinks
|
||||
const renderer = mount(() => createElement('SourceView', null))
|
||||
// The precondition: a frame was mounted, so its absence below is the toggle's doing.
|
||||
expect(findHosts(renderer, 'iframe'), String(opensLinks)).toHaveLength(1)
|
||||
const toSource = findHosts(renderer, 'Pressable').find(
|
||||
(node) => node.props.accessibilityLabel === 'View HTML source'
|
||||
)
|
||||
expect(toSource, String(opensLinks)).toBeDefined()
|
||||
act(() => toSource?.props.onPress())
|
||||
expect(findHosts(renderer, 'SourceView'), String(opensLinks)).toHaveLength(1)
|
||||
// Nothing is parsing the artifact while Source is showing, rewritten or not.
|
||||
expect(findHosts(renderer, 'iframe'), String(opensLinks)).toHaveLength(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Whether a tap on a link inside the HTML preview reaches anything.
|
||||
*
|
||||
* Native: this app is both halves of that path. The preview is its own WebView and
|
||||
* `onShouldStartLoadWithRequest` hands every request straight to `openExternalLink`, so there is
|
||||
* nothing to negotiate and nothing that can be missing. The `.web.ts` sibling is where the question
|
||||
* has an answer other than yes, because there the tap becomes a top-frame navigation that only the
|
||||
* shell around the page can cancel and open.
|
||||
*/
|
||||
export function useHtmlPreviewLinkGrant(): boolean {
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* The page's own question, driven through the real bridge handshake rather than a stubbed client:
|
||||
* what the preview reads is what `init` actually carried.
|
||||
*
|
||||
* `use-browser-binary-screencast-grant.web.test.tsx`'s shape, against the other token that is
|
||||
* neither a verb nor a notify.
|
||||
*/
|
||||
import type { ReactElement } from 'react'
|
||||
import { act, create } from 'react-test-renderer'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// The provider module re-exports the screen hooks, and reaching the real ones imports the Expo
|
||||
// runtime this test does not have. Nothing below calls one.
|
||||
vi.mock('../transport/host-client-hooks', () => ({
|
||||
useDisconnectHostClient: () => () => {},
|
||||
useForceReconnect: () => () => Promise.resolve(),
|
||||
useForgetHostClient: () => () => {},
|
||||
useHostClient: () => ({ client: null, clientId: null, state: 'disconnected' }),
|
||||
usePrimeHosts: () => () => {},
|
||||
useRefreshHostClient: () => () => {}
|
||||
}))
|
||||
|
||||
import { MobileWebBundleRouteSchema } from '../../../src/shared/mobile-web-bundle/manifest-contract'
|
||||
import { BRIDGE_EXTERNAL_NAVIGATION_GRANT } from '../mobile-web-shell/cancelled-navigation-target'
|
||||
import { RpcClientProvider } from '../transport/client-context.web'
|
||||
import {
|
||||
createFakeBridgePortPair,
|
||||
type BridgePortPair
|
||||
} from '../mobile-web-shell/bridge/bridge-port-pair-test-harness'
|
||||
import { useHtmlPreviewLinkGrant } from './use-html-preview-link-grant.web'
|
||||
import { useHtmlPreviewLinkGrant as useHtmlPreviewLinkGrantNatively } from './use-html-preview-link-grant'
|
||||
|
||||
const held: { granted: boolean | null } = { granted: null }
|
||||
|
||||
function Screen(): null {
|
||||
held.granted = useHtmlPreviewLinkGrant()
|
||||
return null
|
||||
}
|
||||
|
||||
function render(pair: BridgePortPair): ReactElement {
|
||||
return (
|
||||
<RpcClientProvider client={pair.client}>
|
||||
<Screen />
|
||||
</RpcClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
async function mount(pair: BridgePortPair): Promise<boolean> {
|
||||
await pair.flush()
|
||||
act(() => {
|
||||
create(render(pair))
|
||||
})
|
||||
if (held.granted === null) {
|
||||
throw new Error('nothing mounted')
|
||||
}
|
||||
return held.granted
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
held.granted = null
|
||||
})
|
||||
|
||||
describe('whether a link in the HTML preview opens, on the web', () => {
|
||||
it('is granted when the shell named it for this route', async () => {
|
||||
const pair = createFakeBridgePortPair({
|
||||
routeGrants: ['navigate', BRIDGE_EXTERNAL_NAVIGATION_GRANT]
|
||||
})
|
||||
expect(await mount(pair)).toBe(true)
|
||||
})
|
||||
|
||||
it('is refused by a shell that cancels the navigation but did not grant this route', async () => {
|
||||
expect(await mount(createFakeBridgePortPair({ routeGrants: ['navigate'] }))).toBe(false)
|
||||
})
|
||||
|
||||
it('is refused by a shell too old to have heard of it at all', async () => {
|
||||
// The state C8.1 exists for: every shell built before the cancelled-navigation event. It drops
|
||||
// the navigation in silence, so a tap on a rendered link would do nothing at all.
|
||||
expect(await mount(createFakeBridgePortPair({ routeGrants: [] }))).toBe(false)
|
||||
})
|
||||
|
||||
it('is unconditional on native, where the preview is its own WebView', () => {
|
||||
expect(useHtmlPreviewLinkGrantNatively()).toBe(true)
|
||||
})
|
||||
|
||||
// Checked against the contract rather than by eye: a route naming a grant the manifest refuses
|
||||
// never reaches a shell, so the preview would hide its links for a reason no screen could report.
|
||||
it('is a name the bundle manifest will carry, on either lane', () => {
|
||||
const route = (grants: string[], optionalGrants?: string[]) => ({
|
||||
pathname: '/h/[hostId]/session/[worktreeId]',
|
||||
grants,
|
||||
...(optionalGrants === undefined ? {} : { optionalGrants })
|
||||
})
|
||||
|
||||
expect(
|
||||
MobileWebBundleRouteSchema.safeParse(route([], [BRIDGE_EXTERNAL_NAVIGATION_GRANT])).success
|
||||
).toBe(true)
|
||||
expect(
|
||||
MobileWebBundleRouteSchema.safeParse(route([BRIDGE_EXTERNAL_NAVIGATION_GRANT])).success
|
||||
).toBe(true)
|
||||
// The dotted spelling this name nearly had, which the grant grammar refuses: it is not a verb.
|
||||
expect(
|
||||
MobileWebBundleRouteSchema.safeParse(route([], ['native.externalNavigation'])).success
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { BRIDGE_EXTERNAL_NAVIGATION_GRANT } from '../mobile-web-shell/cancelled-navigation-target'
|
||||
import { usePageBridgeClient } from '../transport/client-context.web'
|
||||
|
||||
/**
|
||||
* Web sibling: the page asks the shell it is running inside, through the grants `init` gave it.
|
||||
*
|
||||
* Grants rather than a new `init` field, and the same read
|
||||
* `use-browser-binary-screencast-grant.web.ts` makes: one question, "is this name in
|
||||
* `grants.native`", against one list. A shell built before the cancelled-navigation event answers
|
||||
* no, and the preview renders the artifact with its links as text rather than offering a tap that
|
||||
* the shell cancels in silence.
|
||||
*
|
||||
* `externalNavigation` sits on the route's optional lane, so a no here is a hidden affordance and
|
||||
* never a native screen: the artifact still paints and the Source toggle still works, which is what
|
||||
* ruling 37.2 calls a complete screen.
|
||||
*
|
||||
* Read during render rather than per tap: the page entry waits for `init` before it mounts
|
||||
* anything, so the session is already there and its grants do not change for the life of the
|
||||
* document.
|
||||
*/
|
||||
export function useHtmlPreviewLinkGrant(): boolean {
|
||||
const client = usePageBridgeClient()
|
||||
return client.getShellSession()?.grants.native.includes(BRIDGE_EXTERNAL_NAVIGATION_GRANT) === true
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from './bridge/bridge-caps'
|
||||
import { BRIDGE_FAULT_GRANT } from './bridge/bridge-envelope'
|
||||
import { BRIDGE_ROUTE_PARAM_CLEAR } from './bridge/bridge-route-update'
|
||||
import { routeViewOf } from './page-route-policy'
|
||||
|
||||
describe('init and state', () => {
|
||||
it('answers ready with the getters, the caps it enforces, and the grants it honours', () => {
|
||||
@@ -47,6 +48,7 @@ describe('init and state', () => {
|
||||
'externalLink',
|
||||
'screencastBinary',
|
||||
'haptics',
|
||||
'externalNavigation',
|
||||
'native.clipboard.write',
|
||||
'native.clipboard.read',
|
||||
'native.media.pick',
|
||||
@@ -86,6 +88,71 @@ describe('init and state', () => {
|
||||
expect(init.pageRouteGrants).toEqual(PAGE_ROUTE_GRANTS)
|
||||
})
|
||||
|
||||
/**
|
||||
* The publish path end to end, because each half of it looks correct alone.
|
||||
*
|
||||
* The phone reads a manifest route loosely and this schema is `.strict()`, so an entry carrying a
|
||||
* field a newer desktop wrote refuses the pairs -- and the refusal is not the field being dropped,
|
||||
* it is `createBridgeHost` refusing the route and the page never getting an `init` at all. Driven
|
||||
* through `routeViewOf` rather than by handing the harness a pair, because the publish is the
|
||||
* thing under test and a hand-built pair proves nothing about it.
|
||||
*/
|
||||
it('answers init for a route entry carrying a manifest field this build does not read', () => {
|
||||
// A field no build here reads, which is the shape every later desktop field has.
|
||||
const declared = [
|
||||
{
|
||||
pathname: '/h/[hostId]',
|
||||
grants: ['navigate', 'storage', 'haptics'],
|
||||
renderer: 'someLaterDesktopsField'
|
||||
}
|
||||
]
|
||||
const bridge = harness({
|
||||
pageRouteGrants: routeViewOf(declared, ROUTE.pathname).pageRouteGrants
|
||||
})
|
||||
bridge.host.receive(clientFrame({ type: 'ready' }))
|
||||
expect(bridge.routeRefusals).toEqual([])
|
||||
const init = bridge.last()
|
||||
if (init.type !== 'init') {
|
||||
throw new Error('expected an init frame')
|
||||
}
|
||||
// The pairs still cross, so the page keeps the handoff rule it was built with rather than
|
||||
// falling back to "nobody told me" and handing every hop to the shell.
|
||||
expect(init.pageRouteGrants).toEqual([
|
||||
{ pathname: '/h/[hostId]', grants: ['navigate', 'storage', 'haptics'] }
|
||||
])
|
||||
})
|
||||
|
||||
/**
|
||||
* One object for the measure and the measured, read off one `init`.
|
||||
*
|
||||
* `grants.native` is what this session may do and each pair is what the page compares a hop
|
||||
* against (`route-handoff.web.ts`). Both come from `routeViewOf`, so the pair for the pattern the
|
||||
* session was opened on must be the same list `grants.native` carries minus the protocol's own
|
||||
* grant. Two computations here is how a hop is kept local whose target then runs without the
|
||||
* capability it asked for.
|
||||
*/
|
||||
it("grants a session exactly what it publishes as that pattern's pair", () => {
|
||||
const declared = [
|
||||
{
|
||||
pathname: '/h/[hostId]',
|
||||
grants: ['navigate', 'storage', 'haptics'],
|
||||
optionalGrants: ['screencastBinary']
|
||||
}
|
||||
]
|
||||
const view = routeViewOf(declared, ROUTE.pathname)
|
||||
const bridge = harness({ routeGrants: view.routeGrants, pageRouteGrants: view.pageRouteGrants })
|
||||
bridge.host.receive(clientFrame({ type: 'ready' }))
|
||||
const init = bridge.last()
|
||||
if (init.type !== 'init') {
|
||||
throw new Error('expected an init frame')
|
||||
}
|
||||
expect(init.grants.native).toEqual([BRIDGE_FAULT_GRANT, ...view.routeGrants])
|
||||
const pair = (init.pageRouteGrants ?? []).find((entry) => entry.pathname === '/h/[hostId]')
|
||||
expect(pair?.grants).toEqual(view.routeGrants)
|
||||
// The optional name is in both, so the case is the lane and not two equal required lists.
|
||||
expect(init.grants.native).toContain('screencastBinary')
|
||||
})
|
||||
|
||||
it('refuses a grant name the manifest grammar refuses, naming the field it came from', () => {
|
||||
// The host reads the manifest through the same grammar the desktop wrote it under, so a name
|
||||
// the bundle could not have declared cannot reach the page through this field either.
|
||||
|
||||
@@ -20,3 +20,23 @@ import { readBridgeExternalLinkUrl } from './bridge/bridge-caps'
|
||||
export function cancelledShellNavigationTarget(url: unknown): string | null {
|
||||
return typeof url === 'string' ? readBridgeExternalLinkUrl(url) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The grant that names this behaviour, declared beside the rule that acts on it.
|
||||
*
|
||||
* One camelCase token rather than a dotted name, for `screencastBinary`'s reason: the manifest's
|
||||
* grant grammar admits a bare name or a `native.<domain>.<action>` verb, and this is not a verb.
|
||||
* Nothing is requested and nothing is answered -- the shell cancels a navigation the browser hands
|
||||
* it and opens the URL, so there is no reply a page could await. It is not a notify's name either:
|
||||
* the page posts nothing to make this happen.
|
||||
*
|
||||
* It exists as a grant because it is the only thing that can tell a page whether a tap inside the
|
||||
* sealed HTML-preview frame escapes at all. A shell built before C7.10 A cancels the navigation and
|
||||
* drops it in silence, so a page that rendered the artifact's links as links would be offering a
|
||||
* tap that does nothing -- which is what `MobileHtmlPreview.web.tsx` reads this to avoid.
|
||||
*
|
||||
* A constant and not a platform read: both engines dispatch the event
|
||||
* (`ios/MobileWebShellView.swift`, `android/.../MobileWebShellView.kt`), so an app build either
|
||||
* carries the behaviour on both or on neither (ruling 37.1).
|
||||
*/
|
||||
export const BRIDGE_EXTERNAL_NAVIGATION_GRANT = 'externalNavigation'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import { MobileWebBundleRouteSchema } from '../../../src/shared/mobile-web-bundle/manifest-contract'
|
||||
import {
|
||||
matchesRoutePattern,
|
||||
@@ -7,12 +8,15 @@ import {
|
||||
grantsForRoute,
|
||||
routeViewOf
|
||||
} from './page-route-policy'
|
||||
import { BridgePageRouteGrantsSchema } from './bridge/bridge-page-route-grants'
|
||||
import { BRIDGE_EXTERNAL_NAVIGATION_GRANT } from './cancelled-navigation-target'
|
||||
import { BRIDGE_HAPTICS_GRANT } from './bridge/bridge-haptics-notify'
|
||||
import {
|
||||
BRIDGE_NATIVE_METHOD_PREFIX,
|
||||
BRIDGE_NATIVE_VERB_NAMES,
|
||||
BRIDGE_NATIVE_VERBS
|
||||
} from './bridge/bridge-native-verbs'
|
||||
import { BRIDGE_SCREENCAST_BINARY_GRANT } from './bridge/bridge-screencast-grant'
|
||||
|
||||
/**
|
||||
* The patterns a session would be told it may keep, read through the view the reducer builds.
|
||||
@@ -91,6 +95,7 @@ describe('the grants this app implements', () => {
|
||||
'externalLink',
|
||||
'screencastBinary',
|
||||
'haptics',
|
||||
'externalNavigation',
|
||||
'native.clipboard.write',
|
||||
'native.clipboard.read',
|
||||
'native.media.pick',
|
||||
@@ -119,6 +124,36 @@ describe('the grants this app implements', () => {
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* The set is the verb table plus three tokens, and nothing else.
|
||||
*
|
||||
* The verb half is spread from the table and pinned below. This is the other half: a token is a
|
||||
* shell behaviour with no request behind it, so nothing makes one appear except a line in this
|
||||
* list -- and a page reads `init.grants.native` for all of them alike. Named against the modules
|
||||
* that declare them rather than as strings, so a rename has to change both sides.
|
||||
*/
|
||||
it('names exactly three behaviours that are not verbs, each from its own module', () => {
|
||||
const tokens = MOBILE_WEB_SHELL_GRANTS.filter(
|
||||
(grant) => !grant.startsWith(BRIDGE_NATIVE_METHOD_PREFIX)
|
||||
)
|
||||
expect([...tokens]).toEqual([
|
||||
'navigate',
|
||||
'storage',
|
||||
'externalLink',
|
||||
BRIDGE_SCREENCAST_BINARY_GRANT,
|
||||
BRIDGE_HAPTICS_GRANT,
|
||||
BRIDGE_EXTERNAL_NAVIGATION_GRANT
|
||||
])
|
||||
// Not a verb spelling, and not a notify's dotted name: the grammar would refuse either.
|
||||
expect(BRIDGE_EXTERNAL_NAVIGATION_GRANT).toBe('externalNavigation')
|
||||
expect(
|
||||
MobileWebBundleRouteSchema.safeParse({
|
||||
pathname: '/h/[hostId]',
|
||||
grants: ['native.externalNavigation']
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('names every verb in the table there too, so the two lists cannot drift apart', () => {
|
||||
// The grant list spreads the verb tuple today. Read both anyway: the spread is what makes them
|
||||
// agree, and a build that stopped spreading would leave this the only thing that noticed.
|
||||
@@ -156,6 +191,35 @@ describe('the grants this app implements', () => {
|
||||
expect(pageRoutesOf(routes)).toEqual([])
|
||||
})
|
||||
|
||||
/**
|
||||
* The token C8.1 exists for, on the lane it is declared on.
|
||||
*
|
||||
* Optional, because a preview whose links are inert is still a complete screen (ruling 37.2): the
|
||||
* artifact renders, the toggle works, the source tab works. Required would have taken the whole
|
||||
* session screen native on every shell built before C7.10 A.
|
||||
*/
|
||||
it('grants external navigation to a route that declares it optionally', () => {
|
||||
const routes = [
|
||||
{
|
||||
pathname: '/h/[hostId]/session/[worktreeId]',
|
||||
grants: ['navigate', 'storage'],
|
||||
optionalGrants: [BRIDGE_EXTERNAL_NAVIGATION_GRANT]
|
||||
}
|
||||
]
|
||||
expect(grantsForRoute(routes, '/h/host-1/session/wt-1')).toEqual([
|
||||
'navigate',
|
||||
'storage',
|
||||
BRIDGE_EXTERNAL_NAVIGATION_GRANT
|
||||
])
|
||||
// And a shell without the behaviour serves the same route, granting the rest: the page reads
|
||||
// the name absent and hides the affordance, which is the whole of the hide path.
|
||||
const older = MOBILE_WEB_SHELL_GRANTS.filter(
|
||||
(grant) => grant !== BRIDGE_EXTERNAL_NAVIGATION_GRANT
|
||||
)
|
||||
expect(older).not.toContain(BRIDGE_EXTERNAL_NAVIGATION_GRANT)
|
||||
expect(pageRoutesOf(routes)).toEqual(['/h/[hostId]/session/[worktreeId]'])
|
||||
})
|
||||
|
||||
it('resolves the screencast lane for a route that declares it', () => {
|
||||
expect(
|
||||
grantsForRoute(
|
||||
@@ -273,3 +337,161 @@ describe('a page route that needs the haptics token', () => {
|
||||
).toEqual(['/h/[hostId]'])
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* What the publish hands the host, against the schema the host holds it to.
|
||||
*
|
||||
* `BridgePageRouteGrantsSchema` is `.strict()` and the phone's manifest reader is loose, so these
|
||||
* two rules meet on this one value: an entry arrives carrying whatever field the desktop that wrote
|
||||
* it knew about, and a strict parse of that entry refuses the pairs and takes the session with
|
||||
* them. The route below is the shape the next desktop field has -- read by that desktop, unknown
|
||||
* here -- and the case is that it costs this page nothing.
|
||||
*/
|
||||
describe('the pairs a session publishes to the page', () => {
|
||||
// Two unread keys, and neither is a guess: `optionalGrants` is the field this lane added, so it
|
||||
// stands for one a shell built today reads, and `renderer` stands for the next one a desktop
|
||||
// writes and no build here has heard of. The strict pair schema refuses either.
|
||||
const carryingAnUnreadField = [
|
||||
{
|
||||
pathname: '/h/[hostId]',
|
||||
grants: ['navigate', 'storage'],
|
||||
optionalGrants: ['screencastBinary'],
|
||||
renderer: 'someLaterDesktopsField'
|
||||
}
|
||||
]
|
||||
|
||||
it('publishes only the two members that cross, whatever else the entry carried', () => {
|
||||
expect(routeViewOf(carryingAnUnreadField, '/h/host-1').pageRouteGrants).toEqual([
|
||||
{ pathname: '/h/[hostId]', grants: ['navigate', 'storage', 'screencastBinary'] }
|
||||
])
|
||||
})
|
||||
|
||||
it('publishes pairs the host schema accepts, so the session is not refused with them', () => {
|
||||
const { pageRouteGrants } = routeViewOf(carryingAnUnreadField, '/h/host-1')
|
||||
const parsed = BridgePageRouteGrantsSchema.safeParse(pageRouteGrants)
|
||||
expect(parsed.error?.issues[0]?.message ?? 'accepted').toBe('accepted')
|
||||
expect(parsed.success).toBe(true)
|
||||
})
|
||||
|
||||
it('copies the list, so nothing the shell keeps is reachable through the frame it hands out', () => {
|
||||
const routes = [{ pathname: '/h/[hostId]', grants: ['navigate'] }]
|
||||
expect(routeViewOf(routes, '/h/host-1').pageRouteGrants[0]?.grants).not.toBe(routes[0]?.grants)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The optional lane, read off one route (ruling 37).
|
||||
*
|
||||
* `screencastBinary` stands in for the capability under test: a real token this shell implements,
|
||||
* so the cases below measure the lane and not whether a name is known.
|
||||
*/
|
||||
describe('a route that declares an optional grant', () => {
|
||||
const pathname = '/h/[hostId]/session/[worktreeId]'
|
||||
const opened = '/h/host-1/session/wt-1'
|
||||
const declared = [
|
||||
{ pathname, grants: ['navigate', 'storage'], optionalGrants: ['screencastBinary'] }
|
||||
]
|
||||
|
||||
it('is served on its required list alone, which is what decides the route', () => {
|
||||
expect(pageRoutesOf(declared)).toEqual([pathname])
|
||||
// And the required lane still decides it: one required name this shell lacks takes it native
|
||||
// however short the optional list is.
|
||||
expect(pageRoutesOf([{ ...declared[0], grants: ['navigate', 'aGrantFromTheFuture'] }])).toEqual(
|
||||
[]
|
||||
)
|
||||
})
|
||||
|
||||
it('grants the optional name as well, so the page can read it off its own init', () => {
|
||||
expect(grantsForRoute(declared, opened)).toEqual(['navigate', 'storage', 'screencastBinary'])
|
||||
})
|
||||
|
||||
it('drops an optional name this shell does not implement, and serves the route anyway', () => {
|
||||
const fromTheFuture = [
|
||||
{ pathname, grants: ['navigate', 'storage'], optionalGrants: ['aGrantFromTheFuture'] }
|
||||
]
|
||||
expect(grantsForRoute(fromTheFuture, opened)).toEqual(['navigate', 'storage'])
|
||||
// The difference from the required lane, in one place: unimplemented-and-optional is a hidden
|
||||
// affordance, unimplemented-and-required is a native screen.
|
||||
expect(pageRoutesOf(fromTheFuture)).toEqual([pathname])
|
||||
})
|
||||
|
||||
/**
|
||||
* One object for the measure and the measured.
|
||||
*
|
||||
* `routeGrants` is what the session is granted and each pair is what the page compares a hop
|
||||
* against. Two spellings of "what this route gets" is how `route-handoff.web.ts` comes to keep a
|
||||
* hop whose target then runs without the capability it asked for, so they are one computation and
|
||||
* this is the case that says so.
|
||||
*/
|
||||
it('publishes the same list to the page as it grants the session', () => {
|
||||
const view = routeViewOf(declared, opened)
|
||||
expect(view.pageRouteGrants).toEqual([{ pathname, grants: view.routeGrants }])
|
||||
expect(view.routeGrants).toEqual(['navigate', 'storage', 'screencastBinary'])
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Old shell, new manifest: the claim design B rests on, so it gets its own case.
|
||||
*
|
||||
* And the claim as the design stated it is false, which is why this is measured rather than
|
||||
* asserted. The design and ruling 37 say the phone's loose reader "drops the unknown field"; it does
|
||||
* not. `z.looseObject` passes unknown members through (measured on zod 4.4.3, and by the first case
|
||||
* below), so a shell older than the field holds an entry that still carries it. What such a shell
|
||||
* lacks is a policy that reads it -- so it serves the route on its required list and grants nothing
|
||||
* extra, which is the conclusion the design wanted.
|
||||
*
|
||||
* The part that does not survive is the publish: an entry carrying the field reaches the strict pair
|
||||
* schema and refuses the whole session, not one field. So what makes design B safe against a shell
|
||||
* is that shell publishing built pairs rather than forwarded entries -- ruling 37.4's fix is the
|
||||
* compatibility argument, not a tidy-up before it.
|
||||
*
|
||||
* `installedShellPolicy` is the policy half of an older shell, written as the read it makes.
|
||||
*/
|
||||
describe('a shell whose policy has never heard of the optional lane', () => {
|
||||
const pathname = '/h/[hostId]/session/[worktreeId]'
|
||||
const opened = '/h/host-1/session/wt-1'
|
||||
const loose = z.array(
|
||||
z.looseObject({
|
||||
pathname: z.string().min(1).max(255),
|
||||
grants: z.array(z.string().min(1).max(64))
|
||||
})
|
||||
)
|
||||
const written = [
|
||||
{ pathname, grants: ['navigate', 'storage'], optionalGrants: ['screencastBinary'] }
|
||||
]
|
||||
/** The older shell's reading of a route: its required list, narrowed, and no second lane. */
|
||||
const installedShellPolicy = (route: { grants: readonly string[] }) =>
|
||||
route.grants.filter((grant) => MOBILE_WEB_SHELL_GRANTS.some((known) => known === grant))
|
||||
|
||||
it('is handed a manifest that really does carry the field', () => {
|
||||
// The presence precondition. Without it every arm below passes against a fixture that never had
|
||||
// the key, which reads the same as a reader that removed it.
|
||||
const parsed = MobileWebBundleRouteSchema.safeParse(written[0])
|
||||
expect(parsed.success && parsed.data.optionalGrants).toEqual(['screencastBinary'])
|
||||
})
|
||||
|
||||
it('still holds the field after its own reader, which passes unknown members through', () => {
|
||||
const read = loose.parse(written)
|
||||
expect(Object.hasOwn(read[0] ?? {}, 'optionalGrants')).toBe(true)
|
||||
})
|
||||
|
||||
it('serves the route on its required list and grants nothing extra', () => {
|
||||
const read = loose.parse(written)
|
||||
expect(pageRoutesOf(read)).toEqual([pathname])
|
||||
expect(installedShellPolicy(read[0] ?? { grants: [] })).toEqual(['navigate', 'storage'])
|
||||
})
|
||||
|
||||
it('publishes pairs a strict schema takes, which is what keeps the session at all', () => {
|
||||
// The half the design missed. Forwarded entries carry the field into
|
||||
// `BridgePageRouteGrantsSchema`, which refuses them and takes the whole `init` with them;
|
||||
// `bridge-host-init.test.ts` pins that end to end.
|
||||
const pairs = routeViewOf(loose.parse(written), opened).pageRouteGrants
|
||||
expect(BridgePageRouteGrantsSchema.safeParse(pairs).success).toBe(true)
|
||||
})
|
||||
|
||||
it('is the only difference from this build, which reads the field and grants it', () => {
|
||||
// The control: same manifest, this build's policy, one more grant. Without it the arms above
|
||||
// are also what a policy that ignored the lane entirely would report.
|
||||
expect(grantsForRoute(written, opened)).toEqual(['navigate', 'storage', 'screencastBinary'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { MobileWebBundleManifestRead } from '../transport/mobile-web-bundle-reply-schemas'
|
||||
import { BRIDGE_EXTERNAL_NAVIGATION_GRANT } from './cancelled-navigation-target'
|
||||
import { BRIDGE_HAPTICS_GRANT } from './bridge/bridge-haptics-notify'
|
||||
import { BRIDGE_NATIVE_VERB_NAMES } from './bridge/bridge-native-verbs'
|
||||
import { BRIDGE_SCREENCAST_BINARY_GRANT } from './bridge/bridge-screencast-grant'
|
||||
@@ -25,6 +26,10 @@ export const MOBILE_WEB_SHELL_GRANTS = [
|
||||
// than the notify's dotted name, because a notify is not a verb: the dotted names below are the
|
||||
// verb table's, spread from it.
|
||||
BRIDGE_HAPTICS_GRANT,
|
||||
// A gesture-started navigation off the shell's own document, cancelled and opened outside the
|
||||
// app. Named where the rule that acts on it lives, and a third token that is neither verb nor
|
||||
// notify: the page posts nothing for it, so only this list can tell a page a tap escapes.
|
||||
BRIDGE_EXTERNAL_NAVIGATION_GRANT,
|
||||
// Spread rather than restated: the verb table is keyed on this same tuple, so a verb cannot be
|
||||
// advertised without a row and a row cannot exist without being advertised.
|
||||
...BRIDGE_NATIVE_VERB_NAMES
|
||||
@@ -71,6 +76,23 @@ function implementedPageRouteEntries(
|
||||
return (routes ?? []).filter((route) => route.grants.every(implementsGrant))
|
||||
}
|
||||
|
||||
/**
|
||||
* One route's effective grants: what it declared on either lane, narrowed to what this shell does.
|
||||
*
|
||||
* The one place a session's list is computed, because it is read twice -- once for the route the
|
||||
* shell opened and once for every pattern the page is told it may keep -- and two spellings of
|
||||
* "what this route gets" is how the page's handoff rule and the host's enforcement drift apart.
|
||||
*
|
||||
* `optionalGrants` is what the screen is better with and complete without, so it joins the list a
|
||||
* session is granted without joining the list that decides whether the route is served at all. A
|
||||
* name in it this shell does not implement simply is not granted: the page reads its own
|
||||
* `init.grants.native`, finds the name absent, and hides the affordance -- the same answer it gets
|
||||
* from a shell too old to have heard of the name, which is the answer ruling 37 rests on.
|
||||
*/
|
||||
function effectiveRouteGrants(route: MobileWebPageRoute): string[] {
|
||||
return [...route.grants, ...(route.optionalGrants ?? [])].filter(implementsGrant)
|
||||
}
|
||||
|
||||
/** The patterns alone, for the readers in this module that only name routes. */
|
||||
function implementedPageRoutes(routes: readonly MobileWebPageRoute[] | undefined): string[] {
|
||||
return implementedPageRouteEntries(routes).map((route) => route.pathname)
|
||||
@@ -107,7 +129,7 @@ export function grantsForRoute(
|
||||
pathname: string
|
||||
): string[] {
|
||||
const declared = (routes ?? []).find((route) => matchesRoutePattern(pathname, route.pathname))
|
||||
return declared === undefined ? [] : declared.grants.filter(implementsGrant)
|
||||
return declared === undefined ? [] : effectiveRouteGrants(declared)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,12 +138,26 @@ export function grantsForRoute(
|
||||
* Derived together because they are one reading of one list: the patterns the page may keep, what
|
||||
* each of them declared, and what this route itself was granted. Three sites used to spell this
|
||||
* out; a fourth spelling is how they drift.
|
||||
*
|
||||
* `pageRouteGrants` is built rather than passed through, and that is load-bearing. The phone reads
|
||||
* a manifest route loosely, so an entry arrives carrying whatever the desktop that wrote it knew
|
||||
* about; `BridgePageRouteGrantsSchema` is `.strict()`, so one unread key refuses the pairs, and
|
||||
* `bridge-host.ts` refuses the whole session with them. A desktop field this build has never heard
|
||||
* of must cost the page nothing, which means only the two members that cross may be handed over.
|
||||
*
|
||||
* Each pair carries the target's effective list, the same one `routeGrants` answers with, because
|
||||
* the page compares the two: `route-handoff.web.ts` keeps a hop local when the target's pair is
|
||||
* covered by what this session holds. A pair naming the required lane alone would keep a hop whose
|
||||
* target then runs without the capability it asked for, which is the drift that rule exists against.
|
||||
*/
|
||||
export function routeViewOf(routes: readonly MobileWebPageRoute[] | undefined, pathname: string) {
|
||||
const entries = implementedPageRouteEntries(routes)
|
||||
return {
|
||||
pageRoutes: entries.map((route) => route.pathname),
|
||||
pageRouteGrants: entries,
|
||||
pageRouteGrants: entries.map((route) => ({
|
||||
pathname: route.pathname,
|
||||
grants: effectiveRouteGrants(route)
|
||||
})),
|
||||
routeGrants: grantsForRoute(routes, pathname)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,10 +29,21 @@ const MAX_DATA_BASE64_LENGTH = Math.ceil(MOBILE_WEB_BUNDLE_CHUNK_BYTES / 3) * 4
|
||||
/** A screen the desktop asks this shell to render from the bundle. Optional, because a desktop
|
||||
* older than the field sends none and every route then stays native, which is where they all
|
||||
* start. Loose for the same reason the manifest is: a grant name this build does not know is not a
|
||||
* reason to refuse a bundle, it is a reason to leave that one route native. */
|
||||
* reason to refuse a bundle, it is a reason to leave that one route native.
|
||||
*
|
||||
* `optionalGrants` is read so this build can honour it, and typed here only — the host's own
|
||||
* schema is where its grammar and the ceiling over the union live. A shell without this line still
|
||||
* receives the key, because loose passes unknown members through rather than stripping them; what
|
||||
* such a shell lacks is a policy that reads it, so it serves the route on `grants` alone. The
|
||||
* member that must not travel on from here is the whole entry: `BridgePageRouteGrantsSchema` is
|
||||
* `.strict()`, so `routeViewOf` builds the pairs it publishes rather than forwarding these. */
|
||||
const pageRouteSchema = z.looseObject({
|
||||
pathname: z.string().min(1).max(255),
|
||||
grants: z.array(z.string().min(1).max(64)).max(MOBILE_WEB_BUNDLE_MAX_ROUTE_GRANTS)
|
||||
grants: z.array(z.string().min(1).max(64)).max(MOBILE_WEB_BUNDLE_MAX_ROUTE_GRANTS),
|
||||
optionalGrants: z
|
||||
.array(z.string().min(1).max(64))
|
||||
.max(MOBILE_WEB_BUNDLE_MAX_ROUTE_GRANTS)
|
||||
.optional()
|
||||
})
|
||||
|
||||
const assetSchema = z.looseObject({
|
||||
|
||||
@@ -135,7 +135,11 @@
|
||||
},
|
||||
{
|
||||
"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 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."
|
||||
"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. That token is granted only when the shell says it can open such a navigation: without the externalNavigation grant this file renders the artifact with its links as text and seals the frame with an empty sandbox instead (C8.1, ruling 37.2)."
|
||||
},
|
||||
{
|
||||
"file": "src/components/use-html-preview-link-grant.web.ts",
|
||||
"reason": "Natively the HTML preview is its own WebView and onShouldStartLoadWithRequest hands every request to openExternalLink, so there is nothing to negotiate and the native sibling answers yes. In the page a tap on a link inside the sealed frame becomes a top-frame navigation only the shell around the document can cancel and open, and a shell built before that event drops it in silence. This file asks the shell through the grants init carried (C8.1, ruling 37), and without the externalNavigation grant the preview renders the artifact with its links as text rather than offering a tap that does nothing."
|
||||
},
|
||||
{
|
||||
"file": "src/mobile-web-shell/catch-all-page-route.web.tsx",
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
MOBILE_WEB_BUNDLE_ENTRYPOINT,
|
||||
MOBILE_WEB_BUNDLE_MAX_ASSETS,
|
||||
MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES,
|
||||
MOBILE_WEB_BUNDLE_MAX_ROUTE_GRANTS,
|
||||
MOBILE_WEB_BUNDLE_MAX_ROUTES,
|
||||
MOBILE_WEB_BUNDLE_MAX_TOTAL_BYTES,
|
||||
MOBILE_WEB_BUNDLE_SCHEMA_VERSION,
|
||||
@@ -163,6 +164,60 @@ describe('the page routes a manifest declares', () => {
|
||||
expect(withRoutes([{ pathname: '/h', grants: [], screen: 'x' }])).toBe(false)
|
||||
})
|
||||
|
||||
/**
|
||||
* The optional lane (ruling 37), held to the required lane's own rules.
|
||||
*
|
||||
* One grammar, because the shell filters both lists against one implemented set and the page reads
|
||||
* one `grants.native`: a name only one lane could carry would be a second vocabulary, which is
|
||||
* what candidate A was rejected for. One ceiling over the union, because the union is what a
|
||||
* session's granted list is built from.
|
||||
*/
|
||||
it('takes an optional grant list, absent or empty, under the same grammar', () => {
|
||||
expect(withRoutes([{ pathname: '/h', grants: ['navigate'], optionalGrants: [] }])).toBe(true)
|
||||
expect(
|
||||
withRoutes([{ pathname: '/h', grants: ['navigate'], optionalGrants: ['screencastBinary'] }])
|
||||
).toBe(true)
|
||||
expect(
|
||||
withRoutes([
|
||||
{ pathname: '/h', grants: ['navigate'], optionalGrants: ['native.clipboard.write'] }
|
||||
])
|
||||
).toBe(true)
|
||||
// The spellings the required lane refuses, refused here too.
|
||||
expect(
|
||||
withRoutes([{ pathname: '/h', grants: [], optionalGrants: ['native.screencast'] }])
|
||||
).toBe(false)
|
||||
expect(withRoutes([{ pathname: '/h', grants: [], optionalGrants: [''] }])).toBe(false)
|
||||
})
|
||||
|
||||
it('bounds the two lists together, not one at a time', () => {
|
||||
const names = (count, prefix) =>
|
||||
Array.from({ length: count }, (_value, index) => `${prefix}${String(index)}`)
|
||||
const half = MOBILE_WEB_BUNDLE_MAX_ROUTE_GRANTS / 2
|
||||
// At the ceiling exactly, split across the lanes.
|
||||
expect(
|
||||
withRoutes([
|
||||
{ pathname: '/h', grants: names(half, 'req'), optionalGrants: names(half, 'opt') }
|
||||
])
|
||||
).toBe(true)
|
||||
// One past it, with neither lane over the ceiling on its own: this is the case a per-array
|
||||
// ceiling passes and a page would be handed twice what the cap bounds.
|
||||
expect(
|
||||
withRoutes([
|
||||
{ pathname: '/h', grants: names(half + 1, 'req'), optionalGrants: names(half, 'opt') }
|
||||
])
|
||||
).toBe(false)
|
||||
// And the per-array ceiling still holds on its own, so the union check is not the only fence.
|
||||
expect(
|
||||
withRoutes([
|
||||
{
|
||||
pathname: '/h',
|
||||
grants: [],
|
||||
optionalGrants: names(MOBILE_WEB_BUNDLE_MAX_ROUTE_GRANTS + 1, 'opt')
|
||||
}
|
||||
])
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('requires the field, so a bundle cannot leave the shell guessing', () => {
|
||||
const manifest = manifestOf([ENTRY])
|
||||
Reflect.deleteProperty(manifest, 'routes')
|
||||
|
||||
@@ -119,13 +119,48 @@ export function computeMobileWebBundleId(assets: readonly MobileWebBundleAsset[]
|
||||
* behalf; a shell that does not implement one of them renders the native screen instead, which is
|
||||
* the capability negotiation that keeps an old app against a new bundle on a working screen rather
|
||||
* than a dead tap.
|
||||
*
|
||||
* `optionalGrants` names what the screen is better with and complete without (ruling 37). Serving
|
||||
* the route reads `grants` alone, so the all-or-nothing rule above is untouched and an author who
|
||||
* cannot show a screen at all without a capability still keeps it native; only the session's
|
||||
* granted list reads both. Which side a capability goes on is the desktop's call, because the
|
||||
* desktop is what knows which screens it has proved.
|
||||
*
|
||||
* Optional rather than defaulted to `[]`: a desktop older than the field writes no key, and a shell
|
||||
* whose policy predates the field never reads one, so it serves the route on its required set.
|
||||
*
|
||||
* What it does NOT get is a reader that strips the key. `pageRouteSchema` on the phone is
|
||||
* `z.looseObject`, which passes unknown keys through rather than dropping them (measured on zod
|
||||
* 4.4.3), so the entry an older shell holds still carries this field. That is why the publish path
|
||||
* must build the pairs it hands the bridge instead of forwarding a manifest entry: the pair schema
|
||||
* is `.strict()`, and an entry reaching it refuses the whole session rather than one field. See
|
||||
* `page-route-policy.ts`'s `routeViewOf`.
|
||||
*/
|
||||
export const MobileWebBundleRouteSchema = z
|
||||
.object({
|
||||
pathname: z.string().min(1).max(MAX_ROUTE_PATHNAME_LENGTH).regex(ROUTE_PATHNAME_PATTERN),
|
||||
grants: z.array(MobileWebBundleGrantNameSchema).max(MOBILE_WEB_BUNDLE_MAX_ROUTE_GRANTS)
|
||||
grants: z.array(MobileWebBundleGrantNameSchema).max(MOBILE_WEB_BUNDLE_MAX_ROUTE_GRANTS),
|
||||
optionalGrants: z
|
||||
.array(MobileWebBundleGrantNameSchema)
|
||||
.max(MOBILE_WEB_BUNDLE_MAX_ROUTE_GRANTS)
|
||||
.optional()
|
||||
})
|
||||
.strict()
|
||||
// The ceiling is over the union, because the union is what a session's granted list is built
|
||||
// from: two lists each under the cap would hand a page twice what the cap bounds. The per-array
|
||||
// ceilings above stay, so the arrays are bounded before this runs.
|
||||
.superRefine((route, context) => {
|
||||
if (
|
||||
route.grants.length + (route.optionalGrants?.length ?? 0) >
|
||||
MOBILE_WEB_BUNDLE_MAX_ROUTE_GRANTS
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['optionalGrants'],
|
||||
message: 'grants and optionalGrants together must not exceed the route grant ceiling'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export type MobileWebBundleRoute = z.infer<typeof MobileWebBundleRouteSchema>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user