/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as os from 'node:os'; import * as path from 'node:path'; import * as fs from 'node:fs/promises'; import { Storage } from '@google/gemini-cli-core'; import { Command, commandCategories, commandDescriptions, defaultKeyBindingConfig, KeyBinding, loadCustomKeybindings, } from './keyBindings.js'; describe('KeyBinding', () => { describe('constructor', () => { it('should parse a simple key', () => { const binding = new KeyBinding('a'); expect(binding.name).toBe('a'); expect(binding.ctrl).toBe(false); expect(binding.shift).toBe(false); expect(binding.alt).toBe(false); expect(binding.cmd).toBe(false); }); it('should parse ctrl+key', () => { const binding = new KeyBinding('ctrl+c'); expect(binding.name).toBe('c'); expect(binding.ctrl).toBe(true); }); it('should parse shift+key', () => { const binding = new KeyBinding('shift+z'); expect(binding.name).toBe('z'); expect(binding.shift).toBe(true); }); it('should parse alt+key', () => { const binding = new KeyBinding('alt+left'); expect(binding.name).toBe('left'); expect(binding.alt).toBe(true); }); it('should parse cmd+key', () => { const binding = new KeyBinding('cmd+f'); expect(binding.name).toBe('f'); expect(binding.cmd).toBe(true); }); it('should handle aliases (option/opt/meta)', () => { const optionBinding = new KeyBinding('option+b'); expect(optionBinding.name).toBe('b'); expect(optionBinding.alt).toBe(true); const optBinding = new KeyBinding('opt+b'); expect(optBinding.name).toBe('b'); expect(optBinding.alt).toBe(true); const metaBinding = new KeyBinding('meta+enter'); expect(metaBinding.name).toBe('enter'); expect(metaBinding.cmd).toBe(true); }); it('should parse multiple modifiers', () => { const binding = new KeyBinding('ctrl+shift+alt+cmd+x'); expect(binding.name).toBe('x'); expect(binding.ctrl).toBe(true); expect(binding.shift).toBe(true); expect(binding.alt).toBe(true); expect(binding.cmd).toBe(true); }); it('should be case-insensitive', () => { const binding = new KeyBinding('CTRL+Shift+F'); expect(binding.name).toBe('f'); expect(binding.ctrl).toBe(true); expect(binding.shift).toBe(true); }); it('should handle named keys with modifiers', () => { const binding = new KeyBinding('ctrl+enter'); expect(binding.name).toBe('enter'); expect(binding.ctrl).toBe(true); }); it('should throw an error for invalid keys or typos in modifiers', () => { expect(() => new KeyBinding('ctrl+unknown')).toThrow( 'Invalid keybinding key: "unknown" in "ctrl+unknown"', ); expect(() => new KeyBinding('ctlr+a')).toThrow( 'Invalid keybinding key: "ctlr+a" in "ctlr+a"', ); }); }); }); describe('keyBindings config', () => { it('should have bindings for all commands', () => { for (const command of Object.values(Command)) { expect(defaultKeyBindingConfig.has(command)).toBe(true); expect(defaultKeyBindingConfig.get(command)?.length).toBeGreaterThan(0); } }); it('should have platform-specific UNDO bindings', () => { const undoBindings = defaultKeyBindingConfig.get(Command.UNDO); if (process.platform === 'win32') { expect(undoBindings?.[0].name).toBe('z'); expect(undoBindings?.[0].ctrl).toBe(true); } else if (process.platform === 'darwin') { expect(undoBindings?.[0].name).toBe('z'); expect(undoBindings?.[0].cmd).toBe(true); } else { expect(undoBindings?.[0].name).toBe('z'); expect(undoBindings?.[0].alt).toBe(true); } }); it('should have platform-specific REDO bindings', () => { const redoBindings = defaultKeyBindingConfig.get(Command.REDO); if (process.platform === 'win32') { expect(redoBindings?.[0].name).toBe('y'); expect(redoBindings?.[0].ctrl).toBe(true); } else if (process.platform === 'darwin') { expect(redoBindings?.[0].name).toBe('z'); expect(redoBindings?.[0].shift).toBe(true); expect(redoBindings?.[0].cmd).toBe(true); } else { expect(redoBindings?.[0].name).toBe('z'); expect(redoBindings?.[0].shift).toBe(true); expect(redoBindings?.[0].alt).toBe(true); } }); describe('command metadata', () => { const commandValues = Object.values(Command); it('has a description entry for every command', () => { const describedCommands = Object.keys(commandDescriptions); expect(describedCommands.sort()).toEqual([...commandValues].sort()); for (const command of commandValues) { expect(typeof commandDescriptions[command]).toBe('string'); expect(commandDescriptions[command]?.trim()).not.toHaveLength(0); } }); it('categorizes each command exactly once', () => { const seen = new Set(); for (const category of commandCategories) { expect(typeof category.title).toBe('string'); expect(Array.isArray(category.commands)).toBe(true); for (const command of category.commands) { expect(commandValues).toContain(command); expect(seen.has(command)).toBe(false); seen.add(command); } } expect(seen.size).toBe(commandValues.length); }); }); }); describe('loadCustomKeybindings', () => { let tempDir: string; let tempFilePath: string; beforeEach(async () => { tempDir = await fs.mkdtemp( path.join(os.tmpdir(), 'gemini-keybindings-test-'), ); tempFilePath = path.join(tempDir, 'keybindings.json'); vi.spyOn(Storage, 'getUserKeybindingsPath').mockReturnValue(tempFilePath); }); afterEach(async () => { await fs.rm(tempDir, { recursive: true, force: true }); vi.restoreAllMocks(); }); it('returns default bindings when file does not exist', async () => { // We don't write the file. const { config, errors } = await loadCustomKeybindings(); expect(errors).toHaveLength(0); expect(config.get(Command.RETURN)).toEqual([new KeyBinding('enter')]); }); it('merges valid custom bindings, prepending them to defaults', async () => { const customJson = JSON.stringify([ { command: Command.RETURN, key: 'ctrl+a' }, ]); await fs.writeFile(tempFilePath, customJson, 'utf8'); const { config, errors } = await loadCustomKeybindings(); expect(errors).toHaveLength(0); expect(config.get(Command.RETURN)).toEqual([ new KeyBinding('ctrl+a'), new KeyBinding('enter'), ]); }); it('handles JSON with comments', async () => { const customJson = ` [ // This is a comment { "command": "${Command.QUIT}", "key": "ctrl+x" } ] `; await fs.writeFile(tempFilePath, customJson, 'utf8'); const { config, errors } = await loadCustomKeybindings(); expect(errors).toHaveLength(0); expect(config.get(Command.QUIT)).toEqual([ new KeyBinding('ctrl+x'), new KeyBinding('ctrl+c'), ]); }); it('returns validation errors for invalid schema', async () => { const invalidJson = JSON.stringify([{ command: 'unknown', key: 'a' }]); await fs.writeFile(tempFilePath, invalidJson, 'utf8'); const { config, errors } = await loadCustomKeybindings(); expect(errors.length).toBeGreaterThan(0); expect(errors[0]).toMatch(/error at 0.command: Invalid command: "unknown"/); // Should still have defaults expect(config.get(Command.RETURN)).toEqual([new KeyBinding('enter')]); }); it('returns validation errors for invalid key patterns but loads valid ones', async () => { const mixedJson = JSON.stringify([ { command: Command.RETURN, key: 'super+a' }, // invalid { command: Command.QUIT, key: 'ctrl+y' }, // valid ]); await fs.writeFile(tempFilePath, mixedJson, 'utf8'); const { config, errors } = await loadCustomKeybindings(); expect(errors.length).toBe(1); expect(errors[0]).toMatch(/Invalid keybinding/); expect(config.get(Command.QUIT)).toEqual([ new KeyBinding('ctrl+y'), new KeyBinding('ctrl+c'), ]); }); it('removes specific bindings when using the minus prefix', async () => { const customJson = JSON.stringify([ { command: `-${Command.RETURN}`, key: 'enter' }, { command: Command.RETURN, key: 'ctrl+a' }, ]); await fs.writeFile(tempFilePath, customJson, 'utf8'); const { config, errors } = await loadCustomKeybindings(); expect(errors).toHaveLength(0); // 'enter' should be gone, only 'ctrl+a' should remain expect(config.get(Command.RETURN)).toEqual([new KeyBinding('ctrl+a')]); }); it('returns an error when attempting to negate a non-existent binding', async () => { const customJson = JSON.stringify([ { command: `-${Command.RETURN}`, key: 'ctrl+z' }, ]); await fs.writeFile(tempFilePath, customJson, 'utf8'); const { config, errors } = await loadCustomKeybindings(); expect(errors.length).toBe(1); expect(errors[0]).toMatch( /Invalid keybinding for command "-basic.confirm": Error: cannot remove "ctrl\+z" since it is not bound/, ); // Defaults should still be present expect(config.get(Command.RETURN)).toEqual([new KeyBinding('enter')]); }); });