| |
| |
| |
| |
| |
|
|
| import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest'; |
| import { EventEmitter } from 'node:events'; |
| import clipboardy from 'clipboardy'; |
| import { |
| isAtCommand, |
| isSlashCommand, |
| copyToClipboard, |
| getUrlOpenCommand, |
| } from './commandUtils.js'; |
| import type { Settings } from '../../config/settingsSchema.js'; |
|
|
| |
| const ESC = '\u001B'; |
| const BEL = '\u0007'; |
| const ST = '\u001B\\'; |
|
|
| |
| vi.mock('clipboardy', () => ({ |
| default: { |
| write: vi.fn(), |
| }, |
| })); |
|
|
| |
| vi.mock('child_process'); |
|
|
| |
| const mockFs = vi.hoisted(() => ({ |
| createWriteStream: vi.fn(), |
| writeSync: vi.fn(), |
| constants: { W_OK: 2 }, |
| })); |
| vi.mock('node:fs', async (importOriginal) => { |
| const actual = await importOriginal<typeof import('node:fs')>(); |
| return { |
| ...actual, |
| default: { |
| ...actual, |
| ...mockFs, |
| }, |
| ...mockFs, |
| }; |
| }); |
|
|
| |
| const mockProcess = vi.hoisted(() => ({ |
| platform: 'darwin', |
| })); |
|
|
| vi.stubGlobal( |
| 'process', |
| Object.create(process, { |
| platform: { |
| get: () => mockProcess.platform, |
| configurable: true, |
| }, |
| }), |
| ); |
|
|
| const makeWritable = (opts?: { isTTY?: boolean; writeReturn?: boolean }) => { |
| const { isTTY = false, writeReturn = true } = opts ?? {}; |
| const stream = Object.assign(new EventEmitter(), { |
| write: vi.fn().mockReturnValue(writeReturn), |
| end: vi.fn(), |
| destroy: vi.fn(), |
| isTTY, |
| once: EventEmitter.prototype.once, |
| on: EventEmitter.prototype.on, |
| off: EventEmitter.prototype.off, |
| removeAllListeners: EventEmitter.prototype.removeAllListeners, |
| }) as unknown as EventEmitter & { |
| write: Mock; |
| end: Mock; |
| isTTY?: boolean; |
| removeAllListeners: Mock; |
| }; |
| return stream; |
| }; |
|
|
| const resetEnv = () => { |
| delete process.env['TMUX']; |
| delete process.env['STY']; |
| delete process.env['SSH_TTY']; |
| delete process.env['SSH_CONNECTION']; |
| delete process.env['SSH_CLIENT']; |
| delete process.env['WSL_DISTRO_NAME']; |
| delete process.env['WSLENV']; |
| delete process.env['WSL_INTEROP']; |
| delete process.env['TERM']; |
| delete process.env['WT_SESSION']; |
| }; |
|
|
| interface MockChildProcess extends EventEmitter { |
| stdin: EventEmitter & { |
| write: Mock; |
| end: Mock; |
| }; |
| stderr: EventEmitter; |
| } |
|
|
| describe('commandUtils', () => { |
| let mockSpawn: Mock; |
| let mockChild: MockChildProcess; |
| let mockClipboardyWrite: Mock; |
|
|
| beforeEach(async () => { |
| vi.clearAllMocks(); |
| |
| mockProcess.platform = 'darwin'; |
|
|
| |
| const { spawn } = await import('node:child_process'); |
| mockSpawn = spawn as Mock; |
|
|
| |
| mockChild = Object.assign(new EventEmitter(), { |
| stdin: Object.assign(new EventEmitter(), { |
| write: vi.fn(), |
| end: vi.fn(), |
| destroy: vi.fn(), |
| }), |
| stdout: Object.assign(new EventEmitter(), { |
| destroy: vi.fn(), |
| }), |
| stderr: Object.assign(new EventEmitter(), { |
| destroy: vi.fn(), |
| }), |
| }) as MockChildProcess; |
|
|
| mockSpawn.mockReturnValue(mockChild as unknown as ReturnType<typeof spawn>); |
|
|
| |
| mockClipboardyWrite = clipboardy.write as Mock; |
|
|
| |
| mockFs.createWriteStream.mockImplementation(() => { |
| const tty = makeWritable({ isTTY: true }); |
| setTimeout(() => tty.emit('open'), 0); |
| return tty; |
| }); |
|
|
| |
| Object.defineProperty(process, 'stderr', { |
| value: makeWritable({ isTTY: false }), |
| configurable: true, |
| }); |
| Object.defineProperty(process, 'stdout', { |
| value: makeWritable({ isTTY: false }), |
| configurable: true, |
| }); |
|
|
| resetEnv(); |
| }); |
|
|
| describe('isAtCommand', () => { |
| it('should return true when query starts with @', () => { |
| expect(isAtCommand('@file')).toBe(true); |
| expect(isAtCommand('@path/to/file')).toBe(true); |
| }); |
|
|
| it('should return true when query contains @ preceded by whitespace', () => { |
| expect(isAtCommand('hello @file')).toBe(true); |
| expect(isAtCommand('some text @path/to/file')).toBe(true); |
| expect(isAtCommand(' @file')).toBe(true); |
| }); |
|
|
| it('should return true when @ is preceded by non-whitespace (external editor scenario)', () => { |
| |
| |
| |
| |
| expect(isAtCommand('check:@file.py')).toBe(true); |
| expect(isAtCommand('analyze(@file.py)')).toBe(true); |
| expect(isAtCommand('hello@file')).toBe(true); |
| expect(isAtCommand('text@path/to/file')).toBe(true); |
| expect(isAtCommand('user@host')).toBe(true); |
| }); |
|
|
| it('should return false when query does not contain any @<path> pattern', () => { |
| expect(isAtCommand('file')).toBe(false); |
| expect(isAtCommand('hello')).toBe(false); |
| expect(isAtCommand('')).toBe(false); |
| |
| expect(isAtCommand('@')).toBe(false); |
| }); |
|
|
| it('should return false when @ is escaped with a backslash', () => { |
| expect(isAtCommand('\\@file')).toBe(false); |
| }); |
|
|
| it('should return true for multi-line external editor prompts with @-references', () => { |
| expect(isAtCommand('Please review:\n@src/main.py\nand fix bugs.')).toBe( |
| true, |
| ); |
| |
| expect(isAtCommand('Files:@src/a.py,@src/b.py')).toBe(true); |
| }); |
| }); |
|
|
| describe('isSlashCommand', () => { |
| it('should return true when query starts with /', () => { |
| expect(isSlashCommand('/help')).toBe(true); |
| expect(isSlashCommand('/memory show')).toBe(true); |
| expect(isSlashCommand('/clear')).toBe(true); |
| expect(isSlashCommand('/')).toBe(true); |
| }); |
|
|
| it('should return false when query does not start with /', () => { |
| expect(isSlashCommand('help')).toBe(false); |
| expect(isSlashCommand('memory show')).toBe(false); |
| expect(isSlashCommand('')).toBe(false); |
| expect(isSlashCommand('path/to/file')).toBe(false); |
| expect(isSlashCommand(' /help')).toBe(false); |
| }); |
|
|
| it('should return false for line comments starting with //', () => { |
| expect(isSlashCommand('// This is a comment')).toBe(false); |
| expect(isSlashCommand('// check if variants base info all filled.')).toBe( |
| false, |
| ); |
| expect(isSlashCommand('//comment without space')).toBe(false); |
| }); |
|
|
| it('should return false for block comments starting with /*', () => { |
| expect(isSlashCommand('/* This is a block comment */')).toBe(false); |
| expect(isSlashCommand('/*\n * Multi-line comment\n */')).toBe(false); |
| expect(isSlashCommand('/*comment without space*/')).toBe(false); |
| }); |
| }); |
|
|
| describe('copyToClipboard', () => { |
| it('uses clipboardy when not in SSH/tmux/screen/WSL (even if TTYs exist)', async () => { |
| const testText = 'Hello, world!'; |
| mockClipboardyWrite.mockResolvedValue(undefined); |
|
|
| |
| Object.defineProperty(process, 'stderr', { |
| value: makeWritable({ isTTY: true }), |
| configurable: true, |
| }); |
| Object.defineProperty(process, 'stdout', { |
| value: makeWritable({ isTTY: true }), |
| configurable: true, |
| }); |
|
|
| await copyToClipboard(testText); |
|
|
| expect(mockClipboardyWrite).toHaveBeenCalledWith(testText); |
| }); |
|
|
| it('writes OSC-52 to /dev/tty when in SSH', async () => { |
| const testText = 'abc'; |
| const tty = makeWritable({ isTTY: true }); |
| mockFs.createWriteStream.mockImplementation(() => { |
| setTimeout(() => tty.emit('open'), 0); |
| return tty; |
| }); |
|
|
| process.env['SSH_CONNECTION'] = '1'; |
|
|
| await copyToClipboard(testText); |
|
|
| const b64 = Buffer.from(testText, 'utf8').toString('base64'); |
| const expected = `${ESC}]52;c;${b64}${BEL}`; |
|
|
| expect(tty.write).toHaveBeenCalledTimes(1); |
| expect(tty.write.mock.calls[0][0]).toBe(expected); |
| expect(tty.end).toHaveBeenCalledTimes(1); |
| expect(mockClipboardyWrite).not.toHaveBeenCalled(); |
| }); |
|
|
| it('uses OSC-52 when useOSC52Copy setting is enabled', async () => { |
| const testText = 'forced-osc52'; |
| const tty = makeWritable({ isTTY: true }); |
| mockFs.createWriteStream.mockImplementation(() => { |
| setTimeout(() => tty.emit('open'), 0); |
| return tty; |
| }); |
|
|
| |
| const settings = { |
| experimental: { useOSC52Copy: true }, |
| } as unknown as Settings; |
|
|
| await copyToClipboard(testText, settings); |
|
|
| const b64 = Buffer.from(testText, 'utf8').toString('base64'); |
| const expected = `${ESC}]52;c;${b64}${BEL}`; |
|
|
| expect(tty.write).toHaveBeenCalledTimes(1); |
| expect(tty.write.mock.calls[0][0]).toBe(expected); |
| expect(mockClipboardyWrite).not.toHaveBeenCalled(); |
| }); |
|
|
| it('wraps OSC-52 for tmux when in SSH', async () => { |
| const testText = 'tmux-copy'; |
| const tty = makeWritable({ isTTY: true }); |
| mockFs.createWriteStream.mockImplementation(() => { |
| setTimeout(() => tty.emit('open'), 0); |
| return tty; |
| }); |
|
|
| process.env['SSH_CONNECTION'] = '1'; |
| process.env['TMUX'] = '1'; |
|
|
| await copyToClipboard(testText); |
|
|
| const written = tty.write.mock.calls[0][0] as string; |
| |
| expect(written.startsWith(`${ESC}Ptmux;`)).toBe(true); |
| expect(written.endsWith(ST)).toBe(true); |
| |
| expect(written).toContain(`${ESC}${ESC}]52;c;`); |
| expect(mockClipboardyWrite).not.toHaveBeenCalled(); |
| }); |
|
|
| it('wraps OSC-52 for GNU screen with chunked DCS when in SSH', async () => { |
| |
| const testText = 'x'.repeat(1200); |
| const tty = makeWritable({ isTTY: true }); |
| mockFs.createWriteStream.mockImplementation(() => { |
| setTimeout(() => tty.emit('open'), 0); |
| return tty; |
| }); |
|
|
| process.env['SSH_CONNECTION'] = '1'; |
| process.env['STY'] = 'screen-session'; |
|
|
| await copyToClipboard(testText); |
|
|
| const written = tty.write.mock.calls[0][0] as string; |
| const chunkStarts = (written.match(new RegExp(`${ESC}P`, 'g')) || []) |
| .length; |
| const chunkEnds = written.split(ST).length - 1; |
|
|
| expect(chunkStarts).toBeGreaterThan(1); |
| expect(chunkStarts).toBe(chunkEnds); |
| expect(written).toContain(']52;c;'); |
| expect(mockClipboardyWrite).not.toHaveBeenCalled(); |
| }); |
|
|
| it('falls back to stderr when /dev/tty unavailable and stderr is a TTY', async () => { |
| const testText = 'stderr-tty'; |
| const stderrStream = makeWritable({ isTTY: true }); |
| Object.defineProperty(process, 'stderr', { |
| value: stderrStream, |
| configurable: true, |
| }); |
|
|
| process.env['SSH_TTY'] = '/dev/pts/1'; |
|
|
| |
| mockFs.createWriteStream.mockImplementation(() => { |
| const tty = makeWritable({ isTTY: true }); |
| setTimeout(() => tty.emit('error', new Error('EACCES')), 0); |
| return tty; |
| }); |
|
|
| await copyToClipboard(testText); |
|
|
| const b64 = Buffer.from(testText, 'utf8').toString('base64'); |
| const expected = `${ESC}]52;c;${b64}${BEL}`; |
|
|
| expect(stderrStream.write).toHaveBeenCalledWith(expected); |
| expect(mockClipboardyWrite).not.toHaveBeenCalled(); |
| }); |
|
|
| it('falls back to clipboardy when no TTY is available', async () => { |
| const testText = 'no-tty'; |
| mockClipboardyWrite.mockResolvedValue(undefined); |
|
|
| |
| mockFs.createWriteStream.mockImplementation(() => { |
| throw new Error('ENOENT'); |
| }); |
|
|
| process.env['SSH_CLIENT'] = 'client'; |
|
|
| await copyToClipboard(testText); |
|
|
| expect(mockClipboardyWrite).toHaveBeenCalledWith(testText); |
| }); |
|
|
| it('resolves on drain when backpressure occurs', async () => { |
| const tty = makeWritable({ isTTY: true, writeReturn: false }); |
| mockFs.createWriteStream.mockImplementation(() => { |
| setTimeout(() => tty.emit('open'), 0); |
| return tty; |
| }); |
| process.env['SSH_CONNECTION'] = '1'; |
|
|
| const p = copyToClipboard('drain-test'); |
| setTimeout(() => { |
| tty.emit('drain'); |
| }, 0); |
| await expect(p).resolves.toBeUndefined(); |
| }); |
|
|
| it('propagates errors from OSC-52 write path', async () => { |
| const tty = makeWritable({ isTTY: true, writeReturn: false }); |
| mockFs.createWriteStream.mockImplementation(() => { |
| setTimeout(() => tty.emit('open'), 0); |
| return tty; |
| }); |
| process.env['SSH_CONNECTION'] = '1'; |
|
|
| const p = copyToClipboard('err-test'); |
| setTimeout(() => { |
| tty.emit('error', new Error('tty error')); |
| }, 0); |
|
|
| await expect(p).rejects.toThrow('tty error'); |
| expect(mockClipboardyWrite).not.toHaveBeenCalled(); |
| }); |
|
|
| it('does nothing for empty string', async () => { |
| await copyToClipboard(''); |
| expect(mockClipboardyWrite).not.toHaveBeenCalled(); |
| |
| const stderrStream = process.stderr as unknown as { write: Mock }; |
| const stdoutStream = process.stdout as unknown as { write: Mock }; |
| expect(stderrStream.write).not.toHaveBeenCalled(); |
| expect(stdoutStream.write).not.toHaveBeenCalled(); |
| }); |
|
|
| it('uses clipboardy when not in eligible env even if /dev/tty exists', async () => { |
| const tty = makeWritable({ isTTY: true }); |
| mockFs.createWriteStream.mockImplementation(() => { |
| setTimeout(() => tty.emit('open'), 0); |
| return tty; |
| }); |
| const text = 'local-terminal'; |
| mockClipboardyWrite.mockResolvedValue(undefined); |
|
|
| await copyToClipboard(text); |
|
|
| expect(mockClipboardyWrite).toHaveBeenCalledWith(text); |
| expect(tty.write).not.toHaveBeenCalled(); |
| expect(tty.end).not.toHaveBeenCalled(); |
| }); |
|
|
| it('falls back if /dev/tty emits error (e.g. sandbox)', async () => { |
| const testText = 'access-denied-fallback'; |
| process.env['SSH_CONNECTION'] = '1'; |
|
|
| mockFs.createWriteStream.mockImplementation(() => { |
| const stream = makeWritable({ isTTY: true }); |
| |
| setTimeout(() => stream.emit('error', new Error('EACCES')), 0); |
| return stream; |
| }); |
|
|
| |
| mockClipboardyWrite.mockResolvedValue(undefined); |
|
|
| await copyToClipboard(testText); |
|
|
| expect(mockFs.createWriteStream).toHaveBeenCalled(); |
| expect(mockClipboardyWrite).toHaveBeenCalledWith(testText); |
| }); |
| it('uses clipboardy in tmux when not in SSH/WSL', async () => { |
| const tty = makeWritable({ isTTY: true }); |
| mockFs.createWriteStream.mockImplementation(() => { |
| setTimeout(() => tty.emit('open'), 0); |
| return tty; |
| }); |
| const text = 'tmux-local'; |
| mockClipboardyWrite.mockResolvedValue(undefined); |
|
|
| process.env['TMUX'] = '1'; |
|
|
| await copyToClipboard(text); |
|
|
| expect(mockClipboardyWrite).toHaveBeenCalledWith(text); |
| expect(tty.write).not.toHaveBeenCalled(); |
| expect(tty.end).not.toHaveBeenCalled(); |
| }); |
|
|
| it('falls back if /dev/tty hangs (timeout)', async () => { |
| const testText = 'timeout-fallback'; |
| process.env['SSH_CONNECTION'] = '1'; |
|
|
| mockFs.createWriteStream.mockImplementation(() => |
| |
| makeWritable({ isTTY: true }), |
| ); |
|
|
| mockClipboardyWrite.mockResolvedValue(undefined); |
|
|
| |
| await copyToClipboard(testText); |
|
|
| expect(mockFs.createWriteStream).toHaveBeenCalled(); |
| expect(mockClipboardyWrite).toHaveBeenCalledWith(testText); |
| }); |
|
|
| it('skips /dev/tty on Windows and uses stderr fallback for OSC-52', async () => { |
| mockProcess.platform = 'win32'; |
| const stderrStream = makeWritable({ isTTY: true }); |
| Object.defineProperty(process, 'stderr', { |
| value: stderrStream, |
| configurable: true, |
| }); |
|
|
| |
| process.env['SSH_CONNECTION'] = '1'; |
|
|
| await copyToClipboard('windows-ssh-test'); |
|
|
| expect(mockFs.createWriteStream).not.toHaveBeenCalled(); |
| expect(stderrStream.write).toHaveBeenCalled(); |
| expect(mockClipboardyWrite).not.toHaveBeenCalled(); |
| }); |
|
|
| it('uses clipboardy on native Windows without SSH/WSL', async () => { |
| mockProcess.platform = 'win32'; |
| mockClipboardyWrite.mockResolvedValue(undefined); |
|
|
| await copyToClipboard('windows-native-test'); |
|
|
| |
| expect(mockClipboardyWrite).toHaveBeenCalledWith('windows-native-test'); |
| expect(mockFs.createWriteStream).not.toHaveBeenCalled(); |
| }); |
|
|
| it('uses OSC-52 on Windows Terminal (WT_SESSION) and prioritizes stdout', async () => { |
| mockProcess.platform = 'win32'; |
| const stdoutStream = makeWritable({ isTTY: true }); |
| const stderrStream = makeWritable({ isTTY: true }); |
| Object.defineProperty(process, 'stdout', { |
| value: stdoutStream, |
| configurable: true, |
| }); |
| Object.defineProperty(process, 'stderr', { |
| value: stderrStream, |
| configurable: true, |
| }); |
|
|
| process.env['WT_SESSION'] = 'some-uuid'; |
|
|
| const testText = 'windows-terminal-test'; |
| await copyToClipboard(testText); |
|
|
| const b64 = Buffer.from(testText, 'utf8').toString('base64'); |
| const expected = `${ESC}]52;c;${b64}${BEL}`; |
|
|
| expect(stdoutStream.write).toHaveBeenCalledWith(expected); |
| expect(stderrStream.write).not.toHaveBeenCalled(); |
| expect(mockClipboardyWrite).not.toHaveBeenCalled(); |
| }); |
|
|
| it('uses fs.writeSync on Windows when stdout has an fd (bypassing Ink)', async () => { |
| mockProcess.platform = 'win32'; |
| const stdoutStream = makeWritable({ isTTY: true }); |
| |
| (stdoutStream as unknown as { fd: number }).fd = 1; |
|
|
| Object.defineProperty(process, 'stdout', { |
| value: stdoutStream, |
| configurable: true, |
| }); |
|
|
| process.env['WT_SESSION'] = 'some-uuid'; |
|
|
| const testText = 'direct-write-test'; |
| await copyToClipboard(testText); |
|
|
| const b64 = Buffer.from(testText, 'utf8').toString('base64'); |
| const expected = `${ESC}]52;c;${b64}${BEL}`; |
|
|
| expect(mockFs.writeSync).toHaveBeenCalledWith(1, expected); |
| expect(stdoutStream.write).not.toHaveBeenCalled(); |
| expect(mockClipboardyWrite).not.toHaveBeenCalled(); |
| }); |
|
|
| it('uses fs.writeSync on Windows when stderr has an fd and stdout is not a TTY', async () => { |
| mockProcess.platform = 'win32'; |
| const stdoutStream = makeWritable({ isTTY: false }); |
| const stderrStream = makeWritable({ isTTY: true }); |
| |
| (stderrStream as unknown as { fd: number }).fd = 2; |
|
|
| Object.defineProperty(process, 'stdout', { |
| value: stdoutStream, |
| configurable: true, |
| }); |
| Object.defineProperty(process, 'stderr', { |
| value: stderrStream, |
| configurable: true, |
| }); |
|
|
| process.env['WT_SESSION'] = 'some-uuid'; |
|
|
| const testText = 'direct-write-stderr-test'; |
| await copyToClipboard(testText); |
|
|
| const b64 = Buffer.from(testText, 'utf8').toString('base64'); |
| const expected = `${ESC}]52;c;${b64}${BEL}`; |
|
|
| expect(mockFs.writeSync).toHaveBeenCalledWith(2, expected); |
| expect(stderrStream.write).not.toHaveBeenCalled(); |
| expect(mockClipboardyWrite).not.toHaveBeenCalled(); |
| }); |
| }); |
|
|
| describe('getUrlOpenCommand', () => { |
| describe('on macOS (darwin)', () => { |
| beforeEach(() => { |
| mockProcess.platform = 'darwin'; |
| }); |
| it('should return open', () => { |
| expect(getUrlOpenCommand()).toBe('open'); |
| }); |
| }); |
|
|
| describe('on Windows (win32)', () => { |
| beforeEach(() => { |
| mockProcess.platform = 'win32'; |
| }); |
| it('should return start', () => { |
| expect(getUrlOpenCommand()).toBe('start'); |
| }); |
| }); |
|
|
| describe('on Linux (linux)', () => { |
| beforeEach(() => { |
| mockProcess.platform = 'linux'; |
| }); |
| it('should return xdg-open', () => { |
| expect(getUrlOpenCommand()).toBe('xdg-open'); |
| }); |
| }); |
|
|
| describe('on unmatched OS', () => { |
| beforeEach(() => { |
| mockProcess.platform = 'unmatched'; |
| }); |
| it('should return xdg-open', () => { |
| expect(getUrlOpenCommand()).toBe('xdg-open'); |
| }); |
| }); |
| }); |
| }); |
|
|