orca/mobile/src/terminal/terminal-webview-scroll-rou...

284 lines
12 KiB
TypeScript

import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
// The in-WebView JS lives in terminal-webview-html.ts; the RN wrapper in
// TerminalWebView.tsx. Concatenate both so assertions resolve regardless of file.
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') +
readFileSync(new URL('./terminal-webview-html.ts', import.meta.url), 'utf8')
const sessionSource = readFileSync(
new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url),
'utf8'
)
const sessionHelperSource = readFileSync(
new URL('../session/mobile-session-route-helpers.ts', import.meta.url),
'utf8'
)
function sliceBetween(startPattern: string, endPattern: string): string {
const start = source.indexOf(startPattern)
expect(start).toBeGreaterThanOrEqual(0)
const end = source.indexOf(endPattern, start)
expect(end).toBeGreaterThan(start)
return source.slice(start, end)
}
describe('TerminalWebView scroll routing', () => {
it('keeps Android touch drags inside the terminal WebView', () => {
expect(source).toContain('nestedScrollEnabled')
})
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);')
const nextViewportY = simulateNormalBufferPull({
baseY: 120,
viewportY: 120,
startY: 300,
endY: 340,
cellHeight: 20
})
expect(nextViewportY).toBe(118)
})
it('routes alternate-screen and mouse-aware scroll before smooth normal scroll', () => {
expect(source).toContain(
'return isWheelMouseTrackingMode(getMouseTrackingMode()) || isAlternateBufferActive();'
)
const touchMoveBlock = sliceBetween(
"targetSurface.addEventListener('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)')
expect(momentumBlock.indexOf('if (shouldRouteScrollToTerminalInput())')).toBeLessThan(
momentumBlock.indexOf('if (!applyNormalBufferScrollDelta(delta))')
)
expect(momentumBlock).toContain('routeScrollLines(lines, ts.lastX, ts.lastY);')
})
it('does not rubber-band normal scroll at scrollback edges', () => {
expect(source).toContain('function canScrollNormalBufferDelta(deltaY)')
const smoothScrollBlock = sliceBetween(
'function applyNormalBufferScrollDelta(deltaY)',
'function enqueueNormalBufferScrollDelta(deltaY)'
)
expect(smoothScrollBlock).toContain('if (!canScrollNormalBufferDelta(deltaY))')
expect(smoothScrollBlock).toContain('resetSmoothScrollOffset();')
expect(smoothScrollBlock).toContain('return false;')
expect(smoothScrollBlock).toContain('return true;')
const touchMoveBlock = sliceBetween(
"targetSurface.addEventListener('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)')
expect(momentumBlock).toContain('if (!applyNormalBufferScrollDelta(delta))')
expect(momentumBlock).toContain('ts.momentumId = null;')
})
it('coalesces normal touch scroll row commits onto animation frames', () => {
const enqueueBlock = sliceBetween(
'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('applyNormalBufferScrollDelta(delta)')
const resetBlock = sliceBetween(
'function resetSmoothScrollOffset()',
'function cellToViewportPx'
)
expect(resetBlock).toContain('pendingNormalScrollDeltaY = 0;')
expect(resetBlock).toContain('cancelAnimationFrame(normalScrollFrameId);')
})
it('drains terminal writes without shifting the queued array', () => {
expect(source).toContain('var writeQueueHead = 0;')
expect(source).toContain('function nextQueuedWrite()')
expect(source).toContain('writeQueueHead++;')
expect(source).toContain('writeQueue = writeQueue.slice(writeQueueHead);')
expect(source).not.toContain('writeQueue.shift()')
})
it('bounds native-side pending WebView writes while preserving control messages', () => {
expect(source).toContain('const MAX_PENDING_WEB_WRITE_BYTES = 1_000_000')
expect(source).toContain('const MAX_PENDING_WEB_WRITE_MESSAGES = 4096')
expect(source).toContain('let pendingWriteBytes = 0')
expect(source).toContain('let pendingWriteCount = 0')
expect(source).toContain('const queue = (msg: TerminalWebViewCommand)')
expect(source).toContain('pendingWriteCount > MAX_PENDING_WEB_WRITE_MESSAGES')
expect(source).toContain("candidate.type === 'write'")
expect(source).toContain('pendingMessages.queue(msg)')
expect(source).toContain('pendingMessages.clear()')
})
it('clears WebView await timers when the real response wins', () => {
const measureBlock = sliceBetween('measureFitDimensions(', 'resetZoom()')
expect(measureBlock).toContain('clearTimeout(timeout)')
expect(measureBlock).toContain('measureResolveRef.current === finish')
const readyBlock = sliceBetween('async awaitReady()', '})')
expect(readyBlock).toContain('clearTimeout(timeout)')
expect(readyBlock).toContain('void p.finally')
})
it('hides xterm scrollbars and drives the mobile scroll indicator from committed rows', () => {
expect(source).toContain('<div id="scroll-indicator"><div id="scroll-thumb"></div></div>')
expect(source).toContain('.xterm .xterm-viewport::-webkit-scrollbar')
expect(source).toContain('.xterm .xterm-scrollable-element > .xterm-scrollbar')
expect(source).toContain('overflow-y: hidden !important;')
expect(source).toContain('display: none !important;')
expect(source).toContain('function updateScrollIndicator(reveal)')
expect(source).toContain('buffer.viewportY / maxViewportY')
expect(source).not.toContain('fractionalRows')
expect(source).toContain('scrollThumb.style.transform =')
expect(source).toContain('updateScrollIndicator(true);')
})
it('does not apply fractional smooth scroll transforms to terminal content', () => {
const updateTransformBlock = sliceBetween(
'function updateTransform()',
'function updateScrollIndicator(reveal)'
)
expect(updateTransformBlock).toContain(
"surface.style.transform = 'translate(' + panX + 'px,' + 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('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;')
})
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;')
)
expect(startBlock.indexOf('term.scrollLines(edgeScrollDir);')).toBeLessThan(
startBlock.indexOf('syncEdgeScrollSelectionEndpoint();')
)
const dragMoveBlock = sliceBetween(
'function handleDragMove(handle, clientX, clientY)',
' // Latching document-level touch dispatcher: see'
)
expect(dragMoveBlock).toContain('edgeScrollClientX = clientX;')
expect(dragMoveBlock).toContain('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(
'if (!isSafeSgrMouseCoordinate(cell.x) || !isSafeSgrMouseCoordinate(cell.y)) return'
)
expect(source).toContain(
'if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return'
)
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 '';")
const touchEndBlock = sliceBetween(
"document.addEventListener('touchend'",
'}, { capture: true, passive: true });'
)
expect(touchEndBlock).toContain(
'notifyTerminalSurfaceTap(tapCandidate.x, tapCandidate.y, true)'
)
const tapHandlerBlock = sliceBetween(
'function notifyTerminalSurfaceTap(originX, originY, focusKeyboard)',
"document.addEventListener('touchstart'"
)
expect(tapHandlerBlock.indexOf('oscLinkAtViewportPoint')).toBeLessThan(
tapHandlerBlock.indexOf('urlAtViewportPoint')
)
expect(tapHandlerBlock.indexOf('urlAtViewportPoint')).toBeLessThan(
tapHandlerBlock.indexOf('filePathAtViewportPoint')
)
expect(tapHandlerBlock.indexOf('filePathAtViewportPoint')).toBeLessThan(
tapHandlerBlock.indexOf('var clickInput = buildMouseClickInput')
)
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()))'
)
expect(tapHandlerBlock).toContain("notify({ type: 'terminal-tap' });")
})
it('allows x10 mouse gesture reports through the mobile session gate', () => {
expect(sessionHelperSource).toContain('function isGestureMouseTrackingMode')
expect(sessionHelperSource).toContain(
"return mode === 'x10' || mode === 'vt200' || mode === 'drag' || mode === 'any'"
)
const inputBlockStart = sessionSource.indexOf('const handleTerminalInput = useCallback')
expect(inputBlockStart).toBeGreaterThanOrEqual(0)
const inputBlockEnd = sessionSource.indexOf(
'async function handleClearTerminal',
inputBlockStart
)
expect(inputBlockEnd).toBeGreaterThan(inputBlockStart)
const inputBlock = sessionSource.slice(inputBlockStart, inputBlockEnd)
expect(inputBlock).toContain('!isGestureMouseTrackingMode(modes?.mouseTrackingMode)')
expect(inputBlock).toContain('const sequenceCount = countTerminalGestureInputSequences(bytes)')
expect(inputBlock.indexOf('countTerminalGestureInputSequences')).toBeLessThan(
inputBlock.indexOf('enqueueTerminalGestureInput')
)
})
})
function simulateNormalBufferPull({
baseY,
viewportY,
startY,
endY,
cellHeight
}: {
baseY: number
viewportY: number
startY: number
endY: number
cellHeight: number
}): number {
const deltaY = startY - endY
if (deltaY > 0 ? viewportY >= baseY : viewportY <= 0) {
return viewportY
}
const smoothScrollOffsetY = -deltaY
const lines = Math.trunc(-smoothScrollOffsetY / cellHeight)
const applied = Math.max(lines, -viewportY)
return viewportY + applied
}