Files
orca/config/scripts/mobile-web-app/mobile-web-app-session-render.test.mjs
T
Kelvin Amoaba 9b4c899dc8 feat(mobile): match every text input to the chat composer and rebuild the terminal dock (#36166)
* feat(mobile): give every text input the chat composer's look

Adds shared MessageBox, TextField and ComposerSendButton built from the
composer's own styles, and moves chat, form, search, Tasks, PR, review and
commit inputs onto them. The commit message becomes multi-line, every input
is 16pt (no page zoom on focus), and focus rings are gone. The terminal and
browser typing docks are unchanged.

* feat(mobile): rebuild the terminal dock around Keys and Message modes

Agent tabs open in Message mode, where you write in the chat composer and
Send types it into the terminal. Shell tabs open in Keys mode, where each
letter goes straight to the terminal through the live field. A pick made on
an agent tab lasts until the agent quits. The shell/agent rule is the one
keyboard dismissal already used, now shared.

The key row carries sticky ctrl, alt and shift (tap for the next key,
double tap to lock) and one arrow pad (tap a side, or hold and drag to
repeat through the existing held-key repeat). Paste, attachments, the
display width, the remaining keys and custom keys move to a ⋯ sheet. Return
in the composer adds a line; Send sends.

* fix(mobile): read arrow-pad taps from the pad and keep every key reachable from ⋯

A touch on a chevron made the icon the touch target, so the side was
measured against the icon's box and most taps read as left. The ⋯ sheet
now leaves out only the keys the current row shows, so agent tabs get Tab
and shell tabs get Shift+Tab and Enter back.

* fix(mobile): keep the last changed files clear of a growing commit box

The file list's bottom inset was a fixed 196 pt, so a commit message several lines long hid the last rows behind the commit bar. The inset now follows the bar's measured height.

* test(mobile): give the file list test its bottom inset
2026-10-30 04:51:47 +00:00

274 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
waitForRecordedRequests,
waitForPageFrames
} from './preview/mobile-web-app-render-harness.mjs'
// Render real session closure under shipped CSP; unit tests mock RN and cannot prove browser import/evaluation, route paint or deferred fetch.
// Assert every console error: unguarded RN-Web BackHandler and rejected diff-note loads once logged silently on mount.
// No terminal attaches against no-RPC double; terminal-render proves terminal content/CSP, session-terminal-init proves session attachment.
const HOST_ROUTE = '/h/render-check-host'
const WORKTREE = 'wt-1'
const SESSION_ROUTE = `${HOST_ROUTE}/session/${WORKTREE}`
const SESSION_PATTERN = '/h/[hostId]/session/[worktreeId]'
const PAGE_ROUTE_PATTERNS = ['/h/[hostId]', SESSION_PATTERN]
const SHELL_SESSION_ID = 'session-render-session'
const SHELL_BUILD_ID = 'session-render-build'
const SHELL_HOST = {
id: 'render-check-host',
name: 'Render Check Host',
endpoint: 'ws://render-check',
lastConnected: 1
}
const UNMATCHED = 'Unmatched Route'
const SESSION_CHUNK_KEY = './h/[hostId]/session/[worktreeId].tsx'
// Read grants from manifest so new route grants are exercised instead of silently omitted by copied fixture.
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 routeChunks = {}
let servedPaths = []
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-session-'))
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
servedPaths = served.requestedPaths
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 openPage(route, replies = {}, { domStorageOff = false } = {}) {
const page = await browser.newPage({ viewport: { width: 390, height: 844 } })
if (domStorageOff) {
// Match Android WebView storage-off behavior: localStorage is null, not undefined; import-time push storage reads once threw getItem here.
await page.addInitScript(() => {
Object.defineProperty(window, 'localStorage', { configurable: true, get: () => null })
})
}
// Install channel at document start like native shell; entry reads it before load.
await page.addInitScript(installShellDouble, {
version: bridgeVersion,
sessionId: SHELL_SESSION_ID,
buildId: SHELL_BUILD_ID,
route: { pathname: route },
host: SHELL_HOST,
storage: {},
faultGrant,
grants: [faultGrant, ...sessionGrants()],
pageRoutes: PAGE_ROUTE_PATTERNS,
replies
})
const errors = []
const warnings = []
const scripts = []
const requestedHosts = []
page.on('pageerror', (error) => errors.push(`${error.name}: ${error.message}`))
page.on('console', (message) => {
if (message.type() === 'error') {
errors.push(`console.error: ${message.text()}`)
}
// Track warnings separately: storage-write-dropped would evade error-only assertions.
if (message.type() === 'warning') {
warnings.push(message.text())
}
})
// Observe requests, not just responses; CSP-blocked fetches must still count as attempts.
page.on('request', (request) => requestedHosts.push(new URL(request.url()).host))
page.on('response', (response) => {
const path = new URL(response.url()).pathname
if (response.status() === 200 && path.endsWith('.js')) {
scripts.push(path)
}
})
return { page, errors, warnings, scripts, requestedHosts }
}
// Wait for route content after mounted: deferred screen chunk may still be arriving.
async function waitForRoute({ page, errors }, route, awaitText) {
const named = (what) =>
new Error(`${route} ${what}: ${errors.join(' | ') || 'no page or console error'}`)
try {
await page.waitForFunction(() => document.documentElement.dataset.orcaWebEntry === 'mounted', {
timeout: 60_000,
polling: 250
})
} catch {
throw named('never mounted')
}
try {
await page.waitForFunction((needle) => document.body.innerText.includes(needle), awaitText, {
timeout: 60_000,
polling: 250
})
} catch {
throw named(`mounted but never painted ${JSON.stringify(awaitText)}`)
}
// Report boundary-caught route fault directly instead of timing out on mount.
for (const fault of await page.evaluate(() => globalThis.__orcaRenderCheckFaults ?? [])) {
errors.push(`page fault: ${fault}`)
}
}
async function openRoute(route, awaitText, replies = {}, options = {}) {
const opened = await openPage(route, replies, options)
await opened.page.goto(`${origin}/`, { waitUntil: 'load' })
await waitForRoute(opened, route, awaitText)
return opened
}
const BACK_LABEL = 'Back to worktrees'
const SESSION_MOUNT_READS = ['worktree.show', 'session.tabs.list', 'terminal.list']
describeRender(
'the session route in a real browser',
() => {
it('mounts the session screen rather than the unmatched route, with nothing on the console', async () => {
const opened = await openRoute(SESSION_ROUTE, 'Terminal')
const text = await opened.page.evaluate(() => document.body.innerText)
// Accessory-bar keys prove session screen, beyond generic header text.
for (const key of ['esc', 'ctrl', 'tab', 'alt']) {
expect(text).toContain(key)
}
expect(text).not.toContain(UNMATCHED)
// Require exact empty list: BackHandler and uncaught diff-note defects were console-only.
expect(opened.errors).toEqual([])
expect(opened.requestedHosts.filter((host) => host !== new URL(origin).host)).toEqual([])
await opened.page.close()
}, 120_000)
it('puts the Back control in the accessibility tree by name', async () => {
const opened = await openRoute(SESSION_ROUTE, 'Terminal')
const control = await opened.page.evaluate((label) => {
const found = document.querySelector(`[aria-label="${label}"]`)
return found === null ? null : { role: found.getAttribute('role'), tag: found.tagName }
}, BACK_LABEL)
// Require actual button role: RN-Web div can carry label without exposing accessible action.
expect(control).toEqual({ role: 'button', tag: 'BUTTON' })
await opened.page.close()
}, 120_000)
it("fetches the session route's own chunk on a client-side navigation", async () => {
const opened = await openRoute(HOST_ROUTE, SHELL_HOST.name)
const loadedForFirstRoute = [...opened.scripts]
await opened.page.evaluate((to) => {
history.pushState(null, '', to)
dispatchEvent(new PopStateEvent('popstate'))
}, SESSION_ROUTE)
await waitForRoute(opened, SESSION_ROUTE, 'Terminal')
const chunk = routeChunks[SESSION_CHUNK_KEY]
expect(chunk, Object.keys(routeChunks).join(' ')).toBeTruthy()
// Identify route chunk through builder to prove fresh fetch, not code already loaded by entry.
expect(opened.scripts.filter((path) => !loadedForFirstRoute.includes(path))).toContain(
`/assets/${chunk}`
)
expect(loadedForFirstRoute).not.toContain(`/assets/${chunk}`)
await opened.page.close()
}, 120_000)
it('asks the origin for no icon, which the shell has none to answer with', async () => {
// data:, icon prevents /favicon.ico fetch forbidden by shell manifest. Only full Chrome requests it; headless shell coverage is a precondition.
// Read whole suite’s server log: browser-process favicon requests never reach page.on(request).
const opened = await openRoute(SESSION_ROUTE, 'Terminal')
// Wait past load: favicon request may arrive after route text paints.
await opened.page.waitForLoadState('networkidle')
expect(servedPaths.filter((path) => path === '/favicon.ico')).toEqual([])
// Require recorded requests so empty log cannot prove no favicon fetch.
expect(servedPaths).toContain('/')
await opened.page.close()
}, 120_000)
it('paints with DOM storage off, which is how the Android shell serves it', async () => {
// Null-storage arm reproduces Android import-time expo-notifications failure; shell owns push and pages must not load it.
const opened = await openRoute(SESSION_ROUTE, 'Terminal', {}, { domStorageOff: true })
// Require all errors absent; filtering known push failure could hide other illegal storage users.
expect(opened.errors).toEqual([])
await opened.page.close()
}, 120_000)
it('writes no storage key it was never handed, on a mount that read the host status', async () => {
// Answer status.get: host-status-gates once wrote unlisted orca:host-app-version key only after readable reply, invisible to no-RPC cases.
const opened = await openRoute(SESSION_ROUTE, 'Terminal', {
'status.get': {
protocolVersion: 9,
minCompatibleMobileVersion: 1,
appVersion: '1.4.191',
capabilities: []
}
})
await waitForRecordedRequests(opened.page, ['status.get'])
await waitForPageFrames(opened.page)
// Reject every dropped storage write, not just known key, to catch future unlisted writers.
expect(opened.warnings.filter((text) => text.includes('storage-write-dropped'))).toEqual([])
await opened.page.close()
}, 120_000)
it('asks the desktop for the session it was opened on, so the page above is live', async () => {
// Require title/tab/inventory RPCs with correct workspace or same chrome could pass without host reads.
// Await effects after paint: under 20× CPU throttle none had fired at initial Terminal commit.
const opened = await openRoute(SESSION_ROUTE, 'Terminal')
const requests = await waitForRecordedRequests(opened.page, SESSION_MOUNT_READS)
for (const method of SESSION_MOUNT_READS) {
const observed = requests.filter((request) => request.method === method)
expect(observed.length, method).toBeGreaterThan(0)
for (const request of observed) {
expect(request.params.worktree, method).toBe(`id:${WORKTREE}`)
}
}
await opened.page.close()
}, 120_000)
},
600_000
)
// Coverage boundaries: terminal-render proves xterm/CSP/transcript; session-terminal-init proves attachment.
// Data-gated chat/picker/clipboard controls use sibling source censuses and host/port-pair suites.
// browser-pane-render proves frame paint; screencast-lane-grant derives route grant; page-async-storage tests store write refusals.