mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
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:
@@ -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
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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 });')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user