Files
orca/config/scripts/mobile-web-app-render.test.mjs
T
Jinwoo Hong a3c6d4266a fix(mobile): admit https: images on the web shell's CSP (OTA phase C, ruling 27) (#21964)
* fix(mobile): admit https: images on the web shell's CSP (OTA phase C, ruling 27)

Native markdown and the native rich editor load images the author referenced
by URL, so the page has to as well or a remote image is a blank where native
paints a picture. `img-src` widens to `img-src 'self' data: https:` on both
platforms; `script-src`, `connect-src`, `object-src`, `frame-src` and
`child-src` do not move.

`http:` stays out, and the pins say so directly rather than by absence: the
Kotlin test's blanket `!contains("http")` could not survive `https:`, so both
native pins now check `http:` (not a substring of `https:`) and check that
`https:` appears in `img-src` and nowhere else, the same shape the `data:`
pin already had.

No behaviour change on released phones: the shell ships in no released tag
(mobile-v0.0.9 predates it), so this reaches devices with the Phase E native
build and not before.

Neither native module has a CI job, so both ran locally: swiftc over the
module plus MobileWebShellChecks, and
`:orca-mobile-web-shell:testDebugUnitTest`. Both were confirmed red against
the old directive first.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): correct what the sealed preview frame is stricter about

The doc comment said the page was deliberately stricter than the native
preview because it loads no remote image and runs no script. Since `img-src`
gained `https:` only the script half is true: the frame loads a remote image
exactly as the native WebView does.

Says instead what an artifact's image URL now is -- a channel that fires on
view and carries whatever its author encoded, with nothing dynamic behind it
because no script runs -- and names `referrerPolicy` as what keeps the
document's own origin out of the request.

Comment only; no behaviour and no test moves.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): measure both halves of the preview frame's image fence

"fetches nothing of the artifact that leaves the origin" stopped being what
the sealed arm proves once `img-src` gained `https:`. The fixture's foreign
origin is `http://127.0.0.1`, so its two images are refused on the scheme
alone and only the font is refused by `font-src 'none'`. Renamed to say
exactly that.

The half that was missing is an https arm. Playwright route interception
answers an `https://…invalid` origin in the page, so the arm needs no TLS
server and no new dependency, and a request only reaches the handler if the
policy let it out. Under the shipped header, on Chromium and WebKit, the
`<img>` and the CSS background are both requested -- `img-src` governs a
background too -- and the font still is not.

`artifact()` takes the subresource origin; the links stay on the cleartext
one so no existing navigation case changes.

Red-first: with `img-src 'self' data:` put back into the parsed Kotlin
policy, the new arm fails on both engines with `expected [] to deeply equal
[ '/css-bg.png', '/img.png' ]`. The directive was restored byte-identical
before this commit.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(scripts): split the preview frame's settling out of the render check

The https arm pushed mobile-web-app-html-preview-render.test.mjs to 620
counted lines, over the 600 cap config/scripts carries. Split at a module
boundary rather than bumped: the four wait-and-settle functions are rig
mechanics with no assertion in them, and they now sit beside the diagnosis
module they already reported through.

`waitForLoadedFrame` and `settleAfterMount` are the two the render check
calls; `waitForRecordedNavigation` and `settleWithoutNavigation` stay
internal to the new module.

Move only. Same 20 tests pass on both engines.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): send Referrer-Policy: no-referrer on the shell document

`img-src https:` gave the page somewhere to send a request, and the document
origin is `orca-mobile-web://<sessionId>/`, so a request that carries a
referrer carries the session id to whatever host an artifact or a markdown
document named.

`referrerPolicy="no-referrer"` on the preview iframe does not cover it.
Measured in the render rig against a permissive control policy: WebKit puts
the embedder's URL on a srcdoc frame's image request despite the attribute,
and Chromium sends none. So the guarantee belongs on the document, where one
header covers every request the page makes, and it rides the document alone
with the policy -- the referrer of a request is decided by the document that
made it, so on a subresource response it would govern nothing.

WKWebView under the custom scheme is unverified: the rig is Playwright
WebKit over http, not WKWebView over `orca-mobile-web://`. The header is the
hedge, and it costs nothing if that host never leaked.

Pinned three ways, each confirmed red first:
- Swift, exit 133 with the header removed.
- Kotlin, MobileWebShellResponseHeadersTest "sends the policy on the
  document" FAILED at :17 with it removed.
- The rig, through a new `readShellDocumentHeaders` that parses the Kotlin
  source the way `readShellCsp` does and throws rather than returning an
  empty map. With the value flipped to `unsafe-url` the WebKit arm fails
  `expected [ …(2) ] to deeply equal [ null, null ]`; with the line deleted
  the parse throws "could not parse the shell document headers".

The rig's arm carries its own presence precondition: a third server serves
the shipped policy with `unsafe-url`, so the WebKit reading is the header
doing the work, and Chromium's null either way is pinned as the browser's
behaviour rather than sold as evidence the header arrived.

MobileHtmlPreview.web.tsx said the iframe attribute kept the origin out of
the request. Corrected to name the header, since the measurement above is
what disproved it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): quote the current directive where the old text was written down

Three comments still read `img-src 'self' data:`, so a grep for the old
directive found live prose that no longer matches the header. Each stays
about `data:`, which is what those paths rest on; only the quoted policy
changes.

The two remaining hits in the repo are src/main/browser/doc-preview-protocol,
which is the desktop preview's own policy and not this one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): name the surfaces img-src https: actually unblocks today

The comment justified `https:` with markdown and the rich editor, and
neither renders a remote image on the page. Verified in the tree:
MobileMarkdown paints `![](...)` as a tappable link at both of its image
branches and never mounts an Image, and it has no `.web` sibling, so that is
what native does too; MobileRichMarkdownEditor.web.tsx is a 92-line
multiline TextInput, still C7.6's plain source field.

What the directive unblocks today is four surfaces, none of them overridden
on the page:
- MobileAgentIcon's favicon, a hardcoded `google.com/s2/favicons` URL, used
  by thirteen callers including the session header and the worktree rows;
- MobileRepoIcon's project icon, a host-named favicon, avatar or upload, on
  the worktree list and the host workspace list;
