Files
orca/mobile/src/terminal/terminal-webview-engine.test.ts
T
Brennan BensonandOrca f4790e9fac Fix blank mobile terminal on Android devices with outdated WebViews or blocked CDN (#7186)
* fix(mobile): bundle terminal engine and show load errors instead of a blank pane

The mobile terminal WebView loaded xterm.js from cdn.jsdelivr.net at
runtime; old WebViews (< Chrome 85) fail to parse the modern bundle and
blocked-CDN networks fail to fetch it, and the resulting error was
silently dropped, leaving the pane permanently blank (#7030).

Bundle the engine into the app via exact-pinned npm deps + a postinstall
esbuild step (chrome74 target, guarded WeakRef/structuredClone/
replaceChildren shims) emitting a gitignored generated module, inline it
into the terminal document, and surface fatal engine failures as a
visible overlay with diagnostics and a Reload wired into the existing
resubscribe path. Non-fatal errors log without covering a live terminal.

Co-authored-by: Orca <help@stably.ai>

* fix(mobile): add a native watchdog so a dead terminal document can't stay silently blank

CodeRabbit round: if the webview document dies before the glue can post
anything (or the RN message bridge never comes up), no error message and
no native handler fires. Arm a 15s foreground-gated watchdog per document
generation that paints the fatal overlay when web-ready never arrives;
first fatal diagnostics win over later cascades. Extract the watchdog and
the public contract types to keep TerminalWebView under the line cap, and
document the SVG xmlns percent-encoding transform.

Co-authored-by: Orca <help@stably.ai>

* test(mobile): unmount TerminalWebView renderers so watchdog timers can't leak across tests

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-03 15:22:00 -07:00

110 lines
4.7 KiB
TypeScript

import { readFileSync } from 'node:fs'
import { Script } from 'node:vm'
import { parse } from 'acorn'
import { describe, expect, it } from 'vitest'
import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated'
import { XTERM_HTML } from './terminal-webview-html'
const terminalHtmlSource = readFileSync(
new URL('./terminal-webview-html.ts', import.meta.url),
'utf8'
)
describe('terminal WebView bundled engine', () => {
it('keeps the assembled terminal HTML free of external engine URLs', () => {
expect(XTERM_HTML).not.toMatch(/\bhttps?:\/\//)
expect(XTERM_HTML).not.toContain('cdn.jsdelivr.net')
expect(XTERM_HTML).not.toContain('<script src=')
expect(XTERM_HTML).not.toContain('rel="stylesheet" href=')
})
it('parses the bundled engine at the Chrome 74 syntax floor', () => {
expect(() => parse(XTERM_ENGINE_JS, { ecmaVersion: 2019 })).not.toThrow()
})
// Why: the context deliberately omits WeakRef (Chrome 84+) / structuredClone
// (Chrome 98+) and supplies an Element without replaceChildren (Chrome 86+) —
// the engine must evaluate on older WebViews via its own guarded runtime shims,
// which are the linchpin of the old-WebView support (esbuild lowers syntax only).
it('exposes the xterm globals and installs the old-WebView runtime shims', () => {
const window: Record<string, unknown> = {}
class ElementStub {}
const context = {
window,
self: window,
document: {},
Element: ElementStub,
navigator: {
platform: 'Linux armv8l',
userAgent: 'Mozilla/5.0 Chrome/74.0.3729.157'
},
console,
setTimeout,
clearTimeout,
queueMicrotask,
URL
}
new Script(XTERM_ENGINE_JS).runInNewContext(context)
expect(window).toMatchObject({
Terminal: expect.any(Function),
Unicode11Addon: { Unicode11Addon: expect.any(Function) },
WebglAddon: { WebglAddon: expect.any(Function) }
})
const weakRef = window.WeakRef as (new (target: unknown) => { deref(): unknown }) | undefined
expect(typeof weakRef).toBe('function')
const token = {}
expect(new weakRef!(token).deref()).toBe(token)
expect(typeof window.structuredClone).toBe('function')
expect(typeof (ElementStub.prototype as { replaceChildren?: unknown }).replaceChildren).toBe(
'function'
)
})
it('keeps the bundled engine from breaking out of its inline script/style tags', () => {
// Why: the engine JS/CSS are inlined into <script>/<style> blocks. </script
// and </style are neutralized at build time; the tokenizer-escape openers that
// could swallow the rest of the document must also be absent from the bundle.
expect(XTERM_ENGINE_JS).not.toMatch(/<\/script/i)
expect(XTERM_ENGINE_JS).not.toMatch(/<script/i)
expect(XTERM_ENGINE_JS).not.toContain('<!--')
expect(XTERM_ENGINE_CSS).not.toMatch(/<\/style/i)
})
it('reports WebView message handler failures instead of swallowing them', () => {
const start = terminalHtmlSource.indexOf('function handleIncomingMessage')
const end = terminalHtmlSource.indexOf("window.addEventListener('resize'", start)
expect(start).toBeGreaterThanOrEqual(0)
expect(end).toBeGreaterThan(start)
const handlerSource = terminalHtmlSource.slice(start, end)
expect(handlerSource).toContain('reportEngineError(')
expect(handlerSource).toContain("'terminal init failed'")
expect(handlerSource).toContain("'terminal message failed'")
expect(handlerSource).not.toContain('catch(ex) {}')
})
it('classifies runtime errors by a document-scoped ever-ready latch', () => {
// Why: init() flips `ready` false on every re-init (live width reflow keeps the
// old surface visible meanwhile), so the fatal default and the init-catch must
// key off `everReady` — otherwise a transient reflow error blanks a live
// terminal behind the fatal overlay. The latch stays set for the document.
expect(terminalHtmlSource).toContain('var everReady = false;')
expect(terminalHtmlSource).toContain('everReady = true;')
expect(terminalHtmlSource).toContain('fatal === undefined ? !everReady : !!fatal')
expect(terminalHtmlSource).toContain("msg.type === 'init' && !everReady")
expect(terminalHtmlSource).not.toMatch(/fatal === undefined \? !ready\b/)
})
it('bounds error capture and non-fatal reporting on a degraded engine', () => {
// Why: a constructed-but-broken engine can throw per render frame; both
// onerror capture sites must cap the buffer and non-fatal notifies must
// stop flooding RN while fatal reports always emit.
const capSites = terminalHtmlSource.match(/__engineErrors\.length < 20/g) ?? []
expect(capSites.length).toBe(2)
expect(terminalHtmlSource).toContain('nonFatalErrorNotifies > 5')
})
})