diff --git a/packages/cli/src/test-utils/customMatchers.ts b/packages/cli/src/test-utils/customMatchers.ts new file mode 100644 index 00000000..c0b4df6b --- /dev/null +++ b/packages/cli/src/test-utils/customMatchers.ts @@ -0,0 +1,63 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/// + +/** + * @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 { + toHaveOnlyValidCharacters(): T; + } + interface AsymmetricMatchersContaining { + toHaveOnlyValidCharacters(): void; + } +} diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts index cf450484..4137dbff 100644 --- a/packages/cli/src/test-utils/mockCommandContext.ts +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -53,8 +53,10 @@ export const createMockCommandContext = ( setPendingItem: vi.fn(), loadHistory: vi.fn(), toggleCorgiMode: vi.fn(), + toggleVimEnabled: vi.fn(), }, session: { + sessionShellAllowlist: new Set(), stats: { sessionStartTime: new Date(), lastPromptTokenCount: 0, diff --git a/packages/cli/src/ui/components/shared/text-buffer.test.ts b/packages/cli/src/ui/components/shared/text-buffer.test.ts index 807c33df..cbceedbc 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.test.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.test.ts @@ -32,6 +32,7 @@ describe('textBufferReducer', () => { it('should return the initial state if state is undefined', () => { const action = { type: 'unknown_action' } as unknown as TextBufferAction; const state = textBufferReducer(initialState, action); + expect(state).toHaveOnlyValidCharacters(); expect(state).toEqual(initialState); }); @@ -42,6 +43,7 @@ describe('textBufferReducer', () => { payload: 'hello\nworld', }; const state = textBufferReducer(initialState, action); + expect(state).toHaveOnlyValidCharacters(); expect(state.lines).toEqual(['hello', 'world']); expect(state.cursorRow).toBe(1); expect(state.cursorCol).toBe(5); @@ -55,6 +57,7 @@ describe('textBufferReducer', () => { pushToUndo: false, }; const state = textBufferReducer(initialState, action); + expect(state).toHaveOnlyValidCharacters(); expect(state.lines).toEqual(['no undo']); expect(state.undoStack.length).toBe(0); }); @@ -64,6 +67,7 @@ describe('textBufferReducer', () => { it('should insert a character', () => { const action: TextBufferAction = { type: 'insert', payload: 'a' }; const state = textBufferReducer(initialState, action); + expect(state).toHaveOnlyValidCharacters(); expect(state.lines).toEqual(['a']); expect(state.cursorCol).toBe(1); }); @@ -72,6 +76,7 @@ describe('textBufferReducer', () => { const stateWithText = { ...initialState, lines: ['hello'] }; const action: TextBufferAction = { type: 'insert', payload: '\n' }; const state = textBufferReducer(stateWithText, action); + expect(state).toHaveOnlyValidCharacters(); expect(state.lines).toEqual(['', 'hello']); expect(state.cursorRow).toBe(1); expect(state.cursorCol).toBe(0); @@ -88,6 +93,7 @@ describe('textBufferReducer', () => { }; const action: TextBufferAction = { type: 'backspace' }; const state = textBufferReducer(stateWithText, action); + expect(state).toHaveOnlyValidCharacters(); expect(state.lines).toEqual(['']); expect(state.cursorCol).toBe(0); }); @@ -101,6 +107,7 @@ describe('textBufferReducer', () => { }; const action: TextBufferAction = { type: 'backspace' }; const state = textBufferReducer(stateWithText, action); + expect(state).toHaveOnlyValidCharacters(); expect(state.lines).toEqual(['helloworld']); expect(state.cursorRow).toBe(0); expect(state.cursorCol).toBe(5); @@ -115,12 +122,14 @@ describe('textBufferReducer', () => { payload: 'test', }; const stateAfterInsert = textBufferReducer(initialState, insertAction); + expect(stateAfterInsert).toHaveOnlyValidCharacters(); expect(stateAfterInsert.lines).toEqual(['test']); expect(stateAfterInsert.undoStack.length).toBe(1); // 2. Undo const undoAction: TextBufferAction = { type: 'undo' }; const stateAfterUndo = textBufferReducer(stateAfterInsert, undoAction); + expect(stateAfterUndo).toHaveOnlyValidCharacters(); expect(stateAfterUndo.lines).toEqual(['']); expect(stateAfterUndo.undoStack.length).toBe(0); expect(stateAfterUndo.redoStack.length).toBe(1); @@ -128,6 +137,7 @@ describe('textBufferReducer', () => { // 3. Redo const redoAction: TextBufferAction = { type: 'redo' }; const stateAfterRedo = textBufferReducer(stateAfterUndo, redoAction); + expect(stateAfterRedo).toHaveOnlyValidCharacters(); expect(stateAfterRedo.lines).toEqual(['test']); expect(stateAfterRedo.undoStack.length).toBe(1); expect(stateAfterRedo.redoStack.length).toBe(0); @@ -144,6 +154,7 @@ describe('textBufferReducer', () => { }; const action: TextBufferAction = { type: 'create_undo_snapshot' }; const state = textBufferReducer(stateWithText, action); + expect(state).toHaveOnlyValidCharacters(); expect(state.lines).toEqual(['hello']); expect(state.cursorRow).toBe(0); @@ -157,16 +168,19 @@ describe('textBufferReducer', () => { }); // Helper to get the state from the hook -const getBufferState = (result: { current: TextBuffer }) => ({ - text: result.current.text, - lines: [...result.current.lines], // Clone for safety - cursor: [...result.current.cursor] as [number, number], - allVisualLines: [...result.current.allVisualLines], - viewportVisualLines: [...result.current.viewportVisualLines], - visualCursor: [...result.current.visualCursor] as [number, number], - visualScrollRow: result.current.visualScrollRow, - preferredCol: result.current.preferredCol, -}); +const getBufferState = (result: { current: TextBuffer }) => { + expect(result.current).toHaveOnlyValidCharacters(); + return { + text: result.current.text, + lines: [...result.current.lines], // Clone for safety + cursor: [...result.current.cursor] as [number, number], + allVisualLines: [...result.current.allVisualLines], + viewportVisualLines: [...result.current.viewportVisualLines], + visualCursor: [...result.current.visualCursor] as [number, number], + visualScrollRow: result.current.visualScrollRow, + preferredCol: result.current.preferredCol, + }; +}; describe('useTextBuffer', () => { 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.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', () => { @@ -1159,7 +1189,7 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots const { result } = renderHook(() => useTextBuffer({ viewport, isValidPath: () => false }), ); - const textWithAnsi = '\x1B[31mHello\x1B[0m'; + const textWithAnsi = '\x1B[31mHello\x1B[0m \x1B[32mWorld\x1B[0m'; act(() => result.current.handleInput({ name: '', @@ -1170,7 +1200,7 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots sequence: textWithAnsi, }), ); - expect(getBufferState(result).text).toBe('Hello'); + expect(getBufferState(result).text).toBe('Hello World'); }); it('should strip control characters from input', () => { @@ -1425,6 +1455,7 @@ describe('textBufferReducer vim operations', () => { }; 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) expect(result.lines).toEqual(['line1', 'line3']); @@ -1452,6 +1483,7 @@ describe('textBufferReducer vim operations', () => { }; const result = textBufferReducer(initialState, action); + expect(result).toHaveOnlyValidCharacters(); // Should delete line2 and line3, leaving line1 and line4 expect(result.lines).toEqual(['line1', 'line4']); @@ -1479,6 +1511,7 @@ describe('textBufferReducer vim operations', () => { }; const result = textBufferReducer(initialState, action); + expect(result).toHaveOnlyValidCharacters(); // Should clear the line content but keep the line expect(result.lines).toEqual(['']); @@ -1506,6 +1539,7 @@ describe('textBufferReducer vim operations', () => { }; const result = textBufferReducer(initialState, action); + expect(result).toHaveOnlyValidCharacters(); // Should delete the last line completely, not leave empty line expect(result.lines).toEqual(['line1']); @@ -1534,6 +1568,7 @@ describe('textBufferReducer vim operations', () => { }; const afterDelete = textBufferReducer(initialState, deleteAction); + expect(afterDelete).toHaveOnlyValidCharacters(); // After deleting all lines, should have one empty line expect(afterDelete.lines).toEqual(['']); @@ -1547,6 +1582,7 @@ describe('textBufferReducer vim operations', () => { }; const afterPaste = textBufferReducer(afterDelete, pasteAction); + expect(afterPaste).toHaveOnlyValidCharacters(); // All lines including the first one should be present expect(afterPaste.lines).toEqual(['new1', 'new2', 'new3', 'new4']); diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index d2d9087a..cf5ce889 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -271,26 +271,23 @@ export const replaceRangeInternal = ( .replace(/\r/g, '\n'); const replacementParts = normalisedReplacement.split('\n'); - // Replace the content - if (startRow === endRow) { - newLines[startRow] = prefix + normalisedReplacement + suffix; + // The combined first line of the new text + const firstLine = prefix + replacementParts[0]; + + 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 { - const firstLine = prefix + replacementParts[0]; - if (replacementParts.length === 1) { - // Single line of replacement text, but spanning multiple original lines - newLines.splice(startRow, endRow - startRow + 1, firstLine + suffix); - } else { - // Multi-line replacement text - const lastLine = replacementParts[replacementParts.length - 1] + suffix; - const middleLines = replacementParts.slice(1, -1); - newLines.splice( - startRow, - endRow - startRow + 1, - firstLine, - ...middleLines, - lastLine, - ); - } + // Newlines in replacement: create new lines. + const lastLine = replacementParts[replacementParts.length - 1] + suffix; + const middleLines = replacementParts.slice(1, -1); + newLines.splice( + startRow, + endRow - startRow + 1, + firstLine, + ...middleLines, + lastLine, + ); } const finalCursorRow = startRow + replacementParts.length - 1; diff --git a/packages/cli/src/ui/components/shared/vim-buffer-actions.test.ts b/packages/cli/src/ui/components/shared/vim-buffer-actions.test.ts index f268bb1e..8f7f72ab 100644 --- a/packages/cli/src/ui/components/shared/vim-buffer-actions.test.ts +++ b/packages/cli/src/ui/components/shared/vim-buffer-actions.test.ts @@ -36,7 +36,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(2); expect(result.preferredCol).toBeNull(); }); @@ -49,7 +49,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(0); }); @@ -61,7 +61,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorRow).toBe(0); expect(result.cursorCol).toBe(4); // On last character '1' of 'line1' }); @@ -74,7 +74,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorRow).toBe(0); 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, payload: { count: 1 }, }); + expect(state).toHaveOnlyValidCharacters(); expect(state.cursorRow).toBe(1); expect(state.cursorCol).toBe(0); // Should be on 'f' @@ -96,6 +97,7 @@ describe('vim-buffer-actions', () => { type: 'vim_move_left' as const, payload: { count: 1 }, }); + expect(state).toHaveOnlyValidCharacters(); expect(state.cursorRow).toBe(0); 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); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(5); }); @@ -122,7 +124,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(4); // Last character of 'hello' }); @@ -134,7 +136,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorRow).toBe(1); 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 result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorRow).toBe(0); 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 result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); 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 result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorRow).toBe(0); expect(result.cursorCol).toBe(5); // End of 'short' }); @@ -180,7 +182,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorRow).toBe(2); expect(result.cursorCol).toBe(2); }); @@ -193,7 +195,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorRow).toBe(1); }); }); @@ -207,7 +209,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(6); // Start of 'world' }); @@ -219,7 +221,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(12); // Start of 'test' }); @@ -231,7 +233,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(5); // Start of ',' }); }); @@ -245,7 +247,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(6); // Start of 'world' }); @@ -257,7 +259,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(0); // Start of 'hello' }); }); @@ -271,7 +273,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(4); // End of 'hello' }); @@ -283,7 +285,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); 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 result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(0); }); @@ -303,7 +305,7 @@ describe('vim-buffer-actions', () => { const action = { type: 'vim_move_to_line_end' as const }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); 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 result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); 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 result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorRow).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 result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorRow).toBe(2); expect(result.cursorCol).toBe(0); }); @@ -344,7 +346,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorRow).toBe(1); // 0-indexed expect(result.cursorCol).toBe(0); }); @@ -357,7 +359,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorRow).toBe(1); // Last line }); }); @@ -373,7 +375,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.lines[0]).toBe('hllo'); expect(result.cursorCol).toBe(1); }); @@ -386,7 +388,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.lines[0]).toBe('ho'); expect(result.cursorCol).toBe(1); }); @@ -399,7 +401,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.lines[0]).toBe('hel'); expect(result.cursorCol).toBe(3); }); @@ -412,7 +414,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.lines[0]).toBe('hello'); expect(result.cursorCol).toBe(5); }); @@ -427,7 +429,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.lines[0]).toBe('world test'); expect(result.cursorCol).toBe(0); }); @@ -440,7 +442,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.lines[0]).toBe('test'); expect(result.cursorCol).toBe(0); }); @@ -453,7 +455,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.lines[0]).toBe('hello '); expect(result.cursorCol).toBe(6); }); @@ -468,7 +470,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.lines[0]).toBe('hello test'); expect(result.cursorCol).toBe(6); }); @@ -481,7 +483,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.lines[0]).toBe('test'); expect(result.cursorCol).toBe(0); }); @@ -496,7 +498,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.lines).toEqual(['line1', 'line3']); expect(result.cursorRow).toBe(1); expect(result.cursorCol).toBe(0); @@ -510,7 +512,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.lines).toEqual(['line3']); expect(result.cursorRow).toBe(0); expect(result.cursorCol).toBe(0); @@ -524,7 +526,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.lines).toEqual(['']); expect(result.cursorRow).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 result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.lines[0]).toBe('hello'); 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 result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.lines[0]).toBe('hello'); }); }); @@ -560,7 +562,7 @@ describe('vim-buffer-actions', () => { const action = { type: 'vim_insert_at_cursor' as const }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorRow).toBe(0); expect(result.cursorCol).toBe(2); }); @@ -572,7 +574,7 @@ describe('vim-buffer-actions', () => { const action = { type: 'vim_append_at_cursor' as const }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(3); }); @@ -581,7 +583,7 @@ describe('vim-buffer-actions', () => { const action = { type: 'vim_append_at_cursor' as const }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(5); }); }); @@ -592,7 +594,7 @@ describe('vim-buffer-actions', () => { const action = { type: 'vim_append_at_line_end' as const }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(11); }); }); @@ -603,7 +605,7 @@ describe('vim-buffer-actions', () => { const action = { type: 'vim_insert_at_line_start' as const }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(2); }); @@ -612,34 +614,32 @@ describe('vim-buffer-actions', () => { const action = { type: 'vim_insert_at_line_start' as const }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(3); }); }); 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 action = { type: 'vim_open_line_below' as const }; const result = handleVimAction(state, action); - - // The implementation inserts newline at end of current line and cursor moves to column 0 - expect(result.lines[0]).toBe('hello world\n'); - expect(result.cursorRow).toBe(0); - expect(result.cursorCol).toBe(0); // Cursor position after replaceRangeInternal + expect(result).toHaveOnlyValidCharacters(); + expect(result.lines).toEqual(['hello world', '']); + expect(result.cursorRow).toBe(1); + expect(result.cursorCol).toBe(0); }); }); 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 action = { type: 'vim_open_line_above' as const }; const result = handleVimAction(state, action); - - // The implementation inserts newline at beginning of current line - expect(result.lines).toEqual(['hello', '\nworld']); + expect(result).toHaveOnlyValidCharacters(); + expect(result.lines).toEqual(['hello', '', 'world']); expect(result.cursorRow).toBe(1); expect(result.cursorCol).toBe(0); }); @@ -651,7 +651,7 @@ describe('vim-buffer-actions', () => { const action = { type: 'vim_escape_insert_mode' as const }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(2); }); @@ -660,7 +660,7 @@ describe('vim-buffer-actions', () => { const action = { type: 'vim_escape_insert_mode' as const }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(0); }); }); @@ -676,7 +676,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.lines[0]).toBe('world test'); expect(result.cursorCol).toBe(0); }); @@ -691,7 +691,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.lines[0]).toBe(''); expect(result.cursorCol).toBe(0); }); @@ -706,7 +706,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.lines[0]).toBe('hel world'); expect(result.cursorCol).toBe(3); }); @@ -719,7 +719,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.lines[0]).toBe('hellorld'); // Deletes ' wo' (3 chars to the right) expect(result.cursorCol).toBe(5); }); @@ -732,7 +732,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); // 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 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); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorRow).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 result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(0); // Should be last character position }); @@ -773,7 +773,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); // Should move to next line with content expect(result.cursorRow).toBe(2); expect(result.cursorCol).toBe(0); @@ -789,7 +789,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.undoStack).toHaveLength(2); // Original plus new snapshot }); }); diff --git a/packages/cli/test-setup.ts b/packages/cli/test-setup.ts new file mode 100644 index 00000000..a419c873 --- /dev/null +++ b/packages/cli/test-setup.ts @@ -0,0 +1,7 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import './src/test-utils/customMatchers.js'; diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index 8f67a0be..5a3f99fe 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -18,6 +18,7 @@ export default defineConfig({ outputFile: { junit: 'junit.xml', }, + setupFiles: ['./test-setup.ts'], coverage: { enabled: true, provider: 'v8',