hermes-agent / apps /desktop /src /lib /keybinds /combo.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
2b2ba56 verified
Raw History Blame Contribute Delete
10.7 kB
import { afterEach, describe, expect, it, vi } from 'vitest'
// `IS_MAC` is resolved once at module load from `navigator`, so each platform
// case overrides the platform and re-imports the module fresh.
async function loadCombo(platform: string) {
Object.defineProperty(window.navigator, 'platform', { value: platform, configurable: true })
vi.resetModules()
return import('./combo')
}
function keydown(init: KeyboardEventInit): KeyboardEvent {
return new KeyboardEvent('keydown', init)
}
afterEach(() => {
vi.resetModules()
})
describe('comboFromEvent β€” ctrl as a distinct modifier on macOS', () => {
it('reports Control+Tab as "ctrl+tab" on macOS (not Cmd)', async () => {
const { comboFromEvent } = await loadCombo('MacIntel')
expect(comboFromEvent(keydown({ code: 'Tab', ctrlKey: true }))).toBe('ctrl+tab')
expect(comboFromEvent(keydown({ code: 'Tab', ctrlKey: true, shiftKey: true }))).toBe('ctrl+shift+tab')
})
it('keeps Cmd as "mod" and distinct from Control on macOS', async () => {
const { comboFromEvent } = await loadCombo('MacIntel')
expect(comboFromEvent(keydown({ code: 'KeyK', metaKey: true }))).toBe('mod+k')
expect(comboFromEvent(keydown({ code: 'KeyK', ctrlKey: true }))).toBe('ctrl+k')
})
it('uses layout-aware letters for Cmd shortcuts on non-QWERTY layouts', async () => {
const { comboFromEvent } = await loadCombo('MacIntel')
expect(comboFromEvent(keydown({ code: 'KeyI', key: 'c', metaKey: true }))).toBe('mod+c')
expect(comboFromEvent(keydown({ code: 'KeyI', key: 'C', metaKey: true, shiftKey: true }))).toBe('mod+shift+c')
})
it('keeps shifted punctuation anchored to the physical key token', async () => {
const { comboFromEvent } = await loadCombo('MacIntel')
expect(comboFromEvent(keydown({ code: 'Slash', key: '?', metaKey: true, shiftKey: true }))).toBe('mod+shift+/')
})
it('uses layout-aware punctuation for Cmd shortcuts on non-QWERTY layouts', async () => {
const { comboFromEvent } = await loadCombo('MacIntel')
// Dvorak puts "." on the physical QWERTY V key β€” ⌘. must still reach the
// command center rather than resolving to the physical token.
expect(comboFromEvent(keydown({ code: 'KeyV', key: '.', metaKey: true }))).toBe('mod+.')
expect(comboFromEvent(keydown({ code: 'KeyW', key: ',', metaKey: true }))).toBe('mod+,')
expect(comboFromEvent(keydown({ code: 'BracketLeft', key: '/', metaKey: true }))).toBe('mod+/')
// AZERTY reaches "," from the physical QWERTY M key.
expect(comboFromEvent(keydown({ code: 'KeyM', key: ',', metaKey: true }))).toBe('mod+,')
})
it("keeps digits physical so AZERTY's shifted number row still binds", async () => {
const { comboFromEvent } = await loadCombo('MacIntel')
// On AZERTY the unshifted "1" key types "&", and "1" only with Shift held.
// Both must resolve to the same `mod+1` the QWERTY user gets.
expect(comboFromEvent(keydown({ code: 'Digit1', key: '&', metaKey: true }))).toBe('mod+1')
expect(comboFromEvent(keydown({ code: 'Digit1', key: '1', metaKey: true }))).toBe('mod+1')
})
it('falls back to the physical key for glyphs we do not ship as tokens', async () => {
const { comboFromEvent } = await loadCombo('MacIntel')
// Option-modified glyphs, dead keys, and non-Latin scripts are not combo
// tokens, so the physical code keeps the binding reachable.
expect(comboFromEvent(keydown({ code: 'KeyK', key: '˚', metaKey: true, altKey: true }))).toBe('mod+alt+k')
expect(comboFromEvent(keydown({ code: 'KeyN', key: 'Dead', metaKey: true, altKey: true }))).toBe('mod+alt+n')
expect(comboFromEvent(keydown({ code: 'KeyK', key: 'Π»', metaKey: true }))).toBe('mod+k')
})
it('treats Control as the "mod" accelerator off macOS', async () => {
const { comboFromEvent } = await loadCombo('Win32')
expect(comboFromEvent(keydown({ code: 'Tab', ctrlKey: true }))).toBe('mod+tab')
expect(comboFromEvent(keydown({ code: 'Tab', ctrlKey: true, shiftKey: true }))).toBe('mod+shift+tab')
})
it('recognizes PageUp and PageDown chords', async () => {
const { comboFromEvent } = await loadCombo('MacIntel')
expect(comboFromEvent(keydown({ code: 'PageUp', ctrlKey: true }))).toBe('ctrl+pageup')
expect(comboFromEvent(keydown({ code: 'PageDown', ctrlKey: true }))).toBe('ctrl+pagedown')
})
})
describe('canonicalizeCombo', () => {
it('leaves "ctrl+…" untouched on macOS', async () => {
const { canonicalizeCombo } = await loadCombo('MacIntel')
expect(canonicalizeCombo('ctrl+tab')).toBe('ctrl+tab')
expect(canonicalizeCombo('ctrl+shift+tab')).toBe('ctrl+shift+tab')
})
it('folds "ctrl+…" to "mod+…" off macOS so a real Control press resolves', async () => {
const { canonicalizeCombo } = await loadCombo('Win32')
expect(canonicalizeCombo('ctrl+tab')).toBe('mod+tab')
expect(canonicalizeCombo('ctrl+shift+tab')).toBe('mod+shift+tab')
// Non-ctrl combos are unchanged.
expect(canonicalizeCombo('mod+k')).toBe('mod+k')
})
})
describe('formatCombo β€” honest Control labels', () => {
it('renders the Control glyph on macOS', async () => {
const { formatCombo, formatModifierToken } = await loadCombo('MacIntel')
expect(formatCombo('ctrl+tab')).toBe('βŒƒβ‡₯')
expect(formatCombo('ctrl+shift+tab')).toBe('βŒƒβ‡§β‡₯')
expect(formatCombo('mod+enter')).toBe('βŒ˜β†΅')
expect(formatModifierToken('mod')).toBe('⌘')
})
it.each(['Linux x86_64', 'Win32'])('renders "Ctrl+…" off macOS on %s (base key keeps its glyph)', async platform => {
const { formatCombo, formatModifierToken } = await loadCombo(platform)
expect(formatCombo('ctrl+tab')).toBe('Ctrl+β‡₯')
expect(formatCombo('ctrl+shift+tab')).toBe('Ctrl+Shift+β‡₯')
expect(formatCombo('mod+enter')).toBe('Ctrl+↡')
expect(formatModifierToken('mod')).toBe('Ctrl')
})
it('renders PageUp and PageDown with compact labels', async () => {
const { formatCombo } = await loadCombo('Win32')
expect(formatCombo('ctrl+pageup')).toBe('Ctrl+PgUp')
expect(formatCombo('ctrl+pagedown')).toBe('Ctrl+PgDn')
})
})
describe('actionAllowedInInput', () => {
it('keeps primary-modifier chords global while typing, gating bare/Shift combos to the allowlist', async () => {
const { actionAllowedInInput } = await loadCombo('MacIntel')
// Mod/Ctrl chords are deliberate two-key gestures β€” they fire even with
// focus in the composer (⌘N, ⌘T, βŒ˜β‡§N, ⌘K, βŒƒTab, …), matching every browser
// and chat app and the pre-#86586 behavior.
expect(actionAllowedInInput('session.new', 'mod+n')).toBe(true)
expect(actionAllowedInInput('session.newTab', 'mod+t')).toBe(true)
expect(actionAllowedInInput('session.newWindow', 'mod+shift+n')).toBe(true)
expect(actionAllowedInInput('session.next', 'ctrl+tab')).toBe(true)
expect(actionAllowedInInput('session.prev', 'ctrl+shift+tab')).toBe(true)
expect(actionAllowedInInput('nav.commandPalette', 'mod+k')).toBe(true)
expect(actionAllowedInInput('view.findInPage', 'mod+f')).toBe(true)
expect(actionAllowedInInput('nav.skills', 'mod+k')).toBe(true)
expect(actionAllowedInInput('view.showTerminal', 'ctrl+`')).toBe(true)
expect(actionAllowedInInput('profile.next', 'mod+shift+]')).toBe(true)
// A global action rebound onto a text-editing chord (Ctrl+A select-all,
// Ctrl+E line-end, …) fires deliberately while typing β€” mod-chords are
// two-key gestures, the pre-#86586 semantic. Unbound chords (the shipped
// default for a/e/u/backspace) never reach this function: the dispatcher
// returns before the gate when no action is bound, so the input keeps its
// native editing behavior out of the box. An editing-chord exclusion set
// would have to dodge shipped defaults (⌘K, ⌘W, ⌘D, ⌘F, ⌘B) or re-break them.
expect(actionAllowedInInput('session.new', 'ctrl+a')).toBe(true)
// Bare modifiers are not real chords β€” `comboFromEvent` never yields
// them, so a malformed stored binding must not pass the shape-only check.
expect(actionAllowedInInput('session.new', 'mod')).toBe(false)
expect(actionAllowedInInput('session.new', 'ctrl')).toBe(false)
// Bare/Shift-only combos must never hijack typing: a rebound ⌘N β†’ 'n'
// (or the pre-#76185 'shift+n') must not fire while the user types N.
expect(actionAllowedInInput('session.new', 'n')).toBe(false)
expect(actionAllowedInInput('session.new', 'shift+n')).toBe(false)
})
it('leaves text navigation chords with the focused input even when rebound to an allowed action', async () => {
const { actionAllowedInInput } = await loadCombo('Win32')
expect(actionAllowedInInput('session.next', 'mod+right')).toBe(false)
expect(actionAllowedInInput('session.prev', 'mod+left')).toBe(false)
expect(actionAllowedInInput('nav.commandPalette', 'mod+pageup')).toBe(false)
expect(actionAllowedInInput('view.findInPage', 'mod+end')).toBe(false)
})
})
describe('comboFromEvent β€” IME composition keydowns never resolve to combos (#84957)', () => {
it('returns null while a composition is in progress (isComposing)', async () => {
const { comboFromEvent } = await loadCombo('MacIntel')
// Typing δ½  with a Chinese IME: the preedit keydowns carry isComposing.
// Before the guard, these canonicalized to combos and fired keybinds
// (e.g. dispatched `session.new` mid-composition).
expect(comboFromEvent(keydown({ code: 'KeyN', isComposing: true, key: 'n' }))).toBeNull()
expect(comboFromEvent(keydown({ code: 'Enter', isComposing: true, key: 'Enter' }))).toBeNull()
expect(comboFromEvent(keydown({ code: 'Space', isComposing: true, key: ' ' }))).toBeNull()
})
it('returns null for the legacy key="Process" (VK_PROCESSKEY) keydown', async () => {
const { comboFromEvent } = await loadCombo('Win32')
expect(comboFromEvent(keydown({ code: 'KeyW', key: 'Process' }))).toBeNull()
})
it('ignores IME-synthesized modifier-name keys on non-modifier codes', async () => {
const { comboFromEvent } = await loadCombo('Win32')
// Q9 2002-style legacy IMEs synthesize key="Control" with code="KeyW",
// which would otherwise canonicalize to a phantom ctrl+w (close tab).
expect(comboFromEvent(keydown({ code: 'KeyW', key: 'Control' }))).toBeNull()
expect(comboFromEvent(keydown({ code: 'KeyA', key: 'Shift' }))).toBeNull()
})
it('still resolves real combos after composition ends', async () => {
const { comboFromEvent } = await loadCombo('MacIntel')
expect(comboFromEvent(keydown({ code: 'KeyN', isComposing: false, key: 'n', metaKey: true }))).toBe('mod+n')
})
})