feat(mobile): generate the terminal document from its modules

The WebView document is no longer a hand-written IIFE pasted into a template
string. `scripts/build-terminal-document-script.mjs` reads `document-scope.ts`
and the 36 modules under `src/terminal/document/` in document order, strips
their imports, exports and line-scoped lint directives, substitutes the
`document-constants.ts` exports textually, reprints each with esbuild and wraps
the result in one IIFE. `terminal-webview-html.ts` composes the shell, that
generated script and the close fragment. The artifact is gitignored and built by
postinstall, like the two engine artifacts.

The emitted document is token-equivalent to the old one under eight counted
normalisation classes, each pinned as an exact number in
`document/terminal-document-flip.test.ts` against the pre-flip text:

  qualifiedReferences     609
  scopeFieldDeclarations   73
  rebindings              373
  bracedBodies            279
  unboundCatches           36
  numberProperties         17
  shorthandProperties       4
  unshadowedNames           7

Any other difference fails with the token index and both sides. The second case
pins that the new document adds the scope object and nothing else.

Ruling 17: the behavioural tests now grep the generated document through
`XTERM_HTML`, never a module source, so every assertion still speaks about what
the WebView runs. Every assertion stays and the `expect` count per file is
unchanged: scroll-routing 95, text-zoom 59, engine 49, url-tap 33, reflow 22,
keyboard-avoidance 18, query-reply 14. One control per file was run by deleting
the module line the updated pattern guards; all seven red, and the tree restores
green.

Pattern changes, old -> new.

