mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 00:02:29 +00:00
* fix(mobile): keep a painted frame under the page until its first paint The shell tore its own frame down the moment a generation was on screen (`MobileWebShellScreen.tsx`, the `ready` branch), and a mounted WebView draws nothing until its document paints. What showed for the whole of the page's boot was the surface behind it with nothing on it: 1.42 s on a cached generation, against a one-frame budget. The page is the only thing that knows when it has a frame, so it says so. It declares `painted` in `ready.reports` and posts the notify after the browser has painted its first commit; the shell holds the same neutral frame it was already painting while it opened the generation, then fades it out. The wait is bounded by the declaration and never by a timer: a generation served by an older desktop declares nothing and is uncovered on `ready`, which is what every shell did before this. iOS painted white rather than nothing: a WKWebView is opaque by default, so the shell's own surface never showed through. It is now transparent, as the Android view already was. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hold the cover through the compositor handover The page reports the paint its own renderer made; putting that on the app's surface costs another frame or two. A linear fade from the report left two frames of bare surface between the two on an emulator, which is the hole the cover exists to close. Eased in over 220 ms, the cover keeps most of its opacity across that handover: five reopens now show 0-21 ms of bare surface against 102-2043 ms on the build without it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): negotiate the paint report in both directions The page posted `painted` whatever shell it met, and `notify` is a closed union: every shell installed before this answered it with an error frame, once per mount. The shell now advertises the name in `init.accepts` beside the param clear and the client identity, and the page posts only when it was advertised. The declaration in `ready.reports` stays unconditional, because it is an optional field an older reader strips rather than a new opcode, and because the first `ready` — the only one that matters for the first paint — is sent before any `init` has arrived. The accepts list moves into `bridge-init-frame.ts` beside the grants, which is the module that builds the frame carrying it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read the cover's colour instead of asserting its shape Two gate findings on the round-two head. The cover test reached the background through a cast of the style prop; it now reads it through a checked narrowing, so the test proves the shape it depends on rather than declaring it. `use-mobile-web-shell-bridge.test.ts` stopped typechecking when the bridge args gained `onPagePainted`: its harness is a literal, so a new required handler is a missing property. The probe now counts paints and one case spends the counter, which is what a handler wired only to satisfy a type would not do. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): move the cached-generation opening out of the reducer `mobile-web-shell-session.ts` crossed `max-lines` after the merge: the refused- update work and the paint handling both grew it. What comes out is one thing — putting a generation already on disk on screen, and deciding whether this route is one that bundle carries. It is the reducer's cache path and its refused- update path both, and it was already three functions sitting together. `step` goes into a module of its own because the two now share it; a copy in each would be two spellings of one transition, and exporting it from either would point the dependency the wrong way. No behaviour moves: the reducer's table tests are unchanged and the page closure is unchanged at 4,211, since neither new module is reachable from a page route. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): drop the previous document's paint when a new one starts A document that replaced a painted one inside the same mount inherited its `pagePainted`, so the cover lifted before the replacement had drawn anything. The native view already reports `loading`; the screen dropped it. It now reaches the reducer as `document-started` and clears the page document state. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): make the declaration case call the frame policy The case compared the name to itself and never called `shellPageFrame`, so it passed for a policy that ignored the declaration entirely. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): report the page's frame from the route screen, not the router Every route screen is behind `import()`, so the wrapper above expo-router commits with a suspense fallback while the chunk is still arriving. The paint report hung there, which uncovered the shell's view over an empty body on a cold chunk. It now hangs on the screen the manifest resolves, layouts excluded. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): retire the readiness wait a replaced document armed `document-started` cleared the page document state and left the flow alone, so the previous document's readiness deadline passed the flow check, read `pageReady` as false and failed a session whose replacement was still loading. The flow moves with the document, for the reason `remounted` already moves it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let a departing route screen take its paint report back The report waits two frames, and nothing cancelled the second one, so a screen unmounted in between still told the shell to uncover. The reporter now answers with a take-back the wrapper returns as its cleanup, and the once-per-document latch frees only when a report was cancelled before it landed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let the screen that arrived take over a frame still owed A screen committing inside the two frames an earlier one was owed found the latch taken and reported nothing; the earlier screen then freed that latch on its way out and nobody was left to lift the cover. The newest commit now supersedes the pending report, and only a posted one spends the latch. Covers the redirect window with a render check against the pr route, whose target chunk is held open while the document sits on the hub's fallback. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): make the take-over case turn on the take-over The case cancelled the first screen's frame through the cleanup path, so it passed with the take-over deleted. It now leaves that screen mounted and reads the clock: the frame after the replacement commits is the replacement's first, not the one the screen behind it was still owed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
818 lines
38 KiB
JavaScript
818 lines
38 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,
|
|
readBridgePagePainted,
|
|
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'
|
|
|
|
/**
|
|
* Chunk paths the server holds until the check lets them go, so "the chunk has not arrived" is a
|
|
* state the check controls rather than a window it has to win a race against.
|
|
*/
|
|
const heldChunks = new Map()
|
|
let paintName = null
|
|
|
|
beforeAll(async () => {
|
|
cspHeader = await readShellCsp()
|
|
bridgeVersion = await readBridgeProtocolVersion()
|
|
faultGrant = await readBridgeFaultGrant()
|
|
paintName = await readBridgePagePainted()
|
|
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,
|
|
handleRequest: (request, response, path) => {
|
|
const held = heldChunks.get(path)
|
|
if (!held) {
|
|
return false
|
|
}
|
|
held
|
|
.then(() => readFile(join(outDir, path.slice(1))))
|
|
.then((real) => {
|
|
response.writeHead(200, { 'content-type': 'text/javascript' })
|
|
response.end(real)
|
|
})
|
|
return true
|
|
}
|
|
})
|
|
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,
|
|
shellAccepts = 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,
|
|
accepts: shellAccepts
|
|
})
|
|
}
|
|
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
|
|
}
|
|
}
|
|
|
|
/** How many frames the double has heard under the paint name, which is what uncovers the view. */
|
|
const paintReports = (page, name) =>
|
|
page.evaluate(
|
|
(paint) =>
|
|
(globalThis.__orcaRenderCheckNotifies ?? []).filter((frame) => frame.name === paint).length,
|
|
name
|
|
)
|
|
|
|
/** 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('reports its frame from the route screen, not from the router shell above it', async () => {
|
|
// The gap the shell's cover exists for. The entry's wrapper commits against the suspense
|
|
// fallback of a chunk still in flight, so a report hung there uncovers an empty body.
|
|
const chunk = routeChunks['./h/[hostId]/index.tsx']
|
|
expect(chunk, Object.keys(routeChunks).join(' ')).toBeTruthy()
|
|
const path = `/assets/${chunk}`
|
|
let arrive = () => {}
|
|
heldChunks.set(
|
|
path,
|
|
new Promise((resolve) => {
|
|
arrive = resolve
|
|
})
|
|
)
|
|
try {
|
|
// The one case that advertises the report, because it is the only one asserting on it.
|
|
const opened = await openPage({
|
|
shellRoute: { pathname: HOST_ROUTE },
|
|
shellAccepts: [paintName]
|
|
})
|
|
await opened.page.goto(`${origin}/`, { waitUntil: 'load' })
|
|
await opened.page.waitForFunction(
|
|
() => document.documentElement.dataset.orcaWebEntry === 'mounted',
|
|
{ timeout: 30_000, polling: 250 }
|
|
)
|
|
// Mounted, and nothing drawn: the body is the fallback's, which is what the old seam
|
|
// reported on.
|
|
expect(await opened.page.evaluate(() => document.body.innerText)).not.toContain(
|
|
SHELL_HOST.name
|
|
)
|
|
await opened.page.waitForTimeout(1_000)
|
|
expect(await paintReports(opened.page, paintName)).toBe(0)
|
|
|
|
arrive()
|
|
await waitForRoute(opened, HOST_ROUTE, SHELL_HOST.name)
|
|
await opened.page.waitForFunction(
|
|
(name) =>
|
|
(globalThis.__orcaRenderCheckNotifies ?? []).filter((frame) => frame.name === name)
|
|
.length > 0,
|
|
paintName,
|
|
{ timeout: 30_000, polling: 250 }
|
|
)
|
|
expect(opened.errors).toEqual([])
|
|
await opened.page.close()
|
|
} finally {
|
|
arrive()
|
|
heldChunks.delete(path)
|
|
}
|
|
}, 120_000)
|
|
|
|
it('says nothing for a redirect screen whose target is still behind its chunk', async () => {
|
|
// The `pr` route renders a `Redirect` into the source-control hub and nothing else. It commits,
|
|
// sends the document on, and stays mounted behind the target's fallback while that chunk loads.
|
|
const target = routeChunks['./h/[hostId]/source-control/[worktreeId].tsx']
|
|
expect(target, Object.keys(routeChunks).join(' ')).toBeTruthy()
|
|
const path = `/assets/${target}`
|
|
let arrive = () => {}
|
|
heldChunks.set(
|
|
path,
|
|
new Promise((resolve) => {
|
|
arrive = resolve
|
|
})
|
|
)
|
|
try {
|
|
const route = `${HOST_ROUTE}/pr/render-check-tree`
|
|
const opened = await openPage({
|
|
shellRoute: { pathname: route },
|
|
shellAccepts: [paintName],
|
|
shellPageRoutes: [HOST_ROUTE_PATTERN, '/h/[hostId]/pr/[worktreeId]']
|
|
})
|
|
await opened.page.goto(`${origin}/`, { waitUntil: 'load' })
|
|
await opened.page.waitForFunction(
|
|
() => location.pathname.includes('/source-control/'),
|
|
undefined,
|
|
{ timeout: 30_000, polling: 250 }
|
|
)
|
|
// The router has moved on and the hub is still arriving, so the document is showing nothing.
|
|
await opened.page.waitForTimeout(1_000)
|
|
expect(await paintReports(opened.page, paintName)).toBe(0)
|
|
|
|
arrive()
|
|
await opened.page.waitForFunction(
|
|
(name) =>
|
|
(globalThis.__orcaRenderCheckNotifies ?? []).filter((frame) => frame.name === name)
|
|
.length > 0,
|
|
paintName,
|
|
{ timeout: 30_000, polling: 250 }
|
|
)
|
|
await opened.page.close()
|
|
} finally {
|
|
arrive()
|
|
heldChunks.delete(path)
|
|
}
|
|
}, 120_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)
|
|
})
|