Files
orca/config/scripts/mobile-web-app-files-render.test.mjs
T
Jinwoo Hong c77a82f783 feat(mobile): fire the page's haptics over the bridge notify (OTA phase C, C7.10 E) (#21864)
* feat(mobile): give the page a haptics notify and the grant that gates it

`native.haptics.trigger` joins the envelope's notify union with a `kind` of exactly
the five `src/platform/haptics.ts` has, and the single token `haptics` joins
`BRIDGE_NOTIFY_GRANTS` and `MOBILE_WEB_SHELL_GRANTS`. A notify rather than a verb
because nothing is owed back: a reply would spend a slot in the same 64-deep
in-flight window a forwarded request does, and there are 90 call sites in this app,
some of them one per row of a scrolling list (rulings-ota-c7.md ruling 30).

The arm's fields live in their own module because `bridge-envelope.ts` is at its
line cap, as `bridge-event-envelope-bytes.ts` already is; the version literal stays
in the envelope, so the fields are spread in beside it rather than reading it back
through an import cycle.

The shell's half rides `onHaptic` on `BridgeHostOptions`, as every other
device-local notify does: the host is the protocol's side of the bridge and a static
import of the app's haptics would put `react-native` and `expo-haptics` in its
graph, which breaks every test that loads it. `page-haptics.ts` is the one mapping —
`haptics.ts`'s own functions, its `Platform.OS` split and its Android
`HapticFeedbackConstants` untouched.

The dispatch branch rides along with the union rather than waiting for the page
side: `Record<BridgeNotifyName, …>` and the `notify` fall-through are total over
that union, so the shell does not compile without it. That is the totality working,
and `bridge-notify-grants.test.ts` shows it as the TS2741 a missing row is.

Red first: the envelope cases per kind, the ungranted refusal, the grant-list pin
and the missing-row type error all failed against the tree before this. Control on
the dispatch: neutering `options.onHaptic` reds 2 of the 29 cases.

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

* feat(mobile): post the page's haptics over the notify instead of doing nothing

`haptics.web.ts` stops being five no-ops. Each of the five posts its own kind
through the notify seam the entry publishes — the same shape
`publishExternalLinkOpener` has, and for the same reason: every caller is a plain
function inside a row's press handler that no provider wraps. `notifyHaptics` joins
the page client beside the other gated notifies and answers whether the frame left,
which nothing reads: a tap that did not buzz is what the page did before this, and a
warning per refusal would be one per row of a scrolling list.

Measured off the frame the client posted rather than a written copy of its shape,
which is what drifts: 77 / 74 / 72 / 70 / 73 bytes for mediumImpact / selection /
success / error / edgeBump, the widest under 0.012% of `BRIDGE_MAX_MESSAGE_BYTES`,
and a twelve-row scroll 888 bytes across twelve frames.

The `web-overrides.json` reason now says what the file does instead of what it
declines to do.

Red first: the nine web-seam cases failed on `publishHapticsNotifier is not a
function`, and the six client cases on `notifyHaptics is not a function`.

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

* feat(mobile): grant haptics on every page route, with a census that derives the list

All five declared page routes carry the `haptics` grant, and the list is a
measurement rather than a hand choice: `WorktreeListRow` is in every page closure and
calls the seam, so a route without the grant is a page whose taps stop buzzing with
nothing on screen to say why. Grants are resolved once from the route the shell
opened and held for the session, so the declaration is the only place to fix it.

`mobile-web-app-haptics-seam.mjs` is the shared walk, beside the external-link one:
it reads the kinds off the tuple that declares them, finds every exported `trigger…`
function in a haptics module, and reports the kind each one posts. The posting call is
found through the binding `publishHapticsNotifier` assigns rather than a local spelled
`post`, because a rename would otherwise turn every posting site into a non-posting
one and leave this green on a page with no haptics at all.

The census proper holds each route's closure to the `.web.ts` sibling, asserts at
least one importer so the grant is not idle, and derives the granted-route list from
the closures. The control is the design's: the same walk over the native sibling
finds the same five functions and no posting site, so "all five post" is a number
rather than an empty scan.

Controls run: dropping `haptics` from one route reds 1 of 23; neutering one web post
reds 1 of 23.

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

* test(mobile): record what the haptics notify costs a page closure

