From f3ffaf09c7205700a2f871511f3752bf130833aa Mon Sep 17 00:00:00 2001 From: hritan <48129645+hritan@users.noreply.github.com> Date: Fri, 17 Oct 2025 16:53:31 +0530 Subject: [PATCH] fix: copy command delay in Linux handled (#6856) Co-authored-by: Hriday Taneja --- .../cli/src/ui/utils/commandUtils.test.ts | 87 +++++++++++++++++-- packages/cli/src/ui/utils/commandUtils.ts | 24 ++++- 2 files changed, 102 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/ui/utils/commandUtils.test.ts b/packages/cli/src/ui/utils/commandUtils.test.ts index b48bb4c96e..660ec60ddb 100644 --- a/packages/cli/src/ui/utils/commandUtils.test.ts +++ b/packages/cli/src/ui/utils/commandUtils.test.ts @@ -23,12 +23,15 @@ const mockProcess = vi.hoisted(() => ({ platform: 'darwin', })); -vi.stubGlobal('process', { - ...process, - get platform() { - return mockProcess.platform; - }, -}); +vi.stubGlobal( + 'process', + Object.create(process, { + platform: { + get: () => mockProcess.platform, + configurable: true, // Allows the property to be changed later if needed + }, + }), +); interface MockChildProcess extends EventEmitter { stdin: EventEmitter & { @@ -53,8 +56,14 @@ describe('commandUtils', () => { 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(), }), - stderr: new EventEmitter(), }) as MockChildProcess; mockSpawn.mockReturnValue(mockChild as unknown as ReturnType); @@ -220,6 +229,70 @@ describe('commandUtils', () => { expect(mockChild.stdin.end).toHaveBeenCalled(); }); + it('should successfully copy on Linux when receiving an "exit" event', async () => { + const testText = 'Hello, linux!'; + const linuxOptions: SpawnOptions = { + stdio: ['pipe', 'inherit', 'pipe'], + }; + + // Simulate successful execution via 'exit' event + setTimeout(() => { + mockChild.emit('exit', 0); + }, 0); + + await copyToClipboard(testText); + + expect(mockSpawn).toHaveBeenCalledWith( + 'xclip', + ['-selection', 'clipboard'], + linuxOptions, + ); + expect(mockChild.stdin.write).toHaveBeenCalledWith(testText); + expect(mockChild.stdin.end).toHaveBeenCalled(); + }); + + it('should handle command failure on Linux via "exit" event', async () => { + const testText = 'Hello, linux!'; + let callCount = 0; + + mockSpawn.mockImplementation(() => { + const child = 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(), + }), + }); + + setTimeout(() => { + if (callCount === 0) { + // First call (xclip) fails with 'exit' + child.stderr.emit('data', 'xclip failed'); + child.emit('exit', 127); + } else { + // Second call (xsel) also fails with 'exit' + child.stderr.emit('data', 'xsel failed'); + child.emit('exit', 127); + } + callCount++; + }, 0); + + return child as unknown as ReturnType; + }); + + await expect(copyToClipboard(testText)).rejects.toThrow( + 'All copy commands failed. "\'xclip\' exited with code 127: xclip failed", "\'xsel\' exited with code 127: xsel failed".', + ); + + expect(mockSpawn).toHaveBeenCalledTimes(2); + }); + it('should fall back to xsel when xclip fails', async () => { const testText = 'Hello, world!'; let callCount = 0; diff --git a/packages/cli/src/ui/utils/commandUtils.ts b/packages/cli/src/ui/utils/commandUtils.ts index 32bebcebb7..a3333c448b 100644 --- a/packages/cli/src/ui/utils/commandUtils.ts +++ b/packages/cli/src/ui/utils/commandUtils.ts @@ -53,8 +53,7 @@ export const copyToClipboard = async (text: string): Promise => { if (child.stderr) { child.stderr.on('data', (chunk) => (stderr += chunk.toString())); } - child.on('error', reject); - child.on('close', (code) => { + const copyResult = (code: number | null) => { if (code === 0) return resolve(); const errorMsg = stderr.trim(); reject( @@ -62,7 +61,28 @@ export const copyToClipboard = async (text: string): Promise => { `'${cmd}' exited with code ${code}${errorMsg ? `: ${errorMsg}` : ''}`, ), ); + }; + + // The 'exit' event workaround is only needed for the specific stdio + // configuration used on Linux. + if (process.platform === 'linux') { + child.on('exit', (code) => { + child.stdin?.destroy(); + child.stdout?.destroy(); + child.stderr?.destroy(); + copyResult(code); + }); + } + + child.on('error', reject); + + // For win32/darwin, 'close' is the safest event, guaranteeing all I/O is flushed. + // For Linux, this acts as a fallback. This is safe because the promise + // can only be settled once. + child.on('close', (code) => { + copyResult(code); }); + if (child.stdin) { child.stdin.on('error', reject); child.stdin.write(text);