fix(ux) bug in replaceRange dealing with newLines that was breaking vim support (#5320)

This commit is contained in:
Jacob Richman 2025-07-31 16:16:29 -07:00 committed by GitHub
parent 32809a7be7
commit 61e382444a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 204 additions and 98 deletions

View File

@ -0,0 +1,63 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/// <reference types="vitest/globals" />
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { expect } from 'vitest';
import type { TextBuffer } from '../ui/components/shared/text-buffer.js';
// RegExp to detect invalid characters: backspace, and ANSI escape codes
// eslint-disable-next-line no-control-regex
const invalidCharsRegex = /[\b\x1b]/;
function toHaveOnlyValidCharacters(this: vi.Assertion, buffer: TextBuffer) {
const { isNot } = this;
let pass = true;
const invalidLines: Array<{ line: number; content: string }> = [];
for (let i = 0; i < buffer.lines.length; i++) {
const line = buffer.lines[i];
if (line.includes('\n')) {
pass = false;
invalidLines.push({ line: i, content: line });
break; // Fail fast on newlines
}
if (invalidCharsRegex.test(line)) {
pass = false;
invalidLines.push({ line: i, content: line });
}
}
return {
pass,
message: () =>
`Expected buffer ${isNot ? 'not ' : ''}to have only valid characters, but found invalid characters in lines:\n${invalidLines
.map((l) => ` [${l.line}]: "${l.content}"`) /* This line was changed */
.join('\n')}`,
actual: buffer.lines,
expected: 'Lines with no line breaks, backspaces, or escape codes.',
};
}
expect.extend({
toHaveOnlyValidCharacters,
});
// Extend Vitest's `expect` interface with the custom matcher's type definition.
declare module 'vitest' {
interface Assertion<T> {
toHaveOnlyValidCharacters(): T;
}
interface AsymmetricMatchersContaining {
toHaveOnlyValidCharacters(): void;
}
}

View File

@ -53,8 +53,10 @@ export const createMockCommandContext = (
setPendingItem: vi.fn(), setPendingItem: vi.fn(),
loadHistory: vi.fn(), loadHistory: vi.fn(),
toggleCorgiMode: vi.fn(), toggleCorgiMode: vi.fn(),
toggleVimEnabled: vi.fn(),
}, },
session: { session: {
sessionShellAllowlist: new Set<string>(),
stats: { stats: {
sessionStartTime: new Date(), sessionStartTime: new Date(),
lastPromptTokenCount: 0, lastPromptTokenCount: 0,

View File

@ -32,6 +32,7 @@ describe('textBufferReducer', () => {
it('should return the initial state if state is undefined', () => { it('should return the initial state if state is undefined', () => {
const action = { type: 'unknown_action' } as unknown as TextBufferAction; const action = { type: 'unknown_action' } as unknown as TextBufferAction;
const state = textBufferReducer(initialState, action); const state = textBufferReducer(initialState, action);
expect(state).toHaveOnlyValidCharacters();
expect(state).toEqual(initialState); expect(state).toEqual(initialState);
}); });
@ -42,6 +43,7 @@ describe('textBufferReducer', () => {
payload: 'hello\nworld', payload: 'hello\nworld',
}; };
const state = textBufferReducer(initialState, action); const state = textBufferReducer(initialState, action);
expect(state).toHaveOnlyValidCharacters();
expect(state.lines).toEqual(['hello', 'world']); expect(state.lines).toEqual(['hello', 'world']);
expect(state.cursorRow).toBe(1); expect(state.cursorRow).toBe(1);
expect(state.cursorCol).toBe(5); expect(state.cursorCol).toBe(5);
@ -55,6 +57,7 @@ describe('textBufferReducer', () => {
pushToUndo: false, pushToUndo: false,
}; };
const state = textBufferReducer(initialState, action); const state = textBufferReducer(initialState, action);
expect(state).toHaveOnlyValidCharacters();
expect(state.lines).toEqual(['no undo']); expect(state.lines).toEqual(['no undo']);
expect(state.undoStack.length).toBe(0); expect(state.undoStack.length).toBe(0);
}); });
@ -64,6 +67,7 @@ describe('textBufferReducer', () => {
it('should insert a character', () => { it('should insert a character', () => {
const action: TextBufferAction = { type: 'insert', payload: 'a' }; const action: TextBufferAction = { type: 'insert', payload: 'a' };
const state = textBufferReducer(initialState, action); const state = textBufferReducer(initialState, action);
expect(state).toHaveOnlyValidCharacters();
expect(state.lines).toEqual(['a']); expect(state.lines).toEqual(['a']);
expect(state.cursorCol).toBe(1); expect(state.cursorCol).toBe(1);
}); });
@ -72,6 +76,7 @@ describe('textBufferReducer', () => {
const stateWithText = { ...initialState, lines: ['hello'] }; const stateWithText = { ...initialState, lines: ['hello'] };
const action: TextBufferAction = { type: 'insert', payload: '\n' }; const action: TextBufferAction = { type: 'insert', payload: '\n' };
const state = textBufferReducer(stateWithText, action); const state = textBufferReducer(stateWithText, action);
expect(state).toHaveOnlyValidCharacters();
expect(state.lines).toEqual(['', 'hello']); expect(state.lines).toEqual(['', 'hello']);
expect(state.cursorRow).toBe(1); expect(state.cursorRow).toBe(1);
expect(state.cursorCol).toBe(0); expect(state.cursorCol).toBe(0);
@ -88,6 +93,7 @@ describe('textBufferReducer', () => {
}; };
const action: TextBufferAction = { type: 'backspace' }; const action: TextBufferAction = { type: 'backspace' };
const state = textBufferReducer(stateWithText, action); const state = textBufferReducer(stateWithText, action);
expect(state).toHaveOnlyValidCharacters();
expect(state.lines).toEqual(['']); expect(state.lines).toEqual(['']);
expect(state.cursorCol).toBe(0); expect(state.cursorCol).toBe(0);
}); });
@ -101,6 +107,7 @@ describe('textBufferReducer', () => {
}; };
const action: TextBufferAction = { type: 'backspace' }; const action: TextBufferAction = { type: 'backspace' };
const state = textBufferReducer(stateWithText, action); const state = textBufferReducer(stateWithText, action);
expect(state).toHaveOnlyValidCharacters();
expect(state.lines).toEqual(['helloworld']); expect(state.lines).toEqual(['helloworld']);
expect(state.cursorRow).toBe(0); expect(state.cursorRow).toBe(0);
expect(state.cursorCol).toBe(5); expect(state.cursorCol).toBe(5);
@ -115,12 +122,14 @@ describe('textBufferReducer', () => {
payload: 'test', payload: 'test',
}; };
const stateAfterInsert = textBufferReducer(initialState, insertAction); const stateAfterInsert = textBufferReducer(initialState, insertAction);
expect(stateAfterInsert).toHaveOnlyValidCharacters();
expect(stateAfterInsert.lines).toEqual(['test']); expect(stateAfterInsert.lines).toEqual(['test']);
expect(stateAfterInsert.undoStack.length).toBe(1); expect(stateAfterInsert.undoStack.length).toBe(1);
// 2. Undo // 2. Undo
const undoAction: TextBufferAction = { type: 'undo' }; const undoAction: TextBufferAction = { type: 'undo' };
const stateAfterUndo = textBufferReducer(stateAfterInsert, undoAction); const stateAfterUndo = textBufferReducer(stateAfterInsert, undoAction);
expect(stateAfterUndo).toHaveOnlyValidCharacters();
expect(stateAfterUndo.lines).toEqual(['']); expect(stateAfterUndo.lines).toEqual(['']);
expect(stateAfterUndo.undoStack.length).toBe(0); expect(stateAfterUndo.undoStack.length).toBe(0);
expect(stateAfterUndo.redoStack.length).toBe(1); expect(stateAfterUndo.redoStack.length).toBe(1);
@ -128,6 +137,7 @@ describe('textBufferReducer', () => {
// 3. Redo // 3. Redo
const redoAction: TextBufferAction = { type: 'redo' }; const redoAction: TextBufferAction = { type: 'redo' };
const stateAfterRedo = textBufferReducer(stateAfterUndo, redoAction); const stateAfterRedo = textBufferReducer(stateAfterUndo, redoAction);
expect(stateAfterRedo).toHaveOnlyValidCharacters();
expect(stateAfterRedo.lines).toEqual(['test']); expect(stateAfterRedo.lines).toEqual(['test']);
expect(stateAfterRedo.undoStack.length).toBe(1); expect(stateAfterRedo.undoStack.length).toBe(1);
expect(stateAfterRedo.redoStack.length).toBe(0); expect(stateAfterRedo.redoStack.length).toBe(0);
@ -144,6 +154,7 @@ describe('textBufferReducer', () => {
}; };
const action: TextBufferAction = { type: 'create_undo_snapshot' }; const action: TextBufferAction = { type: 'create_undo_snapshot' };
const state = textBufferReducer(stateWithText, action); const state = textBufferReducer(stateWithText, action);
expect(state).toHaveOnlyValidCharacters();
expect(state.lines).toEqual(['hello']); expect(state.lines).toEqual(['hello']);
expect(state.cursorRow).toBe(0); expect(state.cursorRow).toBe(0);
@ -157,16 +168,19 @@ describe('textBufferReducer', () => {
}); });
// Helper to get the state from the hook // Helper to get the state from the hook
const getBufferState = (result: { current: TextBuffer }) => ({ const getBufferState = (result: { current: TextBuffer }) => {
text: result.current.text, expect(result.current).toHaveOnlyValidCharacters();
lines: [...result.current.lines], // Clone for safety return {
cursor: [...result.current.cursor] as [number, number], text: result.current.text,
allVisualLines: [...result.current.allVisualLines], lines: [...result.current.lines], // Clone for safety
viewportVisualLines: [...result.current.viewportVisualLines], cursor: [...result.current.cursor] as [number, number],
visualCursor: [...result.current.visualCursor] as [number, number], allVisualLines: [...result.current.allVisualLines],
visualScrollRow: result.current.visualScrollRow, viewportVisualLines: [...result.current.viewportVisualLines],
preferredCol: result.current.preferredCol, visualCursor: [...result.current.visualCursor] as [number, number],
}); visualScrollRow: result.current.visualScrollRow,
preferredCol: result.current.preferredCol,
};
};
describe('useTextBuffer', () => { describe('useTextBuffer', () => {
let viewport: Viewport; let viewport: Viewport;
@ -1152,6 +1166,22 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
expect(state.text).toBe('fiXrd'); expect(state.text).toBe('fiXrd');
expect(state.cursor).toEqual([0, 3]); // After 'X' expect(state.cursor).toEqual([0, 3]); // After 'X'
}); });
it('should replace a single-line range with multi-line text', () => {
const { result } = renderHook(() =>
useTextBuffer({
initialText: 'one two three',
viewport,
isValidPath: () => false,
}),
);
// Replace "two" with "new\nline"
act(() => result.current.replaceRange(0, 4, 0, 7, 'new\nline'));
const state = getBufferState(result);
expect(state.lines).toEqual(['one new', 'line three']);
expect(state.text).toBe('one new\nline three');
expect(state.cursor).toEqual([1, 4]); // cursor after 'line'
});
}); });
describe('Input Sanitization', () => { describe('Input Sanitization', () => {
@ -1159,7 +1189,7 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
const { result } = renderHook(() => const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }), useTextBuffer({ viewport, isValidPath: () => false }),
); );
const textWithAnsi = '\x1B[31mHello\x1B[0m'; const textWithAnsi = '\x1B[31mHello\x1B[0m \x1B[32mWorld\x1B[0m';
act(() => act(() =>
result.current.handleInput({ result.current.handleInput({
name: '', name: '',
@ -1170,7 +1200,7 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
sequence: textWithAnsi, sequence: textWithAnsi,
}), }),
); );
expect(getBufferState(result).text).toBe('Hello'); expect(getBufferState(result).text).toBe('Hello World');
}); });
it('should strip control characters from input', () => { it('should strip control characters from input', () => {
@ -1425,6 +1455,7 @@ describe('textBufferReducer vim operations', () => {
}; };
const result = textBufferReducer(initialState, action); const result = textBufferReducer(initialState, action);
expect(result).toHaveOnlyValidCharacters();
// After deleting line2, we should have line1 and line3, with cursor on line3 (now at index 1) // After deleting line2, we should have line1 and line3, with cursor on line3 (now at index 1)
expect(result.lines).toEqual(['line1', 'line3']); expect(result.lines).toEqual(['line1', 'line3']);
@ -1452,6 +1483,7 @@ describe('textBufferReducer vim operations', () => {
}; };
const result = textBufferReducer(initialState, action); const result = textBufferReducer(initialState, action);
expect(result).toHaveOnlyValidCharacters();
// Should delete line2 and line3, leaving line1 and line4 // Should delete line2 and line3, leaving line1 and line4
expect(result.lines).toEqual(['line1', 'line4']); expect(result.lines).toEqual(['line1', 'line4']);
@ -1479,6 +1511,7 @@ describe('textBufferReducer vim operations', () => {
}; };
const result = textBufferReducer(initialState, action); const result = textBufferReducer(initialState, action);
expect(result).toHaveOnlyValidCharacters();
// Should clear the line content but keep the line // Should clear the line content but keep the line
expect(result.lines).toEqual(['']); expect(result.lines).toEqual(['']);
@ -1506,6 +1539,7 @@ describe('textBufferReducer vim operations', () => {
}; };
const result = textBufferReducer(initialState, action); const result = textBufferReducer(initialState, action);
expect(result).toHaveOnlyValidCharacters();
// Should delete the last line completely, not leave empty line // Should delete the last line completely, not leave empty line
expect(result.lines).toEqual(['line1']); expect(result.lines).toEqual(['line1']);
@ -1534,6 +1568,7 @@ describe('textBufferReducer vim operations', () => {
}; };
const afterDelete = textBufferReducer(initialState, deleteAction); const afterDelete = textBufferReducer(initialState, deleteAction);
expect(afterDelete).toHaveOnlyValidCharacters();
// After deleting all lines, should have one empty line // After deleting all lines, should have one empty line
expect(afterDelete.lines).toEqual(['']); expect(afterDelete.lines).toEqual(['']);
@ -1547,6 +1582,7 @@ describe('textBufferReducer vim operations', () => {
}; };
const afterPaste = textBufferReducer(afterDelete, pasteAction); const afterPaste = textBufferReducer(afterDelete, pasteAction);
expect(afterPaste).toHaveOnlyValidCharacters();
// All lines including the first one should be present // All lines including the first one should be present
expect(afterPaste.lines).toEqual(['new1', 'new2', 'new3', 'new4']); expect(afterPaste.lines).toEqual(['new1', 'new2', 'new3', 'new4']);

View File

@ -271,26 +271,23 @@ export const replaceRangeInternal = (
.replace(/\r/g, '\n'); .replace(/\r/g, '\n');
const replacementParts = normalisedReplacement.split('\n'); const replacementParts = normalisedReplacement.split('\n');
// Replace the content // The combined first line of the new text
if (startRow === endRow) { const firstLine = prefix + replacementParts[0];
newLines[startRow] = prefix + normalisedReplacement + suffix;
if (replacementParts.length === 1) {
// No newlines in replacement: combine prefix, replacement, and suffix on one line.
newLines.splice(startRow, endRow - startRow + 1, firstLine + suffix);
} else { } else {
const firstLine = prefix + replacementParts[0]; // Newlines in replacement: create new lines.
if (replacementParts.length === 1) { const lastLine = replacementParts[replacementParts.length - 1] + suffix;
// Single line of replacement text, but spanning multiple original lines const middleLines = replacementParts.slice(1, -1);
newLines.splice(startRow, endRow - startRow + 1, firstLine + suffix); newLines.splice(
} else { startRow,
// Multi-line replacement text endRow - startRow + 1,
const lastLine = replacementParts[replacementParts.length - 1] + suffix; firstLine,
const middleLines = replacementParts.slice(1, -1); ...middleLines,
newLines.splice( lastLine,
startRow, );
endRow - startRow + 1,
firstLine,
...middleLines,
lastLine,
);
}
} }
const finalCursorRow = startRow + replacementParts.length - 1; const finalCursorRow = startRow + replacementParts.length - 1;

View File

@ -36,7 +36,7 @@ describe('vim-buffer-actions', () => {
}; };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(2); expect(result.cursorCol).toBe(2);
expect(result.preferredCol).toBeNull(); expect(result.preferredCol).toBeNull();
}); });
@ -49,7 +49,7 @@ describe('vim-buffer-actions', () => {
}; };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(0); expect(result.cursorCol).toBe(0);
}); });
@ -61,7 +61,7 @@ describe('vim-buffer-actions', () => {
}; };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(0); expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(4); // On last character '1' of 'line1' expect(result.cursorCol).toBe(4); // On last character '1' of 'line1'
}); });
@ -74,7 +74,7 @@ describe('vim-buffer-actions', () => {
}; };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(0); expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(1); // On 'b' after 5 left movements expect(result.cursorCol).toBe(1); // On 'b' after 5 left movements
}); });
@ -88,6 +88,7 @@ describe('vim-buffer-actions', () => {
type: 'vim_move_right' as const, type: 'vim_move_right' as const,
payload: { count: 1 }, payload: { count: 1 },
}); });
expect(state).toHaveOnlyValidCharacters();
expect(state.cursorRow).toBe(1); expect(state.cursorRow).toBe(1);
expect(state.cursorCol).toBe(0); // Should be on 'f' expect(state.cursorCol).toBe(0); // Should be on 'f'
@ -96,6 +97,7 @@ describe('vim-buffer-actions', () => {
type: 'vim_move_left' as const, type: 'vim_move_left' as const,
payload: { count: 1 }, payload: { count: 1 },
}); });
expect(state).toHaveOnlyValidCharacters();
expect(state.cursorRow).toBe(0); expect(state.cursorRow).toBe(0);
expect(state.cursorCol).toBe(10); // Should be on 'd', not past it expect(state.cursorCol).toBe(10); // Should be on 'd', not past it
}); });
@ -110,7 +112,7 @@ describe('vim-buffer-actions', () => {
}; };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(5); expect(result.cursorCol).toBe(5);
}); });
@ -122,7 +124,7 @@ describe('vim-buffer-actions', () => {
}; };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(4); // Last character of 'hello' expect(result.cursorCol).toBe(4); // Last character of 'hello'
}); });
@ -134,7 +136,7 @@ describe('vim-buffer-actions', () => {
}; };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(1); expect(result.cursorRow).toBe(1);
expect(result.cursorCol).toBe(0); expect(result.cursorCol).toBe(0);
}); });
@ -146,7 +148,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_move_up' as const, payload: { count: 2 } }; const action = { type: 'vim_move_up' as const, payload: { count: 2 } };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(0); expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(3); expect(result.cursorCol).toBe(3);
}); });
@ -156,7 +158,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_move_up' as const, payload: { count: 5 } }; const action = { type: 'vim_move_up' as const, payload: { count: 5 } };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(0); expect(result.cursorRow).toBe(0);
}); });
@ -165,7 +167,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_move_up' as const, payload: { count: 1 } }; const action = { type: 'vim_move_up' as const, payload: { count: 1 } };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(0); expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(5); // End of 'short' expect(result.cursorCol).toBe(5); // End of 'short'
}); });
@ -180,7 +182,7 @@ describe('vim-buffer-actions', () => {
}; };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(2); expect(result.cursorRow).toBe(2);
expect(result.cursorCol).toBe(2); expect(result.cursorCol).toBe(2);
}); });
@ -193,7 +195,7 @@ describe('vim-buffer-actions', () => {
}; };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(1); expect(result.cursorRow).toBe(1);
}); });
}); });
@ -207,7 +209,7 @@ describe('vim-buffer-actions', () => {
}; };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(6); // Start of 'world' expect(result.cursorCol).toBe(6); // Start of 'world'
}); });
@ -219,7 +221,7 @@ describe('vim-buffer-actions', () => {
}; };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(12); // Start of 'test' expect(result.cursorCol).toBe(12); // Start of 'test'
}); });
@ -231,7 +233,7 @@ describe('vim-buffer-actions', () => {
}; };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(5); // Start of ',' expect(result.cursorCol).toBe(5); // Start of ','
}); });
}); });
@ -245,7 +247,7 @@ describe('vim-buffer-actions', () => {
}; };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(6); // Start of 'world' expect(result.cursorCol).toBe(6); // Start of 'world'
}); });
@ -257,7 +259,7 @@ describe('vim-buffer-actions', () => {
}; };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(0); // Start of 'hello' expect(result.cursorCol).toBe(0); // Start of 'hello'
}); });
}); });
@ -271,7 +273,7 @@ describe('vim-buffer-actions', () => {
}; };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(4); // End of 'hello' expect(result.cursorCol).toBe(4); // End of 'hello'
}); });
@ -283,7 +285,7 @@ describe('vim-buffer-actions', () => {
}; };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(10); // End of 'world' expect(result.cursorCol).toBe(10); // End of 'world'
}); });
}); });
@ -294,7 +296,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_move_to_line_start' as const }; const action = { type: 'vim_move_to_line_start' as const };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(0); expect(result.cursorCol).toBe(0);
}); });
@ -303,7 +305,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_move_to_line_end' as const }; const action = { type: 'vim_move_to_line_end' as const };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(10); // Last character of 'hello world' expect(result.cursorCol).toBe(10); // Last character of 'hello world'
}); });
@ -312,7 +314,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_move_to_first_nonwhitespace' as const }; const action = { type: 'vim_move_to_first_nonwhitespace' as const };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(3); // Position of 'h' expect(result.cursorCol).toBe(3); // Position of 'h'
}); });
@ -321,7 +323,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_move_to_first_line' as const }; const action = { type: 'vim_move_to_first_line' as const };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(0); expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(0); expect(result.cursorCol).toBe(0);
}); });
@ -331,7 +333,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_move_to_last_line' as const }; const action = { type: 'vim_move_to_last_line' as const };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(2); expect(result.cursorRow).toBe(2);
expect(result.cursorCol).toBe(0); expect(result.cursorCol).toBe(0);
}); });
@ -344,7 +346,7 @@ describe('vim-buffer-actions', () => {
}; };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(1); // 0-indexed expect(result.cursorRow).toBe(1); // 0-indexed
expect(result.cursorCol).toBe(0); expect(result.cursorCol).toBe(0);
}); });
@ -357,7 +359,7 @@ describe('vim-buffer-actions', () => {
}; };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(1); // Last line expect(result.cursorRow).toBe(1); // Last line
}); });
}); });
@ -373,7 +375,7 @@ describe('vim-buffer-actions', () => {
}; };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hllo'); expect(result.lines[0]).toBe('hllo');
expect(result.cursorCol).toBe(1); expect(result.cursorCol).toBe(1);
}); });
@ -386,7 +388,7 @@ describe('vim-buffer-actions', () => {
}; };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('ho'); expect(result.lines[0]).toBe('ho');
expect(result.cursorCol).toBe(1); expect(result.cursorCol).toBe(1);
}); });
@ -399,7 +401,7 @@ describe('vim-buffer-actions', () => {
}; };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hel'); expect(result.lines[0]).toBe('hel');
expect(result.cursorCol).toBe(3); expect(result.cursorCol).toBe(3);
}); });
@ -412,7 +414,7 @@ describe('vim-buffer-actions', () => {
}; };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hello'); expect(result.lines[0]).toBe('hello');
expect(result.cursorCol).toBe(5); expect(result.cursorCol).toBe(5);
}); });
@ -427,7 +429,7 @@ describe('vim-buffer-actions', () => {
}; };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('world test'); expect(result.lines[0]).toBe('world test');
expect(result.cursorCol).toBe(0); expect(result.cursorCol).toBe(0);
}); });
@ -440,7 +442,7 @@ describe('vim-buffer-actions', () => {
}; };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('test'); expect(result.lines[0]).toBe('test');
expect(result.cursorCol).toBe(0); expect(result.cursorCol).toBe(0);
}); });
@ -453,7 +455,7 @@ describe('vim-buffer-actions', () => {
}; };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hello '); expect(result.lines[0]).toBe('hello ');
expect(result.cursorCol).toBe(6); expect(result.cursorCol).toBe(6);
}); });
@ -468,7 +470,7 @@ describe('vim-buffer-actions', () => {
}; };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hello test'); expect(result.lines[0]).toBe('hello test');
expect(result.cursorCol).toBe(6); expect(result.cursorCol).toBe(6);
}); });
@ -481,7 +483,7 @@ describe('vim-buffer-actions', () => {
}; };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('test'); expect(result.lines[0]).toBe('test');
expect(result.cursorCol).toBe(0); expect(result.cursorCol).toBe(0);
}); });
@ -496,7 +498,7 @@ describe('vim-buffer-actions', () => {
}; };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['line1', 'line3']); expect(result.lines).toEqual(['line1', 'line3']);
expect(result.cursorRow).toBe(1); expect(result.cursorRow).toBe(1);
expect(result.cursorCol).toBe(0); expect(result.cursorCol).toBe(0);
@ -510,7 +512,7 @@ describe('vim-buffer-actions', () => {
}; };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['line3']); expect(result.lines).toEqual(['line3']);
expect(result.cursorRow).toBe(0); expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(0); expect(result.cursorCol).toBe(0);
@ -524,7 +526,7 @@ describe('vim-buffer-actions', () => {
}; };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['']); expect(result.lines).toEqual(['']);
expect(result.cursorRow).toBe(0); expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(0); expect(result.cursorCol).toBe(0);
@ -537,7 +539,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_delete_to_end_of_line' as const }; const action = { type: 'vim_delete_to_end_of_line' as const };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hello'); expect(result.lines[0]).toBe('hello');
expect(result.cursorCol).toBe(5); expect(result.cursorCol).toBe(5);
}); });
@ -547,7 +549,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_delete_to_end_of_line' as const }; const action = { type: 'vim_delete_to_end_of_line' as const };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hello'); expect(result.lines[0]).toBe('hello');
}); });
}); });
@ -560,7 +562,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_insert_at_cursor' as const }; const action = { type: 'vim_insert_at_cursor' as const };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(0); expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(2); expect(result.cursorCol).toBe(2);
}); });
@ -572,7 +574,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_append_at_cursor' as const }; const action = { type: 'vim_append_at_cursor' as const };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(3); expect(result.cursorCol).toBe(3);
}); });
@ -581,7 +583,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_append_at_cursor' as const }; const action = { type: 'vim_append_at_cursor' as const };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(5); expect(result.cursorCol).toBe(5);
}); });
}); });
@ -592,7 +594,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_append_at_line_end' as const }; const action = { type: 'vim_append_at_line_end' as const };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(11); expect(result.cursorCol).toBe(11);
}); });
}); });
@ -603,7 +605,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_insert_at_line_start' as const }; const action = { type: 'vim_insert_at_line_start' as const };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(2); expect(result.cursorCol).toBe(2);
}); });
@ -612,34 +614,32 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_insert_at_line_start' as const }; const action = { type: 'vim_insert_at_line_start' as const };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(3); expect(result.cursorCol).toBe(3);
}); });
}); });
describe('vim_open_line_below', () => { describe('vim_open_line_below', () => {
it('should insert newline at end of current line', () => { it('should insert a new line below the current one', () => {
const state = createTestState(['hello world'], 0, 5); const state = createTestState(['hello world'], 0, 5);
const action = { type: 'vim_open_line_below' as const }; const action = { type: 'vim_open_line_below' as const };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
// The implementation inserts newline at end of current line and cursor moves to column 0 expect(result.lines).toEqual(['hello world', '']);
expect(result.lines[0]).toBe('hello world\n'); expect(result.cursorRow).toBe(1);
expect(result.cursorRow).toBe(0); expect(result.cursorCol).toBe(0);
expect(result.cursorCol).toBe(0); // Cursor position after replaceRangeInternal
}); });
}); });
describe('vim_open_line_above', () => { describe('vim_open_line_above', () => {
it('should insert newline before current line', () => { it('should insert a new line above the current one', () => {
const state = createTestState(['hello', 'world'], 1, 2); const state = createTestState(['hello', 'world'], 1, 2);
const action = { type: 'vim_open_line_above' as const }; const action = { type: 'vim_open_line_above' as const };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
// The implementation inserts newline at beginning of current line expect(result.lines).toEqual(['hello', '', 'world']);
expect(result.lines).toEqual(['hello', '\nworld']);
expect(result.cursorRow).toBe(1); expect(result.cursorRow).toBe(1);
expect(result.cursorCol).toBe(0); expect(result.cursorCol).toBe(0);
}); });
@ -651,7 +651,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_escape_insert_mode' as const }; const action = { type: 'vim_escape_insert_mode' as const };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(2); expect(result.cursorCol).toBe(2);
}); });
@ -660,7 +660,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_escape_insert_mode' as const }; const action = { type: 'vim_escape_insert_mode' as const };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(0); expect(result.cursorCol).toBe(0);
}); });
}); });
@ -676,7 +676,7 @@ describe('vim-buffer-actions', () => {
}; };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('world test'); expect(result.lines[0]).toBe('world test');
expect(result.cursorCol).toBe(0); expect(result.cursorCol).toBe(0);
}); });
@ -691,7 +691,7 @@ describe('vim-buffer-actions', () => {
}; };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe(''); expect(result.lines[0]).toBe('');
expect(result.cursorCol).toBe(0); expect(result.cursorCol).toBe(0);
}); });
@ -706,7 +706,7 @@ describe('vim-buffer-actions', () => {
}; };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hel world'); expect(result.lines[0]).toBe('hel world');
expect(result.cursorCol).toBe(3); expect(result.cursorCol).toBe(3);
}); });
@ -719,7 +719,7 @@ describe('vim-buffer-actions', () => {
}; };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hellorld'); // Deletes ' wo' (3 chars to the right) expect(result.lines[0]).toBe('hellorld'); // Deletes ' wo' (3 chars to the right)
expect(result.cursorCol).toBe(5); expect(result.cursorCol).toBe(5);
}); });
@ -732,7 +732,7 @@ describe('vim-buffer-actions', () => {
}; };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
// The movement 'j' with count 2 changes 2 lines starting from cursor row // The movement 'j' with count 2 changes 2 lines starting from cursor row
// Since we're at cursor position 2, it changes lines starting from current row // Since we're at cursor position 2, it changes lines starting from current row
expect(result.lines).toEqual(['line1', 'line2', 'line3']); // No change because count > available lines expect(result.lines).toEqual(['line1', 'line2', 'line3']); // No change because count > available lines
@ -751,7 +751,7 @@ describe('vim-buffer-actions', () => {
}; };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(0); expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(0); expect(result.cursorCol).toBe(0);
}); });
@ -761,7 +761,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_move_to_line_end' as const }; const action = { type: 'vim_move_to_line_end' as const };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(0); // Should be last character position expect(result.cursorCol).toBe(0); // Should be last character position
}); });
@ -773,7 +773,7 @@ describe('vim-buffer-actions', () => {
}; };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
// Should move to next line with content // Should move to next line with content
expect(result.cursorRow).toBe(2); expect(result.cursorRow).toBe(2);
expect(result.cursorCol).toBe(0); expect(result.cursorCol).toBe(0);
@ -789,7 +789,7 @@ describe('vim-buffer-actions', () => {
}; };
const result = handleVimAction(state, action); const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.undoStack).toHaveLength(2); // Original plus new snapshot expect(result.undoStack).toHaveLength(2); // Original plus new snapshot
}); });
}); });

View File

@ -0,0 +1,7 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import './src/test-utils/customMatchers.js';

View File

@ -18,6 +18,7 @@ export default defineConfig({
outputFile: { outputFile: {
junit: 'junit.xml', junit: 'junit.xml',
}, },
setupFiles: ['./test-setup.ts'],
coverage: { coverage: {
enabled: true, enabled: true,
provider: 'v8', provider: 'v8',