- PRCommentCard's author avatar, from the review reply schema;
- the sealed HTML preview frame, which inherits the policy.

Markdown and the editor are named as the anticipated surfaces ruling 26
points at, so a later reader does not take the loosening as already covering
them. Both native pins carried the same wrong claim and are corrected.

That comment is the only record of why the policy loosened, so it says what
is true now and what is coming, separately.

Comment only: the parsed header is unchanged, checked through the harness
reader the render suite uses.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): point the new source-control route pin at the current directive

Merge resolution, not a conflict git could see. #21957 landed the
source-control and review page routes on main while this branch was open,
and its render check pins the directive text twice: `cspHeader` by substring,
which survives the widening, and the Swift source by the quoted literal
`"img-src 'self' data:"`, which does not. Two PRs green alone, red on the
merge.

Both pins now read the current directive.

One comment goes with it. "Not one request left the origin, so there is
nothing for the policy to have refused" now needs saying why: `https:` is
admitted, so an empty host list is these two closures fetching nothing
rather than the policy refusing something. The avatar that would fetch needs
provider data this page never gets, which the file's own closing note
already explains.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): wait for the admitted images before reading their hits

CI's Chrome 152 recorded the CSS background and not the `<img>` by the time
the bounded settle returned, so both https arms failed on a count: "expected
[ '/css-bg.png' ] to deeply equal [ '/css-bg.png', '/img.png' ]" and
"expected 1 to be 2". The reads were absence-shaped -- two frames and 200 ms
-- and the claim they carry is a presence.

So the arms wait for their own evidence, the way the `'refusal'` arm already
does. `frameReady: 'images'` polls until both admitted paths are recorded,
bounded by nothing but the case's own `ctx.signal`. It sits after the marker
wait, because an image is requested by a document that has parsed, and the
arm hands its reader in rather than the settling module reaching for state
that belongs to an arm.

One reader now serves the wait and the reading. An arm that waits on one
list and asserts on another has proved nothing about the list it asserts on.

The `/probe.woff2` absence is untouched and is now an absence standing
behind two presences rather than beside them.

What the wait prints when it does not arrive, captured by making the paths
unsatisfiable against a 12 s case:

  [html-preview-render] the arm recorded ["/img.png","/css-bg.png"] of
  ["/css-bg.png","/img.png","/never-arrives.png"]; #remote
  {"complete":true,"naturalWidth":1,
  "currentSrc":"https://artifact-images.invalid/img.png?n=n1",
  "loading":null}: arm csp=shipped sandbox=product frameReady=images
  nonce=n1 | browser 147.0.7727.15 | ... | frames [...]

`complete` with a zero `naturalWidth` is a request that finished and
produced no image; `complete` false is one still in flight. So a Chrome that
never issues the request says which of those it was, instead of a bare count.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): say why an admitted image never arrived, and hand back the context

CI's Chrome 152 read the `<img>` as complete with a zero naturalWidth and a
resolved currentSrc while the route handler never saw the request, and the
CSS background from the same origin did reach it. The diagnosis could say
the image failed but not why, because nothing was watching the request.

Now four sources are, for the `.invalid` origin only, in a module of their
own so the rig file stays under its cap: `request` says whether the page
asked at all, `requestfailed` carries the browser's `errorText`, and CDP's
`Network.loadingFailed` adds `blockedReason` and `corsErrorStatus`, which is
the only place a refusal names itself once the request never reaches a route
handler. `Network.requestWillBeSent` records the resource type, the initiator
and the frame, which separates an image the parser found from one nothing
asked for. They fill arrays while an arm passes and are only read on abort.

Proved by forcing the abort rather than assuming: with the awaited paths made
unsatisfiable, the reading names the font's refusal in both vocabularies at
once, `failed [{"url":".../probe.woff2","errorText":"csp"}]` and `cdp
loadingFailed [{"errorText":"","blockedReason":"csp",...,"type":"Font"}]`,
beside `cdp sent` showing every request's type, initiator and frameId.

Teardown: `open()` now takes an explicit context and closes both the page and
the context in a `finally`. The close used to sit on the happy path, so an
arm whose wait aborted and whose result reads then raced vitest's teardown
left its page and its implicit context open on a browser every later case in
that engine still runs on.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): correct three rationales the widening left wrong

(a) A review comment's avatar is not a surface the widening unblocks.
PRCommentCard renders it only under `Platform.OS !== 'web'` and a component
test pins the skip, so on the page it never renders. Dropped from both native
rationales and moved to the anticipated list beside markdown and the editor,
with the reason each is anticipated rather than current.

(b) The Kotlin rationale quoted the iOS origin. Android serves from
`https://<sha256(sessionId) first 32 hex>.orca-mobile-web.invalid/`, so a
referrer there carries a stable per-session handle and not the id itself,
while iOS serves `orca-mobile-web://<sessionId>/` and carries it verbatim.
Both are something an image host can key on across requests, which is what
the header is for; each file now names its own origin.

(c) "Only the script half of that is stricter than native" overstated it.
`font-src 'none'` and `connect-src 'self'` are stricter too. Images are the
one of the four that stopped being stricter, and the comment now says which
three remain and why.

A fourth, found while checking (a): the skip's own comment justified itself
with `img-src` being `'self' data:`, so a provider avatar would be "one
refused request per card". That is no longer true -- the avatar would load
now -- so the skip is a page capability gap rather than a policy consequence.
Recorded as such at the guard. Whether to lift the guard is a ruling-26
question and not this PR's.

Comments only. The parsed policy and document headers are unchanged, checked
through the harness readers the render suite uses.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): probe why Chrome never asks for the artifact image

CI's read was decisive: on Chrome 152 only the CSS background was requested,
while the `<img>` reported complete with a zero naturalWidth and a resolved
currentSrc. A request that went out and failed cannot produce both readings,
so the next probe asks the frame rather than the network.

On abort it now reads, inside the artifact frame: readyState, the init
script's own moment, document.images.length, every
`performance.getEntriesByType('resource')` name, the navigation entry types,
and for #remote its src, isConnected, complete, naturalWidth, currentSrc and
the outcome of decode(). A resource entry for a URL the rig never saw would
mean the request left the frame and died before reaching it.

