diff --git a/mobile/src/terminal/document/normal-buffer-smooth-scroll.ts b/mobile/src/terminal/document/normal-buffer-smooth-scroll.ts index ac51924e747..87edd63727f 100644 --- a/mobile/src/terminal/document/normal-buffer-smooth-scroll.ts +++ b/mobile/src/terminal/document/normal-buffer-smooth-scroll.ts @@ -43,6 +43,8 @@ export function applyNormalBufferScrollDelta(scope: TerminalDocumentScope, delta const applied = clampNormalScrollLines(scope, lines) if (applied !== 0) { scope.term.scrollLines(applied) + // Why: a touch that scrolled a row is a scroll, even inside TAP_SLOP. + scope.tapCandidate = null // Why: xterm's renderer is row-based. Buffer touch pixels and only // commit whole rows so TUI canvas layers do not shimmer between // fractional transforms and xterm repaints. @@ -52,13 +54,6 @@ export function applyNormalBufferScrollDelta(scope: TerminalDocumentScope, delta scope.smoothScrollOffsetY = 0 } } - const limit = effectiveCellH - 1 - if (scope.smoothScrollOffsetY > limit) { - scope.smoothScrollOffsetY = limit - } - if (scope.smoothScrollOffsetY < -limit) { - scope.smoothScrollOffsetY = -limit - } updateScrollIndicator(scope, true) return true } @@ -89,6 +84,15 @@ export function enqueueNormalBufferScrollDelta(scope: TerminalDocumentScope, del return true } +/** Whether the deltas queued for the next frame, with the remainder already held, move a row. */ +export function queuedNormalBufferScrollReachesRow(scope: TerminalDocumentScope) { + const effectiveCellH = getCellHeight(scope) * getTotalScale(scope) + return ( + effectiveCellH > 0 && + Math.abs(scope.smoothScrollOffsetY - scope.pendingNormalScrollDeltaY) >= effectiveCellH + ) +} + export function resetSmoothScrollOffset(scope: TerminalDocumentScope) { scope.pendingNormalScrollDeltaY = 0 if (scope.normalScrollFrameId !== null) { diff --git a/mobile/src/terminal/document/selection-mode.ts b/mobile/src/terminal/document/selection-mode.ts index aa9c7c5033a..64d5bc2855e 100644 --- a/mobile/src/terminal/document/selection-mode.ts +++ b/mobile/src/terminal/document/selection-mode.ts @@ -2,6 +2,8 @@ import type { TerminalDocumentScope } from './document-scope' import { notify } from './host-notify' import { repositionOverlay, stopEdgeScroll } from './selection-overlay' import { seedWordSelection } from './selection-range' +import { resetSmoothScrollOffset } from './normal-buffer-smooth-scroll' +import { stopSurfaceTouchGestures } from './surface-touch-gestures' // Why: entering and leaving select mode drives the overlay; kept out of selection-range so the // range math the overlay reads doesn't import the overlay back. @@ -27,6 +29,8 @@ export function cancelSelect(scope: TerminalDocumentScope) { } export function enterSelect(scope: TerminalDocumentScope, col: number, absRow: number) { + stopSurfaceTouchGestures(scope) + resetSmoothScrollOffset(scope) scope.selMode = 'select' seedWordSelection(scope, col, absRow) scope.selectionOverlay!.classList.add('active') diff --git a/mobile/src/terminal/document/surface-touch-gestures.ts b/mobile/src/terminal/document/surface-touch-gestures.ts index b0a436810cd..2aa13228bed 100644 --- a/mobile/src/terminal/document/surface-touch-gestures.ts +++ b/mobile/src/terminal/document/surface-touch-gestures.ts @@ -22,6 +22,9 @@ import { updateTransform } from './viewport-transform' import { viewportPoint } from './viewport-cell' import { attachSurfaceWheelHandler } from './wheel-scroll' +/** How long after the last touchmove its velocity still launches momentum, in milliseconds. */ +const RELEASE_VELOCITY_MAX_AGE_MS = 100 + /** A surface that has already been wired, so a re-mount does not stack handlers. */ type TerminalGestureSurface = HTMLElement & { __orcaSurfaceHandlersAttached?: boolean } @@ -106,11 +109,14 @@ export function attachSurfaceEventHandlers( if (scope.touchGesture.momentumId) { cancelAnimationFrame(scope.touchGesture.momentumId) scope.touchGesture.momentumId = null + // Why: this touch stopped a coast; lifting it must not also tap. + scope.tapCandidate = null } const touches = touchesInRoot(scope.root, e.touches) if (touches.length === 2) { + stopSurfaceTouchGestures(scope) + resetSmoothScrollOffset(scope) scope.touchGesture.isPinching = true - scope.smoothScrollOffsetY = 0 scope.touchGesture.pinchDist = getDistance(touches[0], touches[1]) scope.touchGesture.pinchScale = scope.userScale const mid = pinchMidpoint(scope, touches[0], touches[1]) @@ -193,6 +199,8 @@ export function attachSurfaceEventHandlers( if (lines !== 0) { scope.touchGesture.accumDelta -= lines * effectiveCellH routeScrollLines(scope, lines, x, y) + // Why: a touch that scrolled a row is a scroll, even inside TAP_SLOP. + scope.tapCandidate = null } } else { if (enqueueNormalBufferScrollDelta(scope, deltaY)) { @@ -247,7 +255,9 @@ export function attachSurfaceEventHandlers( } if (touches.length === 0) { - let vel = scope.touchGesture.velY + // Why: velY is the speed of the last move; a finger that then rested has none to launch. + const rested = Date.now() - scope.touchGesture.lastTime > RELEASE_VELOCITY_MAX_AGE_MS + let vel = rested ? 0 : scope.touchGesture.velY const FRICTION = 0.972 const MIN_VEL = 0.012 let lastMomentumTime = performance.now() @@ -284,6 +294,18 @@ export function attachSurfaceEventHandlers( }, { capture: true, passive: true } ) + + targetSurface.addEventListener( + 'touchcancel', + function () { + if (targetSurface !== scope.surface) { + return + } + stopSurfaceTouchGestures(scope) + resetSmoothScrollOffset(scope) + }, + { capture: true, passive: true } + ) } export function startSurfaceTouchGestures(scope: TerminalDocumentScope) { diff --git a/mobile/src/terminal/document/tap-dispatch.ts b/mobile/src/terminal/document/tap-dispatch.ts index 82a142e7768..518d61db3a2 100644 --- a/mobile/src/terminal/document/tap-dispatch.ts +++ b/mobile/src/terminal/document/tap-dispatch.ts @@ -3,6 +3,7 @@ import { cancelSelect, enterSelect } from './selection-mode' import { notify } from './host-notify' import { viewportToCell } from './viewport-cell' import type { TerminalDocumentScope } from './document-scope' +import { queuedNormalBufferScrollReachesRow } from './normal-buffer-smooth-scroll' import { notifyTerminalSurfaceTap } from './surface-tap' import { eventTargetInRoot, @@ -247,7 +248,9 @@ function onDocumentTouchEnd(scope: TerminalDocumentScope, e: TouchEvent) { touches.length === 0 && scope.tapCandidate && scope.selMode !== 'select' && - Date.now() - scope.tapCandidate.t <= TAP_MAX_MS + Date.now() - scope.tapCandidate.t <= TAP_MAX_MS && + // Why: a lift can beat the frame that commits the row; movement that cancelled out leaves a tap. + !queuedNormalBufferScrollReachesRow(scope) ) { notifyTerminalSurfaceTap(scope, scope.tapCandidate.x, scope.tapCandidate.y, true) } diff --git a/mobile/src/terminal/terminal-webview-tap-routing.test.ts b/mobile/src/terminal/terminal-webview-tap-routing.test.ts index bc70b7ae52f..06d24813612 100644 --- a/mobile/src/terminal/terminal-webview-tap-routing.test.ts +++ b/mobile/src/terminal/terminal-webview-tap-routing.test.ts @@ -3,7 +3,7 @@ // printed http(s) URL must post an `open-url` message (which RN routes to the // in-app/phone browser). Regression guard for taps that jitter a few pixels — // those were being swallowed because the tap shared the long-press slop gate. -import { beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TERMINAL_DOCUMENT_SCRIPT } from './terminal-webview-document-script.generated' import { TERMINAL_DOCUMENT_MARKUP } from './terminal-webview-html' @@ -12,7 +12,11 @@ function bodyMarkup(): string { } // Minimal xterm stub: one scrollback line containing a URL, fixed 8x15 cells. -function makeTerminal(lineRef: { current: string }, mouseTrackingMode = 'none') { +function makeTerminal( + lineRef: { current: string }, + mouseTrackingMode = 'none', + scrollbackRows = 0 +) { return { cols: 80, rows: 24, @@ -23,7 +27,7 @@ function makeTerminal(lineRef: { current: string }, mouseTrackingMode = 'none') buffer: { active: { viewportY: 0, - baseY: 0, + baseY: scrollbackRows, length: 1, cursorY: 0, type: 'normal' as const, @@ -70,13 +74,14 @@ type OscLinkRange = { row: number; startCol: number; endCol: number; uri: string function boot( line: string, oscLinks?: OscLinkRange[], - mouseTrackingMode = 'none' + mouseTrackingMode = 'none', + scrollbackRows = 0 ): { posted: Posted; setLine: (line: string) => void } { const posted: Posted = [] const lineRef = { current: line } const w = window as unknown as { Terminal: unknown; ReactNativeWebView: unknown } w.Terminal = function () { - return makeTerminal(lineRef, mouseTrackingMode) + return makeTerminal(lineRef, mouseTrackingMode, scrollbackRows) } w.ReactNativeWebView = { postMessage(s: string) { @@ -106,8 +111,8 @@ function fireTouch(type: string, touches: Array<{ x: number; y: number }>): void Object.defineProperty(ev, 'touches', { value: touches.map((p, i) => ({ identifier: i, clientX: p.x, clientY: p.y, target: surface })) }) - Object.defineProperty(ev, 'target', { value: surface }) - document.dispatchEvent(ev) + // Dispatched on the surface so its own scroll handlers see the touch, as on a device. + surface.dispatchEvent(ev) } // Wait one macrotask so init()'s rAF chain (term.open -> ready) settles. @@ -122,10 +127,22 @@ describe('terminal WebView tap routing', () => { const screenXForCol = (col: number): number => col * 8 * 0.3125 beforeEach(() => { + vi.spyOn(window, 'addEventListener') + vi.spyOn(document, 'addEventListener') Object.defineProperty(window, 'innerWidth', { value: 200, configurable: true }) Object.defineProperty(window, 'innerHeight', { value: 400, configurable: true }) }) + afterEach(() => { + for (const [type, listener, options] of vi.mocked(window.addEventListener).mock.calls) { + window.removeEventListener(type, listener, options) + } + for (const [type, listener, options] of vi.mocked(document.addEventListener).mock.calls) { + document.removeEventListener(type, listener, options) + } + vi.restoreAllMocks() + }) + it('posts open-url when a clean tap lands on a URL', async () => { const { posted } = boot(URL_LINE) await settle() @@ -304,4 +321,77 @@ describe('terminal WebView tap routing', () => { fireTouch('touchend', []) expect(posted.find((m) => m.type === 'open-url')).toBeUndefined() }) + + // Why: 15px rows at this fit scale are ~4.7px on screen, so the drags below + // scroll rows while staying inside TAP_SLOP. + it('does not tap after a short drag that scrolled scrollback', async () => { + const { posted } = boot('plain output', undefined, 'none', 50) + await settle() + fireTouch('touchstart', [{ x: 20, y: 100 }]) + fireTouch('touchmove', [{ x: 20, y: 88 }]) + fireTouch('touchend', []) + expect(posted.find((m) => m.type === 'terminal-tap')).toBeUndefined() + }) + + it('does not tap or click after a short drag that scrolled a mouse-tracking TUI', async () => { + const { posted } = boot('interactive prompt', undefined, 'drag') + await settle() + window.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify({ type: 'write', data: '\u001b[?1006l' }) + }) + ) + + fireTouch('touchstart', [{ x: 20, y: 100 }]) + fireTouch('touchmove', [{ x: 20, y: 88 }]) + fireTouch('touchend', []) + + expect(posted.find((m) => m.type === 'terminal-tap')).toBeUndefined() + const input = posted + .filter((m) => m.type === 'terminal-input') + .map((m) => String(m.bytes)) + .join('') + // Wheel reports only: no button-press (space) or release (#) report. + const reports = input.split('\u001b[M').slice(1) + expect(reports.length).toBeGreaterThan(0) + expect(reports.every((report) => report[0] === '`' || report[0] === 'a')).toBe(true) + }) + + it('still taps when the touch moves less than a row', async () => { + const { posted } = boot('plain output', undefined, 'none', 50) + await settle() + fireTouch('touchstart', [{ x: 20, y: 100 }]) + fireTouch('touchmove', [{ x: 20, y: 97 }]) + fireTouch('touchend', []) + expect(posted.filter((m) => m.type === 'terminal-tap')).toHaveLength(1) + }) + + it('still taps when the touch moves a row and back before anything scrolls', async () => { + const { posted } = boot('plain output', undefined, 'none', 50) + await settle() + fireTouch('touchstart', [{ x: 20, y: 100 }]) + fireTouch('touchmove', [{ x: 20, y: 94 }]) + fireTouch('touchmove', [{ x: 20, y: 100 }]) + fireTouch('touchend', []) + expect(posted.filter((m) => m.type === 'terminal-tap')).toHaveLength(1) + }) + + it('does not tap when the touch stops a coast', async () => { + let now = 1_000 + vi.spyOn(Date, 'now').mockImplementation(() => now) + const { posted } = boot('plain output', undefined, 'none', 50) + await settle() + fireTouch('touchstart', [{ x: 20, y: 300 }]) + now += 16 + fireTouch('touchmove', [{ x: 20, y: 240 }]) + now += 16 + fireTouch('touchmove', [{ x: 20, y: 180 }]) + fireTouch('touchend', []) + const beforeStoppingTouch = posted.length + + fireTouch('touchstart', [{ x: 20, y: 180 }]) + fireTouch('touchend', []) + + expect(posted.slice(beforeStoppingTouch).find((m) => m.type === 'terminal-tap')).toBeUndefined() + }) }) diff --git a/mobile/src/terminal/terminal-webview-touch-scroll.test.ts b/mobile/src/terminal/terminal-webview-touch-scroll.test.ts new file mode 100644 index 00000000000..02efeeff194 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-touch-scroll.test.ts @@ -0,0 +1,332 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { terminalDocumentDouble } from './document/document-terminal-double.test-support' +import type { TerminalDocumentTerminal } from './document/document-terminal-shape' +import { createTerminalDocument } from './document/create-terminal-document' +import { TERMINAL_DOCUMENT_SCRIPT } from './terminal-webview-document-script.generated' +import { TERMINAL_DOCUMENT_MARKUP } from './terminal-webview-html' + +type BufferState = { + baseY: number + type: 'alternate' | 'normal' + viewportY: number +} + +// 40 cols of 8px cells fit a 381px viewport unscaled, so one row is 15 screen px. +const CELL_HEIGHT = 15 +const FRAME_MS = 16 +const ESC_ARROW_DOWN = '\u001b[B' + +function makeTerminal( + buffer: BufferState, + scrollLines: (lines: number) => void +): TerminalDocumentTerminal { + let openedOn: HTMLElement | undefined + return { + ...terminalDocumentDouble().terminal, + cols: 40, + rows: 24, + modes: { mouseTrackingMode: 'none' }, + _core: { + _renderService: { dimensions: { css: { cell: { width: 8, height: CELL_HEIGHT } } } } + }, + buffer: { + active: { + get baseY() { + return buffer.baseY + }, + get type() { + return buffer.type + }, + get viewportY() { + return buffer.viewportY + }, + cursorY: 0, + length: 1, + getLine: () => undefined + } + }, + get element() { + return openedOn + }, + open(surface: HTMLElement) { + openedOn = surface + }, + scrollLines + } +} + +describe('terminal WebView touch scrolling', () => { + let animationFrames: Map void> + let nextFrameId: number + let buffer: BufferState + let now: number + let postMessage: ReturnType void>> + let scrollLines: ReturnType void>> + let stopDocument: (() => void) | undefined + + function boot(importedDocument = false): void { + document.body.innerHTML = TERMINAL_DOCUMENT_MARKUP + // The bundle the WebView loads, run as the WebView runs it. + if (importedDocument) { + const terminalDocument = createTerminalDocument() + stopDocument = () => terminalDocument.stop() + } else { + new Function(TERMINAL_DOCUMENT_SCRIPT)() + } + window.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify({ type: 'init', cols: 40, rows: 24, initialData: '' }) + }) + ) + // Why: init commits the replacement surface on the next animation frame. + runFrames(8) + } + + function runFrames(count: number): void { + for (let i = 0; i < count && animationFrames.size > 0; i++) { + now += FRAME_MS + const currentFrames = [...animationFrames] + for (const [id, callback] of currentFrames) { + if (animationFrames.delete(id)) { + callback(now) + } + } + } + } + + function fireTouch(type: string, point: { x: number; y: number } | null): void { + fireTouches(type, point ? [point] : []) + } + + function fireTouches(type: string, points: Array<{ x: number; y: number }>): void { + const surface = document.getElementById('terminal-surface') + if (!surface) { + throw new Error('terminal surface missing') + } + const event = new Event(type, { bubbles: true, cancelable: true }) + const touches = points.map((point, identifier) => ({ + identifier, + clientX: point.x, + clientY: point.y, + target: surface + })) + Object.defineProperty(event, 'touches', { value: touches }) + surface.dispatchEvent(event) + } + + /** A finger moving up the screen, one touchmove per frame; returns where it ended. */ + function dragUp(fromY: number, stepPx: number, steps: number): number { + let y = fromY + for (let i = 0; i < steps; i++) { + y -= stepPx + now += FRAME_MS + fireTouch('touchmove', { x: 40, y }) + runFrames(1) + } + return y + } + + function scrolledRows(): number { + return scrollLines.mock.calls.reduce((total, [lines]) => total + lines, 0) + } + + function terminalInputBytes(): string { + return postMessage.mock.calls + .map(([raw]): { bytes?: string; type?: string } => JSON.parse(raw)) + .filter((msg) => msg.type === 'terminal-input') + .map((msg) => msg.bytes ?? '') + .join('') + } + + beforeEach(() => { + vi.spyOn(window, 'addEventListener') + vi.spyOn(document, 'addEventListener') + animationFrames = new Map() + nextFrameId = 1 + buffer = { baseY: 500, type: 'normal', viewportY: 100 } + now = 1_000 + scrollLines = vi.fn<(lines: number) => void>() + postMessage = vi.fn<(data: string) => void>() + vi.spyOn(Date, 'now').mockImplementation(() => now) + vi.spyOn(performance, 'now').mockImplementation(() => now) + vi.stubGlobal('requestAnimationFrame', (callback: (frameTime: number) => void) => { + const id = nextFrameId++ + animationFrames.set(id, callback) + return id + }) + vi.stubGlobal('cancelAnimationFrame', (id: number) => { + animationFrames.delete(id) + }) + Object.defineProperty(window, 'innerWidth', { value: 381, configurable: true }) + Object.defineProperty(window, 'innerHeight', { value: 612, configurable: true }) + Object.assign(window, { + Terminal: function () { + return makeTerminal(buffer, scrollLines) + }, + ReactNativeWebView: { postMessage } + }) + }) + + afterEach(() => { + stopDocument?.() + stopDocument = undefined + for (const [type, listener, options] of vi.mocked(window.addEventListener).mock.calls) { + window.removeEventListener(type, listener, options) + } + for (const [type, listener, options] of vi.mocked(document.addEventListener).mock.calls) { + document.removeEventListener(type, listener, options) + } + vi.restoreAllMocks() + vi.unstubAllGlobals() + vi.useRealTimers() + }) + + it('scrolls scrollback for a drag slower than one pixel per frame', () => { + boot() + + fireTouch('touchstart', { x: 40, y: 400 }) + dragUp(400, 0.5, 200) + + // 100px of travel over 15px rows. + expect(scrolledRows()).toBe(6) + }) + + it('carries the sub-row remainder of a fast drag into the next move', () => { + boot() + + fireTouch('touchstart', { x: 40, y: 400 }) + const y = dragUp(400, 100, 1) + expect(scrolledRows()).toBe(6) + + // 10px left over from the first move plus 5px more is one more row. + dragUp(y, 5, 1) + expect(scrolledRows()).toBe(7) + }) + + it('coasts when the finger lifts mid-drag', () => { + boot() + + fireTouch('touchstart', { x: 40, y: 400 }) + dragUp(400, 20, 5) + const rowsWhileTouching = scrolledRows() + fireTouch('touchend', null) + runFrames(30) + + expect(scrolledRows()).toBeGreaterThan(rowsWhileTouching) + }) + + it('does not coast when the finger rested before lifting', () => { + boot() + + fireTouch('touchstart', { x: 40, y: 400 }) + dragUp(400, 20, 5) + const rowsWhileTouching = scrolledRows() + now += 500 + fireTouch('touchend', null) + runFrames(30) + + expect(scrolledRows()).toBe(rowsWhileTouching) + }) + + it('does not send a resting finger’s momentum to an alternate-screen app', () => { + buffer = { baseY: 0, type: 'alternate', viewportY: 0 } + boot() + + fireTouch('touchstart', { x: 40, y: 400 }) + dragUp(400, 20, 5) + const bytesWhileTouching = terminalInputBytes() + expect(bytesWhileTouching).toContain(ESC_ARROW_DOWN) + now += 500 + fireTouch('touchend', null) + runFrames(30) + + expect(terminalInputBytes()).toBe(bytesWhileTouching) + }) + + it('drops an uncommitted scroll when a second finger starts a pinch', () => { + boot() + fireTouch('touchstart', { x: 40, y: 400 }) + now += FRAME_MS + fireTouch('touchmove', { x: 40, y: 380 }) + fireTouches('touchstart', [ + { x: 40, y: 380 }, + { x: 140, y: 380 } + ]) + runFrames(1) + fireTouch('touchend', null) + runFrames(30) + + expect(scrolledRows()).toBe(0) + expect(terminalInputBytes()).toBe('') + }) + + it('drops queued scroll and velocity when the platform cancels a touch', () => { + boot() + fireTouch('touchstart', { x: 40, y: 400 }) + now += FRAME_MS + fireTouch('touchmove', { x: 40, y: 320 }) + fireTouch('touchcancel', null) + fireTouch('touchend', null) + runFrames(30) + + expect(scrolledRows()).toBe(0) + expect(terminalInputBytes()).toBe('') + fireTouch('touchstart', { x: 40, y: 320 }) + fireTouch('touchend', null) + expect( + postMessage.mock.calls.filter(([raw]) => JSON.parse(raw).type === 'terminal-tap') + ).toHaveLength(1) + }) + + it('does not send cancelled momentum to an alternate-screen app', () => { + buffer = { baseY: 0, type: 'alternate', viewportY: 0 } + boot() + fireTouch('touchstart', { x: 40, y: 400 }) + dragUp(400, 20, 5) + const bytesBeforeCancel = terminalInputBytes() + expect(bytesBeforeCancel).toContain(ESC_ARROW_DOWN) + fireTouch('touchcancel', null) + fireTouch('touchend', null) + runFrames(30) + + expect(terminalInputBytes()).toBe(bytesBeforeCancel) + }) + + it('retires queued scroll and momentum when the document is disposed', () => { + boot(true) + fireTouch('touchstart', { x: 40, y: 400 }) + now += FRAME_MS + fireTouch('touchmove', { x: 40, y: 320 }) + fireTouch('touchend', null) + stopDocument?.() + runFrames(30) + + expect(scrolledRows()).toBe(0) + expect(terminalInputBytes()).toBe('') + }) + + it('retires subthreshold scroll velocity when a long press enters selection', () => { + boot() + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + fireTouch('touchstart', { x: 40, y: 400 }) + now += 480 + fireTouch('touchmove', { x: 40, y: 399.5 }) + now += 19 + fireTouch('touchmove', { x: 40, y: 391.5 }) + now += 1 + vi.advanceTimersByTime(500) + expect( + postMessage.mock.calls.some(([raw]) => { + const message = JSON.parse(raw) + return message.type === 'set-select-mode' && message.enabled + }) + ).toBe(true) + + fireTouch('touchend', null) + runFrames(30) + + expect(scrolledRows()).toBe(0) + expect(terminalInputBytes()).toBe('') + }) +})