terminal-webview-scroll-routing.test.ts
  var deltaY = ts.lastY - y;                    -> const deltaY = ts.lastY - y;
  smoothScrollOffsetY -= deltaY;                -> scope.smoothScrollOffsetY -= deltaY;
  var lines = Math.trunc(-smoothScrollOffsetY / effectiveCellH);
                                                -> const lines = Math.trunc(-scope.smoothScrollOffsetY / effectiveCellH);
  'touchmove' single-quoted, one line           -> "touchmove" double-quoted, printer line break
  }, { capture: true, passive: false });        -> { capture: true, passive: false }
  function momentumStep()                       -> let momentumStep = function()
  pendingNormalScrollDeltaY += deltaY;          -> scope.pendingNormalScrollDeltaY += deltaY;
  if (normalScrollFrameId !== null) return true; -> if (scope.normalScrollFrameId !== null) {
  normalScrollFrameId = requestAnimationFrame(  -> scope.normalScrollFrameId = requestAnimationFrame(
  pendingNormalScrollDeltaY = 0;                -> scope.pendingNormalScrollDeltaY = 0;
  cancelAnimationFrame(normalScrollFrameId);    -> cancelAnimationFrame(scope.normalScrollFrameId);
  var writeQueueHead = 0;                       -> scope.writeQueueHead = 0;
  writeQueueHead++;                             -> scope.writeQueueHead++;
  writeQueue = writeQueue.slice(writeQueueHead); -> scope.writeQueue = scope.writeQueue.slice(scope.writeQueueHead);
  surface.style.transform = 'translate(' + panX  -> scope.surface.style.transform = "translate(" + scope.panX
  getVisualPanY() + 'px) scale('                -> getVisualPanY() + "px) scale("
  var FRICTION = 0.972;                         -> const FRICTION = 0.972;
  var MIN_VEL = 0.012;                          -> const MIN_VEL = 0.012;
  edgeScrollDir = dir;                          -> scope.edgeScrollDir = dir;
  term.scrollLines(edgeScrollDir);              -> scope.term.scrollLines(scope.edgeScrollDir);
  // Latching document-level touch dispatcher    -> function attachSurfaceEventHandlers(
  edgeScrollClientX = clientX;                  -> scope.edgeScrollClientX = clientX;
  edgeScrollClientY = clientY;                  -> scope.edgeScrollClientY = clientY;
  return mode !== 'none';                       -> return mode !== "none";
  var pixelX = cell.x;                          -> const pixelX = cell.x;
  var pixelY = cell.y;                          -> const pixelY = cell.y;
  ...isSafeSgrMouseCoordinate(cell.y)) return   -> ...isSafeSgrMouseCoordinate(cell.y)) {
  ...isSafeSgrMouseCoordinate(sgrRow)) return   -> ...isSafeSgrMouseCoordinate(sgrRow)) {
  if (mouseTrackingMode === 'x10') return pixelPress; -> if (mouseTrackingMode === "x10") { return pixelPress;
  if (mouseTrackingMode === 'x10') return sgrPress;   -> if (mouseTrackingMode === "x10") { return sgrPress;
  if (mouseTrackingMode === 'x10') return press;      -> if (mouseTrackingMode === "x10") { return press;
  if (col > 126 || row > 126) return '';        -> if (col > 126 || row > 126) { return "";
  document.addEventListener('touchend'          -> document.addEventListener( "touchend"
  }, { capture: true, passive: true });         -> { capture: true, passive: true }
  notifyTerminalSurfaceTap(tapCandidate.x, ...) -> notifyTerminalSurfaceTap(scope.tapCandidate.x, ...)
  document.addEventListener('touchstart'        -> document.addEventListener( "touchstart"
  var clickInput = buildMouseClickInput         -> const clickInput = buildMouseClickInput
  notify({ type: 'open-url', url: tappedUrl });      -> notify({ type: "open-url", url: tappedUrl });
  notify({ type: 'terminal-input', bytes: clickInput }); -> notify({ type: "terminal-input", bytes: clickInput });

terminal-webview-text-zoom.test.ts
  var CLAUDE_STATUS_DOT =                       -> scope.CLAUDE_STATUS_DOT =
  var PRIVATE_MODE_SCAN_TAIL_LIMIT              -> scope.PRIVATE_MODE_SCAN_TAIL_LIMIT
  \n\n  function enqueueWrite                   -> \n  function enqueueWrite
  var terminalFontFamily =                      -> scope.terminalFontFamily =
  output = terminalFontFamily;                  -> output = scope.terminalFontFamily;
  String.fromCharCode(0x23fa)                   -> String.fromCharCode(9210)
  TEXT_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0e)  -> scope.TEXT_PRESENTATION_SELECTOR = String.fromCharCode(65038)
  EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0f) -> scope.EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(65039)
  data.replace(CLAUDE_STATUS_DOT_PATTERN, ...)  -> data.replace( scope.CLAUDE_STATUS_DOT_PATTERN, scope.CLAUDE_STATUS_DOT + scope.TEXT_PRESENTATION_SELECTOR )
  writeQueue.push(normalizeStatusDotPresentation(data)) -> scope.writeQueue.push(normalizeStatusDotPresentation(data))
  var replayData = normalizeInitialData(initialData) -> const replayData = normalizeInitialData(initialData)
  } else if (msg.type === 'clear') {            -> } else if (msg.type === "clear") {
  } else if (msg.type === 'measure')            -> } else if (msg.type === "measure")
  statusDotPendingSelector = false              -> scope.statusDotPendingSelector = false   (x2)
  term.open(surface)                            -> scope.term.open(scope.surface)
  term.unicode.activeVersion = '11'             -> scope.term.unicode.activeVersion = "11"
  enqueueWrite(ESC + '[0m' + replayData)        -> enqueueWrite(scope.ESC + "[0m" + replayData)
  fontFamily: terminalFontFamily                -> fontFamily: scope.terminalFontFamily
  fontWeight: '300'                             -> fontWeight: "300"
  fontWeightBold: '500'                         -> fontWeightBold: "500"

terminal-webview-engine.test.ts
  var webglAddon = null; .. var webglRecoveryTimer = null;
                                                -> the refreshTerminalSurface()..init( block, with the scope preamble
  window.addEventListener('resize'              -> window.addEventListener("resize"
  'terminal init failed'                        -> "terminal init failed"
  'terminal message failed'                     -> "terminal message failed"
  var everReady = false;                        -> scope.everReady = false;
  everReady = true;                             -> scope.everReady = true;
  fatal === undefined ? !everReady : !!fatal    -> fatal === void 0 ? !scope.everReady : !!fatal
  msg.type === 'init' && !everReady             -> msg.type === "init" && !scope.everReady
  /fatal === undefined \? !ready\b/             -> /fatal === void 0 \? !scope\.ready\b/
  if (msg.type === 'ping')                      -> if (msg.type === "ping")
  notify({ type: 'pong', pingId: msg.id })      -> notify({ type: "pong", pingId: msg.id })

terminal-webview-reflow.test.ts
  } else if (msg.type === 'reflow') {           -> } else if (msg.type === "reflow") {   (x2)
  var MIN_FIT_COLS = 20;                        -> scope.MIN_FIT_COLS = 20;
  if (cols < MIN_FIT_COLS) return;              -> if (cols < scope.MIN_FIT_COLS) {
  flog('measure-skip-small-width'               -> flog("measure-skip-small-width"
  notify({ type: 'measure-result', ... })       -> notify({ type: "measure-result", ... })
  var dispatch = { mode: 'idle'                 -> const dispatch = { mode: "idle"
  window.addEventListener('message'             -> window.addEventListener("message"

terminal-keyboard-avoidance-webview.test.ts
  \n  // reflow()                               -> \n  function reflow(
  } else if (msg.type === 'clear') {            -> } else if (msg.type === "clear") {
  } else if (msg.type === 'measure')            -> } else if (msg.type === "measure")
  \n  var panX                                  -> \n  scope.panX
  TERMINAL_REFLOW_JS fragment import            -> the reflow(cols, rows)..notify( slice of the document

terminal-webview-query-reply.test.ts
  attachTerminalQueryReplyBridge(term, gen)     -> attachTerminalQueryReplyBridge(scope.term, gen)   (x2)
  term.attachCustomKeyEventHandler(function() { return false; })
                                                -> term.attachCustomKeyEventHandler(function() { \n return false; \n });
  term.textarea.readOnly = true                 -> term.textarea.readOnly = true;
  } else if (msg.type === 'clear') {            -> } else if (msg.type === "clear") {
  } else if (msg.type === 'measure')            -> } else if (msg.type === "measure")

terminal-webview-url-tap.test.ts
  notify({ type: 'open-url', url: tappedUrl }); -> notify({ type: "open-url", url: tappedUrl });

terminal-webview-payload-hash.test.ts is the document byte pin; it moves to the
generated document's digest, 730472 -> 723480 bytes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-20 10:39:02 -04:00
parent a8f9af6e99
commit 2a5d3eccdb
20 changed files with 5368 additions and 2167 deletions
+1
View File
@@ -1,5 +1,6 @@
node_modules/
src/terminal/terminal-webview-engine.generated.ts
src/terminal/terminal-webview-document-script.generated.ts
src/components/pr-sidebar/mermaid-webview-engine.generated.ts
.expo/
dist/
+1 -1
View File
@@ -7,7 +7,7 @@
"start": "node scripts/start-expo.mjs",
"android": "expo run:android",
"ios": "expo run:ios",
"postinstall": "node scripts/build-terminal-webview-engine.mjs && node scripts/build-mermaid-webview-engine.mjs",
"postinstall": "node scripts/build-terminal-webview-engine.mjs && node scripts/build-mermaid-webview-engine.mjs && node scripts/build-terminal-document-script.mjs",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"typecheck:tests": "tsc --noEmit -p tsconfig.test.json",
@@ -1,7 +1,11 @@
import { readFile } from 'node:fs/promises'
import { readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import * as esbuild from 'esbuild'
import { importTypeScriptModule } from './import-typescript-module.mjs'
import {
TERMINAL_DOCUMENT_MODULE_ORDER,
TERMINAL_DOCUMENT_SCOPE_MODULE
} from './terminal-document-module-order.mjs'
/**
* Turns one module of the in-WebView terminal document back into the script text the document
@@ -131,3 +135,43 @@ export async function emitTerminalDocumentModule(modulePath) {
.map((line) => (line.length === 0 ? line : `${INDENT}${line}`))
.join('\n')
}
const documentDirectory = path.join(import.meta.dirname, '..', 'src', 'terminal', 'document')
export const TERMINAL_DOCUMENT_SCRIPT_PATH = path.join(
import.meta.dirname,
'..',
'src',
'terminal',
'terminal-webview-document-script.generated.ts'
)
/**
* The document's whole script: every module in the order the document had, inside the one function
* scope it has always been.
*/
export async function buildTerminalDocumentScript() {
const emitted = []
// The scope object goes first: every module below reads it, and the document is one function
// scope, so it has to exist before any of them run. It is the only part of the emitted script
// the hand-written document did not have.
for (const name of [TERMINAL_DOCUMENT_SCOPE_MODULE, ...TERMINAL_DOCUMENT_MODULE_ORDER]) {
emitted.push(await emitTerminalDocumentModule(path.join(documentDirectory, `${name}.ts`)))
}
return `(function() {\n${emitted.join('\n')}\n})();`
}
async function main() {
const script = await buildTerminalDocumentScript()
await writeFile(
TERMINAL_DOCUMENT_SCRIPT_PATH,
`// Generated by scripts/build-terminal-document-script.mjs. Do not edit.\n` +
`// The source is mobile/src/terminal/document/, in the order\n` +
`// scripts/terminal-document-module-order.mjs pins.\n` +
`export const TERMINAL_DOCUMENT_SCRIPT = ${JSON.stringify(script)}\n`
)
}
if (process.argv[1] === import.meta.filename) {
await main()
}
@@ -5,6 +5,9 @@
*
* Both the generator and the equivalence test read this, so neither can drift from the other.
*/
/** The scope object, emitted ahead of everything else because everything else reads it. */
export const TERMINAL_DOCUMENT_SCOPE_MODULE = 'document-scope'
export const TERMINAL_DOCUMENT_MODULE_ORDER = [
'runtime-constants',
'terminal-handle',
@@ -1,5 +1,4 @@
import { terminalTextScalePresets } from './document-constants'
import { DEFAULT_TERMINAL_THEME } from '../terminal-webview-html/theme'
import { terminalDefaultTheme, terminalTextScalePresets } from './document-constants'
import type { TerminalDocumentThemeMessage } from './terminal-theme'
/**
* The state the in-WebView terminal document shares across its parts.
@@ -327,8 +326,8 @@ export function createTerminalDocumentScope(): TerminalDocumentScope {
webglAddon: null,
webglRecoveryTimer: null,
terminalThemeInput: null,
defaultTheme: DEFAULT_TERMINAL_THEME,
terminalTheme: DEFAULT_TERMINAL_THEME,
defaultTheme: terminalDefaultTheme,
terminalTheme: terminalDefaultTheme,
terminalMinimumContrastRatio: 3,
initialOscLinks: [],
initialOscLinkRowOffset: 0,
@@ -1,22 +1,30 @@
import { fileURLToPath } from 'node:url'
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { emitTerminalDocumentModule } from '../../../scripts/build-terminal-document-script.mjs'
import { TERMINAL_DOCUMENT_MODULE_ORDER } from '../../../scripts/terminal-document-module-order.mjs'
import { XTERM_ENGINE_JS } from '../terminal-webview-engine.generated'
import { XTERM_HTML } from '../terminal-webview-html'
import { fileURLToPath } from 'node:url'
import {
compareTerminalDocumentScripts,
readTerminalDocumentScript
} from './terminal-document-equivalence.test-support'
buildTerminalDocumentScript,
emitTerminalDocumentModule
} from '../../../scripts/build-terminal-document-script.mjs'
import { TERMINAL_DOCUMENT_MODULE_ORDER } from '../../../scripts/terminal-document-module-order.mjs'
import { compareTerminalDocumentScripts } from './terminal-document-equivalence.test-support'
/**
* The review of the move, as one number per difference class.
*
* Every line of the document's script is now a module, and this says the two are the same program
* `terminal-document-pre-flip-script.txt` is the hand-written script exactly as it stood before any
* of this, taken from the byte fixture that pinned it. This says the modules emit the same program
* modulo the qualifier and the repository's own rules rewriting an ES5 document the moment its
* source is a linted module. Anything outside those classes refuses with the token index and both
* sides, so a reordered statement, a changed literal or a renamed local cannot pass here.
*
* The scope object is the one thing the emitted script has that the document did not, so it is
* pinned on its own below rather than folded into a count.
*/
const preFlipScript = readFileSync(
new URL('../terminal-document-pre-flip-script.txt', import.meta.url),
'utf8'
)
describe('the whole terminal document script', () => {
it('is what the modules emit, modulo the seven normalisations', async () => {
const emitted = await Promise.all(
@@ -25,8 +33,7 @@ describe('the whole terminal document script', () => {
)
)
const candidate = `(function() {\n${emitted.join('\n')}\n})();`
const baseline = readTerminalDocumentScript(XTERM_HTML, XTERM_ENGINE_JS)
expect(compareTerminalDocumentScripts(baseline, candidate, 'scope')).toEqual({
expect(compareTerminalDocumentScripts(preFlipScript, candidate, 'scope')).toEqual({
equivalent: true,
normalisations: {
// The qualifier, partitioned: 609 reads and writes of a name whose declaration stayed put,
@@ -48,4 +55,21 @@ describe('the whole terminal document script', () => {
}
})
})
it('adds the scope object and nothing else', async () => {
const script = await buildTerminalDocumentScript()
const emitted = await Promise.all(
TERMINAL_DOCUMENT_MODULE_ORDER.map((name) =>
emitTerminalDocumentModule(fileURLToPath(new URL(`./${name}.ts`, import.meta.url)))
)
)
const body = emitted.join('\n')
const at = script.indexOf(body)
expect(at).toBeGreaterThan(-1)
const preamble = script.slice('(function() {\n'.length, at)
expect(script.slice(at + body.length)).toBe('\n})();')
expect(preamble).toContain('function createTerminalDocumentScope()')
expect(preamble).toContain('const scope = createTerminalDocumentScope();')
expect(preamble.split('createTerminalDocumentScope').length - 1).toBe(2)
})
})
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,16 +1,11 @@
import { readFileSync } from 'node:fs'
import { Script } from 'node:vm'
import { Terminal } from '@xterm/xterm'
import { describe, expect, it, vi } from 'vitest'
import { TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS } from './terminal-keyboard-avoidance-metrics-injected'
import { parseTerminalKeyboardAvoidanceMetrics } from './terminal-webview-contract'
import { readTerminalWebViewHtmlSource } from './terminal-webview-html-source.test-support'
import { XTERM_HTML } from './terminal-webview-html'
const terminalHtmlSource = readTerminalWebViewHtmlSource()
const reflowSource = readFileSync(
new URL('./terminal-webview-reflow-injected.ts', import.meta.url),
'utf8'
)
const terminalHtmlSource = XTERM_HTML
type Cell = { isBgDefault: () => boolean; isInverse: () => number }
type MetricsNotification = {
@@ -193,17 +188,19 @@ describe('terminal keyboard-avoidance WebView metrics', () => {
it('refreshes metrics after every buffer geometry reset', () => {
const resizeStart = terminalHtmlSource.indexOf(' function resize(cols, rows)')
const resizeEnd = terminalHtmlSource.indexOf('\n // reflow()', resizeStart)
const clearStart = terminalHtmlSource.indexOf("} else if (msg.type === 'clear') {")
const clearEnd = terminalHtmlSource.indexOf("} else if (msg.type === 'measure')", clearStart)
const resizeEnd = terminalHtmlSource.indexOf('\n function reflow(', resizeStart)
const clearStart = terminalHtmlSource.indexOf('} else if (msg.type === "clear") {')
const clearEnd = terminalHtmlSource.indexOf('} else if (msg.type === "measure")', clearStart)
const textScaleStart = terminalHtmlSource.indexOf(' function applyTextScale(scale)')
const textScaleEnd = terminalHtmlSource.indexOf('\n var panX', textScaleStart)
const textScaleEnd = terminalHtmlSource.indexOf('\n scope.panX', textScaleStart)
const reflowStart = terminalHtmlSource.indexOf(' function reflow(cols, rows)')
const reflowEnd = terminalHtmlSource.indexOf('\n function notify(', reflowStart)
for (const block of [
terminalHtmlSource.slice(resizeStart, resizeEnd),
terminalHtmlSource.slice(clearStart, clearEnd),
terminalHtmlSource.slice(textScaleStart, textScaleEnd),
reflowSource
terminalHtmlSource.slice(reflowStart, reflowEnd)
]) {
expect(block.indexOf('emitKeyboardAvoidanceMetrics()')).toBeGreaterThan(
block.includes('term.resize') ? block.indexOf('term.resize') : block.indexOf('term.reset')
@@ -3,21 +3,26 @@ import { parse } from 'acorn'
import { describe, expect, it, vi } from 'vitest'
import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated'
import { XTERM_HTML } from './terminal-webview-html'
import { readTerminalWebViewHtmlSource } from './terminal-webview-html-source.test-support'
import { TERMINAL_WEBGL_RECOVERY_JS } from './terminal-webview-webgl-recovery-injected'
// Assert against the assembled document so extracted fragments cannot silently
// disappear from the WebView while source-level checks still pass.
const terminalHtmlSource = readTerminalWebViewHtmlSource()
const terminalHtmlSource = XTERM_HTML
/** The scope object the document opens with; the extracted block reads its state through it. */
function documentScopePreamble(): string {
const start = terminalHtmlSource.indexOf('(function() {\n')
const end = terminalHtmlSource.indexOf(' scope.surface = document.getElementById', start)
if (start === -1 || end <= start) {
throw new Error('the document does not open with the scope object')
}
return terminalHtmlSource.slice(start + '(function() {\n'.length, end)
}
function createWebglRecoveryHarness(failSecondAttach = false) {
const variablesStart = terminalHtmlSource.indexOf(' var webglAddon = null;')
const variablesEnd = terminalHtmlSource.indexOf(
'\n',
terminalHtmlSource.indexOf(' var webglRecoveryTimer = null;')
)
expect(variablesStart).toBeGreaterThanOrEqual(0)
expect(variablesEnd).toBeGreaterThan(variablesStart)
const recoveryStart = terminalHtmlSource.indexOf(' function refreshTerminalSurface()')
const recoveryEnd = terminalHtmlSource.indexOf(' function init(', recoveryStart)
expect(recoveryStart).toBeGreaterThanOrEqual(0)
expect(recoveryEnd).toBeGreaterThan(recoveryStart)
const timers: Array<() => void> = []
const addons: Array<{
@@ -74,8 +79,11 @@ function createWebglRecoveryHarness(failSecondAttach = false) {
terminalThemeInput,
window: { WebglAddon: { WebglAddon } }
}
new Script(`${terminalHtmlSource.slice(variablesStart, variablesEnd)}
${TERMINAL_WEBGL_RECOVERY_JS}
new Script(`${documentScopePreamble()}
scope.term = term;
scope.terminalGeneration = terminalGeneration;
scope.terminalThemeInput = terminalThemeInput;
${terminalHtmlSource.slice(recoveryStart, recoveryEnd)}
attachWebglAddon(true);`).runInNewContext(context)
return {
addons,
@@ -154,14 +162,14 @@ describe('terminal WebView bundled engine', () => {
it('reports WebView message handler failures instead of swallowing them', () => {
const start = terminalHtmlSource.indexOf('function handleIncomingMessage')
const end = terminalHtmlSource.indexOf("window.addEventListener('resize'", start)
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).toContain('"terminal init failed"')
expect(handlerSource).toContain('"terminal message failed"')
expect(handlerSource).not.toContain('catch(ex) {}')
})
@@ -170,11 +178,11 @@ describe('terminal WebView bundled engine', () => {
// 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/)
expect(terminalHtmlSource).toContain('scope.everReady = false;')
expect(terminalHtmlSource).toContain('scope.everReady = true;')
expect(terminalHtmlSource).toContain('fatal === void 0 ? !scope.everReady : !!fatal')
expect(terminalHtmlSource).toContain('msg.type === "init" && !scope.everReady')
expect(terminalHtmlSource).not.toMatch(/fatal === void 0 \? !scope\.ready\b/)
})
it('bounds error capture and non-fatal reporting on a degraded engine', () => {
@@ -235,7 +243,7 @@ describe('terminal WebView bundled engine', () => {
})
it('answers native readiness probes from the live document', () => {
expect(terminalHtmlSource).toContain("if (msg.type === 'ping')")
expect(terminalHtmlSource).toContain("notify({ type: 'pong', pingId: msg.id })")
expect(terminalHtmlSource).toContain('if (msg.type === "ping")')
expect(terminalHtmlSource).toContain('notify({ type: "pong", pingId: msg.id })')
})
})
@@ -1,31 +1,32 @@
import { readFileSync } from 'node:fs'
import { readdirSync, readFileSync } from 'node:fs'
const COMPOSER_FILE = './terminal-webview-html.ts'
const SLICE_IMPORT_RE = /^import \{[^}]*\} from '(\.\/terminal-webview-html\/[\w-]+)'$/gm
const COMPOSED_ENTRY_RE = /^ {2}TERMINAL_HTML_\w+,?$/gm
const DOCUMENT_DIRECTORY = './document/'
/** The parts of the document that are still markup rather than program. */
const SHELL_FILES = [
'./terminal-webview-html/document-shell.ts',
'./terminal-webview-html/document-close.ts',
'./terminal-webview-html/theme.ts'
]
function readSource(relativePath: string): string {
return readFileSync(new URL(relativePath, import.meta.url), 'utf8')
}
/**
* Reads the TypeScript source that assembles the in-WebView document.
* Reads the TypeScript source the in-WebView document is built from.
*
* Why: the slice list is derived from the composer's own imports rather than duplicated, so a
* new slice cannot join the emitted document while staying invisible to the tests that search
* this source. The count cross-check catches an import shape the regex cannot see.
* Why a directory and not a list: the document's script is generated from every module under
* `document/`, so a new one cannot join the emitted document while staying invisible to the tests
* that search this source.
*/
export function readTerminalWebViewHtmlSource(): string {
const composer = readSource(COMPOSER_FILE)
const slices = [...composer.matchAll(SLICE_IMPORT_RE)].map((match) => `${match[1]}.ts`)
const composedCount = [...composer.matchAll(COMPOSED_ENTRY_RE)].length
if (composedCount === 0) {
throw new Error('no composed WebView document slices found')
const modules = readdirSync(new URL(DOCUMENT_DIRECTORY, import.meta.url))
.filter((name) => name.endsWith('.ts') && !name.endsWith('.test.ts'))
.sort()
.map((name) => readSource(DOCUMENT_DIRECTORY + name))
if (modules.length < 30) {
throw new Error(`expected the document's modules, found ${modules.length}`)
}
if (slices.length !== composedCount) {
throw new Error(
`WebView document slice imports (${slices.length}) do not match composed entries (${composedCount})`
)
}
return [composer, ...slices.map(readSource)].join('\n')
return [readSource(COMPOSER_FILE), ...SHELL_FILES.map(readSource), ...modules].join('\n')
}
+6 -32
View File
@@ -1,41 +1,15 @@
import { TERMINAL_HTML_DOCUMENT_SHELL } from './terminal-webview-html/document-shell'
import { TERMINAL_HTML_RUNTIME_CONSTANTS } from './terminal-webview-html/runtime-constants'
import { TERMINAL_HTML_RUNTIME_STATE_AND_TEXT_SCALING } from './terminal-webview-html/runtime-state-and-text-scaling'
import { TERMINAL_HTML_FIT_SCALE } from './terminal-webview-html/terminal-fit-scale'
import { TERMINAL_HTML_MOUSE_MODE_DECSET_SCAN } from './terminal-webview-html/mouse-mode-decset-scan'
import { TERMINAL_HTML_WRITE_QUEUE } from './terminal-webview-html/write-queue'
import { TERMINAL_HTML_INIT_AND_WRITE } from './terminal-webview-html/terminal-init-and-write'
import { TERMINAL_HTML_HOST_MESSAGE_ROUTER } from './terminal-webview-html/host-message-router'
import { TERMINAL_HTML_SELECTION_STATE_AND_EVICTION } from './terminal-webview-html/selection-state-and-eviction'
import { TERMINAL_HTML_OBSERVERS_AND_MODE_MIRRORING } from './terminal-webview-html/term-observers-and-mode-mirroring'
import { TERMINAL_HTML_MOUSE_REPORT_AND_SCROLL_ROUTING } from './terminal-webview-html/mouse-report-and-scroll-routing'
import { TERMINAL_HTML_SMOOTH_SCROLL_AND_CELL_GEOMETRY } from './terminal-webview-html/smooth-scroll-and-cell-geometry'
import { TERMINAL_HTML_SELECTION_OVERLAY } from './terminal-webview-html/selection-overlay'
import { TERMINAL_HTML_SURFACE_TOUCH_GESTURES } from './terminal-webview-html/surface-touch-gestures'
import { TERMINAL_HTML_MESSAGE_BRIDGE } from './terminal-webview-html/message-bridge'
import { TERMINAL_DOCUMENT_SCRIPT } from './terminal-webview-document-script.generated'
import { TERMINAL_HTML_DOCUMENT_CLOSE } from './terminal-webview-html/document-close'
import { TERMINAL_HTML_DOCUMENT_SHELL } from './terminal-webview-html/document-shell'
export { MOBILE_TERMINAL_CARET_OPTIONS } from './terminal-webview-html/theme'
// Why: keep the document source stable while each script/style concern remains independently
// reviewable. Boundaries can only fall where the emitted document allows, so a few modules
// carry a second concern noted at the top of the file.
// Why: the script the WebView runs is generated from `src/terminal/document/`, the same modules the
// web page imports, so there is one source for both. The shell and the close are still text: they
// are markup, not program.
export const XTERM_HTML = [
TERMINAL_HTML_DOCUMENT_SHELL,
TERMINAL_HTML_RUNTIME_CONSTANTS,
TERMINAL_HTML_RUNTIME_STATE_AND_TEXT_SCALING,
TERMINAL_HTML_FIT_SCALE,
TERMINAL_HTML_MOUSE_MODE_DECSET_SCAN,
TERMINAL_HTML_WRITE_QUEUE,
TERMINAL_HTML_INIT_AND_WRITE,
TERMINAL_HTML_HOST_MESSAGE_ROUTER,
TERMINAL_HTML_SELECTION_STATE_AND_EVICTION,
TERMINAL_HTML_OBSERVERS_AND_MODE_MIRRORING,
TERMINAL_HTML_MOUSE_REPORT_AND_SCROLL_ROUTING,
TERMINAL_HTML_SMOOTH_SCROLL_AND_CELL_GEOMETRY,
TERMINAL_HTML_SELECTION_OVERLAY,
TERMINAL_HTML_SURFACE_TOUCH_GESTURES,
TERMINAL_HTML_MESSAGE_BRIDGE,
TERMINAL_DOCUMENT_SCRIPT,
TERMINAL_HTML_DOCUMENT_CLOSE
].join('')
@@ -1,5 +1,5 @@
// Closes the IIFE the whole script runs in, then the document itself.
export const TERMINAL_HTML_DOCUMENT_CLOSE = `})();
// Closes the document after the generated script.
export const TERMINAL_HTML_DOCUMENT_CLOSE = `
</script>
</body>
</html>`
@@ -161,5 +161,4 @@ window.onerror = function(msg) {
</div>
<script>${XTERM_ENGINE_JS}</script>
<script>
(function() {
`
@@ -6,8 +6,8 @@ import { XTERM_HTML } from './terminal-webview-html'
// uncovered region ships silently. A diff here means the emitted WebView source changed —
// update these values only when that change is deliberate, and only after checking the
// document still runs. Refactors that merely move slice boundaries must leave them alone.
const EXPECTED_SHA256 = '25b800f342c972f0b8eaba54367bd8b02b7518e9ea6a25e04ab89b3a2ad7d21b'
const EXPECTED_LENGTH = 730472
const EXPECTED_SHA256 = 'c84ce5fc7343546427ad875aeebea90e54560579a1d18b3b700076a1c4b4623f'
const EXPECTED_LENGTH = 723480
describe('terminal WebView payload', () => {
it('composes the expected document', () => {
@@ -39,7 +39,7 @@ describe('mobile terminal query replies', () => {
it('forwards xterm-generated data only after initial replay drains', () => {
const listenerIndex = XTERM_WEBVIEW_SOURCE.html.indexOf('term.onData(function(data)')
const enableIndex = XTERM_WEBVIEW_SOURCE.html.indexOf(
'attachTerminalQueryReplyBridge(term, gen)',
'attachTerminalQueryReplyBridge(scope.term, gen)',
listenerIndex
)
const notifyIndex = XTERM_WEBVIEW_SOURCE.html.indexOf(
@@ -52,9 +52,9 @@ describe('mobile terminal query replies', () => {
expect(notifyIndex).toBeGreaterThan(listenerIndex)
expect(XTERM_WEBVIEW_SOURCE.html).toContain('disableStdin: false')
expect(XTERM_WEBVIEW_SOURCE.html).toContain(
'term.attachCustomKeyEventHandler(function() { return false; })'
'term.attachCustomKeyEventHandler(function() {\n return false;\n });'
)
expect(XTERM_WEBVIEW_SOURCE.html).toContain('term.textarea.readOnly = true')
expect(XTERM_WEBVIEW_SOURCE.html).toContain('term.textarea.readOnly = true;')
})
it('mutes a replacement terminal until its own replay drains', () => {
@@ -64,7 +64,7 @@ describe('mobile terminal query replies', () => {
initIndex
)
const enableIndex = XTERM_WEBVIEW_SOURCE.html.indexOf(
'attachTerminalQueryReplyBridge(term, gen)',
'attachTerminalQueryReplyBridge(scope.term, gen)',
disableIndex
)
@@ -114,9 +114,9 @@ describe('mobile terminal query replies', () => {
gate.forward('\x1b[3;4R')
expect(messages).toEqual([{ type: 'terminal-data', bytes: '\x1b[3;4R' }])
const clearStart = XTERM_WEBVIEW_SOURCE.html.indexOf("} else if (msg.type === 'clear') {")
const clearStart = XTERM_WEBVIEW_SOURCE.html.indexOf('} else if (msg.type === "clear") {')
const clearEnd = XTERM_WEBVIEW_SOURCE.html.indexOf(
"} else if (msg.type === 'measure')",
'} else if (msg.type === "measure")',
clearStart
)
expect(XTERM_WEBVIEW_SOURCE.html.slice(clearStart, clearEnd)).toContain(
@@ -1,7 +1,6 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { XTERM_HTML } from './terminal-webview-html'
import { readTerminalWebViewHtmlSource } from './terminal-webview-html-source.test-support'
// The reflow logic lives as injected in-WebView JS; the message dispatch and
// handle wiring live in terminal-webview-html.ts / TerminalWebView.tsx. Assert
@@ -10,8 +9,8 @@ const reflowSource = readFileSync(
new URL('./terminal-webview-reflow-injected.ts', import.meta.url),
'utf8'
)
// Use the assembled document so the test covers the fragments that run in the WebView.
const htmlSource = readTerminalWebViewHtmlSource()
// Use the assembled document so the test covers what the WebView actually runs.
const htmlSource = XTERM_HTML
const handleSource = readFileSync(new URL('./TerminalWebView.tsx', import.meta.url), 'utf8')
function reflowFnBody(): string {
@@ -46,16 +45,16 @@ describe('terminal WebView reflow', () => {
})
it('is dispatched by the reflow WebView message and exposed on the handle', () => {
expect(htmlSource).toContain("} else if (msg.type === 'reflow') {")
expect(htmlSource).toContain('} else if (msg.type === "reflow") {')
expect(htmlSource).toContain('reflow(msg.cols, msg.rows);')
expect(handleSource).toContain("postMessage({ type: 'reflow', cols, rows })")
})
it('does not locally resize hidden WebViews to a one-column grid', () => {
expect(htmlSource).toContain('var MIN_FIT_COLS = 20;')
expect(htmlSource).toContain('if (cols < MIN_FIT_COLS) return;')
expect(htmlSource).toContain("flog('measure-skip-small-width'")
expect(htmlSource).toContain("notify({ type: 'measure-result', cols: null, rows: null });")
expect(htmlSource).toContain('scope.MIN_FIT_COLS = 20;')
expect(htmlSource).toContain('if (cols < scope.MIN_FIT_COLS) {')
expect(htmlSource).toContain('flog("measure-skip-small-width"')
expect(htmlSource).toContain('notify({ type: "measure-result", cols: null, rows: null });')
})
// Why: the raw-source assertions above pass even if the reflow module is
@@ -74,7 +73,7 @@ describe('terminal WebView reflow', () => {
})
it('still routes the reflow message to the injected routine', () => {
expect(XTERM_HTML).toContain("} else if (msg.type === 'reflow') {")
expect(XTERM_HTML).toContain('} else if (msg.type === "reflow") {')
expect(XTERM_HTML).toContain('reflow(msg.cols, msg.rows);')
})
@@ -84,8 +83,8 @@ describe('terminal WebView reflow', () => {
// between them; if its IIFE-time code threw, the listener below would
// never bind and reflow messages would silently no-op.
const reflowAt = XTERM_HTML.indexOf('function reflow(cols, rows) {')
const dispatchAt = XTERM_HTML.indexOf("var dispatch = { mode: 'idle'")
const listenerAt = XTERM_HTML.indexOf("window.addEventListener('message'")
const dispatchAt = XTERM_HTML.indexOf('const dispatch = {\n mode: "idle"')
const listenerAt = XTERM_HTML.indexOf('window.addEventListener("message"')
expect(reflowAt).toBeGreaterThanOrEqual(0)
expect(dispatchAt).toBeGreaterThan(reflowAt)
expect(listenerAt).toBeGreaterThan(dispatchAt)
@@ -1,6 +1,6 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { readTerminalWebViewHtmlSource } from './terminal-webview-html-source.test-support'
import { XTERM_HTML } from './terminal-webview-html'
// The in-WebView JS lives in terminal-webview-html.ts; the RN wrapper in
// TerminalWebView.tsx. Concatenate both so assertions resolve regardless of file.
@@ -8,8 +8,7 @@ const source =
readFileSync(new URL('./TerminalWebView.tsx', import.meta.url), 'utf8') +
readFileSync(new URL('./terminal-webview-pending-messages.ts', import.meta.url), 'utf8') +
readFileSync(new URL('./terminal-webview-url-tap.ts', import.meta.url), 'utf8') +
readFileSync(new URL('./terminal-webview-tap-dispatch-injected.ts', import.meta.url), 'utf8') +
readTerminalWebViewHtmlSource()
XTERM_HTML
const sessionSource = readFileSync(
new URL('../session/use-mobile-session-terminal-input.ts', import.meta.url),
'utf8'
@@ -33,9 +32,11 @@ describe('TerminalWebView scroll routing', () => {
})
it('maps a downward pull at the bottom to older scrollback rows', () => {
expect(source).toContain('var deltaY = ts.lastY - y;')
expect(source).toContain('smoothScrollOffsetY -= deltaY;')
expect(source).toContain('var lines = Math.trunc(-smoothScrollOffsetY / effectiveCellH);')
expect(source).toContain('const deltaY = ts.lastY - y;')
expect(source).toContain('scope.smoothScrollOffsetY -= deltaY;')
expect(source).toContain(
'const lines = Math.trunc(-scope.smoothScrollOffsetY / effectiveCellH);'
)
const nextViewportY = simulateNormalBufferPull({
baseY: 120,
@@ -54,15 +55,18 @@ describe('TerminalWebView scroll routing', () => {
)
const touchMoveBlock = sliceBetween(
"targetSurface.addEventListener('touchmove'",
'}, { capture: true, passive: false });'
'targetSurface.addEventListener(\n "touchmove"',
'{ capture: true, passive: false }'
)
expect(touchMoveBlock.indexOf('if (shouldRouteScrollToTerminalInput())')).toBeLessThan(
touchMoveBlock.indexOf('if (enqueueNormalBufferScrollDelta(deltaY))')
)
expect(touchMoveBlock).toContain('routeScrollLines(lines, x, y);')
const momentumBlock = sliceBetween('function momentumStep()', 'if (Math.abs(vel) > MIN_VEL)')
const momentumBlock = sliceBetween(
'let momentumStep = function()',
'if (Math.abs(vel) > MIN_VEL)'
)
expect(momentumBlock.indexOf('if (shouldRouteScrollToTerminalInput())')).toBeLessThan(
momentumBlock.indexOf('if (!applyNormalBufferScrollDelta(delta))')
)
@@ -81,13 +85,16 @@ describe('TerminalWebView scroll routing', () => {
expect(smoothScrollBlock).toContain('return true;')
const touchMoveBlock = sliceBetween(
"targetSurface.addEventListener('touchmove'",
'}, { capture: true, passive: false });'
'targetSurface.addEventListener(\n "touchmove"',
'{ capture: true, passive: false }'
)
expect(touchMoveBlock).toContain('if (enqueueNormalBufferScrollDelta(deltaY))')
expect(touchMoveBlock).toContain('ts.velY = 0;')
const momentumBlock = sliceBetween('function momentumStep()', 'if (Math.abs(vel) > MIN_VEL)')
const momentumBlock = sliceBetween(
'let momentumStep = function()',
'if (Math.abs(vel) > MIN_VEL)'
)
expect(momentumBlock).toContain('if (!applyNormalBufferScrollDelta(delta))')
expect(momentumBlock).toContain('ts.momentumId = null;')
})
@@ -97,24 +104,24 @@ describe('TerminalWebView scroll routing', () => {
'function enqueueNormalBufferScrollDelta(deltaY)',
'function resetSmoothScrollOffset()'
)
expect(enqueueBlock).toContain('pendingNormalScrollDeltaY += deltaY;')
expect(enqueueBlock).toContain('if (normalScrollFrameId !== null) return true;')
expect(enqueueBlock).toContain('normalScrollFrameId = requestAnimationFrame(function()')
expect(enqueueBlock).toContain('scope.pendingNormalScrollDeltaY += deltaY;')
expect(enqueueBlock).toContain('if (scope.normalScrollFrameId !== null) {')
expect(enqueueBlock).toContain('scope.normalScrollFrameId = requestAnimationFrame(function()')
expect(enqueueBlock).toContain('applyNormalBufferScrollDelta(delta)')
const resetBlock = sliceBetween(
'function resetSmoothScrollOffset()',
'function cellToViewportPx'
)
expect(resetBlock).toContain('pendingNormalScrollDeltaY = 0;')
expect(resetBlock).toContain('cancelAnimationFrame(normalScrollFrameId);')
expect(resetBlock).toContain('scope.pendingNormalScrollDeltaY = 0;')
expect(resetBlock).toContain('cancelAnimationFrame(scope.normalScrollFrameId);')
})
it('drains terminal writes without shifting the queued array', () => {
expect(source).toContain('var writeQueueHead = 0;')
expect(source).toContain('scope.writeQueueHead = 0;')
expect(source).toContain('function nextQueuedWrite()')
expect(source).toContain('writeQueueHead++;')
expect(source).toContain('writeQueue = writeQueue.slice(writeQueueHead);')
expect(source).toContain('scope.writeQueueHead++;')
expect(source).toContain('scope.writeQueue = scope.writeQueue.slice(scope.writeQueueHead);')
expect(source).not.toContain('writeQueue.shift()')
})
@@ -159,67 +166,67 @@ describe('TerminalWebView scroll routing', () => {
'function updateScrollIndicator(reveal)'
)
expect(updateTransformBlock).toContain(
"surface.style.transform = 'translate(' + panX + 'px,' + panY + 'px) scale(' + getTotalScale() + ')';"
'scope.surface.style.transform = "translate(" + scope.panX + "px," + scope.panY + "px) scale(" + getTotalScale() + ")"'
)
expect(source).not.toContain("querySelector('.xterm-screen')")
expect(source).not.toContain('updateTerminalScreenTransform')
expect(updateTransformBlock).not.toContain("getVisualPanY() + 'px) scale('")
expect(updateTransformBlock).not.toContain('getVisualPanY() + "px) scale("')
expect(updateTransformBlock).not.toContain('smoothScrollOffsetY')
})
it('smooths velocity samples and uses lower friction for mobile momentum', () => {
expect(source).toContain('function updateTouchVelocity(deltaY, dt)')
expect(source).toContain('ts.velY * 0.55 + instantVelocity * 0.45')
expect(source).toContain('var FRICTION = 0.972;')
expect(source).toContain('var MIN_VEL = 0.012;')
expect(source).toContain('const FRICTION = 0.972;')
expect(source).toContain('const MIN_VEL = 0.012;')
})
it('keeps selection edge autoscroll active and extends the dragged endpoint', () => {
const startBlock = sliceBetween('function startEdgeScroll(dir)', 'function stopEdgeScroll()')
expect(startBlock.indexOf('stopEdgeScroll();')).toBeLessThan(
startBlock.indexOf('edgeScrollDir = dir;')
startBlock.indexOf('scope.edgeScrollDir = dir;')
)
expect(startBlock.indexOf('term.scrollLines(edgeScrollDir);')).toBeLessThan(
expect(startBlock.indexOf('scope.term.scrollLines(scope.edgeScrollDir);')).toBeLessThan(
startBlock.indexOf('syncEdgeScrollSelectionEndpoint();')
)
const dragMoveBlock = sliceBetween(
'function handleDragMove(handle, clientX, clientY)',
' // Latching document-level touch dispatcher: see'
'function attachSurfaceEventHandlers('
)
expect(dragMoveBlock).toContain('edgeScrollClientX = clientX;')
expect(dragMoveBlock).toContain('edgeScrollClientY = clientY;')
expect(dragMoveBlock).toContain('scope.edgeScrollClientX = clientX;')
expect(dragMoveBlock).toContain('scope.edgeScrollClientY = clientY;')
expect(dragMoveBlock).toContain('syncSelectionHandleToViewportPoint(handle, clientX, clientY)')
})
it('opens links and paths from surface taps before mouse/focus fallback', () => {
expect(source).toContain('function buildMouseClickInput(clientX, clientY)')
expect(source).toContain('function isClickMouseTrackingMode(mode)')
expect(source).toContain("return mode !== 'none';")
expect(source).toContain('var pixelX = cell.x;')
expect(source).toContain('var pixelY = cell.y;')
expect(source).toContain('return mode !== "none";')
expect(source).toContain('const pixelX = cell.x;')
expect(source).toContain('const pixelY = cell.y;')
expect(source).toContain(
'if (!isSafeSgrMouseCoordinate(cell.x) || !isSafeSgrMouseCoordinate(cell.y)) return'
'if (!isSafeSgrMouseCoordinate(cell.x) || !isSafeSgrMouseCoordinate(cell.y)) {'
)
expect(source).toContain(
'if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return'
'if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) {'
)
expect(source).toContain("if (mouseTrackingMode === 'x10') return pixelPress;")
expect(source).toContain("if (mouseTrackingMode === 'x10') return sgrPress;")
expect(source).toContain("if (mouseTrackingMode === 'x10') return press;")
expect(source).toContain("if (col > 126 || row > 126) return '';")
expect(source).toContain('if (mouseTrackingMode === "x10") {\n return pixelPress;')
expect(source).toContain('if (mouseTrackingMode === "x10") {\n return sgrPress;')
expect(source).toContain('if (mouseTrackingMode === "x10") {\n return press;')
expect(source).toContain('if (col > 126 || row > 126) {\n return "";')
const touchEndBlock = sliceBetween(
"document.addEventListener('touchend'",
'}, { capture: true, passive: true });'
'document.addEventListener(\n "touchend"',
'{ capture: true, passive: true }'
)
expect(touchEndBlock).toContain(
'notifyTerminalSurfaceTap(tapCandidate.x, tapCandidate.y, true)'
'notifyTerminalSurfaceTap(scope.tapCandidate.x, scope.tapCandidate.y, true)'
)
const tapHandlerBlock = sliceBetween(
'function notifyTerminalSurfaceTap(originX, originY, focusKeyboard)',
"document.addEventListener('touchstart'"
'document.addEventListener(\n "touchstart"'
)
expect(tapHandlerBlock.indexOf('oscLinkAtViewportPoint')).toBeLessThan(
tapHandlerBlock.indexOf('urlAtViewportPoint')
@@ -228,10 +235,10 @@ describe('TerminalWebView scroll routing', () => {
tapHandlerBlock.indexOf('filePathAtViewportPoint')
)
expect(tapHandlerBlock.indexOf('filePathAtViewportPoint')).toBeLessThan(
tapHandlerBlock.indexOf('var clickInput = buildMouseClickInput')
tapHandlerBlock.indexOf('const clickInput = buildMouseClickInput')
)
expect(tapHandlerBlock).toContain("notify({ type: 'open-url', url: tappedUrl });")
expect(tapHandlerBlock).toContain("notify({ type: 'terminal-input', bytes: clickInput });")
expect(tapHandlerBlock).toContain('notify({ type: "open-url", url: tappedUrl });')
expect(tapHandlerBlock).toContain('notify({ type: "terminal-input", bytes: clickInput });')
expect(tapHandlerBlock).toContain(
'if (focusKeyboard || !isClickMouseTrackingMode(getMouseTrackingMode()))'
)
@@ -1,7 +1,7 @@
import { readFileSync } from 'node:fs'
import { Script } from 'node:vm'
import { describe, expect, it } from 'vitest'
import { readTerminalWebViewHtmlSource } from './terminal-webview-html-source.test-support'
import { XTERM_HTML } from './terminal-webview-html'
const terminalWebViewSource = readFileSync(
new URL('./TerminalWebView.tsx', import.meta.url),
@@ -15,24 +15,37 @@ const terminalHtmlDocumentShellSource = readFileSync(
new URL('./terminal-webview-html/document-shell.ts', import.meta.url),
'utf8'
)
// Read behavior from the assembled document; the module source only contains
// fragment imports and cannot prove the injected code is present.
const terminalHtmlSource = readTerminalWebViewHtmlSource()
// Read behavior from the assembled document: it is what the WebView runs, and the module source
// alone cannot prove the generated script carries the code.
const terminalHtmlSource = XTERM_HTML
/**
* The scope object the document opens with, which every extracted fragment below needs in order to
* run: the fragments read and write document state through it.
*/
function documentScopePreamble(): string {
const start = terminalHtmlSource.indexOf('(function() {\n')
const end = terminalHtmlSource.indexOf(' scope.surface = document.getElementById', start)
if (start === -1 || end <= start) {
throw new Error('the document does not open with the scope object')
}
return terminalHtmlSource.slice(start + '(function() {\n'.length, end)
}
const terminalWebglRecoverySource = readFileSync(
new URL('./terminal-webview-webgl-recovery-injected.ts', import.meta.url),
'utf8'
)
function extractStatusDotNormalizer() {
const declarationStart = terminalHtmlSource.indexOf(' var CLAUDE_STATUS_DOT =')
const declarationEnd = terminalHtmlSource.indexOf(' var PRIVATE_MODE_SCAN_TAIL_LIMIT')
const declarationStart = terminalHtmlSource.indexOf(' scope.CLAUDE_STATUS_DOT =')
const declarationEnd = terminalHtmlSource.indexOf(' scope.PRIVATE_MODE_SCAN_TAIL_LIMIT')
const functionStart = terminalHtmlSource.indexOf(' function isStatusDotPresentationSelector')
const functionEnd = terminalHtmlSource.indexOf('\n\n function enqueueWrite', functionStart)
const functionEnd = terminalHtmlSource.indexOf('\n function enqueueWrite', functionStart)
expect(declarationStart).toBeGreaterThanOrEqual(0)
expect(declarationEnd).toBeGreaterThan(declarationStart)
expect(functionStart).toBeGreaterThan(declarationEnd)
expect(functionEnd).toBeGreaterThan(functionStart)
return `${terminalHtmlSource.slice(declarationStart, declarationEnd)}\n${terminalHtmlSource.slice(functionStart, functionEnd)}`
return `${documentScopePreamble()}${terminalHtmlSource.slice(declarationStart, declarationEnd)}\n${terminalHtmlSource.slice(functionStart, functionEnd)}`
}
function normalizeStatusDotChunks(chunks: string[]) {
@@ -52,8 +65,8 @@ function resolveTerminalFontFamily(navigatorValue: {
// Slice only the font block itself (isIOSWebView + terminalFontFamily), anchored
// on font-related markers so unrelated edits below it can't break this extraction.
const functionStart = terminalHtmlSource.indexOf(' function isIOSWebView()')
const declarationLine = terminalHtmlSource.indexOf(' var terminalFontFamily =', functionStart)
const declarationEnd = terminalHtmlSource.indexOf('\n', declarationLine)
const declarationLine = terminalHtmlSource.indexOf(' scope.terminalFontFamily =', functionStart)
const declarationEnd = terminalHtmlSource.indexOf(';\n', declarationLine) + 1
expect(functionStart).toBeGreaterThanOrEqual(0)
expect(declarationLine).toBeGreaterThan(functionStart)
expect(declarationEnd).toBeGreaterThan(declarationLine)
@@ -61,8 +74,8 @@ function resolveTerminalFontFamily(navigatorValue: {
navigator: navigatorValue
}
new Script(`
${terminalHtmlSource.slice(functionStart, declarationEnd)}
output = terminalFontFamily;
${documentScopePreamble()}${terminalHtmlSource.slice(functionStart, declarationEnd)}
output = scope.terminalFontFamily;
`).runInNewContext(context)
return context.output ?? ''
}
@@ -92,16 +105,20 @@ describe('TerminalWebView text zoom', () => {
it('forces the Claude status dot to text presentation before xterm writes', () => {
expect(terminalHtmlSource).toContain('font-variant-emoji: text')
expect(terminalHtmlSource).toContain('var CLAUDE_STATUS_DOT = String.fromCharCode(0x23fa)')
expect(terminalHtmlSource).toContain('TEXT_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0e)')
expect(terminalHtmlSource).toContain('scope.CLAUDE_STATUS_DOT = String.fromCharCode(9210)')
expect(terminalHtmlSource).toContain(
'EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0f)'
'scope.TEXT_PRESENTATION_SELECTOR = String.fromCharCode(65038)'
)
expect(terminalHtmlSource).toContain(
'scope.EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(65039)'
)
expect(terminalHtmlSource).toContain('function normalizeStatusDotPresentation(data)')
expect(terminalHtmlSource).toContain(
'data.replace(CLAUDE_STATUS_DOT_PATTERN, CLAUDE_STATUS_DOT + TEXT_PRESENTATION_SELECTOR)'
'data.replace(\n scope.CLAUDE_STATUS_DOT_PATTERN,\n scope.CLAUDE_STATUS_DOT + scope.TEXT_PRESENTATION_SELECTOR\n )'
)
expect(terminalHtmlSource).toContain(
'scope.writeQueue.push(normalizeStatusDotPresentation(data))'
)
expect(terminalHtmlSource).toContain('writeQueue.push(normalizeStatusDotPresentation(data))')
})
it('normalizes Claude status dots idempotently across write chunks', () => {
@@ -133,28 +150,28 @@ describe('TerminalWebView text zoom', () => {
it('resets pending Claude status dot selector state when the terminal lifecycle resets', () => {
const initStart = terminalHtmlSource.indexOf('function init(')
const initReplay = terminalHtmlSource.indexOf(
'var replayData = normalizeInitialData(initialData)'
'const replayData = normalizeInitialData(initialData)'
)
const clearStart = terminalHtmlSource.indexOf("} else if (msg.type === 'clear') {")
const clearEnd = terminalHtmlSource.indexOf("} else if (msg.type === 'measure')", clearStart)
const clearStart = terminalHtmlSource.indexOf('} else if (msg.type === "clear") {')
const clearEnd = terminalHtmlSource.indexOf('} else if (msg.type === "measure")', clearStart)
expect(initStart).toBeGreaterThanOrEqual(0)
expect(initReplay).toBeGreaterThan(initStart)
expect(clearStart).toBeGreaterThanOrEqual(0)
expect(clearEnd).toBeGreaterThan(clearStart)
expect(terminalHtmlSource.slice(initStart, initReplay)).toContain(
'statusDotPendingSelector = false'
'scope.statusDotPendingSelector = false'
)
expect(terminalHtmlSource.slice(clearStart, clearEnd)).toContain(
'statusDotPendingSelector = false'
'scope.statusDotPendingSelector = false'
)
})
it('loads Unicode 11 before replaying mobile terminal bytes', () => {
expect(terminalHtmlDocumentShellSource).toContain('XTERM_ENGINE_JS')
expect(terminalHtmlSource).toContain('window.Unicode11Addon.Unicode11Addon')
const open = terminalHtmlSource.indexOf('term.open(surface)')
const unicode = terminalHtmlSource.indexOf("term.unicode.activeVersion = '11'")
const replay = terminalHtmlSource.indexOf("enqueueWrite(ESC + '[0m' + replayData)")
const open = terminalHtmlSource.indexOf('scope.term.open(scope.surface)')
const unicode = terminalHtmlSource.indexOf('scope.term.unicode.activeVersion = "11"')
const replay = terminalHtmlSource.indexOf('enqueueWrite(scope.ESC + "[0m" + replayData)')
expect(open).toBeGreaterThanOrEqual(0)
expect(unicode).toBeGreaterThan(open)
expect(replay).toBeGreaterThan(unicode)
@@ -164,9 +181,9 @@ describe('TerminalWebView text zoom', () => {
expect(terminalHtmlSource).not.toContain('cdn.jsdelivr.net')
expect(terminalWebglRecoverySource).toContain('window.WebglAddon.WebglAddon')
expect(terminalHtmlSource).toContain('function isIOSWebView()')
expect(terminalHtmlSource).toContain('fontFamily: terminalFontFamily')
expect(terminalHtmlSource).toContain("fontWeight: '300'")
expect(terminalHtmlSource).toContain("fontWeightBold: '500'")
expect(terminalHtmlSource).toContain('fontFamily: scope.terminalFontFamily')
expect(terminalHtmlSource).toContain('fontWeight: "300"')
expect(terminalHtmlSource).toContain('fontWeightBold: "500"')
expect(terminalWebglRecoverySource).toContain('new window.WebglAddon.WebglAddon()')
})
@@ -204,6 +204,6 @@ describe('findUrlAtColumn', () => {
expect(XTERM_HTML).toContain('function isLocalFileUriHostname(')
expect(XTERM_HTML).toContain('return parsePathLineCol(value);')
expect(XTERM_HTML).toContain('function notifyTerminalSurfaceTap(')
expect(XTERM_HTML).toContain("notify({ type: 'open-url', url: tappedUrl });")
expect(XTERM_HTML).toContain('notify({ type: "open-url", url: tappedUrl });')
})
})