Initial import of NousResearch/hermes-agent
Deploy Site / deploy-vercel (push) Has been cancelled
Deploy Site / deploy-docs (push) Has been cancelled
Docker / shell lint / Lint Dockerfile (hadolint) (push) Has been cancelled
Docker / shell lint / Lint docker/ shell scripts (shellcheck) (push) Has been cancelled
Docker Build and Publish / build-amd64 (push) Has been cancelled
Docker Build and Publish / build-arm64 (push) Has been cancelled
Lint (ruff + ty) / ruff + ty diff (push) Has been cancelled
Lint (ruff + ty) / ruff enforcement (blocking) (push) Has been cancelled
Lint (ruff + ty) / Windows footguns (blocking) (push) Has been cancelled
Nix Lockfile Fix / auto-fix-main (push) Has been cancelled
Nix Lockfile Fix / fix (push) Has been cancelled
Nix / nix (macos-latest) (push) Has been cancelled
Nix / nix (ubuntu-latest) (push) Has been cancelled
OSV-Scanner / Scan lockfiles (push) Has been cancelled
Build Skills Index / build-index (push) Has been cancelled
Tests / test (1) (push) Has been cancelled
Tests / test (2) (push) Has been cancelled
Tests / test (3) (push) Has been cancelled
Tests / test (4) (push) Has been cancelled
Tests / test (5) (push) Has been cancelled
Tests / test (6) (push) Has been cancelled
Tests / e2e (push) Has been cancelled
uv.lock check / uv lock --check (push) Has been cancelled
Docker Build and Publish / merge (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
Tests / save-durations (push) Has been cancelled
Deploy Site / deploy-vercel (push) Has been cancelled
Deploy Site / deploy-docs (push) Has been cancelled
Docker / shell lint / Lint Dockerfile (hadolint) (push) Has been cancelled
Docker / shell lint / Lint docker/ shell scripts (shellcheck) (push) Has been cancelled
Docker Build and Publish / build-amd64 (push) Has been cancelled
Docker Build and Publish / build-arm64 (push) Has been cancelled
Lint (ruff + ty) / ruff + ty diff (push) Has been cancelled
Lint (ruff + ty) / ruff enforcement (blocking) (push) Has been cancelled
Lint (ruff + ty) / Windows footguns (blocking) (push) Has been cancelled
Nix Lockfile Fix / auto-fix-main (push) Has been cancelled
Nix Lockfile Fix / fix (push) Has been cancelled
Nix / nix (macos-latest) (push) Has been cancelled
Nix / nix (ubuntu-latest) (push) Has been cancelled
OSV-Scanner / Scan lockfiles (push) Has been cancelled
Build Skills Index / build-index (push) Has been cancelled
Tests / test (1) (push) Has been cancelled
Tests / test (2) (push) Has been cancelled
Tests / test (3) (push) Has been cancelled
Tests / test (4) (push) Has been cancelled
Tests / test (5) (push) Has been cancelled
Tests / test (6) (push) Has been cancelled
Tests / e2e (push) Has been cancelled
uv.lock check / uv lock --check (push) Has been cancelled
Docker Build and Publish / merge (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
Tests / save-durations (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,386 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
configureDetectedTerminalKeybindings,
|
||||
configureTerminalKeybindings,
|
||||
detectVSCodeLikeTerminal,
|
||||
getVSCodeStyleConfigDir,
|
||||
shouldPromptForTerminalSetup,
|
||||
stripJsonComments
|
||||
} from '../lib/terminalSetup.js'
|
||||
|
||||
describe('terminalSetup helpers', () => {
|
||||
it('detects VS Code family terminals from environment', () => {
|
||||
expect(detectVSCodeLikeTerminal({ CURSOR_TRACE_ID: 'x' } as NodeJS.ProcessEnv)).toBe('cursor')
|
||||
expect(detectVSCodeLikeTerminal({ VSCODE_GIT_ASKPASS_MAIN: '/tmp/windsurf' } as NodeJS.ProcessEnv)).toBe('windsurf')
|
||||
expect(detectVSCodeLikeTerminal({ TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv)).toBe('vscode')
|
||||
expect(detectVSCodeLikeTerminal({} as NodeJS.ProcessEnv)).toBeNull()
|
||||
})
|
||||
|
||||
it('computes VS Code style config dirs cross-platform', () => {
|
||||
expect(getVSCodeStyleConfigDir('Code', 'darwin', {} as NodeJS.ProcessEnv, '/home/me')).toBe(
|
||||
'/home/me/Library/Application Support/Code/User'
|
||||
)
|
||||
expect(getVSCodeStyleConfigDir('Code', 'linux', {} as NodeJS.ProcessEnv, '/home/me')).toBe(
|
||||
'/home/me/.config/Code/User'
|
||||
)
|
||||
expect(
|
||||
getVSCodeStyleConfigDir(
|
||||
'Code',
|
||||
'win32',
|
||||
{ APPDATA: 'C:/Users/me/AppData/Roaming' } as NodeJS.ProcessEnv,
|
||||
'/home/me'
|
||||
)
|
||||
).toBe('C:/Users/me/AppData/Roaming/Code/User')
|
||||
})
|
||||
|
||||
it('strips line comments from keybindings JSON', () => {
|
||||
expect(stripJsonComments('// comment\n[{"key":"shift+enter"}]')).toBe('\n[{"key":"shift+enter"}]')
|
||||
})
|
||||
|
||||
it('strips inline comments and block comments', () => {
|
||||
expect(stripJsonComments('[{"key":"a"} // inline\n]')).toBe('[{"key":"a"} \n]')
|
||||
expect(stripJsonComments('[/* block */{"key":"a"}]')).toBe('[{"key":"a"}]')
|
||||
})
|
||||
|
||||
it('removes trailing commas before ] or }', () => {
|
||||
expect(JSON.parse(stripJsonComments('[{"key":"a"},]'))).toEqual([{ key: 'a' }])
|
||||
expect(JSON.parse(stripJsonComments('[{"key":"a",}]'))).toEqual([{ key: 'a' }])
|
||||
})
|
||||
|
||||
it('preserves comment-like sequences inside strings', () => {
|
||||
const input = '[{"key":"a","args":{"text":"// not a comment"}}]'
|
||||
expect(JSON.parse(stripJsonComments(input))).toEqual([{ key: 'a', args: { text: '// not a comment' } }])
|
||||
})
|
||||
|
||||
it('handles unterminated block comments gracefully', () => {
|
||||
const input = '[{"key":"a"} /* never closed'
|
||||
const stripped = stripJsonComments(input)
|
||||
// The unterminated comment is consumed to end-of-file; the remainder is parseable
|
||||
expect(stripped).toBe('[{"key":"a"} ')
|
||||
})
|
||||
})
|
||||
|
||||
describe('configureTerminalKeybindings', () => {
|
||||
it('writes missing bindings into a VS Code style keybindings file', async () => {
|
||||
const mkdir = vi.fn().mockResolvedValue(undefined)
|
||||
const readFile = vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
|
||||
const writeFile = vi.fn().mockResolvedValue(undefined)
|
||||
const copyFile = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const result = await configureTerminalKeybindings('vscode', {
|
||||
fileOps: { copyFile, mkdir, readFile, writeFile },
|
||||
homeDir: '/Users/me',
|
||||
platform: 'darwin'
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.requiresRestart).toBe(true)
|
||||
expect(writeFile).toHaveBeenCalledTimes(1)
|
||||
expect(copyFile).not.toHaveBeenCalled() // no existing file to back up
|
||||
const written = writeFile.mock.calls[0]?.[1] as string
|
||||
expect(written).toContain('cmd+c')
|
||||
expect(written).toContain('terminalTextSelected')
|
||||
expect(written).toContain('\\u001b[99;13u')
|
||||
expect(written).toContain('shift+enter')
|
||||
expect(written).toContain('cmd+enter')
|
||||
expect(written).toContain('cmd+z')
|
||||
})
|
||||
|
||||
it('only adds the Cmd+C forwarding binding on macOS', async () => {
|
||||
const mkdir = vi.fn().mockResolvedValue(undefined)
|
||||
const readFile = vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
|
||||
const writeFile = vi.fn().mockResolvedValue(undefined)
|
||||
const copyFile = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const result = await configureTerminalKeybindings('vscode', {
|
||||
fileOps: { copyFile, mkdir, readFile, writeFile },
|
||||
homeDir: '/home/me',
|
||||
platform: 'linux'
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const written = writeFile.mock.calls[0]?.[1] as string
|
||||
expect(written).not.toContain('cmd+c')
|
||||
expect(written).not.toContain('terminalTextSelected')
|
||||
expect(written).not.toContain('\\u001b[99;13u')
|
||||
expect(written).toContain('shift+enter')
|
||||
})
|
||||
|
||||
it('reports conflicts without overwriting existing bindings', async () => {
|
||||
const mkdir = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const readFile = vi.fn().mockResolvedValue(
|
||||
JSON.stringify([
|
||||
{
|
||||
key: 'cmd+z',
|
||||
command: 'something.else',
|
||||
when: 'terminalFocus',
|
||||
args: { text: 'noop' }
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const writeFile = vi.fn().mockResolvedValue(undefined)
|
||||
const copyFile = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const result = await configureTerminalKeybindings('cursor', {
|
||||
fileOps: { copyFile, mkdir, readFile, writeFile },
|
||||
homeDir: '/Users/me',
|
||||
platform: 'darwin'
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.message).toContain('cmd+z')
|
||||
expect(writeFile).not.toHaveBeenCalled()
|
||||
expect(copyFile).not.toHaveBeenCalled() // no backup when not writing
|
||||
})
|
||||
|
||||
it('flags a global (when-less) binding on the same key as a conflict', async () => {
|
||||
// A user's keybindings.json `cmd+c` with no `when` clause is global —
|
||||
// it overlaps any context, including our terminal scope. We must NOT
|
||||
// silently add a terminal-scoped cmd+c that would shadow it.
|
||||
const mkdir = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const readFile = vi.fn().mockResolvedValue(
|
||||
JSON.stringify([
|
||||
{
|
||||
key: 'cmd+c',
|
||||
command: 'myExtension.smartCopy'
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const writeFile = vi.fn().mockResolvedValue(undefined)
|
||||
const copyFile = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const result = await configureTerminalKeybindings('vscode', {
|
||||
fileOps: { copyFile, mkdir, readFile, writeFile },
|
||||
homeDir: '/Users/me',
|
||||
platform: 'darwin'
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.message).toContain('cmd+c')
|
||||
expect(writeFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('flags an overlapping terminal-context binding as a conflict', async () => {
|
||||
// Existing `cmd+c` scoped to plain `terminalFocus` overlaps with our
|
||||
// `terminalFocus && terminalTextSelected` — both fire when the
|
||||
// terminal is focused with text selected, so the existing binding
|
||||
// would shadow ours. Treat as a conflict even though the strings
|
||||
// aren't identical.
|
||||
const mkdir = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const readFile = vi.fn().mockResolvedValue(
|
||||
JSON.stringify([
|
||||
{
|
||||
key: 'cmd+c',
|
||||
command: 'workbench.action.terminal.copySelection',
|
||||
when: 'terminalFocus'
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const writeFile = vi.fn().mockResolvedValue(undefined)
|
||||
const copyFile = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const result = await configureTerminalKeybindings('vscode', {
|
||||
fileOps: { copyFile, mkdir, readFile, writeFile },
|
||||
homeDir: '/Users/me',
|
||||
platform: 'darwin'
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.message).toContain('cmd+c')
|
||||
expect(writeFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not flag a negated terminalTextSelected binding as a conflict', async () => {
|
||||
// A binding scoped to "terminal focused but no selected text" is
|
||||
// logically disjoint from our copy-forwarding binding, which requires
|
||||
// terminalTextSelected.
|
||||
const mkdir = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const readFile = vi.fn().mockResolvedValue(
|
||||
JSON.stringify([
|
||||
{
|
||||
key: 'cmd+c',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
when: 'terminalFocus && !terminalTextSelected',
|
||||
args: { text: '\u0003' }
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const writeFile = vi.fn().mockResolvedValue(undefined)
|
||||
const copyFile = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const result = await configureTerminalKeybindings('vscode', {
|
||||
fileOps: { copyFile, mkdir, readFile, writeFile },
|
||||
homeDir: '/Users/me',
|
||||
platform: 'darwin'
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(writeFile).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not flag a disjoint-when binding on the same key as a conflict', async () => {
|
||||
// VS Code allows multiple bindings for the same key when their `when`
|
||||
// clauses don't overlap. A user's pre-existing cmd+c binding scoped to
|
||||
// editor focus should NOT block our terminal-scoped cmd+c binding.
|
||||
const mkdir = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const readFile = vi.fn().mockResolvedValue(
|
||||
JSON.stringify([
|
||||
{
|
||||
key: 'cmd+c',
|
||||
command: 'editor.action.clipboardCopyAction',
|
||||
when: 'editorFocus'
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const writeFile = vi.fn().mockResolvedValue(undefined)
|
||||
const copyFile = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const result = await configureTerminalKeybindings('vscode', {
|
||||
fileOps: { copyFile, mkdir, readFile, writeFile },
|
||||
homeDir: '/Users/me',
|
||||
platform: 'darwin'
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(writeFile).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('backs up existing keybindings.json only when writing changes', async () => {
|
||||
const mkdir = vi.fn().mockResolvedValue(undefined)
|
||||
const readFile = vi.fn().mockResolvedValue(JSON.stringify([]))
|
||||
const writeFile = vi.fn().mockResolvedValue(undefined)
|
||||
const copyFile = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const result = await configureTerminalKeybindings('vscode', {
|
||||
fileOps: { copyFile, mkdir, readFile, writeFile },
|
||||
homeDir: '/Users/me',
|
||||
platform: 'darwin'
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(writeFile).toHaveBeenCalledTimes(1)
|
||||
expect(copyFile).toHaveBeenCalledTimes(1) // backup created before writing
|
||||
})
|
||||
|
||||
it('reports error when keybindings.json is not readable (EACCES)', async () => {
|
||||
const mkdir = vi.fn().mockResolvedValue(undefined)
|
||||
const readFile = vi.fn().mockRejectedValue(Object.assign(new Error('permission denied'), { code: 'EACCES' }))
|
||||
const writeFile = vi.fn().mockResolvedValue(undefined)
|
||||
const copyFile = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const result = await configureTerminalKeybindings('vscode', {
|
||||
fileOps: { copyFile, mkdir, readFile, writeFile },
|
||||
homeDir: '/Users/me',
|
||||
platform: 'darwin'
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.message).toContain('Failed to read')
|
||||
expect(writeFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('auto-detects the current IDE terminal', async () => {
|
||||
const mkdir = vi.fn().mockResolvedValue(undefined)
|
||||
const readFile = vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
|
||||
const writeFile = vi.fn().mockResolvedValue(undefined)
|
||||
const copyFile = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const result = await configureDetectedTerminalKeybindings({
|
||||
env: { CURSOR_TRACE_ID: 'trace' } as NodeJS.ProcessEnv,
|
||||
fileOps: { copyFile, mkdir, readFile, writeFile },
|
||||
homeDir: '/Users/me',
|
||||
platform: 'darwin'
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(writeFile).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses to configure IDE bindings from an SSH session', async () => {
|
||||
const result = await configureDetectedTerminalKeybindings({
|
||||
env: { SSH_CONNECTION: '1 2 3 4', TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv,
|
||||
homeDir: '/Users/me',
|
||||
platform: 'darwin'
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.message).toContain('local machine')
|
||||
})
|
||||
|
||||
it('prompts for setup when bindings are missing and suppresses prompt when complete', async () => {
|
||||
const readMissing = vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
|
||||
await expect(
|
||||
shouldPromptForTerminalSetup({
|
||||
env: { TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv,
|
||||
fileOps: { readFile: readMissing }
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
|
||||
const readComplete = vi.fn().mockResolvedValue(
|
||||
JSON.stringify([
|
||||
{
|
||||
key: 'cmd+c',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
when: 'terminalFocus && terminalTextSelected',
|
||||
args: { text: '\u001b[99;13u' }
|
||||
},
|
||||
{
|
||||
key: 'shift+enter',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
when: 'terminalFocus',
|
||||
args: { text: '\\\r\n' }
|
||||
},
|
||||
{
|
||||
key: 'ctrl+enter',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
when: 'terminalFocus',
|
||||
args: { text: '\\\r\n' }
|
||||
},
|
||||
{
|
||||
key: 'cmd+enter',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
when: 'terminalFocus',
|
||||
args: { text: '\\\r\n' }
|
||||
},
|
||||
{
|
||||
key: 'cmd+z',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
when: 'terminalFocus',
|
||||
args: { text: '\u001b[122;9u' }
|
||||
},
|
||||
{
|
||||
key: 'shift+cmd+z',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
when: 'terminalFocus',
|
||||
args: { text: '\u001b[122;10u' }
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
await expect(
|
||||
shouldPromptForTerminalSetup({
|
||||
env: { TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv,
|
||||
fileOps: { readFile: readComplete }
|
||||
})
|
||||
).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('suppresses terminal setup prompts inside SSH sessions', async () => {
|
||||
await expect(
|
||||
shouldPromptForTerminalSetup({
|
||||
env: { SSH_CONNECTION: '1 2 3 4', TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv
|
||||
})
|
||||
).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user