hermes-agent/ui-tui/src/__tests__/textInputCursorSourceOfTruth.test.ts
github-actions[bot] 75f45a0692
fmt(js): npm run fix on merge (#65229)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-15 21:40:56 +00:00

100 lines
4 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { fastAppendEffect, fastBackspaceEffect, resolveCursorLayout } from '../components/textInput.js'
import { cursorLayout } from '../lib/inputMetrics.js'
// Closes Copilot follow-up on PR #26717: the original cursor-drift
// fix bumped Ink's displayCursor / cursorDeclaration on fast-echo, but
// if TextInput itself re-renders before the deferred 16ms `setCur`
// flushes (parent state change, status-bar tick, spinner) the layout
// effect inside `useDeclaredCursor` re-publishes a declaration
// computed from the STALE React `cur` state and clobbers the Ink-level
// bump. The fix is structural: read `curRef.current` (always
// up-to-date) when computing the layout, not the `cur` state.
//
// These tests exercise the real, exported `resolveCursorLayout`,
// `fastBackspaceEffect`, and `fastAppendEffect` helpers that
// `textInput.tsx` calls at its render site and fast-echo call sites —
// no source-text regex, no readFileSync.
describe('resolveCursorLayout', () => {
it('uses curRefCurrent (the fresh ref value), not the stale cur state', () => {
// Simulate the exact bug scenario: `cur` (React state) is stale —
// it still reflects the value before a fast-echo append — while
// `curRef.current` has already advanced past it.
const display = 'hello world'
const staleCur = 5
const freshCurRefCurrent = 11
const columns = 80
const result = resolveCursorLayout(display, staleCur, freshCurRefCurrent, columns)
const expected = cursorLayout(display, freshCurRefCurrent, columns)
expect(result).toEqual(expected)
})
it('does not match the layout computed from the stale cur value', () => {
const display = 'hello world'
const staleCur = 5
const freshCurRefCurrent = 11
const columns = 80
const result = resolveCursorLayout(display, staleCur, freshCurRefCurrent, columns)
const staleLayout = cursorLayout(display, staleCur, columns)
expect(result).not.toEqual(staleLayout)
})
it('matches cursorLayout(display, curRefCurrent, columns) even when cur and curRefCurrent agree', () => {
const display = 'hello'
const cur = 5
const columns = 80
expect(resolveCursorLayout(display, cur, cur, columns)).toEqual(cursorLayout(display, cur, columns))
})
})
describe('fastBackspaceEffect', () => {
it('removes the last character, moves the cursor back one, and pairs the write with the advance delta', () => {
const effect = fastBackspaceEffect('hello', 5)
expect(effect.newValue).toBe('hell')
expect(effect.newCursor).toBe(4)
expect(effect.removed).toBe('o')
// Both the stdout write and the noteCursorAdvance delta live on the
// same returned object — a caller cannot apply `write` without also
// having `advanceDelta` in hand, so the pairing can't silently drift.
expect(effect.write).toBe('\b \b')
expect(effect.advanceDelta).toBe(-1)
})
it('handles deleting from the middle of the fast-echo-eligible tail', () => {
const effect = fastBackspaceEffect('abc', 3)
expect(effect.newValue).toBe('ab')
expect(effect.newCursor).toBe(2)
expect(effect.removed).toBe('c')
expect(effect.write).toBe('\b \b')
expect(effect.advanceDelta).toBe(-1)
})
})
describe('fastAppendEffect', () => {
it('appends the text, advances the cursor by the inserted length, and pairs the write with the advance delta', () => {
const effect = fastAppendEffect('hello', 5, ' world')
expect(effect.newValue).toBe('hello world')
expect(effect.newCursor).toBe(11)
// The stdout write is exactly the inserted text, and the
// noteCursorAdvance delta is bundled into the same object.
expect(effect.write).toBe(' world')
expect(effect.advanceDelta).toBe(' world'.length)
})
it('advance delta always matches the inserted text length, not a hardcoded value', () => {
const effect = fastAppendEffect('x', 1, 'abc')
expect(effect.newValue).toBe('xabc')
expect(effect.advanceDelta).toBe(3)
expect(effect.write).toBe('abc')
})
})