@@ -15,6 +15,11 @@ import {
1515 getUrlOpenCommand ,
1616} from './commandUtils.js' ;
1717
18+ // Constants used by OSC-52 tests
19+ const ESC = '\u001B' ;
20+ const BEL = '\u0007' ;
21+ const ST = '\u001B\\' ;
22+
1823// Mock clipboardy
1924vi . mock ( 'clipboardy' , ( ) => ( {
2025 default : {
@@ -25,6 +30,14 @@ vi.mock('clipboardy', () => ({
2530// Mock child_process
2631vi . mock ( 'child_process' ) ;
2732
33+ // fs (for /dev/tty)
34+ const mockFs = vi . hoisted ( ( ) => ( {
35+ createWriteStream : vi . fn ( ) ,
36+ } ) ) ;
37+ vi . mock ( 'node:fs' , ( ) => ( {
38+ default : mockFs ,
39+ } ) ) ;
40+
2841// Mock process.platform for platform-specific tests
2942const mockProcess = vi . hoisted ( ( ) => ( {
3043 platform : 'darwin' ,
@@ -40,6 +53,36 @@ vi.stubGlobal(
4053 } ) ,
4154) ;
4255
56+ const makeWritable = ( opts ?: { isTTY ?: boolean ; writeReturn ?: boolean } ) => {
57+ const { isTTY = false , writeReturn = true } = opts ?? { } ;
58+ const stream = Object . assign ( new EventEmitter ( ) , {
59+ write : vi . fn ( ) . mockReturnValue ( writeReturn ) ,
60+ end : vi . fn ( ) ,
61+ destroy : vi . fn ( ) ,
62+ isTTY,
63+ once : EventEmitter . prototype . once ,
64+ on : EventEmitter . prototype . on ,
65+ off : EventEmitter . prototype . off ,
66+ } ) as unknown as EventEmitter & {
67+ write : Mock ;
68+ end : Mock ;
69+ isTTY ?: boolean ;
70+ } ;
71+ return stream ;
72+ } ;
73+
74+ const resetEnv = ( ) => {
75+ delete process . env [ 'TMUX' ] ;
76+ delete process . env [ 'STY' ] ;
77+ delete process . env [ 'SSH_TTY' ] ;
78+ delete process . env [ 'SSH_CONNECTION' ] ;
79+ delete process . env [ 'SSH_CLIENT' ] ;
80+ delete process . env [ 'WSL_DISTRO_NAME' ] ;
81+ delete process . env [ 'WSLENV' ] ;
82+ delete process . env [ 'WSL_INTEROP' ] ;
83+ delete process . env [ 'TERM' ] ;
84+ } ;
85+
4386interface MockChildProcess extends EventEmitter {
4487 stdin : EventEmitter & {
4588 write : Mock ;
@@ -78,6 +121,23 @@ describe('commandUtils', () => {
78121
79122 // Setup clipboardy mock
80123 mockClipboardyWrite = clipboardy . write as Mock ;
124+
125+ // default: no /dev/tty available
126+ mockFs . createWriteStream . mockImplementation ( ( ) => {
127+ throw new Error ( 'ENOENT' ) ;
128+ } ) ;
129+
130+ // default: stdio are not TTY for tests unless explicitly set
131+ Object . defineProperty ( process , 'stderr' , {
132+ value : makeWritable ( { isTTY : false } ) ,
133+ configurable : true ,
134+ } ) ;
135+ Object . defineProperty ( process , 'stdout' , {
136+ value : makeWritable ( { isTTY : false } ) ,
137+ configurable : true ,
138+ } ) ;
139+
140+ resetEnv ( ) ;
81141 } ) ;
82142
83143 describe ( 'isAtCommand' , ( ) => {
@@ -139,23 +199,160 @@ describe('commandUtils', () => {
139199 } ) ;
140200
141201 describe ( 'copyToClipboard' , ( ) => {
142- it ( 'should successfully copy text to clipboard using clipboardy ' , async ( ) => {
202+ it ( 'uses clipboardy when not in SSH/tmux/screen/WSL (even if TTYs exist) ' , async ( ) => {
143203 const testText = 'Hello, world!' ;
144204 mockClipboardyWrite . mockResolvedValue ( undefined ) ;
145205
206+ // even if stderr/stdout are TTY, without the env signals we fallback
207+ Object . defineProperty ( process , 'stderr' , {
208+ value : makeWritable ( { isTTY : true } ) ,
209+ configurable : true ,
210+ } ) ;
211+ Object . defineProperty ( process , 'stdout' , {
212+ value : makeWritable ( { isTTY : true } ) ,
213+ configurable : true ,
214+ } ) ;
215+
146216 await copyToClipboard ( testText ) ;
147217
148218 expect ( mockClipboardyWrite ) . toHaveBeenCalledWith ( testText ) ;
149219 } ) ;
150220
151- it ( 'should propagate errors from clipboardy ' , async ( ) => {
152- const testText = 'Hello, world! ' ;
153- const error = new Error ( 'Clipboard error' ) ;
154- mockClipboardyWrite . mockRejectedValue ( error ) ;
221+ it ( 'writes OSC-52 to /dev/tty when in SSH ' , async ( ) => {
222+ const testText = 'abc ' ;
223+ const tty = makeWritable ( { isTTY : true } ) ;
224+ mockFs . createWriteStream . mockReturnValue ( tty ) ;
155225
156- await expect ( copyToClipboard ( testText ) ) . rejects . toThrow (
157- 'Clipboard error' ,
158- ) ;
226+ process . env [ 'SSH_CONNECTION' ] = '1' ;
227+
228+ await copyToClipboard ( testText ) ;
229+
230+ const b64 = Buffer . from ( testText , 'utf8' ) . toString ( 'base64' ) ;
231+ const expected = `${ ESC } ]52;c;${ b64 } ${ BEL } ` ;
232+
233+ expect ( tty . write ) . toHaveBeenCalledTimes ( 1 ) ;
234+ expect ( ( tty . write as Mock ) . mock . calls [ 0 ] [ 0 ] ) . toBe ( expected ) ;
235+ expect ( tty . end ) . toHaveBeenCalledTimes ( 1 ) ; // /dev/tty closed after write
236+ expect ( mockClipboardyWrite ) . not . toHaveBeenCalled ( ) ;
237+ } ) ;
238+
239+ it ( 'wraps OSC-52 for tmux' , async ( ) => {
240+ const testText = 'tmux-copy' ;
241+ const tty = makeWritable ( { isTTY : true } ) ;
242+ mockFs . createWriteStream . mockReturnValue ( tty ) ;
243+
244+ process . env [ 'TMUX' ] = '1' ;
245+
246+ await copyToClipboard ( testText ) ;
247+
248+ const written = ( tty . write as Mock ) . mock . calls [ 0 ] [ 0 ] as string ;
249+ // Starts with tmux DCS wrapper and ends with ST
250+ expect ( written . startsWith ( `${ ESC } Ptmux;` ) ) . toBe ( true ) ;
251+ expect ( written . endsWith ( ST ) ) . toBe ( true ) ;
252+ // ESC bytes in payload are doubled
253+ expect ( written ) . toContain ( `${ ESC } ${ ESC } ]52;c;` ) ;
254+ expect ( mockClipboardyWrite ) . not . toHaveBeenCalled ( ) ;
255+ } ) ;
256+
257+ it ( 'wraps OSC-52 for GNU screen with chunked DCS' , async ( ) => {
258+ // ensure payload > chunk size (240) so there are multiple chunks
259+ const testText = 'x' . repeat ( 1200 ) ;
260+ const tty = makeWritable ( { isTTY : true } ) ;
261+ mockFs . createWriteStream . mockReturnValue ( tty ) ;
262+
263+ process . env [ 'STY' ] = 'screen-session' ;
264+
265+ await copyToClipboard ( testText ) ;
266+
267+ const written = ( tty . write as Mock ) . mock . calls [ 0 ] [ 0 ] as string ;
268+ const chunkStarts = ( written . match ( new RegExp ( `${ ESC } P` , 'g' ) ) || [ ] )
269+ . length ;
270+ const chunkEnds = written . split ( ST ) . length - 1 ;
271+
272+ expect ( chunkStarts ) . toBeGreaterThan ( 1 ) ;
273+ expect ( chunkStarts ) . toBe ( chunkEnds ) ;
274+ expect ( written ) . toContain ( ']52;c;' ) ; // contains base OSC-52 marker
275+ expect ( mockClipboardyWrite ) . not . toHaveBeenCalled ( ) ;
276+ } ) ;
277+
278+ it ( 'falls back to stderr when /dev/tty unavailable and stderr is a TTY' , async ( ) => {
279+ const testText = 'stderr-tty' ;
280+ const stderrStream = makeWritable ( { isTTY : true } ) ;
281+ Object . defineProperty ( process , 'stderr' , {
282+ value : stderrStream ,
283+ configurable : true ,
284+ } ) ;
285+
286+ process . env [ 'SSH_TTY' ] = '/dev/pts/1' ;
287+
288+ await copyToClipboard ( testText ) ;
289+
290+ const b64 = Buffer . from ( testText , 'utf8' ) . toString ( 'base64' ) ;
291+ const expected = `${ ESC } ]52;c;${ b64 } ${ BEL } ` ;
292+
293+ expect ( stderrStream . write ) . toHaveBeenCalledWith ( expected ) ;
294+ expect ( mockClipboardyWrite ) . not . toHaveBeenCalled ( ) ;
295+ } ) ;
296+
297+ it ( 'falls back to clipboardy when no TTY is available' , async ( ) => {
298+ const testText = 'no-tty' ;
299+ mockClipboardyWrite . mockResolvedValue ( undefined ) ;
300+
301+ // /dev/tty throws; stderr/stdout are non-TTY by default
302+ process . env [ 'SSH_CLIENT' ] = 'client' ;
303+
304+ await copyToClipboard ( testText ) ;
305+
306+ expect ( mockClipboardyWrite ) . toHaveBeenCalledWith ( testText ) ;
307+ } ) ;
308+
309+ it ( 'resolves on drain when backpressure occurs' , async ( ) => {
310+ const tty = makeWritable ( { isTTY : true , writeReturn : false } ) ;
311+ mockFs . createWriteStream . mockReturnValue ( tty ) ;
312+ process . env [ 'SSH_CONNECTION' ] = '1' ;
313+
314+ const p = copyToClipboard ( 'drain-test' ) ;
315+ setTimeout ( ( ) => {
316+ tty . emit ( 'drain' ) ;
317+ } , 0 ) ;
318+ await expect ( p ) . resolves . toBeUndefined ( ) ;
319+ } ) ;
320+
321+ it ( 'propagates errors from OSC-52 write path' , async ( ) => {
322+ const tty = makeWritable ( { isTTY : true , writeReturn : false } ) ;
323+ mockFs . createWriteStream . mockReturnValue ( tty ) ;
324+ process . env [ 'SSH_CONNECTION' ] = '1' ;
325+
326+ const p = copyToClipboard ( 'err-test' ) ;
327+ setTimeout ( ( ) => {
328+ tty . emit ( 'error' , new Error ( 'tty error' ) ) ;
329+ } , 0 ) ;
330+
331+ await expect ( p ) . rejects . toThrow ( 'tty error' ) ;
332+ expect ( mockClipboardyWrite ) . not . toHaveBeenCalled ( ) ;
333+ } ) ;
334+
335+ it ( 'does nothing for empty string' , async ( ) => {
336+ await copyToClipboard ( '' ) ;
337+ expect ( mockClipboardyWrite ) . not . toHaveBeenCalled ( ) ;
338+ // ensure no accidental writes to stdio either
339+ const stderrStream = process . stderr as unknown as { write : Mock } ;
340+ const stdoutStream = process . stdout as unknown as { write : Mock } ;
341+ expect ( stderrStream . write ) . not . toHaveBeenCalled ( ) ;
342+ expect ( stdoutStream . write ) . not . toHaveBeenCalled ( ) ;
343+ } ) ;
344+
345+ it ( 'uses clipboardy when not in eligible env even if /dev/tty exists' , async ( ) => {
346+ const tty = makeWritable ( { isTTY : true } ) ;
347+ mockFs . createWriteStream . mockReturnValue ( tty ) ;
348+ const text = 'local-terminal' ;
349+ mockClipboardyWrite . mockResolvedValue ( undefined ) ;
350+
351+ await copyToClipboard ( text ) ;
352+
353+ expect ( mockClipboardyWrite ) . toHaveBeenCalledWith ( text ) ;
354+ expect ( tty . write ) . not . toHaveBeenCalled ( ) ;
355+ expect ( tty . end ) . not . toHaveBeenCalled ( ) ;
159356 } ) ;
160357 } ) ;
161358
0 commit comments