mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
fix(mobile): give the page a client identity so its terminal reaches init
The page's RPC provider answered `getClientId` with `null`, and the session route refuses `terminal.subscribe` without a client identity. No subscribe meant no scrollback, so `init` never reached the terminal document: the surface stayed 0x0 and the document answered every measure with `notReady`. `canSend` reads the same value, so live input was dead for the same reason. The identity is the page's shell session, not the pairing credential `init` deliberately withholds: the host only ever uses `client.id` as an opaque in-memory key for the mobile input floor and the viewport claim. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -375,6 +375,27 @@ export function installShellDouble({
|
||||
channel.onmessage?.({ data: json })
|
||||
return 'posted'
|
||||
}
|
||||
/**
|
||||
* One JSON stream event from the shell, on the same ledger the binary emitter uses.
|
||||
*
|
||||
* The host serves `session.tabs.subscribe` and `terminal.subscribe` as JSON events — the native
|
||||
* client decodes the terminal's binary frames into `scrollback`/`data` payloads before the bridge
|
||||
* ever sees them — so a check that drives a screen off a live stream needs this and not the
|
||||
* binary arm. No window rule: these payloads are a check's own fixtures and are nowhere near the
|
||||
* cap, and a drop here would read as the page ignoring an event it was never sent.
|
||||
*/
|
||||
globalThis.__orcaRenderCheckEmitEvent = (id, payload) => {
|
||||
const stream = openStreams.get(id)
|
||||
if (!stream) {
|
||||
return 'no-stream'
|
||||
}
|
||||
const seq = stream.seq + 1
|
||||
const json = JSON.stringify({ v: version, type: 'event', id, seq, payload })
|
||||
stream.seq = seq
|
||||
stream.unacked.push({ seq, bytes: new TextEncoder().encode(json).length })
|
||||
channel.onmessage?.({ data: json })
|
||||
return 'posted'
|
||||
}
|
||||
globalThis.orcaBridge = channel
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
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 { MOBILE_WEB_PAGE_ROUTES } from './mobile-web-page-routes.mjs'
|
||||
import {
|
||||
createBundleServer,
|
||||
installShellDouble,
|
||||
readBridgeFaultGrant,
|
||||
readBridgeProtocolVersion,
|
||||
readShellCsp
|
||||
} from './mobile-web-app-render-harness.mjs'
|
||||
|
||||
/**
|
||||
* The session route's terminal, driven from a shell double that answers the streams a host does.
|
||||
*
|
||||
* `mobile-web-app-session-render.test.mjs` mounts this route against a double that answers no RPC,
|
||||
* so its terminal never attaches and it says so. This is the other half: a tab snapshot with one
|
||||
* terminal in it, and a `terminal.subscribe` stream the double serves. Both are the shapes the page
|
||||
* reads, not the host's — the double decides no behaviour, it only carries what this file wrote.
|
||||
*
|
||||
* The oracle is the document, not the route's chrome: `#terminal-surface` exists the moment the
|
||||
* component mounts, and it stays 0x0 with no children until the document receives `init`. So the
|
||||
* check is that the surface has a real grid, which is exactly what the hybrid RC emulator run did
|
||||
* not get — `[fit]measure-fail` with `notReady: true`, a document that answered a measure with no
|
||||
* `term` because no `init` had ever reached it.
|
||||
*
|
||||
* What made it red: the page's client provider answered `getClientId` with `null`, and
|
||||
* `use-mobile-session-terminal-subscription.ts` refuses to subscribe without a client identity. No
|
||||
* `terminal.subscribe`, no `scrollback`, no `init`. The first case below is the narrow one and
|
||||
* catches a regression at its source; the second reads the painted grid.
|
||||
*/
|
||||
|
||||
const HOST_ID = 'terminal-init-host'
|
||||
const WORKTREE = 'wt-1'
|
||||
const SESSION_ROUTE = `/h/${HOST_ID}/session/${WORKTREE}`
|
||||
const SESSION_PATTERN = '/h/[hostId]/session/[worktreeId]'
|
||||
const PAGE_ROUTE_PATTERNS = [SESSION_PATTERN]
|
||||
const SHELL_SESSION_ID = 'terminal-init-session'
|
||||
const SHELL_BUILD_ID = 'terminal-init-build'
|
||||
const SHELL_HOST = {
|
||||
id: HOST_ID,
|
||||
name: 'Terminal Init Host',
|
||||
endpoint: 'ws://terminal-init',
|
||||
lastConnected: 1
|
||||
}
|
||||
const HANDLE = 'pty-handle-terminal-init'
|
||||
const TAB_ID = 'tab-terminal-init'
|
||||
/** The bytes the snapshot carries, so the buffer the document opens holds this run's fixture. */
|
||||
const SCROLLBACK_MARKER = 'orca-terminal-init-marker'
|
||||
|
||||
/** The tab snapshot the route applies, in the shape `applySessionTabs` reads off the stream. */
|
||||
const TABS_SNAPSHOT = {
|
||||
type: 'snapshot',
|
||||
worktree: `id:${WORKTREE}`,
|
||||
publicationEpoch: 'terminal-init-epoch',
|
||||
snapshotVersion: 1,
|
||||
activeTabId: TAB_ID,
|
||||
activeTabType: 'terminal',
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: TAB_ID,
|
||||
title: 'bash',
|
||||
terminal: HANDLE,
|
||||
status: 'ready',
|
||||
isActive: true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/** The first stream event a live terminal sends, which is what carries `init` into the document. */
|
||||
const SCROLLBACK_EVENT = {
|
||||
type: 'scrollback',
|
||||
seq: 1,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
serialized: `${SCROLLBACK_MARKER}\r\n`,
|
||||
displayMode: 'phone'
|
||||
}
|
||||
|
||||
/** Read off the manifest rather than restated, for the reason the session render check reads it. */
|
||||
function sessionGrants() {
|
||||
const declared = MOBILE_WEB_PAGE_ROUTES.find((route) => route.pathname === SESSION_PATTERN)
|
||||
if (!declared) {
|
||||
throw new Error(`${SESSION_PATTERN} is not registered`)
|
||||
}
|
||||
return declared.grants
|
||||
}
|
||||
|
||||
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-terminal-init-'))
|
||||
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 } : {}) })
|
||||
}, 600_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
server?.close()
|
||||
if (scratch) {
|
||||
await rm(scratch, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* The session route, mounted, with its tab snapshot already applied.
|
||||
*
|
||||
* The two streams are named so the double accepts them instead of refusing; the replies are the
|
||||
* reads the screen makes on the way up, each answered with the least the screen needs so nothing
|
||||
* below is looking at a retry or an error state.
|
||||
*/
|
||||
async function openSessionWithTerminalTab() {
|
||||
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: SESSION_ROUTE },
|
||||
host: SHELL_HOST,
|
||||
storage: {},
|
||||
faultGrant,
|
||||
grants: [faultGrant, ...sessionGrants()],
|
||||
pageRoutes: PAGE_ROUTE_PATTERNS,
|
||||
streams: ['session.tabs.subscribe', 'terminal.subscribe'],
|
||||
replies: {
|
||||
'worktree.show': { worktree: { id: WORKTREE, name: WORKTREE, path: `/tmp/${WORKTREE}` } },
|
||||
'terminal.list': {
|
||||
terminals: [{ handle: HANDLE, title: 'bash', worktree: `id:${WORKTREE}` }]
|
||||
}
|
||||
}
|
||||
})
|
||||
const errors = []
|
||||
page.on('pageerror', (error) => errors.push(`${error.name}: ${error.message}`))
|
||||
await page.goto(`${origin}/`, { waitUntil: 'load' })
|
||||
await page.waitForFunction(
|
||||
() => document.documentElement.dataset.orcaWebEntry === 'mounted',
|
||||
undefined,
|
||||
{ timeout: 60_000, polling: 250 }
|
||||
)
|
||||
const tabs = await waitForSubscribe(page, 'session.tabs.subscribe')
|
||||
await page.evaluate(
|
||||
([id, event]) => globalThis.__orcaRenderCheckEmitEvent(id, event),
|
||||
[tabs.id, TABS_SNAPSHOT]
|
||||
)
|
||||
return { errors, page }
|
||||
}
|
||||
|
||||
/**
|
||||
* The surface's box, and the grid xterm opened inside it, in cells.
|
||||
*
|
||||
* Serialized into the page, so it closes over nothing. The screen is sized in pixels and the helper
|
||||
* textarea is exactly one cell, which is how a cell count is read back from a renderer that paints
|
||||
* to a canvas and leaves no row elements to count.
|
||||
*/
|
||||
function readTerminalGrid() {
|
||||
const surface = document.getElementById('terminal-surface')
|
||||
const box = surface.getBoundingClientRect()
|
||||
const read = { surface: { width: Math.round(box.width), height: Math.round(box.height) } }
|
||||
const screen = surface.querySelector('.xterm-screen')
|
||||
const cell = surface.querySelector('.xterm-helper-textarea')
|
||||
if (!screen || !cell) {
|
||||
return { ...read, grid: null }
|
||||
}
|
||||
const cellBox = cell.getBoundingClientRect()
|
||||
if (cellBox.width <= 0 || cellBox.height <= 0) {
|
||||
return { ...read, grid: null }
|
||||
}
|
||||
const screenBox = screen.getBoundingClientRect()
|
||||
return {
|
||||
...read,
|
||||
grid: {
|
||||
cols: Math.round(screenBox.width / cellBox.width),
|
||||
rows: Math.round(screenBox.height / cellBox.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The stream the page opened for `method`, or a failure naming every stream it did open. */
|
||||
async function waitForSubscribe(page, method) {
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
(name) => (globalThis.__orcaRenderCheckSubscribes ?? []).some((one) => one.method === name),
|
||||
method,
|
||||
// Both streams open within a second of the mount they follow, so this is headroom rather
|
||||
// than a wait: what it bounds is how long a broken attach path takes to say so.
|
||||
{ timeout: 30_000, polling: 100 }
|
||||
)
|
||||
} catch {
|
||||
const opened = await page.evaluate(() => globalThis.__orcaRenderCheckSubscribes ?? [])
|
||||
throw new Error(
|
||||
`the page never subscribed to ${method}; it opened: ${
|
||||
opened.map((one) => one.method).join(', ') || 'nothing'
|
||||
}`
|
||||
)
|
||||
}
|
||||
const found = await page.evaluate(
|
||||
(name) => (globalThis.__orcaRenderCheckSubscribes ?? []).find((one) => one.method === name),
|
||||
method
|
||||
)
|
||||
return found
|
||||
}
|
||||
|
||||
describeRender(
|
||||
'the session route attaches its terminal on the page',
|
||||
() => {
|
||||
it('subscribes to the terminal the tab snapshot named, carrying a client identity', async () => {
|
||||
const { page } = await openSessionWithTerminalTab()
|
||||
const subscribe = await waitForSubscribe(page, 'terminal.subscribe')
|
||||
expect(subscribe.params.terminal).toBe(HANDLE)
|
||||
// The gate that was closed. `client.id` is what the host keys the mobile input floor and the
|
||||
// viewport claim on, and the page refuses to subscribe at all without one — silently, because
|
||||
// the diagnostic that would have named it is compiled out of a release build.
|
||||
expect(subscribe.params.client).toEqual({
|
||||
id: expect.any(String),
|
||||
type: 'mobile'
|
||||
})
|
||||
expect(subscribe.params.client.id.length).toBeGreaterThan(0)
|
||||
await page.close()
|
||||
}, 300_000)
|
||||
|
||||
it('receives init in the document and measures a non-zero grid', async () => {
|
||||
const { errors, page } = await openSessionWithTerminalTab()
|
||||
const subscribe = await waitForSubscribe(page, 'terminal.subscribe')
|
||||
await page.evaluate(
|
||||
([id, event]) => globalThis.__orcaRenderCheckEmitEvent(id, event),
|
||||
[subscribe.id, SCROLLBACK_EVENT]
|
||||
)
|
||||
// The surface exists from the moment the component mounts, so its presence proves nothing.
|
||||
// `.xterm-screen` does: it is created by `term.open()`, which only ever runs from the
|
||||
// document's `init`. The emulator run had the surface with zero children inside a correctly
|
||||
// sized container, which is this element missing.
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('.xterm-screen') !== null,
|
||||
undefined,
|
||||
{
|
||||
timeout: 60_000,
|
||||
polling: 250
|
||||
}
|
||||
)
|
||||
const painted = await page.evaluate(readTerminalGrid)
|
||||
console.log('[terminal-init]', JSON.stringify(painted))
|
||||
// The box the emulator read as 0x0.
|
||||
expect(painted.surface.width).toBeGreaterThan(0)
|
||||
expect(painted.surface.height).toBeGreaterThan(0)
|
||||
// And the grid it opened, which is the one this run's scrollback named: the screen is sized
|
||||
// in cells, so a document that opened on its own defaults rather than on this event would
|
||||
// report a different pair. This is what the terminal renders through — under WebGL there are
|
||||
// no row elements and no text on the document, so the bytes in the buffer are
|
||||
// `mobile-web-app-terminal-render.test.mjs`'s oracle and the attach path is this one's.
|
||||
expect(painted.grid).toEqual({ cols: SCROLLBACK_EVENT.cols, rows: SCROLLBACK_EVENT.rows })
|
||||
expect(errors).toEqual([])
|
||||
await page.close()
|
||||
}, 300_000)
|
||||
},
|
||||
900_000
|
||||
)
|
||||
@@ -133,6 +133,35 @@ describe('the page provider', () => {
|
||||
expect(context.getLastConnectedAt('host-a')).toBe(1700)
|
||||
})
|
||||
|
||||
it('identifies the page to the host by its shell session', () => {
|
||||
const channel = installChannel()
|
||||
const client = createReadyClient(channel.deliver)
|
||||
act(() => {
|
||||
create(render(client))
|
||||
})
|
||||
|
||||
// What `terminal.subscribe` carries as `client.id` and what the send gate reads. The native
|
||||
// provider answers with the pairing credential, which `init` deliberately does not carry; the
|
||||
// host only ever uses this as an opaque key, so the session id is the whole identity. Null was
|
||||
// not a smaller answer — the session route refuses to subscribe without one, so no scrollback
|
||||
// arrives, the terminal document never receives `init`, and live input never opens.
|
||||
expect(readContext().getClientId('host-a')).toBe(INIT.sessionId)
|
||||
})
|
||||
|
||||
it('has no identity to give before the shell has named a session', () => {
|
||||
installChannel()
|
||||
const client = createShellPageClient()
|
||||
if (client === null) {
|
||||
throw new Error('no channel installed')
|
||||
}
|
||||
act(() => {
|
||||
create(render(client))
|
||||
})
|
||||
|
||||
// The precondition for the case above: it reads the session rather than answering a constant.
|
||||
expect(readContext().getClientId('host-a')).toBe(null)
|
||||
})
|
||||
|
||||
it('carries a state change from the shell to the screens watching it', () => {
|
||||
const channel = installChannel()
|
||||
const client = createReadyClient(channel.deliver)
|
||||
|
||||
@@ -55,7 +55,22 @@ export function RpcClientProvider({
|
||||
getState: () => client.getState(),
|
||||
// Nothing mounts before `init`, so the page's state is never the unknown this answers null for.
|
||||
getKnownState: () => client.getState(),
|
||||
getClientId: () => null,
|
||||
/**
|
||||
* The page's identity to the host, which is its shell session and not the pairing credential.
|
||||
*
|
||||
* The native provider answers with `host.deviceToken`, and the page holds no keychain: `init`
|
||||
* carries the host "minus the credential the bridge already carries for it", and the token is
|
||||
* keychain-only by the same rule that keeps it out of AsyncStorage. But the host only ever
|
||||
* uses `client.id` as an opaque key — the mobile input floor and the viewport claim, both
|
||||
* in-memory and both scoped to a live subscription — so the session id is a whole identity
|
||||
* for it, and one no document has to be trusted with a credential to have.
|
||||
*
|
||||
* Null was not a smaller answer, it was no terminal at all: the session route refuses
|
||||
* `terminal.subscribe` without a client identity, so no scrollback arrived, `init` never
|
||||
* reached the document and the terminal surface stayed 0x0. `canSend` reads the same value,
|
||||
* so live input was dead for the same reason.
|
||||
*/
|
||||
getClientId: () => client.getShellSession()?.sessionId ?? null,
|
||||
getReconnectAttempt: () => client.getReconnectAttempt(),
|
||||
getLastConnectedAt: () => client.getLastConnectedAt(),
|
||||
// The page reaches its host through the shell bridge, which rides whatever path the RN
|
||||
|
||||
Reference in New Issue
Block a user