Then it issues a `new Image()` at a URL that has never existed and reports two
seconds later whether the rig saw it. That splits the two live explanations: if
the fresh request is seen and the artifact's was not, the frame can fetch and
the parser-inserted element is the cause; if neither is seen, requests from
this frame are not reaching the rig at all. Subframe document commits are
counted from mount, because a second parse is a new window and leaves nothing
behind to count, and a second parse could be meeting a failure the first
cached.

`cdp sent` was empty on CI even for a request Playwright did record, so the
page's own session is blind to the frame. Chromium isolates sandboxed iframes
into their own process, srcdoc included, so flattened Target.setAutoAttach now
puts each child target on the same connection with Network.enable on the
child, and the attached list reports whether the frame is a separate target
at all.

The navigation arm gets the same reading, since CI showed it fails on its own
rather than behind the aborted image arms.

Verified by forcing the abort rather than assumed. Locally the reading prints
one subframe parse, decode resolved, every resource the document fetched, and
`fresh ... issued true seen true`, with the attached list empty, which is
consistent with this Chrome not isolating the frame and its page session
seeing the requests.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): time the artifact image against the frame's attachment

CI's second read showed the frame did issue the request -- it has a
resource-timing entry and decode rejected with EncodingError -- while the rig
saw only the CSS background, and a fresh image created later from the same
frame was both issued and seen. The remaining question is whether the entry
starts before anything was listening to that frame.

So the entry is now reported in full for the element under test:
responseStatus, transferSize, encodedBodySize, nextHopProtocol, startTime and
duration. A zero status with a zero transferSize is a fetch that reached the
network stack and came back with nothing, which is what an unintercepted
request looks like once `.invalid` fails to resolve.

Both sides of the comparison get a wall clock: `Target.attachedToTarget` and
Playwright's own `frameattached` now carry the moment they fired, and every
recorded request carries the moment it was seen. An entry that starts before
the attachment is the race stated rather than inferred.

Abort path only; the passing run is unchanged.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): serve the artifact's https assets from a real TLS listener

Interception could not measure what the directive admits. Chrome 152 isolates
the sandboxed srcdoc frame into its own target and the parser-inserted `<img>`
is the document's first fetch, issued before interception attaches there: the
request escaped to the real network, `artifact-images.invalid` did not
resolve, and the rig recorded nothing while the frame's own resource timing
showed the fetch and a later fresh image was both issued and seen.

So the assets come from a listener that is already accepting before the page
exists. It cannot be raced: the request arrives or it does not, and either
answer is the measurement. Hits and referrers are recorded server-side, the
way this rig's cleartext origin already does it, and read per arm by nonce.
`img-src 'self' data: https:` matches on scheme, so `https://127.0.0.1:<port>`
exercises the same directive as any other https host.

Lifecycle: started in beforeAll before any browser, closed in afterAll beside
the other servers. Its certificate is generated per run by openssl into the
suite's own scratch directory under `mobile/.tmp`, which the root gitignore
already covers and into which the server writes a second `.gitignore` as well;
the key never leaves that directory and nothing trusts it, since the context
is created with `ignoreHTTPSErrors`. No arm shares state: one hit list keyed
by each arm's nonce, and the permissive-Referrer-Policy control stays what it
was, a second bundle server serving the page, because the control is the
document's header and not the image host's.

