Files
orca/config/scripts/mobile-web-app-terminal-live-input-render.test.mjs
T
Neil 6607c54984 perf(ci): cut redundant tests and reuse expensive setup (#28669)
* perf(ci): reduce test overhead and reuse expensive build work

* test(ci): keep worker startup outside failure-case deadlines

* test: isolate push fixture advisory locks

* test(ci): preserve fixed drain retry allowance

* Align CI contracts with current triggers and guide content

* Stabilize consolidated reveal scenarios and retain behavioral CI contracts

* Fix upstream lint blockers while retaining behavior and assertions

* Classify global Jira preferences in the WSL migration inventory

* Align Jira test fixtures with startup and build safety checks

* Include package-test routing dependency in CI checkouts and fixtures

* Keep native stream classes intact in transcript dependency tests
2026-10-14 16:14:27 -07:00

512 lines
21 KiB
JavaScript

import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
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 {
BUFFERED_FIELD_ID,
LIVE_INPUT_FIELD_ID,
liveInputProbeRouteSource
} from './mobile-web-app-live-input-probe-route.mjs'
import { MOBILE_WEB_APP_ROUTE_ROOT } from './mobile-web-app-route-manifest.mjs'
import {
createBundleServer,
installShellDouble,
readBridgeFaultGrant,
readBridgeProtocolVersion,
readShellCsp
} from './mobile-web-app-render-harness.mjs'
import { LAYOUT_SOURCE } from './mobile-web-app-terminal-probe-route.mjs'
/**
* The terminal's live input, in a real browser, on the bundle the shell would serve.
*
* What only a browser answers: that the writes the live-input hooks make against
* `liveInputRef.current` are writes React Native Web can honour. Natively the ref is a host
* component with `setNativeProps`; on the page it is the DOM node, which has no such method, so
* the call is a `TypeError` thrown from a mount effect — the page faults, the shell tears the view
* down, and the session route comes up with a 0x0 terminal and a keyboard that never opens.
*
* **Why `mobile-web-app-session-render.test.mjs` is green on the same defect.** It opens the real
* session route with a shell double that answers no RPC, so the screen has no tab snapshot and no
* terminal inventory, so `activeHandle` stays null and `liveInputEnabled` is false. The field the
* ref points at is inside that branch and never renders, `liveInputRef.current` is null, and
* `liveInputRef.current?.setNativeProps(...)` is skipped by its own optional chain. The check's
* error list is exactly empty for a page that never made the call. Its two siblings —
* `mobile-web-app-session-terminal-closure.test.mjs` and `mobile-web-app-session-media-picker.test.mjs`
* — are source censuses over the route closure and mount nothing at all.
*
* So this file gives the hooks the one thing that route cannot: a mounted field. Everything else
* is the page as shipped — the real bundler, the real module resolution (a `.web.ts` sibling wins
* here exactly as it would on a registered route), the shell's own policy header, and the page's
* own `PageFaultBoundary` reporting through the bridge.
*/
const PROBE_ROUTE = `/${MOBILE_WEB_APP_ROUTE_ROOT}/live-input-probe`
const SHELL_HOST = {
id: 'live-input-host',
name: 'Live Input Host',
endpoint: 'ws://live-input',
lastConnected: 1
}
const bundles = mobileWebAppDependenciesPresent()
const describeRender = bundles ? describe : describe.skip
let browser = null
let origin = null
let scratch = null
let server = null
let bridgeVersion = null
let faultGrant = null
beforeAll(async () => {
if (!bundles) {
return
}
const projectDir = fileURLToPath(new URL('../..', import.meta.url))
const terminalDir = join(projectDir, 'mobile', 'src', 'terminal')
const cspHeader = await readShellCsp()
bridgeVersion = await readBridgeProtocolVersion()
faultGrant = await readBridgeFaultGrant()
scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-live-input-'))
const appDir = join(scratch, 'app')
const routeDir = join(appDir, MOBILE_WEB_APP_ROUTE_ROOT)
await mkdir(routeDir, { recursive: true })
await writeFile(join(routeDir, '_layout.tsx'), LAYOUT_SOURCE)
// Extensionless, so the bundler picks a `.web.ts` sibling exactly as it would for a real route.
await writeFile(
join(routeDir, 'live-input-probe.tsx'),
liveInputProbeRouteSource({
bindingModule: join(terminalDir, 'use-terminal-text-field-submit-binding'),
commitModule: join(terminalDir, 'use-terminal-live-input-commit'),
draftsModule: join(terminalDir, 'use-buffered-terminal-drafts')
})
)
const built = await buildMobileWebAppBundle({
appDir,
outDir: join(scratch, 'bundle'),
pageRoutes: [{ pathname: PROBE_ROUTE, grants: [] }]
})
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 })
}
})
/**
* Open the probe and wait for the route to settle either way.
*
* Settled is "the probe registered" or "the page reported a fault", because those are the two
* outcomes and waiting only for the first turns the defect into a 60s timeout that names nothing.
*/
async function openProbe({ userAgent } = {}) {
const page = await browser.newPage({
viewport: { width: 390, height: 844 },
...(userAgent ? { userAgent } : {})
})
await page.addInitScript(installShellDouble, {
version: bridgeVersion,
sessionId: 'live-input-session',
buildId: 'live-input-build',
route: { pathname: PROBE_ROUTE, params: {} },
host: SHELL_HOST,
storage: {},
faultGrant,
grants: [faultGrant],
pageRoutes: [PROBE_ROUTE],
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
})
await page.waitForFunction(
() =>
globalThis.__orcaLiveInputProbe !== undefined ||
(globalThis.__orcaRenderCheckFaults ?? []).length > 0,
{ timeout: 60_000, polling: 100 }
)
const faults = await page.evaluate(() => globalThis.__orcaRenderCheckFaults ?? [])
return { errors, faults, page }
}
const fieldValue = (page) =>
page.evaluate((id) => document.getElementById(id)?.value ?? null, LIVE_INPUT_FIELD_ID)
/** Typed text, as the field and the mirror both hold it once a native edit has landed. */
async function typeIntoField(page, text) {
await page.evaluate((typed) => globalThis.__orcaLiveInputProbe.type(typed), text)
await page.waitForFunction(
([id, typed]) => document.getElementById(id)?.value === typed,
[LIVE_INPUT_FIELD_ID, text],
{ timeout: 30_000, polling: 100 }
)
}
describeRender(
'the terminal live input on the page',
() => {
it('mounts a session-startup clear without faulting the page', async () => {
// The emulator's first line: `[web-shell] the page faulted { category: 'TypeError', message:
// 'r.current?.setNativeProps is not a function' }`, then a torn-down view. The route's mount
// effect is the same call from the same place, so an unhonourable write lands here.
const { errors, faults, page } = await openProbe()
expect(faults).toEqual([])
expect(errors).toEqual([])
expect(await fieldValue(page)).toBe('')
await page.close()
}, 300_000)
it('writes the field itself, so a drifted value is cleared without a render', async () => {
const { errors, page } = await openProbe()
// The state the native write exists for: the DOM node holds text React's `value` prop does
// not know about, which is where an IME leaves the field mid-composition. The capture state
// is already '' here, so the clear below is a no-op for React — it re-renders nothing — and
// the only thing that can empty the node is the write itself.
await page.evaluate((id) => {
document.getElementById(id).value = 'ime-preedit'
}, LIVE_INPUT_FIELD_ID)
expect(await fieldValue(page)).toBe('ime-preedit')
await page.evaluate(() => globalThis.__orcaLiveInputProbe.clear())
expect(await fieldValue(page)).toBe('')
expect(errors).toEqual([])
await page.close()
}, 300_000)
it('clears the field when Enter submits, the way it does natively', async () => {
// The emulator's second page-only line: the command ran and the Live input row kept showing
// the text that had just been sent. Keys go in through the browser because the thing under
// test is react-native-web's keydown handler, which is what turns Enter into onSubmitEditing.
const { errors, page } = await openProbe()
await page.focus(`#${LIVE_INPUT_FIELD_ID}`)
await page.keyboard.type('ls')
await page.waitForFunction(
(id) => document.getElementById(id)?.value === 'ls',
LIVE_INPUT_FIELD_ID,
{ timeout: 30_000, polling: 100 }
)
await page.keyboard.press('Enter')
await page.waitForFunction(
() => (globalThis.__orcaLiveInputProbe.sent() ?? []).includes('\r'),
undefined,
{ timeout: 30_000, polling: 100 }
)
// Exact, not `includes`: react-native-web cancels every keydown it submits on, so the page's
// own line-break binding must stay silent here rather than send a second carriage return.
expect(await page.evaluate(() => globalThis.__orcaLiveInputProbe.sent())).toEqual([
'l',
's',
'\r'
])
expect(await fieldValue(page)).toBe('')
expect(errors).toEqual([])
await page.close()
}, 300_000)
it('submits when Enter arrives with the keyboard composition still open', async () => {
// The emulator's page-only defect. An Android soft keyboard holds a composition over the
// word being typed, so the Enter keydown carries `isComposing: true` — and that is exactly
// the condition react-native-web reads to decide `onSubmitEditing` must not fire. Native
// Android has no such suppression: its editor action fires and the field clears.
const { errors, page } = await openProbe()
await page.focus(`#${LIVE_INPUT_FIELD_ID}`)
const input = await page.context().newCDPSession(page)
await input.send('Input.imeSetComposition', {
text: 'ls',
selectionStart: 2,
selectionEnd: 2
})
await page.waitForFunction(
(id) => document.getElementById(id)?.value === 'ls',
LIVE_INPUT_FIELD_ID,
{ timeout: 30_000, polling: 100 }
)
await page.keyboard.press('Enter')
// The held composition is committed to the terminal first, then the carriage return, which
// is the order the native path produces for the same keystroke.
await page.waitForFunction(
() => globalThis.__orcaLiveInputProbe.sent().includes('\r'),
undefined,
{ timeout: 30_000, polling: 100 }
)
expect(await page.evaluate(() => globalThis.__orcaLiveInputProbe.sent())).toEqual([
'ls',
'\r'
])
expect(await fieldValue(page)).toBe('')
expect(errors).toEqual([])
await page.close()
}, 300_000)
it("clears the field when the key bar's Enter chip ends the line", async () => {
// The device trace's variant (a), which is what shots/23 actually was: the chip emits no DOM
// key event at all, so react-native-web's submit handling never runs and the accessory hook
// is the only thing that could end the field's editing session. It did not, and the next
// keystrokes appended to the text still sitting there.
const { errors, page } = await openProbe()
await page.focus(`#${LIVE_INPUT_FIELD_ID}`)
await page.keyboard.type('ls')
await page.waitForFunction(
(id) => document.getElementById(id)?.value === 'ls',
LIVE_INPUT_FIELD_ID,
{ timeout: 30_000, polling: 100 }
)
await page.evaluate(() => globalThis.__orcaLiveInputProbe.accessory({ bytes: '\r' }))
// Exactly once, whichever branch carries it: the hook sends the control itself or defers to
// the caller, and a fix that did both would double the command.
await page.waitForFunction(
() => (globalThis.__orcaLiveInputProbe.sent() ?? []).includes('\r'),
undefined,
{ timeout: 30_000, polling: 100 }
)
expect(await page.evaluate(() => globalThis.__orcaLiveInputProbe.sent())).toEqual([
'l',
's',
'\r'
])
expect(await fieldValue(page)).toBe('')
expect(errors).toEqual([])
await page.close()
}, 300_000)
it('edits the field from the accessory bar and mirrors the erase to the terminal', async () => {
// The second write site: an accessory Backspace is a local edit, so the hook writes the
// shortened text into the field itself and the mirror diff sends the PTY erase. On the page
// the write threw before the send, so the bar did nothing at all.
const { errors, page } = await openProbe()
await typeIntoField(page, 'ab')
const result = await page.evaluate(() =>
globalThis.__orcaLiveInputProbe.accessory({ bytes: '\u007f', localEdit: 'backspace' })
)
expect(result).toEqual({ kind: 'handled' })
await page.waitForFunction(
(id) => document.getElementById(id)?.value === 'a',
LIVE_INPUT_FIELD_ID,
{ timeout: 30_000, polling: 100 }
)
// One DEL reached the terminal, so the field edit above is a mirror of the PTY rather than a
// local edit that silently diverged from it.
expect(await page.evaluate(() => globalThis.__orcaLiveInputProbe.sent())).toEqual([
'ab',
'\u007f'
])
expect(errors).toEqual([])
await page.close()
}, 300_000)
describe('under an Android keyboard, which composes every word it types', () => {
// The OTA shell's WebView. Its keyboards hold a composing region over the Latin word being
// typed, so every input event mid-word says `isComposing: true`; native Android reports no
// range at all, and there each ASCII keystroke reaches the terminal as it is typed.
const ANDROID_WEBVIEW_USER_AGENT =
'Mozilla/5.0 (Linux; Android 16; Pixel 9 Pro Build/BP2A; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/140.0.0.0 Mobile Safari/537.36'
const sent = (page) => page.evaluate(() => globalThis.__orcaLiveInputProbe.sent())
/** One composition step through the browser's own IME path, which fires the DOM composition events. */
async function compose(input, text) {
await input.send('Input.imeSetComposition', {
text,
selectionStart: text.length,
selectionEnd: text.length
})
}
async function waitForField(page, value) {
await page.waitForFunction(
([id, expected]) => document.getElementById(id)?.value === expected,
[LIVE_INPUT_FIELD_ID, value],
{ timeout: 30_000, polling: 50 }
)
}
it('sends each letter of a composed word after a slash as it is typed', async () => {
// The reported shape: `/tui` in a Codex terminal, where the `/` arrived and `tui` did not
// until Enter. The keyboard commits `/` outright and opens a composition for the letters.
const { errors, page } = await openProbe({ userAgent: ANDROID_WEBVIEW_USER_AGENT })
await page.focus(`#${LIVE_INPUT_FIELD_ID}`)
const input = await page.context().newCDPSession(page)
await page.keyboard.type('/')
await waitForField(page, '/')
const afterEachStep = []
for (const text of ['t', 'tu', 'tui']) {
await compose(input, text)
await waitForField(page, `/${text}`)
afterEachStep.push(await sent(page))
}
await input.send('Input.insertText', { text: 'tui' })
await waitForField(page, '/tui')
expect(afterEachStep).toEqual([
['/', 't'],
['/', 't', 'u'],
['/', 't', 'u', 'i']
])
expect(await sent(page)).toEqual(['/', 't', 'u', 'i'])
expect(errors).toEqual([])
await page.close()
}, 300_000)
it('erases and retypes a word the keyboard corrects when it commits', async () => {
const { errors, page } = await openProbe({ userAgent: ANDROID_WEBVIEW_USER_AGENT })
await page.focus(`#${LIVE_INPUT_FIELD_ID}`)
const input = await page.context().newCDPSession(page)
for (const text of ['t', 'te', 'teh']) {
await compose(input, text)
await waitForField(page, text)
}
await input.send('Input.insertText', { text: 'the' })
await waitForField(page, 'the')
await page.waitForFunction(
() => globalThis.__orcaLiveInputProbe.sent().length === 4,
undefined,
{
timeout: 30_000,
polling: 50
}
)
expect(await sent(page)).toEqual(['t', 'e', 'h', '\u007f\u007fhe'])
expect(errors).toEqual([])
await page.close()
}, 300_000)
it("still holds a composition off Android, where it is the text system's marked text", async () => {
// The guard: an iOS WebView composes only what native iOS marks, pinyin before conversion
// among it, and that is not text yet on either side of the bridge.
const { errors, page } = await openProbe({
userAgent:
'Mozilla/5.0 (iPhone; CPU iPhone OS 19_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148'
})
await page.focus(`#${LIVE_INPUT_FIELD_ID}`)
const input = await page.context().newCDPSession(page)
for (const text of ['n', 'ni']) {
await compose(input, text)
await waitForField(page, text)
}
expect(await sent(page)).toEqual([])
expect(errors).toEqual([])
await page.close()
}, 300_000)
})
describe('in buffered mode, where the field holds the draft until Enter', () => {
const bufferedValue = (page) =>
page.evaluate((id) => document.getElementById(id)?.value ?? null, BUFFERED_FIELD_ID)
it('sends the draft and empties the field on a plain Enter', async () => {
// The guard, not a repro: the clear belongs to `beginBufferedTerminalDraftSend`, which
// writes '' for the handle at the start of the send. What a browser adds is that the field
// is a controlled `<input>` here, so this says the draft left the screen and not just the
// store.
const { errors, page } = await openProbe()
await page.focus(`#${BUFFERED_FIELD_ID}`)
await page.keyboard.type('ls -la')
await page.keyboard.press('Enter')
await page.waitForFunction(
(id) => document.getElementById(id)?.value === '',
BUFFERED_FIELD_ID,
{ timeout: 30_000, polling: 100 }
)
expect(await page.evaluate(() => globalThis.__orcaLiveInputProbe.bufferedSent())).toEqual([
'ls -la'
])
expect(errors).toEqual([])
await page.close()
}, 300_000)
it('sends the draft when Enter arrives with the keyboard composition still open', async () => {
// The same react-native-web gate the live field had: this field also reaches its send
// through `onSubmitEditing` alone, so a soft keyboard's open composition swallows Enter and
// the draft neither goes out nor leaves the field.
const { errors, page } = await openProbe()
await page.focus(`#${BUFFERED_FIELD_ID}`)
const input = await page.context().newCDPSession(page)
await input.send('Input.imeSetComposition', {
text: 'ls -la',
selectionStart: 6,
selectionEnd: 6
})
await page.waitForFunction(
(id) => document.getElementById(id)?.value === 'ls -la',
BUFFERED_FIELD_ID,
{ timeout: 30_000, polling: 100 }
)
await page.keyboard.press('Enter')
await page.waitForFunction(
() => (globalThis.__orcaLiveInputProbe.bufferedSent() ?? []).length > 0,
undefined,
{ timeout: 30_000, polling: 100 }
)
expect(await page.evaluate(() => globalThis.__orcaLiveInputProbe.bufferedSent())).toEqual([
'ls -la'
])
expect(await bufferedValue(page)).toBe('')
expect(errors).toEqual([])
await page.close()
}, 300_000)
it("leaves the draft alone when the key bar's Enter chip fires", async () => {
// Buffered mode turns the live handle set empty, so the accessory hook declines at its own
// guard and the chip is a plain terminal key: one carriage return on the wire, and a draft
// the chip never claimed to send.
const { errors, page } = await openProbe()
await page.evaluate(() => globalThis.__orcaLiveInputProbe.setLiveInputEnabled(false))
await page.focus(`#${BUFFERED_FIELD_ID}`)
await page.keyboard.type('ls -la')
const result = await page.evaluate(() =>
globalThis.__orcaLiveInputProbe.accessory({ bytes: '\r' })
)
expect(result).toEqual({ kind: 'allow-raw' })
expect(await page.evaluate(() => globalThis.__orcaLiveInputProbe.sent())).toEqual(['\r'])
expect(await page.evaluate(() => globalThis.__orcaLiveInputProbe.bufferedSent())).toEqual(
[]
)
expect(await bufferedValue(page)).toBe('ls -la')
expect(errors).toEqual([])
await page.close()
}, 300_000)
})
},
900_000
)