/** * @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.key).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.key).toBe('c'); expect(binding.ctrl).toBe(true); }); it('should parse shift+key', () => { const binding = new KeyBinding('shift+z'); expect(binding.key).toBe('z'); expect(binding.shift).toBe(true); }); it('should parse alt+key', () => { const binding = new KeyBinding('alt+left'); expect(binding.key).toBe('left'); expect(binding.alt).toBe(true); }); it('should parse cmd+key', () => { const binding = new KeyBinding('cmd+f'); expect(binding.key).toBe('f'); expect(binding.cmd).toBe(true); }); it('should handle aliases (option/opt/meta)', () => { const optionBinding = new KeyBinding('option+b'); expect(optionBinding.key).toBe('b'); expect(optionBinding.alt).toBe(true); const optBinding = new KeyBinding('opt+b'); expect(optBinding.key).toBe('b'); expect(optBinding.alt).toBe(true); const metaBinding = new KeyBinding('meta+enter'); expect(metaBinding.key).toBe('enter'); expect(metaBinding.cmd).toBe(true); }); it('should parse multiple modifiers', () => { const binding = new KeyBinding('ctrl+shift+alt+cmd+x'); expect(binding.key).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.key).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.key).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); } }); 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 enum value/); // 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'), ]); }); });