The navigation record moves off interception too. It is now `page.on('request')`,
one subscription over every frame, armed after the rig's own `goto` exactly
where the route used to be registered; the route stays only for what only a
route can do, refuse the navigation. That answers the top-nav arm's `recorded
[]`: its record depended on the same per-target interception.

And the arms stop swallowing their clicks. `click(...).catch(() => {})` made a
tap that never landed and a tap that produced no navigation the same empty
counter; `open()` now records the error and the two top-nav arms assert it is
null before reading any count.

One correction to the reading added in the previous commit. The resource-timing
fields came back zero for a request that had plainly succeeded: they are opaque
cross-origin. The listener now sends `Timing-Allow-Origin`, after which
transferSize, encodedBodySize and nextHopProtocol carry real values.
`responseStatus` still reads zero on a successful request, so the comment names
the three that discriminate rather than the four that are printed.

24/24 on both local engines.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): compare the artifact fetch and the attachment on one clock

The early-or-late comparison spanned two clocks and could not answer the
question it was written for. Every `at` in the request log is Node's
`performance.now()`, counting from process start; the resource entry's
`startTime` is the frame's own, counting from that document's navigation. A
frame entry reads as earlier than a Node attachment by roughly the process
uptime, so the comparison would have reported the race as confirmed on every
run, including runs where there was no race. A green CI would not have caught
it.

So the comparison is stated where both numbers actually live: `asked` against
`attached` in the request log, on the Node clock alone. `startTime` and
`duration` stay, labelled as the frame's own account and explicitly not
comparable to an attachment time. The module docstring says the same, so the
next reading added here starts from the rule rather than rediscovering it.

The commit message of b5e82065f3 carries the same overstatement and is left
as it stands; this is the correction.

Also the stale route-handler references, now that the asset listener records
the secure origin and the navigation record is a page subscription. Three were
in the review; two more were not, and both were stale for the same reason:
`waitForRecordedNavigation`'s docstring still credited the route with
recording a main-frame navigation, which stopped being true when the record
moved off interception, and the request log described a refusal as one the
request never reached a route handler with. The route now only refuses; it
counts nothing. The one remaining mention is the deliberate contrast in the
rig that says the record is the page's event and not the route's.

Comments only. 24/24 on both local engines.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-21 08:34:38 -04:00

692 lines
34 KiB
JavaScript

import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { chromium } from 'playwright-core'
import { buildMobileWebAppBundle } from './build-mobile-web-app-bundle.mjs'
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
import {
createBundleServer,
installShellDouble,
parseCspDirectives,
projectDir,
readBridgeFaultGrant,
readBridgeProtocolVersion,
readShellCsp
} from './mobile-web-app-render-harness.mjs'
// Why a real browser: the route tree is handed to expo-router's own ExpoRoot through a synthesized
// RequireContext. Nothing short of mounting it proves that object is the shape ExpoRoot reads.
const HOST_ROUTE = '/h/render-check-host'
/** The pattern `init.pageRoutes` names, which is what the page matches a navigation against. */
const HOST_ROUTE_PATTERN = '/h/[hostId]'
// What the double answers `ready` with. Asserted on the document, so a page that mounted against
// some other session, or against none, fails here rather than on a phone.
const SHELL_SESSION_ID = 'render-check-session'
const SHELL_BUILD_ID = 'render-check-build'
// The host the shell opened the page for. Without it `expo-secure-store` is {} on web and the list
// paints "Host not found" over a host that is right there.
const SHELL_HOST = {
id: 'render-check-host',
name: 'Render Check Host',
endpoint: 'ws://render-check',
lastConnected: 1
}
// The sharded `test` job does not install mobile dependencies, so the page cannot be built there.
// The CSP suite below needs none of them and still runs. pr.yml's mobile_web_app job runs both.
const bundles = mobileWebAppDependenciesPresent()
const describeRender = bundles ? describe : describe.skip
let scratch
let server
let browser
let origin
let routeChunks = {}
let cspHeader = null
let bridgeVersion = null
let faultGrant = null
/**
* Chunk paths the server answers with a module that throws on evaluation.
*
* The one way to reproduce the failure the boundary exists for: a route chunk that never arrives
* intact. Building a second bundle around a throwing route would test a synthetic tree; poisoning
* one file of the real bundle keeps everything else exactly what ships.
*/
const poisonedChunks = new Set()
const POISON_MESSAGE = 'render check poisoned this route chunk'
beforeAll(async () => {
cspHeader = await readShellCsp()
bridgeVersion = await readBridgeProtocolVersion()
faultGrant = await readBridgeFaultGrant()
if (!bundles) {
return
}
scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-app-render-'))
const built = await buildMobileWebAppBundle({ outDir: join(scratch, 'bundle') })
const { outDir } = built
routeChunks = built.routeChunks
// The real bytes with a throw in front: the module still links, so the importer resolves
// every export it asked for and then evaluation throws. A body replaced outright fails at
// link instead, which is a different failure from the one the boundary is here for.
const served = await createBundleServer({
outDir,
cspHeader,
transformChunk: (path, real) =>
poisonedChunks.has(path)
? `throw new Error(${JSON.stringify(POISON_MESSAGE)});\n${real.toString('utf8')}`
: real
})
server = served.server
origin = served.origin
// CI runs this against the runner's Google Chrome rather than paying for a browser download,
// the same reason and the same override shape as the orcad browser-provider job.
const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER
browser = await chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}) })
}, 180_000)
afterAll(async () => {
await browser?.close()
server?.close()
if (scratch) {
await rm(scratch, { recursive: true, force: true })
}
})
// expo-router's Unmatched screen mounts cleanly and paints text, so "no errors, some html" stays
// green with every host route unreachable. Each route below names content only it can produce.
const UNMATCHED = 'Unmatched Route'
/**
* A page with every signal the checks below read: uncaught errors, console errors, and the script
* paths the browser actually fetched. The last one is how a client-side navigation proves it
* pulled the next route's chunk rather than painting out of what the entry already had.
*
* No `shellRoute` installs no double at all, which is the page that never mounts; a null one
* installs a shell that named no screen.
*/
async function openPage({
shellRoute,
shellHost = SHELL_HOST,
shellStorage = {},
shellGrants,
shellPageRoutes = null
} = {}) {
const page = await browser.newPage({ viewport: { width: 390, height: 844 } })
if (shellRoute !== undefined) {
// At document start, where the native shell installs the real channel: the entry reads it
// while its own script runs, so a channel added after `load` would already be too late.
await page.addInitScript(installShellDouble, {
version: bridgeVersion,
sessionId: SHELL_SESSION_ID,
buildId: SHELL_BUILD_ID,
route: shellRoute,
host: shellHost,
storage: shellStorage,
faultGrant,
grants: shellGrants ?? [faultGrant],
pageRoutes: shellPageRoutes
})
}
const errors = []
const scripts = []
let reportUncaught = () => {}
// An uncaught error from the entry means nothing will ever mount. Racing it against the wait
// reports that error in a second instead of a 30s timeout that names nothing -- which is what a
// native-only route module, throwing at import before React runs, looks like from here.
// Resolved rather than rejected: this one settles during goto, before anything awaits it.
const uncaught = new Promise((resolve) => {
reportUncaught = resolve
})
page.on('pageerror', (error) => {
errors.push(`${error.name}: ${error.message}`)
reportUncaught(error)
})
page.on('console', (message) => {
if (message.type() === 'error') {
errors.push(`console.error: ${message.text()}`)
}
})
page.on('response', (response) => {
const path = new URL(response.url()).pathname
if (response.status() === 200 && path.endsWith('.js')) {
scripts.push(path)
}
})
return { page, errors, scripts, uncaught }
}
/**
* Wait for the entry to mount and then for the route's own content, polled rather than read once:
* the route manifest defers every screen behind `import()`, so the entry's `mounted` signal lands
* while the route's chunk is still being fetched and the body is briefly empty. Waiting for the
* string the caller is about to assert is what makes the check about the route and not the timing.
*/
async function waitForRoute({ page, errors, uncaught }, route, awaitText) {
const named = (cause, what) =>
new Error(`${route} ${what}: ${errors.join(' | ') || 'no page or console error'}`, { cause })
const race = async (wait) =>
Promise.race([
wait.then(
() => null,
(error) => error
),
uncaught
])
// The entry's own signal, not "#root has children": an error boundary or a half-painted tree
// also fills #root, and this only lands once expo-router's tree below the wrapper has committed.
// Polled on a timer rather than Playwright's default animation frames, which a page that never
// paints never delivers.
const cause = await race(
page.waitForFunction(() => document.documentElement.dataset.orcaWebEntry === 'mounted', {
timeout: 30_000,
polling: 250
})
)
if (cause) {
const state = await page.evaluate(
() => document.documentElement.dataset.orcaWebEntry ?? 'absent'
)
throw named(cause, `never mounted (entry ${state})`)
}
const paintCause = await race(
page.waitForFunction((needle) => document.body.innerText.includes(needle), awaitText, {
timeout: 30_000,
polling: 250
})
)
if (paintCause) {
throw named(paintCause, `mounted but never painted ${JSON.stringify(awaitText)}`)
}
// Folded into the errors the caller already asserts empty: a throw the boundary caught paints
// nothing and logs nothing a `pageerror` listener hears, so this is the only place it shows up.
for (const fault of await page.evaluate(() => globalThis.__orcaRenderCheckFaults ?? [])) {
errors.push(`page fault: ${fault}`)
}
}
/**
* Opens the document the way the shell does — at `/`, the one path it serves — and lets the page
* route itself from what the double names. Navigating straight to the route would hide exactly the
* step this check exists to prove.
*/
async function render(route, awaitText, { shellRoute = { pathname: route }, ...shell } = {}) {
const opened = await openPage({ shellRoute, ...shell })
await opened.page.goto(`${origin}/`, { waitUntil: 'load' })
await waitForRoute(opened, route, awaitText)
const text = await opened.page.evaluate(() => document.body.innerText)
// What the page believes it is: read off the document rather than off the double, so a tree that
// mounted without a session, or against a session it invented, is not a passing render.
const session = await opened.page.evaluate(() => ({
sessionId: document.documentElement.dataset.orcaWebSessionId ?? null,
buildId: document.documentElement.dataset.orcaWebBuildId ?? null
}))
// The document is served at "/" and the page rewrites its own path before it renders; without
// that, every route below would be expo-router's Unmatched screen.
const url = await opened.page.evaluate(() => location.pathname + location.search)
await opened.page.close()
// A CSP refusal reaches the page as a console error, so the caller's empty-errors assertion is
// also the policy assertion; name it here so a failure says which one broke.
return {
errors: opened.errors,
cspErrors: opened.errors.filter((entry) => entry.includes('Content Security Policy')),
text,
session,
url
}
}
/** The entry's state and what it painted, for a page that is never going to mount a route tree. */
async function renderWithoutTree({ shellRoute } = {}) {
const { page, errors } = await openPage({ shellRoute })
// Read straight after `load` and not polled: the entry decides this synchronously, inside the
// script `load` waits for, so a state that is not settled by now is never going to settle.
await page.goto(`${origin}/`, { waitUntil: 'load' })
const entry = await page.evaluate(() => document.documentElement.dataset.orcaWebEntry ?? 'absent')
const rootChildren = await page.evaluate(() => document.getElementById('root').childElementCount)
const text = await page.evaluate(() => document.body.innerText)
const url = await page.evaluate(() => location.pathname + location.search)
await page.close()
return { entry, errors, rootChildren, text, url }
}
describe('the shell policy this page is tested under', () => {
it('is the same on both platforms, so one render check covers both', async () => {
const swift = await readFile(
join(projectDir, 'mobile/modules/orca-mobile-web-shell/ios/MobileWebShellCsp.swift'),
'utf8'
)
expect(parseCspDirectives(swift, 'static let header = [', '].joined')).toBe(cspHeader)
})
it('reads directives from the source and not from the comments around them', () => {
const source = [
'static let header = [',
" // React Native Web needs \"style-src 'self' 'unsafe-inline'\" and nothing more.",
' "default-src \'none\'",',
' "script-src \'self\'",',
" \"style-src 'self' 'unsafe-inline'\",",
' "img-src \'self\'",',
' "connect-src \'self\'",',
' "worker-src \'none\'",',
' "frame-src \'none\'",',
' "child-src \'none\'",',
' "object-src \'none\'",',
' "base-uri \'none\'",',
' "form-action \'none\'",',
' "frame-ancestors \'none\'"',
'].joined'
].join('\n')
const parsed = parseCspDirectives(source, 'static let header = [', '].joined')
expect(parsed.split('; ')[0]).toBe("default-src 'none'")
expect(parsed.split('; ').filter((entry) => entry.includes('unsafe-inline'))).toEqual([
"style-src 'self' 'unsafe-inline'"
])
})
it('still refuses inline script, which is the directive that matters', () => {
expect(cspHeader).toContain("script-src 'self';")
expect(cspHeader).not.toContain("script-src 'self' 'unsafe-inline'")
})
it('admits data: and https: for images and for nothing else', () => {
expect(cspHeader.split('; ').filter((entry) => entry.includes('data:'))).toEqual([
"img-src 'self' data: https:"
])
expect(cspHeader.split('; ').filter((entry) => entry.includes('https:'))).toEqual([
"img-src 'self' data: https:"
])
// `http:` is not a substring of `https:`, so this still refuses a cleartext source.
expect(cspHeader).not.toContain('http:')
})
})
/** A 1x1 PNG: the smallest payload that proves an image decoded rather than merely being allowed. */
const DATA_URI_IMAGE =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='
describeRender('an image preview under the shell policy', () => {
it('decodes a data: URI, which is the only shape a file preview has', async () => {
// What a preview actually is: normalizeMobileFilePreviewResult composes
// `data:<mime>;base64,<content>` out of a reply the page already holds and hands it to React
// Native Web's Image, which paints it as a CSS background. The `new Image()` below is not a
// stand-in for that: react-native-web 0.21.2 loads through `ImageLoader.load`, which is
// `new window.Image()` with `onload`/`onerror` on it, and the hidden <img> the component also
// renders carries neither — it is there for the browser's image context menu and for
// `getBackgroundSize()`. So this is the same mechanism the screen's own load runs through, and
// its failure is what turns the screen into "Unable to load preview".
const { page, errors } = await openPage()
await page.goto(`${origin}/`, { waitUntil: 'load' })
const naturalWidth = await page.evaluate(
(uri) =>
new Promise((resolve) => {
const image = new Image()
image.addEventListener('load', () => resolve(image.naturalWidth))
image.addEventListener('error', () => resolve(0))
image.src = uri
}),
DATA_URI_IMAGE
)
await page.close()
expect({
naturalWidth,
refused: errors.filter((entry) => entry.includes('Content Security Policy'))
}).toEqual({ naturalWidth: 1, refused: [] })
})
})
describeRender('the page server this check runs against', () => {
it('404s a file path the bundle does not contain', async () => {
// Without this the document answers every path, and a publicPath the script cannot fetch
// from still renders, because the script is fetched from the one prefix that is served.
expect((await fetch(`${origin}/wrong-prefix/entry.js`)).status).toBe(404)
expect((await fetch(`${origin}/assets/not-a-real-hash.js`)).status).toBe(404)
})
it('answers the icon a browser asks for without an error', async () => {
expect((await fetch(`${origin}/favicon.ico`)).status).toBe(204)
})
it('still serves the document at every route depth', async () => {
for (const route of ['/', HOST_ROUTE, `${HOST_ROUTE}/tasks`]) {
const response = await fetch(`${origin}${route}`)
expect(response.status, route).toBe(200)
expect(await response.text(), route).toContain('<div id="root">')
}
})
})
describeRender('the Route A page in a real browser', () => {
it('mounts the worktree list route, not the unmatched screen', async () => {
const { errors, cspErrors, text, session, url } = await render(HOST_ROUTE, SHELL_HOST.name)
expect(cspErrors).toEqual([])
expect(errors).toEqual([])
// The tree that mounted is the one the shell handed a session to, and it says which.
expect(session).toEqual({ sessionId: SHELL_SESSION_ID, buildId: SHELL_BUILD_ID })
// The document was served at `/`; the page put itself on the route the shell named.
expect(url).toBe(HOST_ROUTE)
// The host the shell named, read through host-store.web.ts off `init.host`. Only that route's
// own component names the host; "Host not found" is what it paints without one.
expect(text).toContain(SHELL_HOST.name)
expect(text).not.toContain('Host not found')
expect(text).not.toContain(UNMATCHED)
}, 60_000)
it('fills the view, so what it mounted is painted and takes a tap', async () => {
const opened = await openPage({ shellRoute: { pathname: HOST_ROUTE } })
await opened.page.goto(`${origin}/`, { waitUntil: 'load' })
await waitForRoute(opened, HOST_ROUTE, SHELL_HOST.name)
const layout = await opened.page.evaluate(() => {
// The one control this route paints with no RPC answered. Positioned against the bottom of
// the root, so it is also the element a collapsed root moves furthest.
const fab = [...document.querySelectorAll('[role="button"]')].find(
(element) => element.getAttribute('aria-label') === 'New workspace'
)
const box = fab?.getBoundingClientRect() ?? null
const hit =
box === null
? null
: document.elementFromPoint(box.x + box.width / 2, box.y + box.height / 2)
return {
rootHeight: document.getElementById('root').getBoundingClientRect().height,
viewportHeight: window.innerHeight,
fabTop: box?.top ?? null,
fabBottom: box?.bottom ?? null,
reachesTheControl: hit !== null && fab.contains(hit)
}
})
await opened.page.close()
expect(opened.errors).toEqual([])
// Nothing else here can see a collapsed root: the tree mounts, the text is in the DOM, and
// every assertion on `innerText` passes while the phone paints a blank list under the header.
// A height is the only thing that says the screen is on the screen.
expect(layout.rootHeight).toBe(layout.viewportHeight)
expect(layout.fabTop).toBeGreaterThan(0)
expect(layout.fabBottom).toBeLessThanOrEqual(layout.viewportHeight)
// Laid out is not reachable. A row inside a scroller the collapse clipped keeps its rect and
// takes no taps, which is what both phones found before this file could say so.
expect(layout.reachesTheControl).toBe(true)
}, 60_000)
it('routes a nested dynamic segment through the same context', async () => {
const { errors, cspErrors, text, session } = await render(`${HOST_ROUTE}/tasks`, 'Tasks')
expect(cspErrors).toEqual([])
expect(errors).toEqual([])
expect(session.sessionId).toBe(SHELL_SESSION_ID)
// app/h/[hostId]/tasks.tsx paints its header and its GitHub filter row.
expect(text).toContain('Tasks')
expect(text).toContain('Issues')
expect(text).not.toContain(UNMATCHED)
}, 60_000)
// Both files routes reach OrcaMobileWebShellView from their native file, whose module calls
// requireNativeViewManager at import and throws in a browser. The manifest defers every route
// behind `import()`, so that throw is invisible until the page opens this route — which is why
// it needs a `.web.tsx` sibling and why proving it costs a render of the route itself.
it('mounts the file explorer, which its native route module cannot do', async () => {
const worktreeRoute = `${HOST_ROUTE}/files/worktree-a`
const { errors, cspErrors, text } = await render(worktreeRoute, 'Files', {
shellRoute: { pathname: worktreeRoute, params: { name: 'Example Worktree' } }
})
expect(cspErrors).toEqual([])
expect(errors).toEqual([])
expect(text).toContain('Files')
expect(text).toContain('Example Worktree')
expect(text).not.toContain(UNMATCHED)
}, 60_000)
it('mounts the file preview, reading the file path out of a param and not a segment', async () => {
const previewRoute = `${HOST_ROUTE}/files/preview/worktree-a`
const { errors, cspErrors, text, url } = await render(previewRoute, 'readme.md', {
shellRoute: {
pathname: previewRoute,
params: { relativePath: 'docs/my notes/readme.md', source: 'worktree' }
}
})
expect(cspErrors).toEqual([])
// Empty, and that is the point: React Native Web's BackHandler logs "not supported on web" for
// anyone who registers one, so this line is what proves the screen no longer does. Android back
// inside the page therefore pops the native stack without the unsaved-draft prompt, which lives
// on the page's own Back control.
expect(errors).toEqual([])
// The title is the last segment of the path param, so this says the param reached the screen
// with its last segment intact; `readme.md` is what a truncated or re-split path would also
// end in. The url assertion below pins the outbound leg — what the page encoded into its own
// history, `/` and space included — and no more: a screen that mis-decoded the middle of the
// path would satisfy both lines. The decode leg is proved where it can be read directly, in
// `mobile/src/files/mobile-file-path-route-encoding.test.ts`, which takes each hazard shape
// back out of the href, and `mobile/src/files/mobile-file-preview-route.test.ts`, which drives
// the normalizer the screen reads its params through.
expect(text).toContain('readme.md')
expect(url).toBe(`${previewRoute}?relativePath=docs%2Fmy+notes%2Freadme.md&source=worktree`)
expect(text).not.toContain(UNMATCHED)
}, 60_000)
it('refuses a host-scoped path with no module rather than crashing', async () => {
// The catch-all owns every `/h/<id>/...` pathname the tree has no file for, so this no longer
// reaches expo-router's Unmatched: the refusal is what the page paints instead. Both halves are
// asserted, so the negative is known to discriminate rather than to pass on a blank screen.
const refusal = 'This workspace screen is not available on this host.'
const { errors, cspErrors, text } = await render(`${HOST_ROUTE}/not-a-route`, refusal)
expect(cspErrors).toEqual([])
expect(errors).toEqual([])
expect(text).toContain(refusal)
expect(text).not.toContain(UNMATCHED)
}, 60_000)
it('carries the params the shell named into the url the screen reads', async () => {
const { errors, url } = await render(HOST_ROUTE, SHELL_HOST.name, {
shellRoute: { pathname: HOST_ROUTE, params: { from: 'render check' } }
})
expect(errors).toEqual([])
expect(url).toBe(`${HOST_ROUTE}?from=render+check`)
}, 60_000)
it('paints the not-found state when the shell named no host, which is what makes the row real', async () => {
const { errors, text } = await render(HOST_ROUTE, 'Host not found', { shellHost: null })
expect(errors).toEqual([])
expect(text).toContain('Host not found')
expect(text).not.toContain(SHELL_HOST.name)
}, 60_000)
it('mounts nothing at all when no shell answered, which is what makes the rest real', async () => {
// Without this the checks above would pass against a page that ignores `init` entirely.
const { entry, errors, rootChildren } = await renderWithoutTree()
expect(entry).toBe('unbridged')
expect(rootChildren).toBe(0)
expect(errors).toEqual([])
}, 60_000)
it('says to update the app when the shell that opened it named no screen', async () => {
const { entry, errors, text, url } = await renderWithoutTree({ shellRoute: null })
expect(entry).toBe('shell-too-old')
expect(errors).toEqual([])
expect(text).toContain('Update Orca to open this workspace')
// Never the route tree at `/`: that is the Unmatched screen with a worse explanation.
expect(text).not.toContain(UNMATCHED)
expect(url).toBe('/')
}, 60_000)
it('tells the shell when a route chunk throws, rather than sitting on a blank page', async () => {
const chunk = routeChunks['./h/[hostId]/index.tsx']
expect(chunk, Object.keys(routeChunks).join(' ')).toBeTruthy()
poisonedChunks.add(`/assets/${chunk}`)
try {
const opened = await openPage({ shellRoute: { pathname: HOST_ROUTE } })
await opened.page.goto(`${origin}/`, { waitUntil: 'load' })
const reported = await opened.page
.waitForFunction(
() => {
const faults = globalThis.__orcaRenderCheckFaults ?? []
return faults.length > 0 ? faults : null
},
{ timeout: 30_000, polling: 250 }
)
.then((handle) => handle.jsonValue())
// The message the poisoned module threw, carried across the bridge as the shell sees it. A
// boundary that caught the throw and reported something else would pass an "any fault" check.
expect(reported.join(' | ')).toContain(POISON_MESSAGE)
// And the screen never painted. The router's own shell commits before the deferred chunk
// rejects, so the entry does reach `mounted`; what the boundary takes away is everything
// below it, which is the difference between a reported failure and a blank page nobody hears.
const text = await opened.page.evaluate(() => document.body.innerText)
expect(text).not.toContain('Host not found')
expect(text).not.toContain(UNMATCHED)
await opened.page.close()
} finally {
poisonedChunks.delete(`/assets/${chunk}`)
}
}, 60_000)
it('refuses a target the shell will not take, rather than opening it in the page', async () => {
// The double grants only `fault`, so `notifyNavigate` answers false -- the shell-disposed and
// older-shell cases reach the page the same way. Before C5.1 this left the host route and
// painted Unmatched; the bundle carries every route under app/h, so for a target like
// `session/[worktreeId]` the same fallback mounts a native-only screen on React Native Web.
const opened = await openPage({ shellRoute: { pathname: HOST_ROUTE } })
const { page, errors } = opened
await page.goto(`${origin}/`, { waitUntil: 'load' })
await waitForRoute(opened, HOST_ROUTE, SHELL_HOST.name)
// The one labelled control on this screen that leaves the page: `leaveHostRoute` dismisses to
// `/`, which is a native route and never one the page serves.
await page.getByLabel('Back to hosts').click()
// Nothing to wait for but the absence of a navigation, so settle the microtask the handoff
// would have posted on and then read the page that is still there.
await page.waitForTimeout(1_000)
expect(await page.evaluate(() => location.pathname)).toBe(HOST_ROUTE)
const text = await page.evaluate(() => document.body.innerText)
expect(text).toContain(SHELL_HOST.name)
expect(text).not.toContain(UNMATCHED)
// The absence that says refused rather than handed off. A page that stayed put because the
// notify crossed and the shell did the pushing looks identical on this document otherwise;
// the case below it grants `navigate` and asserts this same frame present.
const notifies = await page.evaluate(() => globalThis.__orcaRenderCheckNotifies ?? [])
expect(notifies.filter((frame) => frame.name === 'navigate')).toEqual([])
// Not a page fault either: a refused target is the page declining to move, not a throw.
expect(await page.evaluate(() => globalThis.__orcaRenderCheckFaults ?? [])).toEqual([])
expect(errors).toEqual([])
await page.close()
}, 60_000)
it("fetches the next route's chunks on a client-side navigation", async () => {
const opened = await openPage({ shellRoute: { pathname: HOST_ROUTE } })
const { page, errors, scripts } = opened
await page.goto(`${origin}/`, { waitUntil: 'load' })
await waitForRoute(opened, HOST_ROUTE, SHELL_HOST.name)
const loadedForFirstRoute = [...scripts]
// What the shell will do in C1.2: the document is fetched once and every later route is a
// history entry, so the tasks screen can only arrive as a chunk fetched now.
await page.evaluate((to) => {
history.pushState(null, '', to)
dispatchEvent(new PopStateEvent('popstate'))
}, `${HOST_ROUTE}/tasks`)
await waitForRoute(opened, `${HOST_ROUTE}/tasks`, 'Issues')
expect(new URL(page.url()).pathname).toBe(`${HOST_ROUTE}/tasks`)
const fetchedOnNavigation = scripts.filter((path) => !loadedForFirstRoute.includes(path))
// Not "some script arrived": the chunk the builder put the tasks route in, named by the
// builder rather than guessed from the bytes, which is the only thing that says the route
// came over the wire now and not out of what the first route had already loaded.
const tasksChunk = routeChunks['./h/[hostId]/tasks.tsx']
expect(tasksChunk, Object.keys(routeChunks).join(' ')).toBeTruthy()
expect(fetchedOnNavigation, scripts.join(' ')).toContain(`/assets/${tasksChunk}`)
expect(loadedForFirstRoute).not.toContain(`/assets/${tasksChunk}`)
const text = await page.evaluate(() => document.body.innerText)
expect(text).toContain('Tasks')
expect(text).not.toContain(UNMATCHED)
expect(errors).toEqual([])
await page.close()
}, 60_000)
})
/**
* What `useRouteHandoff().back()` rests on, measured in a browser rather than assumed.
*
* The handoff keeps a back this document can serve and hands the rest to the shell, and it asks
* expo-router's `canGoBack()` which of the two it is holding. That answer is React Navigation's
* (`expo-router/build/global-state/routing.js` returns `navigationRef.current.canGoBack()`), so it
* is a fact about a mounted tree in a browser and no unit test can settle it.
*
* Read through `router.back()` rather than through `canGoBack()` directly, because the page exposes
* no handle to call it on and a global added for a test is a surface the shipped page would carry
* forever. `goBack()` queues React Navigation's `GO_BACK`, which is exactly what `canGoBack()`
* gates: a Back that moves the page proves the answer was true, one that does not proves it was
* false. `/h/[hostId]/edit` is the call site — a real route of this tree whose chevron is
* expo-router's own `back()`, which is what the handoff falls through to.
*
* The first case is the presence precondition for the two below it. A tap that moved nothing and a
* tap that never reached a handler look identical on the document, so one tap on this same screen
* family is asserted to reach the shell before any absence is read as an answer.
*/
describeRender('the stack the page Back button rests on', () => {
const EDIT_ROUTE = `${HOST_ROUTE}/edit`
const BACK_ON_EDIT = '[aria-label="Back"]'
/** Clicks and then lets the router settle; a `GO_BACK` that changes nothing settles too. */
async function clickAndSettle(page, selector) {
await page.click(selector)
await page.waitForTimeout(500)
return page.evaluate(() => location.pathname + location.search)
}
it('carries a handoff the shell granted across the bridge from a real tap', async () => {
// The `navigate` grant is what `navigate-back` rides, and this chevron is the one control in
// the page tree that reaches the shell through `useRouteHandoff` today. It proves taps land,
// handlers run and a notify crosses — the mechanism `navigate-back` uses, and the reason the
// two absences below are evidence rather than silence.
const opened = await openPage({
shellRoute: { pathname: HOST_ROUTE },
shellGrants: [faultGrant, 'navigate'],
shellPageRoutes: [HOST_ROUTE_PATTERN]
})
await opened.page.goto(`${origin}/`, { waitUntil: 'load' })
await waitForRoute(opened, HOST_ROUTE, SHELL_HOST.name)
const url = await clickAndSettle(opened.page, '[aria-label="Back to hosts"]')
const notifies = await opened.page.evaluate(() => globalThis.__orcaRenderCheckNotifies ?? [])
expect(notifies.filter((frame) => frame.name === 'navigate')).toEqual([
{ v: bridgeVersion, type: 'notify', name: 'navigate', href: '/' }
])
// Handed over, not taken: the page stayed where it was rather than routing to a screen it does
// not carry, which is what a fallthrough to the local router would have painted.
expect(url).toBe(HOST_ROUTE)
expect(opened.errors).toEqual([])
await opened.page.close()
}, 60_000)
it('cannot go back on the document the shell just opened, which is the one screen it has', async () => {
const opened = await openPage({ shellRoute: { pathname: EDIT_ROUTE } })
await opened.page.goto(`${origin}/`, { waitUntil: 'load' })
await waitForRoute(opened, EDIT_ROUTE, 'Edit host')
// One control, so the tap below is known to be this route's chevron and not another screen's.
expect(await opened.page.locator(BACK_ON_EDIT).count()).toBe(1)
expect(await clickAndSettle(opened.page, BACK_ON_EDIT)).toBe(EDIT_ROUTE)
expect(opened.errors).toEqual([])
await opened.page.close()
}, 60_000)
it('is given no stack by a location change either, only by a push this page makes itself', async () => {
// The entry opens every document with `replaceState`, and a later location change resets the
// router's state rather than stacking on it: the same chevron still has nowhere to go with a
// second entry in `history`. So `canGoBack()` is false for everything the shell or the browser
// can do to this page, and the handoff's local branch belongs to a push the page makes through
// `useRouteHandoff` — of which this tree has none today.
const opened = await openPage({ shellRoute: { pathname: HOST_ROUTE } })
await opened.page.goto(`${origin}/`, { waitUntil: 'load' })
await waitForRoute(opened, HOST_ROUTE, SHELL_HOST.name)
const entriesBefore = await opened.page.evaluate(() => history.length)
await opened.page.evaluate((to) => {
history.pushState(null, '', to)
dispatchEvent(new PopStateEvent('popstate'))
}, EDIT_ROUTE)
await waitForRoute(opened, EDIT_ROUTE, 'Edit host')
expect(await opened.page.evaluate(() => history.length)).toBe(entriesBefore + 1)
expect(await clickAndSettle(opened.page, BACK_ON_EDIT)).toBe(EDIT_ROUTE)
// This case drives a synthetic `popstate`, so a throw under the fault boundary would leave the
// page exactly where the assertion above wants it and read as the absence this claims.
expect(opened.errors).toEqual([])
await opened.page.close()
}, 60_000)
})