One module. Every page closure grew by exactly `bridge-haptics-notify.ts`, and it
arrives through `page-route-policy.ts` reading the grant token rather than through the
seam, whose import of the kind type is erased; its only dependency is `zod`, which the
envelope already put in every closure, so the module total moved by the same one.
Local counts per route went 294 → 295, 379 → 380, 435 → 436, 309 → 310, 335 → 336.

Pinned structurally rather than as a total, because an absolute closure count is
main's to move and a number that drifts for unrelated reasons is one nobody reads.

The call sites this replaces, measured over product modules: `triggerError` 43,
`triggerSuccess` 24, `triggerSelection` 12, `triggerMediumImpact` 10,
`triggerEdgeBump` 1 — 90 across 35 importing modules, which is the design's count plus
`page-haptics.ts` itself.

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

* test(mobile): carry the haptics grant into the shell's two grant pins

`bridge-host-init.test.ts` names the grants `init` issues, so the token belongs in
that list. `MobileWebShellScreen.test.tsx` now mocks `expo-haptics` for the reason it
already mocks the clipboard and both pickers: the screen hands `playPageHaptic` over
and reaching the real module pulls in an Expo runtime this test does not have, which
failed the whole suite at import. Which expo member each kind reaches stays in
`page-haptics.test.ts`.

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

* refactor(mobile): map each haptic kind to a named import, not a namespace index

The changed-code gate refuses a computed reference into an imported namespace, in
both the mapping and its test, and it is right to: `haptics[NAME_BY_KIND[kind]]()`
is a call nothing can follow. Each function is a named import instead, which also
keeps the second compile-time direction — a row naming something `haptics.ts` does
not export is now an import error rather than a `keyof` mismatch.

The third direction moves with it, from a namespace read in the test to the census
that already reads both files' text: `hapticsImportedNames` names what the shell's
mapping takes from the app's haptics, and the census holds that to the five the
native file exports. So a haptic added there with no kind of its own still fails,
and now it fails where the other two siblings' names are already compared.

The test's two `as` assertions become one annotated hoisted type, the shape
`MobileWebShellScreen.test.tsx` uses.

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

* docs(mobile): state the true reason the haptics grant is one token

`GRANT_NAME_PATTERN` accepts `native.haptics.trigger` — it admits `native.<a>.<b>`
with lowercase segments, which is why it rejected `native.media.readChunk` and
rejects `navigate-back`, not a dotted name as such. So four comments claiming a route
declaring the notify's own name would have its bundle refused were false, and they
are gone: the grant is a token because the notify table's grants are tokens, a notify
not being a verb, and the dotted names in `MOBILE_WEB_SHELL_GRANTS` are spread from
the verb table alone.

Also folded, with the false claim: `implementedPageRoutes` filters on
`grants.every(implementsGrant)`, so a token every page route declares couples the
whole set to a shell that carries it — against one without it, no page route is
served at all and the phone renders five native screens. Stated in the function's
docstring and beside the census's derived list, and pinned: the same declaration
under a grant this build does not implement comes back empty, with the token-free
route as the control. Removing `BRIDGE_HAPTICS_GRANT` from `MOBILE_WEB_SHELL_GRANTS`
reds that case.

`%#` consumes no argument, so the web seam's five cases were titled with the whole
function body; the kind is the first element now and `%s` names it. One 110-char
comment line in `bridge-client-notifications.ts` wrapped to the file's 100; the two
still over it there are main's.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-21 00:29:52 -04:00

