mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
* refactor(mobile): take the files screens' router from the handoff seam Inside the shell's page a screen is one document standing in for one screen, so a target the page does not render has to be handed back to the app that does. `useRouteHandoff` is where that decision lives, and its web sibling is the only thing that makes it; both files screens held expo-router's own `useRouter`, so on the web the explorer's Back and the preview's Back would post nothing and a target outside the page would paint Unmatched over the page it is on. Natively this is the same object — `route-handoff.ts` is `useRouter()` — so no behaviour moves here, and `back()` stays expo-router's until the navigate-back verb lands and the seam starts wrapping it. A census rather than a behaviour test: neither screen's own tests can see the difference, because a push that is never handed off still works for a target inside the page. It walks this directory, refuses a value import of expo-router, and names the two screens that must hold a router so a walk that found nothing fails instead of passing empty. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): let the shell stand in for the two files routes Both route files take the index.tsx shape — flag, MobileWebShellScreen, native screen as fallback — and both gain the `.web.tsx` sibling that shape forces. Inert until the manifest lists these routes: the shell answers `native-route` for a route the bundle does not name, which is what `fallback` renders, and the flag is `__DEV__`-only besides. Listing them waits on C2.3 and C2.5. The sibling is not a precaution. The manifest defers every route behind `import()`, so a native-only route module is invisible until the page opens that route; the render check now opens both and, without the siblings, painted `expo-modules-core.requireNativeViewManager is not available on web` instead of the screen. That is also why the two cases render the route rather than asserting a file exists. The file path never becomes a path segment: only `hostId` and `worktreeId` are spelled into the pathname, encoded, and everything else — `relativePath`, `absolutePath`, `cwd`, `pathText` — is a param, which is how a `/`, a space or a `..` stays out of the segment vocabulary the bridge holds a route to. The preview render case proves the round trip on `docs/my notes/readme.md`. `mobileFilePreviewShellParams` drops a param the normalizer left `undefined` rather than sending it empty, because the page reads these back through useLocalSearchParams where `line: ''` and no `line` are different screens. Its test drives the normalizer rather than a hand-written literal: the literal omits the key entirely, so it held with the filter removed. The preview case also records what React Native Web says out loud — BackHandler is inert on web, so Android back inside the page skips the unsaved-draft prompt. Named in the assertion rather than filtered out, so closing it is a change to that line. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): ask about an unsaved draft in the screen, not through Alert React Native Web's `Alert` is `static alert() {}`. Inside the shell's page that made Back with an unsaved terminal-artifact draft a button that did nothing at all: no prompt, because the dialog is a no-op, and no navigation either, because the code took the branch that shows one. Silently, with nothing on the console. The prompt is now a row under the header. Not `ConfirmModal`, which every other confirm here uses: that is a `BottomDrawer`, and C1.9 has Reanimated's animated styles never reaching the DOM node on WKWebView, so on iOS in the page the drawer parks off-screen and Back would be dead a second way. This paints the same on every platform with no animation behind it. Hardware back is registered natively only. React Native Web's `BackHandler.addEventListener` logs "BackHandler is not supported on web and should not be used." and hands back an inert subscription, so the guard never armed there regardless; the render check asserted that console error on main and now asserts none. The degradation is real and stated rather than hidden: Android back inside the page pops the native stack without asking, and the page's own Back control is where the question lives. The decision moved to a hook so it is testable without a screen: the prompt also drops itself when the draft it was about is saved or reverted, which is a state `Alert` had no way to be in. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep expo-haptics' DOM shim out of the page expo-haptics has a web build, and with no `navigator.vibrate` — iOS Safari, which is the WebView the page runs in — it fakes a haptic by appending a hidden `<label><input type="checkbox" switch>` to `document.head`, clicking it, and removing it, once per call. C1.9 traced a long press that never fired on the worktree list to exactly that stray click, and the file explorer calls `triggerSelection` on every row tap, so C3 is the first domain to fire it per tap rather than per long press. `haptics.web.ts` answers the same five names with nothing. A phone holding the page is a phone whose native app is right there with the real haptics, and a missing tap feedback is worth less than a tap that does not register. The test reads the shipped bytes rather than the import, because that is the claim: with the override removed the bundle carries `ariaHidden` and `pointer: coarse`; with it, neither, nor the `setAttribute("switch"` that does the clicking. Not `navigator.vibrate` — react-native-web's own Vibration export calls that and touches no DOM until something invokes it, which cost this test one wrong red before it was narrowed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep the files routes native when the page could not be given one A file path is a param, so `/`, spaces and `..` all cross safely — but `BRIDGE_MAX_ROUTE_PARAM_CHARS` is 1024 and a Windows long path is not bounded by anything the user cannot exceed. The symptom is not the blank document the design predicted, and the correction matters: `bridge-host.ts` already parses the route against the page's own schema and drops it to `null` when it fails, so `init` arrives naming no screen and the page paints "Update Orca to open this workspace" — a wrong message about a fine app, over a native screen that works. Deciding before the switch instead leaves the route native, which is where every route starts. The schema is the predicate rather than a copy of its bounds, so the rule cannot drift from the half that matters, which is the half the page reads. The same call also refuses a `worktreeId` the segment rule will not route: `..` survives `encodeURIComponent`, which is the C1.8 class. The tests assert the schema really refuses each input before asserting the guard does, so neither case can pass by being impossible. This belongs in the shell beside the schema; it is in the files domain while the contract files are the C2 lane's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin what keeps a file path out of the route vocabulary Seven shapes, one case each rather than a representative: a plain path, a space, a dot segment, an already-encoded slash, a fragment, non-ASCII, and an absolute path. Each is checked in the two directions a path travels — the href the shell writes into the page's history, and the href the page would hand back — for both the pattern accepting it and the path coming back out of the query unchanged. The counterfactual is in the file: the same paths spelled as a segment are refused. Without that, the cases above would hold for a rule that was never doing any work. Mutating `stringifyRouteHref` to join its query by hand instead of through `URLSearchParams` fails three of them. Also fixes two new test files the tests-typecheck ratchet caught: the partial `react-native` mock needs a typed `addEventListener`, `act` will not take a callback that returns a value, and `findAllByType('Pressable')` does not typecheck against `ElementType` — the neighbouring files that do it are grandfathered, so the tag comparison goes through a helper instead. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): derive the discard prompt instead of clearing it in an effect Both changed-code gate findings, which the lane had not run until the last commit. React Doctor is right: the effect that cleared the prompt when the draft went away adjusted state after a prop changed, so a save landing while the prompt was up painted one frame still offering to discard nothing. The prompt is now `asking && hasUnsavedDraft`, which cannot be stale by construction, and the test that covers it passes unchanged. The hoisted mock's `as` on a string literal is gone too: the literal narrows on its own and the tests reassign it, so the holder is annotated instead. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): add the files routes to the hybrid shell flag census The census pins every file that reads `useMobileWebShellEnabled`, because a reader nobody listed is how a dark feature stops being dark. C3's two routes are deliberate entries: each has a native screen behind it as `fallback`, and each is inert until the manifest lists the route. Found by the full mobile suite rather than by the files subset this lane had been running per commit. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): serve the files explorer and preview from the page The last C3 commit: both routes join MOBILE_WEB_PAGE_ROUTES, and the shell starts rendering the page for them on a phone with the dev flag on. Grants are not the same for the two, and the difference is the point. Both take `navigate` (Back pops the native stack, and the explorer's rows open the preview beside it) and `storage` (the shared components the host layout renders above them). Only the preview takes `externalLink`: a Markdown preview renders links and `MobileMarkdown` opens them through the platform seam. The explorer does not, and measuring is what says so rather than reading. Every page route reaches `external-link.web.ts` — `/h/[hostId]` and agent-history included, both granted nothing for it — because the protocol wall in the shared host layout imports it. So closure membership is not the oracle for a grant; the question is whether the route's own screens call it, and only the preview's do. `MobileMarkdown` is in the preview closure and absent from the explorer's, which the census now asserts in both directions. Neither route writes a clipboard, so neither takes `native.clipboard.write`; the census pins that as the absence of both `ExpoClipboard.web.js` and the clipboard seam, with the tasks closure as the control that the probe can see one when there is one. The seam predicate moved into a module both censuses import rather than being restated per series: two spellings of one rule drift, and this one is a regex. Red-first: both manifest assertions failed on the new entries before they were updated, and routing `MobileMarkdown` around the seam fails the preview's census while leaving the explorer's passing, which is the asymmetry the grants encode. Closure sizes as the page ships them, extensionless so the `.web.tsx` is what is measured: explorer 3439 modules / 302 local / 10 under src/files, preview 3667 / 331 / 20. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): read the files route's ids as one value and key the shell on them Two round-1 findings, both reproduced before the fix. A repeated query key reaches `useLocalSearchParams` as an array, and the explorer read `hostId` and `worktreeId` bare. `String(['a','b'])` is `a,b`, so the template built `/h/host-a%2Chost-b/files/wt-1%2Cwt-2` — a single segment the bridge's rule accepts, and the shell would open a page for a host nobody has. Read through `firstParam` now, as the tasks and agent-history switches do. The preview already went through `singleParam` and is unchanged. Neither switch keyed `MobileWebShellScreen`, where `index.tsx`, `tasks.tsx` and agent-history all do. A host captures the grants its session opened with, so a screen reused across a route change keeps authorising frames under the grants of the route the page has left; only a remount drops that bridge. Both are keyed on the route pathname now, with agent-history's reason. The new route test is the agent-history one's shape. It caught both: the array case landed on no route at all, because `name` was an array too and the schema refuses a non-string param value, and the two lifecycle cases saw a prop update where a remount was owed. It also needs agent-history's `lucide-react-native` mock, since `firstParam` lives in the source-control barrel. `name` is now omitted when empty rather than sent as `name=`, matching the two switches beside it: an absent label lets the panel derive its own. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): confirm a discarded draft with the app's own modal Round-1 findings 3, 4, 5 and the minor one. **ConfirmModal, not the bespoke row.** The row existed because C1.9 had Reanimated's animated styles never reaching the DOM node on WKWebView, which left every BottomDrawer parked off-screen. C1.10 (`b7c06900e2`, an ancestor of this branch) fixed that with a dependency array on the mapper hooks, and the drawer render check now holds it on WebKit as well as Chromium. With the reason gone the row does not stand on its other merits: `Alert.alert` was modal on native before the page existed, and the row quietly changed that for phones too, so the app's own confirm is both the idiom and the closer behaviour. `MobileFilePreviewDiscardPrompt`, its test and its thirty style keys are gone; the hook's state machine and its tests are unchanged. **The encoding test claimed more than it pinned.** Hand-joining the query reds only three of the seven shapes; `docs/readme.md`, `../etc/passwd`, `docs/日本語.md` and `/logs/run.txt` are encoding-neutral in the query, whose pattern half is `[^#\s]*` and admits a slash, a dot segment and non-ASCII verbatim. Rather than narrow the claim in a comment, the split is now pinned by behaviour: each neutral shape must survive the query unencoded, each load-bearing one must not. Moving `docs/readme.md` between the lists fails it. **The manifest comment named one shared-layout opener and there are two.** The New Workspace source field, which the sidebar renders on a wide layout, opens a URL through the seam as well. Both are the shared layout's and every `/h` route reaches both, `/h/[hostId]` included with no `externalLink`, so the tablet tap is dead on all of them — recorded here as pre-existing rather than fixed, since the grants do not move. **Minor:** the dot-segment case in the guard test now asserts the schema refuses the route before asserting the guard returns null, as the length case does. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): stop every page drawer logging a BackHandler error when it opens Round-2 findings. **The registration belongs to the drawer, and that is where the guard went.** `mounted-bottom-drawer.tsx` armed `hardwareBackPress` whenever a drawer was visible and interactive, with no platform check, so the hook's claim to have dropped that console line held only while its prompt was closed — and every page drawer since C1 has logged it on open. Platform-gated at the drawer now; the hook's comment says so rather than claiming the credit. **Nothing had ever opened a modal in a browser.** The render check next door mounts both files routes and reads what they paint but taps nothing, so `ConfirmModal` inside the page — a BottomDrawer, so Reanimated, a portal and a gesture handler — was unproved. A new render file loads an editable terminal artifact through the harness's scripted reply, edits it, taps the page's Back, and asserts the prompt's title is up and no BackHandler line is on the console. Red first on exactly that line; the prompt itself painted, which is also the first proof on a browser that C1.10's fix carries a real drawer in the page. A second case answers Stay and checks the draft survives. Its own file rather than the render check's, which is at 482 of the 600-line cap; registered in pr.yml. **The encoding rule was stated wrong.** Two rules decide it and neither is about paths: the pattern's query half refuses whitespace and `#`, and `URLSearchParams` is form-urlencoded, so it reinterprets `&`, `+` and a valid `%XX`. `a+b.ts` reads back `a b.ts` and `a&b.ts` reads back `a`, so both are load-bearing; `a=b.ts` and `a%b.ts` are not, because only the first `=` splits the pair and a lone `%` begins no escape. A newline joins the load-bearing list as the refused shape rather than the altered one. **The web sibling read its params bare** where the native one uses `firstParam`. Not reachable — the page only arrives through `init.route`, whose params are already `Record<string, string>` — but the two files are meant to be one screen. The preview keys on the pathname alone, and the comment now says why that is enough: every caller in this tree pushes. Closures after this: explorer 3441 / 304 / 10, preview 3666 / 330 / 19. The explorer grew two modules because its web sibling now reaches `firstParam`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): give the explorer the grants the preview needs, and key on the route Bot findings, one of them a real gap. **Pullfrog is right, and my grant oracle was half a rule.** Grants resolve once, from the route the shell opened: `grantsForRoute` reads `session.routePathname` and `init.grants.native` carries the answer for that session. The explorer's rows push to the preview, and because the preview is a page route that push stays inside the same document — no second `init`. So a preview opened that way runs under the explorer's grants, and a Markdown link in it was refused by `notifyExternalLink` with nothing on screen to say why. "Does the route's own screen call it" was right for a route's own screens and wrong for the routes it reaches in-page, so the explorer now declares `externalLink` as a transitive grant, with the comment saying that rather than claiming it opens links. The census pins the pair as a superset; removing the grant reds it. **The seam regexes matched one quote style.** A double-quoted `react-native` specifier walked past both censuses unseen. Both styles now, with the predicate tested directly for the first time. **The discard request outlived its draft.** `asking` stayed set after a save or a revert, so the next edit re-showed the prompt with no Back request behind it. The request is now dropped when the draft it was about goes, adjusted during render rather than in an effect — the shape React Doctor named in the round-1 fold. Red first: save with the prompt up, edit again, prompt is back. **CodeRabbit's keying comment is a correctness point, not the question I answered.** The page learns its route exactly once, out of `init`, so a same-path param change — another file in the same worktree — left the shell mounted and the page still showing the file it was opened on. My comment claimed "the screen reloads the preview from the param either way", which is true only with the shell absent. Both switches key on the whole route now, params included; two tests cover the same-path case and both red on a pathname-only key. `build-mobile-web-app-bundle.test.mjs` hit 601 of its 600-line cap on the way, so the two manifest assertions now share one expected list instead of repeating it. Closures unchanged: explorer 3441 / 304 / 10, preview 3666 / 330 / 19. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the seam test import the module it is testing Round 3. **The blocker is mine and the reviewer's diagnosis is exact.** The seam predicate test imported an absolute path into this lane's worktree. On CI that module does not exist and it takes the whole `config/scripts` suite down; here it resolved to the same file by accident, so the test was green against a tree rather than against the checkout — which is why reverting the double-quote fix left it passing and the predicate untested. Relative now, and proved: reverting the fix in place reds both double-quoted cases, which is the first time this test has failed for the right reason. Every file this PR touches is grepped for `/Users/` and `orca-lanes`; none carries a path. **Three comments outlived the grant change.** The two lists became equal when the explorer took `externalLink`, so "longer than the explorer's" and "declared with different grants" were both false. Corrected to what is actually true: the lists are equal and the reasons are not — the preview has its own consumer in `MobileMarkdown`, the explorer has none and declares the grant because its rows push to the preview in-page. **The duplicated serializer is pinned rather than imported.** `shellRouteHref` lives in `page-bootstrap.ts` beside the page's RPC client and its document channel, so a native route file importing it would pull both into the app. The copy stays, and a test asserts the two agree on three routes; dropping the empty-search branch reds it. **Recorded, not fixed:** the sidebar `HostScreen` pushes to `/h/<id>/tasks` through the handoff, which is local, so on a tablet the tasks page runs without `native.clipboard.write` from any page route and its copy actions refuse silently. Pre-existing since C2.1 for the worktree list and agent history. Named in the explorer's manifest comment as the known remaining hop, with the fix being a handoff rule in its own PR. The equality pin needed `it.each<BridgeInitRoute>`: the inferred table is a union whose members carry `?: undefined`, which the ratchet caught. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
677 lines
33 KiB
JavaScript
677 lines
33 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: for images and for nothing else', () => {
|
|
expect(cspHeader.split('; ').filter((entry) => entry.includes('data:'))).toEqual([
|
|
"img-src 'self' data:"
|
|
])
|
|
})
|
|
})
|
|
|
|
/** 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 is also the proof the param
|
|
// survived the round trip through `URLSearchParams` that `/` and the space go 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('renders the unmatched route rather than crashing on a path with no module', async () => {
|
|
const { errors, cspErrors, text } = await render(`${HOST_ROUTE}/not-a-route`, UNMATCHED)
|
|
expect(cspErrors).toEqual([])
|
|
expect(errors).toEqual([])
|
|
// Asserted positively so the two negatives above are known to discriminate.
|
|
expect(text).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)
|
|
})
|