Files
orca/config/scripts/mobile-web-app-push-subsystem-closure.test.mjs
T
Jinwoo Hong 0e6862cbcc fix(mobile): the page offers no control whose only effect is a re-dial it cannot make (#22326)
* fix(mobile): a Retry that can only re-dial is not offered where nothing dials

Six failed-load screens share one Retry shape: re-dial a host that is not
connected, otherwise re-read. On the page the re-dial is inert
(`client-context.web.tsx:55`) and each screen's load already re-runs when the
shell's client reconnects, so in the disconnected state that Retry did
nothing at all. `connectionRetryAction` makes the decision once and answers
null when a re-dial is needed and none exists; agent history, the file
explorer root, the file preview, git history, the source-control status gate
and the diff review render no Retry for null.

The explorer's per-folder Retry keeps its control: it queues the folder, and
the queue drains on the next `connected` whoever brought it back.

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

* fix(mobile): the page offers no re-dial, so no header offers one

`forceReconnect` on the page was `() => Promise.resolve()`: the shell owns
the connection and nothing in the document can re-dial it. The host header's
Reconnect and the session header's "tap to retry" were wired to it and did
nothing there. The context member is now nullable and the page's provider
hands out null, so the compiler found every caller: both headers render no
reconnect affordance for null, and the session status keeps the verdict
label without promising a tap.

Native providers and the recording adapters still pass a function, so
nothing a phone renders changes. The session route's host-JSX parity hash
moves for the header's extra null check; the page test doubles that stubbed
the old inert re-dial now stub null.

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

* docs(mobile): the auth-failed banner cites the page's re-dial as null

Three comments and the banner's override reason still said the page's
`forceReconnect` was an inert `() => Promise.resolve()`, and cited
`client-context.web.tsx` lines the previous commit moved. They now say null
and point at the lines that hold it.

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

* test(mobile): the session closure gains the page's Retry decision

`connection-retry-action.ts` is the one module the Retry fix adds to the
session route's page closure, reached through the explorer, source control
and git history it docks. Measured on this head with all five generators run
first, and diffed against the pre-change closure: one local module added,
none removed.

Session route closure 4207 -> 4208 modules, local 1021 -> 1022.

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

* docs(mobile): the capability probe belongs on the page, and says why

The push fence excluded `runtime-capability-probe.ts` because the session
route and the host screen run it. The session half holds, and the probe
works there: `status.get` carries no client identity and makes no write, the
shell forwards it like any non-`native.` request, and the desktop's mobile
allowlist admits it. The host-screen half no longer does:
`codex-reset-credit-capability.ts` is reached only from `accounts.tsx`, which
the bundle carries and the page hands to the native screen.

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

* test(mobile): keep the session retry test's cast under its disable line

The formatter wrapped the cast onto the line after the disable comment,
which left it uncovered. The cast now sits on its own line directly below
the SAFETY note.

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

* test(mobile): the agent-history Retry test mocks the pathname the handoff reads

Main's page route handoff now subscribes to `usePathname` (#22300), and the
Retry suite this branch added mounts that handoff with an `expo-router` mock
that lacked it. Same one-line addition main made to the back-handoff suite.

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

* test(mobile): pin the reload each hidden page Retry relies on

Hiding a Retry on the page rests on the screen's load re-running when the
shell's client reconnects, because nothing on the page re-dials. Only the
explorer's folder drain pinned that. Each other site now has a case that
starts unreachable with no Retry and asserts the load goes out on the
client and state the reconnect delivers: agent history (status.get), file
preview (the preview read), diff review (the snapshot load), git history
(git.history) and source-control status (git.status, in the loaders suite
because the panel test mocks the state hook).

Each goes red when the `client`/`connState` dependencies it guards are
removed; for source control that is both `loadStatus` and the
`loadBranchCompare` it depends on.

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

* test(mobile): one import of the transport types in the source-control loaders test

CI's native code-quality audit denies the duplicate-import warning the reload pin added.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 19:41:38 -04:00

80 lines
4.8 KiB
JavaScript

import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import * as esbuild from 'esbuild'
import { describe, expect, it } from 'vitest'
import { mobileWebAppBuildOptions } from './build-mobile-web-app-bundle.mjs'
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
import { collectMobileWebAppRoutes } from './mobile-web-app-route-manifest.mjs'
/**
* The push subsystem, and why no page route may reach any part of it.
*
* Not `runtime-capability-probe.ts`, which `push-registration.ts` also imported. The session route
* reaches it through `use-mobile-session-tab-reconciliation.ts`, and there it works: it sends
* `status.get`, a read with no client identity that the shell forwards like any non-`native.`
* request (`bridge-host-requests.ts`) and the desktop's mobile allowlist admits. Its other importer,
* `codex-reset-credit-capability.ts`, hangs off `app/h/[hostId]/accounts.tsx`, which the bundle
* carries and the page never serves: it hands `/accounts` to the native screen. It left the C1
* layout closure with push and stayed in the bundle, which is why it is pinned nowhere here.
*
* `expo-notifications` first, and why no page route may import it.
*
* It is not a module a browser can merely import. `DevicePushTokenAutoRegistration.fx` runs at
* import: it adds a push-token listener, which React Native Web answers with a warning and an inert
* subscription, and it reads the persisted server registration out of `window.localStorage`. That
* read is guarded by `typeof localStorage === 'undefined'`, and the Android shell's WebView has DOM
* storage off, where `window.localStorage` is `null` rather than undefined — so the guard passes
* and the read raises "Cannot read properties of null (reading 'getItem')". The emulator run saw
* both lines on every page load, the second at error level, from a subsystem the page cannot use:
* push registration needs a device token the shell owns and a gateway the page has no client for.
*
* Two modules imported it — `push-token.ts` and `desktop-notification-channel.ts`, both reached
* through `push-registration.ts`, which `app/h/_layout.tsx` pulled in via the host screen's remove
* action. Both were given `.web` siblings. That was the first fence, and it is kept because
* nothing else stops a third importer: every call in those two files was already inert on web, so
* a page that imports one behaves correctly and still loads the package.
*
* `push-registration.ts` is fenced a layer up, and that is what the assertions below read. The
* page cannot register for push at all: it has no device token and no gateway client, so every
* path through that module either does nothing or writes `orca:remotePushHostRegistrations`
* through a page storage adapter that drops the write and says so. `host-removal-lifecycle.web.ts`
* is what cut it out — removing a host is native-only — and it took the whole of
* `src/notifications` with it, because that import was the only path into the directory from any
* page route. So the fence is stated as the directory rather than as three module names: the two
* `.web` siblings above are no longer in the page bundle either, and a test that asserted their
* presence as its precondition would now be asserting the fence had a hole.
*/
const projectDir = fileURLToPath(new URL('../..', import.meta.url))
const appDir = join(projectDir, 'mobile', 'app')
const describeClosure = mobileWebAppDependenciesPresent() ? describe : describe.skip
describeClosure(
'the push subsystem against the page',
() => {
it('is in no module the shipped bundle contains', async () => {
// The bundle the shell serves, not a closure read per route: the entry's manifest is what
// reaches every route, deferred chunks included, so this is the whole of what a document
// can load. Read per route, a module would only have to move one route over to hide.
const routes = await collectMobileWebAppRoutes(appDir)
expect(routes.length).toBeGreaterThan(5)
const { metafile } = await esbuild.build({
...mobileWebAppBuildOptions(routes),
metafile: true,
write: false
})
const modules = Object.keys(metafile.inputs)
// The precondition, because a walk that resolved nothing would also contain nothing, and one
// that ignored `.web` resolution would report the native lifecycle file rather than the
// sibling whose refusal is the reason push is absent.
expect(modules.length).toBeGreaterThan(1000)
expect(modules).toContain('src/transport/host-removal-lifecycle.web.ts')
expect(modules).not.toContain('src/transport/host-removal-lifecycle.ts')
expect(modules.filter((input) => input.includes('expo-notifications'))).toEqual([])
expect(modules.filter((input) => input.startsWith('src/notifications/'))).toEqual([])
}, 300_000)
},
600_000
)