179 lines
6.7 KiB
JavaScript

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 file preview's unsaved-draft prompt, in a real browser.
*
* The render check next door mounts both files routes and reads what they paint, but it never taps
* anything, so the one control on this screen that opens a modal was unproved on the page. Two
* things only a browser answers for it: that `ConfirmModal` — a `BottomDrawer`, and so Reanimated,
* a portal and a gesture handler — actually paints inside the shell's page, and that opening it
* registers no `hardwareBackPress` handler.
*
* The second is the whole reason this file exists. React Native Web's
* `BackHandler.addEventListener` is a `console.error` and an inert subscription, and the drawer
* registered one whenever it was visible and interactive. So the page's own hardware-back guard
* being platform-gated was never the end of it: any page drawer opening put that line on the
* console. Asserted as the absence of the line, with the modal's title as the precondition that
* something was actually opened.
*/
const HOST_ROUTE = '/h/render-check-host'
const WORKTREE = 'wt-1'
const PREVIEW_ROUTE = `${HOST_ROUTE}/files/preview/${WORKTREE}`
/** The patterns `init.pageRoutes` names, which is what the page matches a navigation against. */
const PAGE_ROUTE_PATTERNS = [
'/h/[hostId]',
'/h/[hostId]/files/[worktreeId]',
'/h/[hostId]/files/preview/[worktreeId]'
]
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
}
/** Exactly what the preview declares in `MOBILE_WEB_PAGE_ROUTES`, plus the protocol's own grant. */
const PREVIEW_GRANTS = ['navigate', 'storage', 'externalLink', 'haptics']
/**
* A terminal artifact, which is the only preview this screen lets anyone edit.
*
* A worktree file is read-only, so it can hold no draft and never reaches the prompt.
* `isEditableMobileTerminalArtifactPreview` wants a ready, untruncated, non-image preview, and
* `hasUnsavedMobileTerminalArtifactDraft` wants the draft to differ from what was loaded.
*/
const ARTIFACT_PATH = '/logs/run.txt'
const ARTIFACT_TITLE = 'run.txt'
const LOADED_CONTENT = 'hello'
const EDITED_CONTENT = 'hello, edited'
const PREVIEW_PARAMS = {
source: 'terminalArtifact',
absolutePath: ARTIFACT_PATH,
grantId: 'grant-1'
}
const REPLIES = {
'files.readTerminalArtifact': {
content: LOADED_CONTENT,
truncated: false,
byteLength: LOADED_CONTENT.length
}
}
const bundles = mobileWebAppDependenciesPresent()
const describeRender = bundles ? describe : describe.skip
let scratch
let server
let browser
let origin
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-files-'))
const built = await buildMobileWebAppBundle({ outDir: join(scratch, 'bundle') })
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 } : {}) })
}, 240_000)
afterAll(async () => {
await browser?.close()
server?.close()
if (scratch) {
await rm(scratch, { recursive: true, force: true })
}
})
async function openPreview() {
const page = await browser.newPage({ viewport: { width: 390, height: 844 } })
await page.addInitScript(installShellDouble, {
version: bridgeVersion,
sessionId: SHELL_SESSION_ID,
buildId: SHELL_BUILD_ID,
route: { pathname: PREVIEW_ROUTE, params: PREVIEW_PARAMS },
host: SHELL_HOST,
storage: {},
faultGrant,
grants: [faultGrant, ...PREVIEW_GRANTS],
pageRoutes: PAGE_ROUTE_PATTERNS,
replies: REPLIES
})
const errors = []
page.on('pageerror', (error) => errors.push(`${error.name}: ${error.message}`))
page.on('console', (message) => {
if (message.type() === 'error') {
errors.push(`console.error: ${message.text()}`)
}
})
await page.goto(`${origin}/`, { waitUntil: 'load' })
await page.waitForFunction(() => document.documentElement.dataset.orcaWebEntry === 'mounted', {
timeout: 60_000,
polling: 250
})
return { page, errors }
}
describeRender(
'the file preview page',
() => {
it('asks before discarding a draft, without registering a hardware back handler', async () => {
const { page, errors } = await openPreview()
// The editor is the proof the artifact loaded and the screen decided it was editable; the
// prompt is unreachable otherwise, so a missing one here would be a vacuous pass below.
const editor = page.getByLabel(`${ARTIFACT_TITLE} editor`)
await editor.waitFor({ timeout: 60_000 })
await editor.fill(EDITED_CONTENT)
await page.getByLabel('Back to files').click()
const title = page.getByText('Discard changes?')
await title.waitFor({ timeout: 30_000 })
expect(await title.isVisible()).toBe(true)
// The line react-native-web logs from `BackHandler.addEventListener`. The drawer registered
// one on every open before it was platform-gated, so this is red without that guard.
expect(errors.filter((entry) => entry.includes('BackHandler'))).toEqual([])
await page.close()
}, 180_000)
it('leaves the draft alone when the answer is to stay', async () => {
const { page } = await openPreview()
const editor = page.getByLabel(`${ARTIFACT_TITLE} editor`)
await editor.waitFor({ timeout: 60_000 })
await editor.fill(EDITED_CONTENT)
await page.getByLabel('Back to files').click()
await page.getByText('Discard changes?').waitFor({ timeout: 30_000 })
await page.getByText('Stay').click()
await page.getByText('Discard changes?').waitFor({ state: 'hidden', timeout: 30_000 })
// Still the page, still the draft: the prompt closing must not have navigated or reloaded.
expect(await editor.inputValue()).toBe(EDITED_CONTENT)
await page.close()
}, 180_000)
},
600_000
)