mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
test(mobile): split terminal mouse click and drag coverage
This commit is contained in:
@@ -1,374 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { XTERM_HTML } from './terminal-webview-html'
|
||||
|
||||
function iifeSource(): string {
|
||||
const start = XTERM_HTML.indexOf('(function() {')
|
||||
const end = XTERM_HTML.lastIndexOf('})();')
|
||||
return XTERM_HTML.slice(start, end + '})();'.length)
|
||||
}
|
||||
|
||||
function bodyMarkup(): string {
|
||||
const start = XTERM_HTML.indexOf('<body>') + '<body>'.length
|
||||
const end = XTERM_HTML.indexOf('<script>', start)
|
||||
return XTERM_HTML.slice(start, end)
|
||||
}
|
||||
|
||||
type BufferState = {
|
||||
baseY: number
|
||||
type: 'alternate' | 'normal'
|
||||
viewportY: number
|
||||
}
|
||||
|
||||
type TerminalStub = ReturnType<typeof makeTerminal>
|
||||
type RegisteredWindowListener = {
|
||||
listener: EventListenerOrEventListenerObject
|
||||
options?: boolean | AddEventListenerOptions
|
||||
type: string
|
||||
}
|
||||
|
||||
const ESC = '\u001b'
|
||||
const DEFAULT_MOUSE_REPORT_RE = new RegExp(`${ESC}\\[M[\\s\\S]{3}`, 'g')
|
||||
|
||||
function makeTerminal(
|
||||
buffer: BufferState,
|
||||
select: (col: number, row: number, len: number) => void
|
||||
) {
|
||||
const terminal = {
|
||||
cols: 40,
|
||||
rows: 24,
|
||||
options: { fontSize: 13 },
|
||||
modes: { mouseTrackingMode: 'none' as string },
|
||||
element: null as HTMLElement | null,
|
||||
_core: {
|
||||
_renderService: { dimensions: { css: { cell: { width: 8, height: 15 } } } }
|
||||
},
|
||||
buffer: {
|
||||
active: {
|
||||
get baseY() {
|
||||
return buffer.baseY
|
||||
},
|
||||
get type() {
|
||||
return buffer.type
|
||||
},
|
||||
get viewportY() {
|
||||
return buffer.viewportY
|
||||
},
|
||||
cursorY: 0,
|
||||
length: 1,
|
||||
getLine: () => null
|
||||
}
|
||||
},
|
||||
write(_data: string, callback?: () => void) {
|
||||
callback?.()
|
||||
},
|
||||
open(surface: HTMLElement) {
|
||||
terminal.element = surface
|
||||
},
|
||||
loadAddon() {},
|
||||
resize(cols: number, rows: number) {
|
||||
terminal.cols = cols
|
||||
terminal.rows = rows
|
||||
},
|
||||
clear() {},
|
||||
reset() {},
|
||||
refresh() {},
|
||||
selectAll() {},
|
||||
clearSelection() {},
|
||||
select,
|
||||
scrollLines() {},
|
||||
scrollToBottom() {},
|
||||
scrollToLine() {},
|
||||
attachCustomKeyEventHandler() {},
|
||||
getSelection: () => '',
|
||||
onData: () => ({ dispose() {} }),
|
||||
onLineFeed: () => ({ dispose() {} }),
|
||||
onScroll: () => ({ dispose() {} }),
|
||||
onWriteParsed: () => ({ dispose() {} }),
|
||||
dispose() {}
|
||||
}
|
||||
return terminal
|
||||
}
|
||||
|
||||
function surfaceEl(): HTMLElement {
|
||||
const surface = document.getElementById('terminal-surface')
|
||||
if (!surface) {
|
||||
throw new Error('terminal surface missing')
|
||||
}
|
||||
return surface
|
||||
}
|
||||
|
||||
function dispatchPointer(
|
||||
type: 'pointerdown' | 'pointermove' | 'pointerup' | 'pointercancel',
|
||||
init: {
|
||||
pointerType?: string
|
||||
x?: number
|
||||
y?: number
|
||||
button?: number
|
||||
buttons?: number
|
||||
} = {}
|
||||
): void {
|
||||
const event = new MouseEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: init.x ?? 40,
|
||||
clientY: init.y ?? 60,
|
||||
button: init.button ?? 0,
|
||||
buttons: init.buttons ?? 0
|
||||
})
|
||||
// Why: happy-dom's PointerEvent init drops pointerType; grafting it onto a
|
||||
// MouseEvent exercises the same duck-typed reads the WebView handler does.
|
||||
Object.defineProperty(event, 'pointerType', { value: init.pointerType ?? 'mouse' })
|
||||
surfaceEl().dispatchEvent(event)
|
||||
}
|
||||
|
||||
function mouseClick(x: number, y: number): void {
|
||||
dispatchPointer('pointerdown', { x, y, button: 0, buttons: 1 })
|
||||
dispatchPointer('pointerup', { x, y, button: 0, buttons: 0 })
|
||||
}
|
||||
|
||||
function mouseDrag(x1: number, y1: number, x2: number, y2: number): void {
|
||||
dispatchPointer('pointerdown', { x: x1, y: y1, button: 0, buttons: 1 })
|
||||
const midX = Math.round((x1 + x2) / 2)
|
||||
const midY = Math.round((y1 + y2) / 2)
|
||||
dispatchPointer('pointermove', { x: midX, y: midY, button: 0, buttons: 1 })
|
||||
dispatchPointer('pointermove', { x: x2, y: y2, button: 0, buttons: 1 })
|
||||
dispatchPointer('pointerup', { x: x2, y: y2, button: 0, buttons: 0 })
|
||||
}
|
||||
|
||||
function postedMessages(postMessage: ReturnType<typeof vi.fn>): Array<Record<string, unknown>> {
|
||||
return postMessage.mock.calls.map(([raw]) => JSON.parse(String(raw)) as Record<string, unknown>)
|
||||
}
|
||||
|
||||
function terminalInputBytes(postMessage: ReturnType<typeof vi.fn>): string {
|
||||
return postedMessages(postMessage)
|
||||
.filter((msg) => msg.type === 'terminal-input')
|
||||
.map((msg) => (msg.bytes as string) ?? '')
|
||||
.join('')
|
||||
}
|
||||
|
||||
describe('terminal WebView external mouse click and drag', () => {
|
||||
let animationFrames: Array<() => void>
|
||||
let buffer: BufferState
|
||||
let postMessage: ReturnType<typeof vi.fn>
|
||||
let registeredWindowListeners: RegisteredWindowListener[]
|
||||
let select: ReturnType<typeof vi.fn>
|
||||
let terminals: TerminalStub[]
|
||||
|
||||
function boot(): void {
|
||||
document.body.innerHTML = bodyMarkup()
|
||||
new Function(iifeSource())()
|
||||
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.
|
||||
while (animationFrames.length > 0) {
|
||||
animationFrames.shift()?.()
|
||||
}
|
||||
}
|
||||
|
||||
function activeTerminal(): TerminalStub {
|
||||
const terminal = terminals[terminals.length - 1]
|
||||
if (!terminal) {
|
||||
throw new Error('terminal missing')
|
||||
}
|
||||
return terminal
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
animationFrames = []
|
||||
buffer = { baseY: 0, type: 'normal', viewportY: 0 }
|
||||
registeredWindowListeners = []
|
||||
select = vi.fn()
|
||||
terminals = []
|
||||
const addWindowEventListener = window.addEventListener.bind(window)
|
||||
vi.spyOn(window, 'addEventListener').mockImplementation(((
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject,
|
||||
options?: boolean | AddEventListenerOptions
|
||||
) => {
|
||||
registeredWindowListeners.push({ type, listener, options })
|
||||
addWindowEventListener(type, listener, options)
|
||||
}) as typeof window.addEventListener)
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: () => void) => {
|
||||
animationFrames.push(callback)
|
||||
return animationFrames.length
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', () => {})
|
||||
Object.defineProperty(window, 'innerWidth', { value: 381, configurable: true })
|
||||
Object.defineProperty(window, 'innerHeight', { value: 612, configurable: true })
|
||||
postMessage = vi.fn()
|
||||
const webWindow = window as unknown as {
|
||||
Terminal: new () => TerminalStub
|
||||
ReactNativeWebView: { postMessage: (data: string) => void }
|
||||
}
|
||||
webWindow.Terminal = function () {
|
||||
const terminal = makeTerminal(buffer, select)
|
||||
terminals.push(terminal)
|
||||
return terminal
|
||||
} as unknown as new () => TerminalStub
|
||||
webWindow.ReactNativeWebView = { postMessage }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const { type, listener, options } of registeredWindowListeners) {
|
||||
window.removeEventListener(type, listener as EventListener, options)
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('reports a mouse click to a click-tracking TUI the way a touch tap does (#8818)', () => {
|
||||
boot()
|
||||
activeTerminal().modes.mouseTrackingMode = 'vt200'
|
||||
|
||||
mouseClick(40, 60)
|
||||
|
||||
// Default (non-SGR) encoding: press (32=' ') then release (35='#'), each
|
||||
// ESC [ M btn col row. Cell bytes depend on the fit scale, not on routing.
|
||||
const bytes = terminalInputBytes(postMessage)
|
||||
expect(bytes).toHaveLength(12)
|
||||
expect(bytes.slice(0, 4)).toBe(`${ESC}[M `)
|
||||
expect(bytes.slice(6, 10)).toBe(`${ESC}[M#`)
|
||||
expect(bytes.slice(4, 6)).toBe(bytes.slice(10, 12))
|
||||
})
|
||||
|
||||
it('sends press, per-cell motion, and release for a drag-tracking mouse drag', () => {
|
||||
boot()
|
||||
activeTerminal().modes.mouseTrackingMode = 'drag'
|
||||
|
||||
mouseDrag(40, 60, 160, 60)
|
||||
|
||||
const bytes = terminalInputBytes(postMessage)
|
||||
const reports = bytes.match(DEFAULT_MOUSE_REPORT_RE) ?? []
|
||||
expect(reports.length).toBeGreaterThanOrEqual(3)
|
||||
expect(reports[0]?.charCodeAt(3)).toBe(32)
|
||||
for (const motion of reports.slice(1, -1)) {
|
||||
expect(motion.charCodeAt(3)).toBe(64)
|
||||
}
|
||||
expect(reports[reports.length - 1]?.charCodeAt(3)).toBe(35)
|
||||
// Motion reports are deduped per cell, so a horizontal drag advances columns.
|
||||
const motionCols = reports.slice(1, -1).map((report) => report.charCodeAt(4))
|
||||
expect(new Set(motionCols).size).toBe(motionCols.length)
|
||||
})
|
||||
|
||||
it('does not report motion or release for an x10 click-only TUI drag', () => {
|
||||
boot()
|
||||
activeTerminal().modes.mouseTrackingMode = 'x10'
|
||||
|
||||
mouseDrag(40, 60, 160, 60)
|
||||
|
||||
// x10 reports presses only; the press goes out at drag start, no motion or
|
||||
// release must follow.
|
||||
const bytes = terminalInputBytes(postMessage)
|
||||
expect(bytes.slice(0, 4)).toBe(`${ESC}[M `)
|
||||
expect(bytes).toHaveLength(6)
|
||||
})
|
||||
|
||||
it('selects character-anchored text on a mouse drag outside tracking mode', () => {
|
||||
boot()
|
||||
// Why: init itself emits a set-select-mode reset; only the drag matters here.
|
||||
postMessage.mockClear()
|
||||
|
||||
mouseDrag(40, 60, 160, 90)
|
||||
|
||||
expect(terminalInputBytes(postMessage)).toBe('')
|
||||
expect(select).toHaveBeenCalled()
|
||||
expect(document.getElementById('selection-overlay')?.classList.contains('active')).toBe(true)
|
||||
const modes = postedMessages(postMessage).filter((msg) => msg.type === 'set-select-mode')
|
||||
expect(modes).toEqual([{ type: 'set-select-mode', enabled: true }])
|
||||
})
|
||||
|
||||
it('dismisses an existing selection with a click without focusing the keyboard', () => {
|
||||
boot()
|
||||
mouseDrag(40, 60, 160, 90)
|
||||
postMessage.mockClear()
|
||||
|
||||
mouseClick(240, 200)
|
||||
|
||||
const messages = postedMessages(postMessage)
|
||||
expect(messages.filter((msg) => msg.type === 'set-select-mode')).toEqual([
|
||||
{ type: 'set-select-mode', enabled: false }
|
||||
])
|
||||
expect(messages.filter((msg) => msg.type === 'terminal-tap')).toEqual([])
|
||||
expect(document.getElementById('selection-overlay')?.classList.contains('active')).toBe(false)
|
||||
})
|
||||
|
||||
it('routes a plain mouse click to the tap pipeline for keyboard focus', () => {
|
||||
boot()
|
||||
|
||||
mouseClick(40, 60)
|
||||
|
||||
const messages = postedMessages(postMessage)
|
||||
expect(messages.filter((msg) => msg.type === 'terminal-tap')).toHaveLength(1)
|
||||
expect(terminalInputBytes(postMessage)).toBe('')
|
||||
})
|
||||
|
||||
it('ignores non-mouse pointers and non-left buttons', () => {
|
||||
boot()
|
||||
activeTerminal().modes.mouseTrackingMode = 'vt200'
|
||||
|
||||
dispatchPointer('pointerdown', { pointerType: 'touch', x: 40, y: 60, button: 0, buttons: 1 })
|
||||
dispatchPointer('pointerup', { pointerType: 'touch', x: 40, y: 60, button: 0, buttons: 0 })
|
||||
dispatchPointer('pointerdown', { x: 40, y: 60, button: 2, buttons: 2 })
|
||||
dispatchPointer('pointerup', { x: 40, y: 60, button: 2, buttons: 0 })
|
||||
|
||||
expect(terminalInputBytes(postMessage)).toBe('')
|
||||
expect(postedMessages(postMessage).filter((msg) => msg.type === 'terminal-tap')).toEqual([])
|
||||
})
|
||||
|
||||
it('lets the touch dispatcher own a gesture when touch events follow pointerdown', () => {
|
||||
boot()
|
||||
activeTerminal().modes.mouseTrackingMode = 'vt200'
|
||||
|
||||
dispatchPointer('pointerdown', { x: 40, y: 60, button: 0, buttons: 1 })
|
||||
const touchStart = new Event('touchstart', { bubbles: true })
|
||||
// Why: the document tap dispatcher reads touches[0]; happy-dom's plain
|
||||
// Event lacks it.
|
||||
Object.defineProperty(touchStart, 'touches', {
|
||||
value: [{ identifier: 7, clientX: 40, clientY: 60 }]
|
||||
})
|
||||
surfaceEl().dispatchEvent(touchStart)
|
||||
dispatchPointer('pointerup', { x: 40, y: 60, button: 0, buttons: 0 })
|
||||
|
||||
// Why: Android SOURCE_MOUSE injections can pair a mouse pointerdown with
|
||||
// real touch events; double-handling would double the click report.
|
||||
expect(terminalInputBytes(postMessage)).toBe('')
|
||||
})
|
||||
|
||||
it('releases a tracked drag when the button state shows the pointerup was lost', () => {
|
||||
boot()
|
||||
activeTerminal().modes.mouseTrackingMode = 'drag'
|
||||
|
||||
dispatchPointer('pointerdown', { x: 40, y: 60, button: 0, buttons: 1 })
|
||||
dispatchPointer('pointermove', { x: 160, y: 60, button: 0, buttons: 1 })
|
||||
// Pointer re-enters with the button already up — the pointerup never arrived.
|
||||
dispatchPointer('pointermove', { x: 200, y: 60, button: 0, buttons: 0 })
|
||||
|
||||
const reports = terminalInputBytes(postMessage).match(DEFAULT_MOUSE_REPORT_RE) ?? []
|
||||
expect(reports[reports.length - 1]?.charCodeAt(3)).toBe(35)
|
||||
|
||||
postMessage.mockClear()
|
||||
dispatchPointer('pointermove', { x: 240, y: 60, button: 0, buttons: 1 })
|
||||
|
||||
// The lost pointerup ended the gesture; a later unrelated move must not
|
||||
// keep emitting motion for it.
|
||||
expect(terminalInputBytes(postMessage)).toBe('')
|
||||
})
|
||||
|
||||
it('releases a tracked drag when the pointer is cancelled mid-gesture', () => {
|
||||
boot()
|
||||
activeTerminal().modes.mouseTrackingMode = 'drag'
|
||||
|
||||
dispatchPointer('pointerdown', { x: 40, y: 60, button: 0, buttons: 1 })
|
||||
dispatchPointer('pointermove', { x: 160, y: 60, button: 0, buttons: 1 })
|
||||
dispatchPointer('pointercancel', { x: 160, y: 60 })
|
||||
|
||||
const bytes = terminalInputBytes(postMessage)
|
||||
const reports = bytes.match(DEFAULT_MOUSE_REPORT_RE) ?? []
|
||||
// Press went to the TUI, so the cancel must not leave the button latched.
|
||||
expect(reports[reports.length - 1]?.charCodeAt(3)).toBe(35)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ESC, useTerminalMouseWebViewHarness } from './terminal-webview-mouse-test-harness'
|
||||
|
||||
describe('terminal WebView external mouse click', () => {
|
||||
const mouse = useTerminalMouseWebViewHarness()
|
||||
|
||||
it('reports a mouse click to a click-tracking TUI the way a touch tap does (#8818)', () => {
|
||||
mouse.boot()
|
||||
mouse.activeTerminal().modes.mouseTrackingMode = 'vt200'
|
||||
|
||||
mouse.mouseClick(40, 60)
|
||||
|
||||
// Default (non-SGR) encoding: press (32=' ') then release (35='#'), each
|
||||
// ESC [ M btn col row. Cell bytes depend on the fit scale, not on routing.
|
||||
const bytes = mouse.terminalInputBytes()
|
||||
expect(bytes).toHaveLength(12)
|
||||
expect(bytes.slice(0, 4)).toBe(`${ESC}[M `)
|
||||
expect(bytes.slice(6, 10)).toBe(`${ESC}[M#`)
|
||||
expect(bytes.slice(4, 6)).toBe(bytes.slice(10, 12))
|
||||
})
|
||||
|
||||
it('dismisses an existing selection with a click without focusing the keyboard', () => {
|
||||
mouse.boot()
|
||||
mouse.mouseDrag(40, 60, 160, 90)
|
||||
mouse.clearPostedMessages()
|
||||
|
||||
mouse.mouseClick(240, 200)
|
||||
|
||||
const messages = mouse.postedMessages()
|
||||
expect(messages.filter((message) => message.type === 'set-select-mode')).toEqual([
|
||||
{ type: 'set-select-mode', enabled: false }
|
||||
])
|
||||
expect(messages.filter((message) => message.type === 'terminal-tap')).toEqual([])
|
||||
expect(document.getElementById('selection-overlay')?.classList.contains('active')).toBe(false)
|
||||
})
|
||||
|
||||
it('routes a plain mouse click to the tap pipeline for keyboard focus', () => {
|
||||
mouse.boot()
|
||||
|
||||
mouse.mouseClick(40, 60)
|
||||
|
||||
const messages = mouse.postedMessages()
|
||||
expect(messages.filter((message) => message.type === 'terminal-tap')).toHaveLength(1)
|
||||
expect(mouse.terminalInputBytes()).toBe('')
|
||||
})
|
||||
|
||||
it('ignores non-mouse pointers and non-left buttons', () => {
|
||||
mouse.boot()
|
||||
mouse.activeTerminal().modes.mouseTrackingMode = 'vt200'
|
||||
|
||||
mouse.dispatchPointer('pointerdown', {
|
||||
pointerType: 'touch',
|
||||
x: 40,
|
||||
y: 60,
|
||||
button: 0,
|
||||
buttons: 1
|
||||
})
|
||||
mouse.dispatchPointer('pointerup', {
|
||||
pointerType: 'touch',
|
||||
x: 40,
|
||||
y: 60,
|
||||
button: 0,
|
||||
buttons: 0
|
||||
})
|
||||
mouse.dispatchPointer('pointerdown', { x: 40, y: 60, button: 2, buttons: 2 })
|
||||
mouse.dispatchPointer('pointerup', { x: 40, y: 60, button: 2, buttons: 0 })
|
||||
|
||||
expect(mouse.terminalInputBytes()).toBe('')
|
||||
expect(mouse.postedMessages().filter((message) => message.type === 'terminal-tap')).toEqual([])
|
||||
})
|
||||
|
||||
it('lets the touch dispatcher own a gesture when touch events follow pointerdown', () => {
|
||||
mouse.boot()
|
||||
mouse.activeTerminal().modes.mouseTrackingMode = 'vt200'
|
||||
|
||||
mouse.dispatchPointer('pointerdown', { x: 40, y: 60, button: 0, buttons: 1 })
|
||||
const touchStart = new Event('touchstart', { bubbles: true })
|
||||
// Why: the document tap dispatcher reads touches[0]; happy-dom's plain Event lacks it.
|
||||
Object.defineProperty(touchStart, 'touches', {
|
||||
value: [{ identifier: 7, clientX: 40, clientY: 60 }]
|
||||
})
|
||||
mouse.terminalSurface().dispatchEvent(touchStart)
|
||||
mouse.dispatchPointer('pointerup', { x: 40, y: 60, button: 0, buttons: 0 })
|
||||
|
||||
// Why: Android SOURCE_MOUSE injections can pair a mouse pointerdown with
|
||||
// real touch events; double-handling would double the click report.
|
||||
expect(mouse.terminalInputBytes()).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DEFAULT_MOUSE_REPORT_RE,
|
||||
ESC,
|
||||
useTerminalMouseWebViewHarness
|
||||
} from './terminal-webview-mouse-test-harness'
|
||||
|
||||
describe('terminal WebView external mouse drag', () => {
|
||||
const mouse = useTerminalMouseWebViewHarness()
|
||||
|
||||
it('sends press, per-cell motion, and release for a drag-tracking mouse drag', () => {
|
||||
mouse.boot()
|
||||
mouse.activeTerminal().modes.mouseTrackingMode = 'drag'
|
||||
|
||||
mouse.mouseDrag(40, 60, 160, 60)
|
||||
|
||||
const reports = mouse.terminalInputBytes().match(DEFAULT_MOUSE_REPORT_RE) ?? []
|
||||
expect(reports.length).toBeGreaterThanOrEqual(3)
|
||||
expect(reports[0]?.charCodeAt(3)).toBe(32)
|
||||
for (const motion of reports.slice(1, -1)) {
|
||||
expect(motion.charCodeAt(3)).toBe(64)
|
||||
}
|
||||
expect(reports[reports.length - 1]?.charCodeAt(3)).toBe(35)
|
||||
// Motion reports are deduped per cell, so a horizontal drag advances columns.
|
||||
const motionCols = reports.slice(1, -1).map((report) => report.charCodeAt(4))
|
||||
expect(new Set(motionCols).size).toBe(motionCols.length)
|
||||
})
|
||||
|
||||
it('does not report motion or release for an x10 click-only TUI drag', () => {
|
||||
mouse.boot()
|
||||
mouse.activeTerminal().modes.mouseTrackingMode = 'x10'
|
||||
|
||||
mouse.mouseDrag(40, 60, 160, 60)
|
||||
|
||||
// x10 reports presses only; the press goes out at drag start, no motion or release follows.
|
||||
const bytes = mouse.terminalInputBytes()
|
||||
expect(bytes.slice(0, 4)).toBe(`${ESC}[M `)
|
||||
expect(bytes).toHaveLength(6)
|
||||
})
|
||||
|
||||
it('selects character-anchored text on a mouse drag outside tracking mode', () => {
|
||||
mouse.boot()
|
||||
// Why: init itself emits a set-select-mode reset; only the drag matters here.
|
||||
mouse.clearPostedMessages()
|
||||
|
||||
mouse.mouseDrag(40, 60, 160, 90)
|
||||
|
||||
expect(mouse.terminalInputBytes()).toBe('')
|
||||
expect(mouse.selectionSpy()).toHaveBeenCalled()
|
||||
expect(document.getElementById('selection-overlay')?.classList.contains('active')).toBe(true)
|
||||
const modes = mouse.postedMessages().filter((message) => message.type === 'set-select-mode')
|
||||
expect(modes).toEqual([{ type: 'set-select-mode', enabled: true }])
|
||||
})
|
||||
|
||||
it('releases a tracked drag when the button state shows the pointerup was lost', () => {
|
||||
mouse.boot()
|
||||
mouse.activeTerminal().modes.mouseTrackingMode = 'drag'
|
||||
|
||||
mouse.dispatchPointer('pointerdown', { x: 40, y: 60, button: 0, buttons: 1 })
|
||||
mouse.dispatchPointer('pointermove', { x: 160, y: 60, button: 0, buttons: 1 })
|
||||
// Pointer re-enters with the button already up — the pointerup never arrived.
|
||||
mouse.dispatchPointer('pointermove', { x: 200, y: 60, button: 0, buttons: 0 })
|
||||
|
||||
const reports = mouse.terminalInputBytes().match(DEFAULT_MOUSE_REPORT_RE) ?? []
|
||||
expect(reports[reports.length - 1]?.charCodeAt(3)).toBe(35)
|
||||
|
||||
mouse.clearPostedMessages()
|
||||
mouse.dispatchPointer('pointermove', { x: 240, y: 60, button: 0, buttons: 1 })
|
||||
|
||||
// The lost pointerup ended the gesture; a later unrelated move must not emit motion.
|
||||
expect(mouse.terminalInputBytes()).toBe('')
|
||||
})
|
||||
|
||||
it('releases a tracked drag when the pointer is cancelled mid-gesture', () => {
|
||||
mouse.boot()
|
||||
mouse.activeTerminal().modes.mouseTrackingMode = 'drag'
|
||||
|
||||
mouse.dispatchPointer('pointerdown', { x: 40, y: 60, button: 0, buttons: 1 })
|
||||
mouse.dispatchPointer('pointermove', { x: 160, y: 60, button: 0, buttons: 1 })
|
||||
mouse.dispatchPointer('pointercancel', { x: 160, y: 60 })
|
||||
|
||||
const reports = mouse.terminalInputBytes().match(DEFAULT_MOUSE_REPORT_RE) ?? []
|
||||
// Press went to the TUI, so the cancel must not leave the button latched.
|
||||
expect(reports[reports.length - 1]?.charCodeAt(3)).toBe(35)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,233 @@
|
||||
import { afterEach, beforeEach, vi, type Mock } from 'vitest'
|
||||
import { XTERM_HTML } from './terminal-webview-html'
|
||||
|
||||
function iifeSource(): string {
|
||||
const start = XTERM_HTML.indexOf('(function() {')
|
||||
const end = XTERM_HTML.lastIndexOf('})();')
|
||||
return XTERM_HTML.slice(start, end + '})();'.length)
|
||||
}
|
||||
|
||||
function bodyMarkup(): string {
|
||||
const start = XTERM_HTML.indexOf('<body>') + '<body>'.length
|
||||
const end = XTERM_HTML.indexOf('<script>', start)
|
||||
return XTERM_HTML.slice(start, end)
|
||||
}
|
||||
|
||||
type BufferState = {
|
||||
baseY: number
|
||||
type: 'alternate' | 'normal'
|
||||
viewportY: number
|
||||
}
|
||||
|
||||
type TerminalStub = ReturnType<typeof makeTerminal>
|
||||
type RegisteredWindowListener = {
|
||||
listener: EventListenerOrEventListenerObject
|
||||
options?: boolean | AddEventListenerOptions
|
||||
type: string
|
||||
}
|
||||
|
||||
type PointerEventType = 'pointerdown' | 'pointermove' | 'pointerup' | 'pointercancel'
|
||||
type PointerEventInit = {
|
||||
button?: number
|
||||
buttons?: number
|
||||
pointerType?: string
|
||||
x?: number
|
||||
y?: number
|
||||
}
|
||||
type PostMessage = Mock<(data: string) => void>
|
||||
type Select = Mock<(col: number, row: number, len: number) => void>
|
||||
|
||||
export const ESC = '\u001b'
|
||||
export const DEFAULT_MOUSE_REPORT_RE = new RegExp(`${ESC}\\[M[\\s\\S]{3}`, 'g')
|
||||
|
||||
function makeTerminal(buffer: BufferState, select: Select) {
|
||||
const terminal = {
|
||||
cols: 40,
|
||||
rows: 24,
|
||||
options: { fontSize: 13 },
|
||||
modes: { mouseTrackingMode: 'none' as string },
|
||||
element: null as HTMLElement | null,
|
||||
_core: {
|
||||
_renderService: { dimensions: { css: { cell: { width: 8, height: 15 } } } }
|
||||
},
|
||||
buffer: {
|
||||
active: {
|
||||
get baseY() {
|
||||
return buffer.baseY
|
||||
},
|
||||
get type() {
|
||||
return buffer.type
|
||||
},
|
||||
get viewportY() {
|
||||
return buffer.viewportY
|
||||
},
|
||||
cursorY: 0,
|
||||
length: 1,
|
||||
getLine: () => null
|
||||
}
|
||||
},
|
||||
write(_data: string, callback?: () => void) {
|
||||
callback?.()
|
||||
},
|
||||
open(surface: HTMLElement) {
|
||||
terminal.element = surface
|
||||
},
|
||||
loadAddon() {},
|
||||
resize(cols: number, rows: number) {
|
||||
terminal.cols = cols
|
||||
terminal.rows = rows
|
||||
},
|
||||
clear() {},
|
||||
reset() {},
|
||||
refresh() {},
|
||||
selectAll() {},
|
||||
clearSelection() {},
|
||||
select,
|
||||
scrollLines() {},
|
||||
scrollToBottom() {},
|
||||
scrollToLine() {},
|
||||
attachCustomKeyEventHandler() {},
|
||||
getSelection: () => '',
|
||||
onData: () => ({ dispose() {} }),
|
||||
onLineFeed: () => ({ dispose() {} }),
|
||||
onScroll: () => ({ dispose() {} }),
|
||||
onWriteParsed: () => ({ dispose() {} }),
|
||||
dispose() {}
|
||||
}
|
||||
return terminal
|
||||
}
|
||||
|
||||
function terminalSurface(): HTMLElement {
|
||||
const surface = document.getElementById('terminal-surface')
|
||||
if (!surface) {
|
||||
throw new Error('terminal surface missing')
|
||||
}
|
||||
return surface
|
||||
}
|
||||
|
||||
function postedMessages(postMessage: PostMessage): Array<Record<string, unknown>> {
|
||||
return postMessage.mock.calls.map(([raw]) => JSON.parse(String(raw)) as Record<string, unknown>)
|
||||
}
|
||||
|
||||
function terminalInputBytes(postMessage: PostMessage): string {
|
||||
return postedMessages(postMessage)
|
||||
.filter((message) => message.type === 'terminal-input')
|
||||
.map((message) => (message.bytes as string) ?? '')
|
||||
.join('')
|
||||
}
|
||||
|
||||
export function useTerminalMouseWebViewHarness() {
|
||||
let animationFrames: Array<() => void>
|
||||
let buffer: BufferState
|
||||
let postMessage: PostMessage
|
||||
let registeredWindowListeners: RegisteredWindowListener[]
|
||||
let select: Select
|
||||
let terminals: TerminalStub[]
|
||||
|
||||
function boot(): void {
|
||||
document.body.innerHTML = bodyMarkup()
|
||||
new Function(iifeSource())()
|
||||
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.
|
||||
while (animationFrames.length > 0) {
|
||||
animationFrames.shift()?.()
|
||||
}
|
||||
}
|
||||
|
||||
function activeTerminal(): TerminalStub {
|
||||
const terminal = terminals[terminals.length - 1]
|
||||
if (!terminal) {
|
||||
throw new Error('terminal missing')
|
||||
}
|
||||
return terminal
|
||||
}
|
||||
|
||||
function dispatchPointer(type: PointerEventType, init: PointerEventInit = {}): void {
|
||||
const event = new MouseEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: init.x ?? 40,
|
||||
clientY: init.y ?? 60,
|
||||
button: init.button ?? 0,
|
||||
buttons: init.buttons ?? 0
|
||||
})
|
||||
// Why: happy-dom's PointerEvent init drops pointerType; grafting it onto a
|
||||
// MouseEvent exercises the same duck-typed reads the WebView handler does.
|
||||
Object.defineProperty(event, 'pointerType', { value: init.pointerType ?? 'mouse' })
|
||||
terminalSurface().dispatchEvent(event)
|
||||
}
|
||||
|
||||
function mouseClick(x: number, y: number): void {
|
||||
dispatchPointer('pointerdown', { x, y, button: 0, buttons: 1 })
|
||||
dispatchPointer('pointerup', { x, y, button: 0, buttons: 0 })
|
||||
}
|
||||
|
||||
function mouseDrag(x1: number, y1: number, x2: number, y2: number): void {
|
||||
dispatchPointer('pointerdown', { x: x1, y: y1, button: 0, buttons: 1 })
|
||||
const midX = Math.round((x1 + x2) / 2)
|
||||
const midY = Math.round((y1 + y2) / 2)
|
||||
dispatchPointer('pointermove', { x: midX, y: midY, button: 0, buttons: 1 })
|
||||
dispatchPointer('pointermove', { x: x2, y: y2, button: 0, buttons: 1 })
|
||||
dispatchPointer('pointerup', { x: x2, y: y2, button: 0, buttons: 0 })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
animationFrames = []
|
||||
buffer = { baseY: 0, type: 'normal', viewportY: 0 }
|
||||
registeredWindowListeners = []
|
||||
select = vi.fn<(col: number, row: number, len: number) => void>()
|
||||
terminals = []
|
||||
const addWindowEventListener = window.addEventListener.bind(window)
|
||||
vi.spyOn(window, 'addEventListener').mockImplementation(((
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject,
|
||||
options?: boolean | AddEventListenerOptions
|
||||
) => {
|
||||
registeredWindowListeners.push({ type, listener, options })
|
||||
addWindowEventListener(type, listener, options)
|
||||
}) as typeof window.addEventListener)
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: () => void) => {
|
||||
animationFrames.push(callback)
|
||||
return animationFrames.length
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', () => {})
|
||||
Object.defineProperty(window, 'innerWidth', { value: 381, configurable: true })
|
||||
Object.defineProperty(window, 'innerHeight', { value: 612, configurable: true })
|
||||
postMessage = vi.fn<(data: string) => void>()
|
||||
const webWindow = window as unknown as {
|
||||
Terminal: new () => TerminalStub
|
||||
ReactNativeWebView: { postMessage: (data: string) => void }
|
||||
}
|
||||
webWindow.Terminal = function () {
|
||||
const terminal = makeTerminal(buffer, select)
|
||||
terminals.push(terminal)
|
||||
return terminal
|
||||
} as unknown as new () => TerminalStub
|
||||
webWindow.ReactNativeWebView = { postMessage }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const { type, listener, options } of registeredWindowListeners) {
|
||||
window.removeEventListener(type, listener as EventListener, options)
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
return {
|
||||
activeTerminal,
|
||||
boot,
|
||||
clearPostedMessages: () => postMessage.mockClear(),
|
||||
dispatchPointer,
|
||||
mouseClick,
|
||||
mouseDrag,
|
||||
postedMessages: () => postedMessages(postMessage),
|
||||
selectionSpy: () => select,
|
||||
terminalInputBytes: () => terminalInputBytes(postMessage),
|
||||
terminalSurface
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user