feat(mobile): serve the tasks screen from the page, with its seams (OTA phase C, C2.1 + C2.5) (#21694)

* fix(mobile): encode the host id in the tasks workspace-creation href (OTA phase C, C2.1)

`use-mobile-tasks-workspace-create-actions.tsx` built
`/h/${hostId}/session/...` with the host id interpolated raw — the C1.2 class.
A host id carrying `/`, `#`, `?` or whitespace reaches the wire as an href
`BRIDGE_ROUTE_HREF_PATTERN` refuses, the handoff falls through to the local
router, and expo-router's Unmatched paints over the page.

Deleted rather than patched: `hostNewWorktreeSessionRoute` already builds
this exact href with both segments encoded, and already has the test that
pins it. The screen now calls it.

The census that caught it stays: no module under `src/tasks` may interpolate
into `/h/${...}` without encoding, which is the rule rather than this one
line. Three refactor-parity hashes move with the statement change and are
recorded in that file the way every earlier movement is.

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

* feat(mobile): route the tasks tree's external links through the seam (OTA phase C, C2.1)

Ten of the twelve call sites in the tasks page closure: the nine under
`src/tasks`, swapped by one export in the dependency barrel, and
`MobileMarkdown.tsx`, which imports react-native directly and is edited in
place.

Inside the shell's WebView react-native-web's `openURL` calls
`window.open(url, '_blank')`, which both shells refuse — iOS returns nil from
`createWebViewWith`, Android false from `onCreateWindow` — and resolves
regardless. Every one of these sites would have reported success into a tap
that opened nothing.

The barrel's `Linking` is typed `{ openURL: (url: string) => void }`, so a
`.catch` on it is a compile error rather than a handler for a rejection that
cannot arrive; the seam names its own failures. `MobileMarkdown`'s own
`.catch(() => {})` goes with the swap for the same reason.

No parity hash moved: the barrel and `MobileMarkdown` are outside the
refactor-parity family's source set.

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

* feat(mobile): route the shared screens' external links through the seam, with a census (OTA phase C, C2.1)

The last two of the twelve call sites in the tasks page closure:
`ProtocolBlockScreen.tsx` and the `openExternalUrl` prop wiring at
`host-screen-overlays.tsx`.

Both are shared with native routes and with the already-live `/h/[hostId]`
page, so this changes that page too: its external links go from the measured
`window.open` no-op — which both shells refuse and which resolves anyway — to
a URL handed to the shell. Nothing changes on a phone, where the seam is
`Linking.openURL` unchanged.

The `openExternalUrl` prop chain is retyped `(url: string) => void` with it,
and `SmartWorkspaceSourceField`'s `.catch(() => {})` goes: the seam names its
own failures and never rejects, so that was a handler for a rejection that
cannot arrive.

The census is the rule rather than today's twelve sites: no module in the
tasks page closure may reach react-native's `Linking`, by name or through a
namespace import. It reads the closure from a new builder export —
`metafile.inputs` for `_layout` plus the route, which is one definition of
what a page contains — and checks which module the name comes from, not which
text a call site writes, since the tasks tree still calls `Linking.openURL`
and that `Linking` is now the barrel's seam-backed export. Confirmed to
discriminate: restoring one react-native import turns it red.

A second case pins that the seam is in the closure, so an empty offender list
cannot also mean a page that reaches no link code at all.

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

* feat(mobile): write the tasks clipboard through the shell's verb (OTA phase C, C2.1)

The two `Clipboard.setStringAsync` sites in the tasks page closure move onto
a seam, `src/platform/clipboard.ts` with a `.web.ts` sibling, registered in
the overrides.

A hook rather than a function because the web form needs the page's bridge
client, which is React context. Native is `expo-clipboard` unchanged. Web
calls `native.clipboard.write` through `useNativeVerbs`, because
`expo-clipboard` on the web is `navigator.clipboard` and needs a secure
context: the iOS shell serves the page from a custom scheme and Android from
`https`, so that path would work on one platform and silently not on the
other, with nothing at the call site able to tell.

Both seams reject rather than return false, and both call sites already wrap
the write in a `catch` that puts the message on screen — so a write that did
not land says so instead of showing "Copied". A route that has not declared
`native.clipboard.write` is refused before a frame is sent and lands in that
same `catch`; the route declares it in the entry commit.

Two parity hashes move, the hook list and the statement hash, each by one
entry, and are recorded in that file. `semantics` holds, as do render and
style: no RPC call, method literal or JSX host signature changed.

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

* feat(mobile): hand the tasks Back button to the shell (OTA phase C, C2.1)

The tasks header's `router.back()` reached expo-router through the dependency
barrel, and inside the page that moves nothing: the document holds the single
history entry the entry wrote with `replaceState`. The stack with somewhere
to go is the native one the shell pushed the page onto.

One line in the barrel, as with `Linking`: `useRouteHandoff` is router-shaped,
so every call site is unchanged. On a phone it is expo-router. Inside the page
it keeps a route the page renders and posts `navigate-back` for a Back the
document cannot serve — the C2.2 seam, which until now had no consumer.

No parity hash moved: the barrel is outside the refactor-parity source set,
and no call site changed.

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

* feat(mobile): render mermaid as its own source box on the web (OTA phase C, C2.5)

`MermaidDiagram` is in the tasks page closure, reached through
`MobileMarkdown`, and it renders the diagram inside a sandboxed `WebView`.
`react-native-webview` is a native component with no browser counterpart:
importing it runs a codegen lookup that throws, and the route manifest imports
every route, so one such import takes the whole page down rather than one
diagram.

The web sibling renders the labelled source box the native component already
falls back to on a parse or render error, with that component's own styles, so
the degradation looks like a state the product already has rather than a
second design.

Not a browser renderer, and the reason is not reach: mermaid is a browser
library and the engine bundle is vendored. It is that the native path's safety
comes from the WebView it runs in — `buildHtml` escapes `</script>` and the
U+2028/U+2029 separators because diagram source is untrusted agent and PR
content — and a DOM path has no such sandbox, so it needs its own escaping and
its own proof. That is a change of its own, not a smaller version of this one.

Registered in the overrides, whose gate fails on an unlisted `.web.*` file.

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

* feat(mobile): turn the tasks route on for the page (OTA phase C, C2.1)

The entry: `/h/[hostId]/tasks` joins `MOBILE_WEB_PAGE_ROUTES`, the route file
becomes the shell's flag switch in `index.tsx`'s shape, and a `.web.tsx`
sibling renders the screen directly, registered in the overrides.

The screen moves to `src/tasks/MobileTasksScreen.tsx` first, verbatim — body
byte-identical, imports rewritten to `./`. It has to: under the builder's
`resolveExtensions` a web sibling importing `./tasks` resolves back to
itself, which is why every other shell route's screen already lives in `src`.

The parity family follows the file rather than the path. `TASKS_ROUTE` leaves
`MOBILE_TASKS_SOURCE_FILES` — `SOURCE_PATTERN` already matches
`MobileTasks*.tsx`, so listing it too would double-count — and the execution
reader points at the new file. Measured rather than predicted: all six
refactor-parity cases pass unchanged. No hash moved, including the family
text and declaration list, because the new name sorts where the route path
sat.

The route declares `navigate`, `storage`, `externalLink` and
`native.clipboard.write`, which the grammar fold made expressible and
per-route scoping makes meaningful: it is granted those and not the rest of
what this shell implements.

The browser check covers what only a browser answers — every module in the
closure evaluating under React Native Web, `taskSource` surviving the
handshake into the page's own URL, and the route's chunk arriving on a
client-side navigation. It states plainly what it does not cover: the three
seams are reached from controls that need provider data the double does not
serve, so a case posting those frames directly would prove the transport and
read as a tap it never performed. Both new checks join the `mobile_web_app`
job.

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

* fix(config): resolve a route closure the way the bundle ships it (OTA phase C, C2.1)

`mobileWebAppRouteClosure` took the route's explicit `.tsx` path as an entry
point, so esbuild used that file directly and `resolveExtensions` never ran.
For a route with a `.web.tsx` sibling that measured the native switch, which
no browser loads: the tasks closure came back carrying
`MobileWebShellScreen`, and with it a `Linking` import the census then
reported as an offender.

Extensionless now, so the closure is the one the page actually contains:
3775 modules, 428 local, with `external-link.web.ts` and `clipboard.web.ts`
in it and the shell screen out.

The route-manifest pins move with the tasks route joining
`MOBILE_WEB_PAGE_ROUTES`, in both the declaration check and the built
manifest.

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

* fix(mobile): cover the clipboard seam, close two page escapes, share the mermaid props (OTA phase C, C2.1)

Four from round 1.

The clipboard seam shipped untested. Both halves have one now: the native
form rejects when `setStringAsync` answers false and resolves when it does
not, and the web form is driven through the real port pair — resolving on a
reply, rejecting when the shell says the pasteboard refused, and rejecting on
an ungranted route without putting a frame on the wire.

The tasks barrel still re-exported `expo-clipboard` with no consumer, which
kept `ExpoClipboard.web.js` — the `navigator.clipboard` path this series
exists to avoid — inside the page closure. Deleted, and asserted as the
module's absence from that closure rather than as a count of importers: a new
import puts the file back whoever writes it.

`ProtocolBlockScreen` reached expo-router's singleton for its way out to the
host list. A singleton is the one shape the handoff cannot intercept — it is
not a hook, so the page's bridge client is never consulted — and `/` is a
route the page does not carry, so inside the shell that replace rendered the
root route in the WebView instead of leaving it. Pre-existing and live via
`/h/[hostId]`; routed through the handoff now. Two suites' `expo-router`
mocks gain the hook the handoff reads.

`MermaidDiagram.web.tsx` redeclared its props; it imports the native
component's type, so drift fails tsc.

No parity hash moved: none of these files is in the refactor-parity source
set.

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

* style(config): use endsWith for the clipboard module check

The changed-code gate refuses a dollar-anchored regex where `String#endsWith`
says the same thing. No behaviour change.

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

* fix(mobile): close the href census gap, read route params through firstParam (OTA phase C, C2.1)

Five from round 2, two of them real.

The raw-interpolation census inspected only the leading `${...}`, so
`` `/h/${encodeURIComponent(hostId)}/session/${worktreeId}` `` passed it — and
a worktree id carrying `/`, `#`, `?` or whitespace breaks the href exactly as
a host id does. It now refuses any hand-built `/h/...` template with any
interpolation left raw, whichever segment it is. Proved against exactly that
shape in a throwaway before the change, which the old rule admitted.

The tasks switch read `hostId` and `taskSource` as plain strings. expo-router
hands back an array for a repeated query key, so a duplicate `?hostId=` built
`/h/host-a%2Chost-b/tasks`; both go through `firstParam` now, as the
agent-history switch does. `index.tsx` is untouched, per the Phase D list.

Three in the render check's prose: the header claimed the browser proves the
three seams fire from a tap, which the file's own closing note denies; a
module count repeated a number the closure test already pins; and a `replies`
parameter was threaded through without ever being supplied.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo Hong
2026-09-19 13:40:26 -04:00
committed by GitHub
parent b0cbb919ba
commit b6e8b1a7b2
35 changed files with 1076 additions and 143 deletions
@@ -338,6 +338,44 @@ export function routeChunkNames(metafile, routes, renamed) {
const isScriptOutput = (path) => path.endsWith('.js')
// appDir is a seam for the tests, which bundle a scratch route tree; production always uses mobile/app.
/**
* Every source module one page route reaches, as the builder itself resolves them.
*
* One definition of "what a page contains", read from `metafile.inputs` — the modules the route
* pulls in — rather than from `entryStaticClosure`, which walks emitted chunks and answers what a
* browser must download. Both entry points are needed: `app/h/_layout.tsx` wraps every route under
* it, and its imports are part of the page as surely as the route module's.
*
* `splitting: false` and a per-name output are required for a two-entry build; with the defaults
* esbuild fails on two outputs claiming `dist/entry.js`.
*
* Note for anyone comparing this with a parity pin: `c1-page-closure.ts`, and the closures C2.6,
* C5.2 and C3.2 generate, derive theirs by the C1.6 method inside the mobile suite. The two are
* not the same computation, and a divergence between them is a finding rather than noise.
*/
export async function mobileWebAppRouteClosure(routeModule) {
const base = mobileWebAppBuildOptions(MOBILE_WEB_PAGE_ROUTES)
const result = await esbuild.build({
...base,
// Extensionless, so `resolveExtensions` picks the same file the bundle ships: a route with a
// `.web.tsx` sibling resolves to that one, and naming the `.tsx` path explicitly would measure
// the native switch no browser ever loads.
entryPoints: ['app/h/_layout', routeModule.replace(/\.tsx?$/, '')],
splitting: false,
entryNames: '[name]',
plugins: base.plugins.filter((plugin) => plugin.name !== ROUTE_MANIFEST_PLUGIN_NAME),
write: false,
metafile: true,
logLevel: 'silent'
})
const inputs = Object.keys(result.metafile.inputs)
return {
modules: inputs,
/** Everything outside `node_modules`: this repository's own source, which a census reads. */
local: inputs.filter((input) => !input.includes('node_modules'))
}
}
export async function bundleMobileWebApp({ appDir = defaultAppDir } = {}) {
const routes = await collectMobileWebAppRoutes(appDir)
await assertRoutesCarryNoSynchronousExports(routes)
@@ -90,7 +90,11 @@ describe('the page routes the manifest declares', () => {
const keys = await collectMobileWebAppRouteKeys(appDir)
expect(resolveMobileWebPageRoutes(keys)).toEqual([
{ pathname: '/h/[hostId]', grants: ['navigate', 'storage'] },
{ pathname: '/h/[hostId]/agent-history/[worktreeId]', grants: ['navigate', 'storage'] }
{ pathname: '/h/[hostId]/agent-history/[worktreeId]', grants: ['navigate', 'storage'] },
{
pathname: '/h/[hostId]/tasks',
grants: ['navigate', 'storage', 'externalLink', 'native.clipboard.write']
}
])
})
@@ -112,7 +116,11 @@ describe('the page routes the manifest declares', () => {
const { manifest } = await buildMobileWebAppBundle({ outDir: join(scratch, 'bundle') })
expect(manifest.routes).toEqual([
{ pathname: '/h/[hostId]', grants: ['navigate', 'storage'] },
{ pathname: '/h/[hostId]/agent-history/[worktreeId]', grants: ['navigate', 'storage'] }
{ pathname: '/h/[hostId]/agent-history/[worktreeId]', grants: ['navigate', 'storage'] },
{
pathname: '/h/[hostId]/tasks',
grants: ['navigate', 'storage', 'externalLink', 'native.clipboard.write']
}
])
// The routes are derived from the same tree the script is built from, so the assets
// already decide them and the id has no reason to carry them as well.
@@ -0,0 +1,97 @@
/**
* Nothing the tasks page reaches opens a URL through react-native, or a clipboard through the
* browser's.
*
* Inside the shell's WebView react-native-web's `Linking.openURL` calls
* `window.open(url, '_blank')`, which both shells refuse — iOS returns nil from
* `createWebViewWith`, Android false from `onCreateWindow` — and resolves whether or not anything
* opened. So a call site left on that path reports success into a tap that did nothing, which is
* the one failure the `externalLink` grant exists to remove.
*
* The rule, not the twelve call sites it happens to have today: a module entering this closure
* later is held to it without anyone remembering to add it here.
*/
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { mobileWebAppRouteClosure } from './build-mobile-web-app-bundle.mjs'
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
const mobileDir = fileURLToPath(new URL('../../mobile/', import.meta.url))
const describeClosure = mobileWebAppDependenciesPresent() ? describe : describe.skip
/** The seam, as the web build resolves it: `.web.ts` wins under the builder's resolveExtensions,
* and it is the one module in this closure allowed to reach react-native's `Linking`. */
const SEAM = 'src/platform/external-link.web.ts'
/** Whether a module reaches react-native's own `Linking`, by name or through a namespace import. */
function reachesReactNativeLinking(source) {
const named = /import\s*\{[^}]*\bLinking\b[^}]*\}\s*from\s*'react-native'/s
const namespace = /import\s*\*\s*as\s*(\w+)\s*from\s*'react-native'/
const asNamespace = namespace.exec(source)
return (
named.test(source) || (asNamespace !== null && source.includes(`${asNamespace[1]}.Linking`))
)
}
describeClosure(
'the tasks page closure',
() => {
it('opens every external URL through the platform seam', async () => {
const closure = await mobileWebAppRouteClosure('app/h/[hostId]/tasks.tsx')
const offenders = closure.local
.filter((file) => file !== SEAM)
.filter((file) => {
try {
// Which module the name comes from, not which text a call site writes: the tasks tree
// still calls `Linking.openURL`, and that `Linking` is the barrel's seam-backed export.
return reachesReactNativeLinking(readFileSync(join(mobileDir, file), 'utf8'))
} catch {
return false
}
})
expect(offenders.sort()).toEqual([])
})
it('contains the seam, so the rule above is not vacuous', async () => {
// Without this an empty offender list would also be what a closure that reaches no link code
// at all produces, and the census would pass against a page that opens nothing.
const closure = await mobileWebAppRouteClosure('app/h/[hostId]/tasks.tsx')
expect(closure.local).toContain(SEAM)
expect(closure.local.length).toBeGreaterThan(400)
})
},
180_000
)
/**
* Nothing the tasks page reaches writes the clipboard through the browser's own.
*
* `expo-clipboard` resolves to `ExpoClipboard.web.js`, which is `navigator.clipboard`: it needs a
* secure context, and the iOS shell serves the page from a custom scheme while Android serves
* `https`, so that path works on one platform and silently not on the other. The verb exists so
* neither has to be guessed at a call site.
*
* Asserted as the module's absence from the closure rather than as a count of importers: a new
* import anywhere in the tree puts the file back, whoever writes it and whatever they name it.
*/
describeClosure(
'the clipboard the tasks page reaches',
() => {
it("does not carry expo-clipboard's web module at all", async () => {
const closure = await mobileWebAppRouteClosure('app/h/[hostId]/tasks.tsx')
const browserClipboard = closure.modules.filter((file) =>
file.endsWith('ExpoClipboard.web.js')
)
expect(browserClipboard).toEqual([])
})
it('carries the seam that replaced it, so the absence above is not vacuous', async () => {
// An empty list is also what a closure reaching no clipboard code at all would produce.
const closure = await mobileWebAppRouteClosure('app/h/[hostId]/tasks.tsx')
expect(closure.local).toContain('src/platform/clipboard.web.ts')
})
},
180_000
)
@@ -0,0 +1,247 @@
import { mkdtemp, 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,
readBridgeFaultGrant,
readBridgeProtocolVersion,
readShellCsp
} from './mobile-web-app-render-harness.mjs'
/**
* The tasks page route in a real browser: its own file, as C1.10 split the harness for.
*
* What only a browser answers for this route: that every module in its closure evaluates under
* React Native Web, that the provider param the shell names reaches the screen, and that its chunk
* arrives over the wire.
*
* It does not cover the three seams this series added. The closing note below says why, and where
* each is proved instead.
*/
const HOST_ROUTE = '/h/render-check-host'
const TASKS_ROUTE = `${HOST_ROUTE}/tasks`
/** The patterns `init.pageRoutes` names, which is what the page matches a navigation against. */
const PAGE_ROUTE_PATTERNS = ['/h/[hostId]', '/h/[hostId]/tasks']
const SHELL_SESSION_ID = 'render-check-session'
const SHELL_BUILD_ID = 'render-check-build'
const SHELL_HOST = {
id: 'render-check-host',
name: 'Render Check Host',
endpoint: 'ws://render-check',
lastConnected: 1
}
const UNMATCHED = 'Unmatched Route'
const ROUTE_KEY = './h/[hostId]/tasks.tsx'
/** Exactly what the route declares in `MOBILE_WEB_PAGE_ROUTES`, plus the protocol's own grant. */
const TASKS_GRANTS = ['navigate', 'storage', 'externalLink', 'native.clipboard.write']
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
beforeAll(async () => {
if (!bundles) {
return
}
cspHeader = await readShellCsp()
bridgeVersion = await readBridgeProtocolVersion()
faultGrant = await readBridgeFaultGrant()
scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-app-tasks-'))
const built = await buildMobileWebAppBundle({ outDir: join(scratch, 'bundle') })
routeChunks = built.routeChunks
const served = await createBundleServer({ outDir: built.outDir, cspHeader })
server = served.server
origin = served.origin
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 })
}
})
/** A page carrying every signal these cases read: uncaught errors, console errors, script paths. */
async function openPage({ shellRoute, shellGrants, shellPageRoutes = null } = {}) {
const page = await browser.newPage({ viewport: { width: 390, height: 844 } })
// 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: SHELL_HOST,
storage: {},
faultGrant,
// The harness falls back to the fault grant alone, which is the ungranted page.
grants: shellGrants ?? [faultGrant],
pageRoutes: shellPageRoutes
})
const errors = []
const scripts = []
let reportUncaught = () => {}
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:
* every screen is deferred behind `import()`, so `mounted` lands while the chunk is still arriving.
*/
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
])
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)}`)
}
for (const fault of await page.evaluate(() => globalThis.__orcaRenderCheckFaults ?? [])) {
errors.push(`page fault: ${fault}`)
}
}
/** Opens the document at `/`, the one path the shell serves, and lets the page route itself. */
async function openRoute(route, awaitText, options = {}) {
const opened = await openPage({ shellRoute: { pathname: route }, ...options })
await opened.page.goto(`${origin}/`, { waitUntil: 'load' })
await waitForRoute(opened, route, awaitText)
return opened
}
describeRender('the tasks route in a real browser', () => {
/**
* Every module in this route's closure imports and evaluates under React Native Web. How many
* that is, and which, is pinned by `mobile-web-app-tasks-external-links.test.mjs`; repeating a
* count here would be a second number to keep in step with the first.
*
* The unit tests cannot say this: they mock react-native, safe-area, svg, lucide and the icon
* assets away, because react-native is Flow source vitest will not parse. Import-time breakage
* in any of those modules has no other test.
*/
it('mounts the tasks screen rather than the unmatched route', async () => {
const opened = await openRoute(TASKS_ROUTE, 'Tasks', {
shellGrants: [faultGrant, ...TASKS_GRANTS],
shellPageRoutes: PAGE_ROUTE_PATTERNS
})
const text = await opened.page.evaluate(() => document.body.innerText)
expect(text).toContain('Tasks')
expect(text).not.toContain(UNMATCHED)
expect(opened.errors).toEqual([])
await opened.page.close()
}, 60_000)
it('carries the provider the shell named into the url the screen reads', async () => {
// `taskSource` is the one page route with a query param, and it crosses in
// `init.route.params`. Without this the param is only assumed to survive the handshake.
const opened = await openPage({
shellRoute: { pathname: TASKS_ROUTE, params: { taskSource: 'linear' } },
shellGrants: [faultGrant, ...TASKS_GRANTS],
shellPageRoutes: PAGE_ROUTE_PATTERNS
})
await opened.page.goto(`${origin}/`, { waitUntil: 'load' })
await waitForRoute(opened, TASKS_ROUTE, 'Tasks')
const url = await opened.page.evaluate(() => location.pathname + location.search)
expect(url).toBe(`${TASKS_ROUTE}?taskSource=linear`)
expect(opened.errors).toEqual([])
await opened.page.close()
}, 60_000)
it("fetches this route's own chunk on a client-side navigation", async () => {
const opened = await openRoute(HOST_ROUTE, SHELL_HOST.name, {
shellGrants: [faultGrant, ...TASKS_GRANTS],
shellPageRoutes: PAGE_ROUTE_PATTERNS
})
const loadedForFirstRoute = [...opened.scripts]
await opened.page.evaluate((to) => {
history.pushState(null, '', to)
dispatchEvent(new PopStateEvent('popstate'))
}, TASKS_ROUTE)
await waitForRoute(opened, TASKS_ROUTE, 'Tasks')
const chunk = routeChunks[ROUTE_KEY]
expect(chunk, Object.keys(routeChunks).join(' ')).toBeTruthy()
// Named by the builder rather than guessed from the bytes: this is what says the route came
// over the wire now and not out of what the first route had already loaded.
expect(opened.scripts.filter((path) => !loadedForFirstRoute.includes(path))).toContain(
`/assets/${chunk}`
)
expect(loadedForFirstRoute).not.toContain(`/assets/${chunk}`)
await opened.page.close()
}, 60_000)
})
/**
* What this file deliberately does not claim.
*
* The three seams this series added — the barrel's `Linking`, the router handoff and the clipboard
* verb — are each reached from a control that only renders once the screen has provider data, and
* the shell double answers no provider RPC. A case that posted those frames onto the channel
* itself would prove the double and the transport, which the bridge suites already prove, and
* would read as a tap that it never performed.
*
* Where each is proved instead: the barrel's export and the router's, by the source census in
* `mobile/src/tasks/mobile-tasks-external-link.test.ts`; the closure having no react-native
* `Linking` left in it, by `mobile-web-app-tasks-external-links.test.mjs`; the verb end to end,
* by the host and port-pair suites. A tap-level proof needs provider replies lifted from the
* recorded corpus, the way the agent-history check lifts its session list, and belongs with the
* device proof rather than here.
*/
+9 -1
View File
@@ -20,5 +20,13 @@ export const MOBILE_WEB_PAGE_ROUTES = [
// Agent session history. `navigate` because a resumed session opens the session screen, which is
// native, and because the list above now reaches this one without leaving the page. `storage`
// because the host layout above every page route reads the app's own sidebar width.
{ pathname: '/h/[hostId]/agent-history/[worktreeId]', grants: ['navigate', 'storage'] }
{ pathname: '/h/[hostId]/agent-history/[worktreeId]', grants: ['navigate', 'storage'] },
// Tasks. `navigate` for the session screens its rows open and for the Back that pops the native
// stack; `storage` for the shared components it renders; `externalLink` for the provider links
// in its items, checks and drawers; `native.clipboard.write` for the two copy actions in its
// comment review. Grants are scoped per route, so naming fewer here serves fewer.
{
pathname: '/h/[hostId]/tasks',
grants: ['navigate', 'storage', 'externalLink', 'native.clipboard.write']
}
]