From 9bdeb71a19c275ba53879a4bfb5dbb8b2381e40e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Fri, 24 Apr 2026 16:30:05 +0800 Subject: [PATCH] fix(core): compare default shell output by logical wraps --- .../services/shellExecutionService.test.ts | 56 ++++++++++++++++++ .../src/services/shellExecutionService.ts | 59 ++++++++++++------- 2 files changed, 95 insertions(+), 20 deletions(-) diff --git a/packages/core/src/services/shellExecutionService.test.ts b/packages/core/src/services/shellExecutionService.test.ts index 5522ed5df..5cc203929 100644 --- a/packages/core/src/services/shellExecutionService.test.ts +++ b/packages/core/src/services/shellExecutionService.test.ts @@ -167,6 +167,21 @@ const expectNormalizedWindowsPathEnv = (env: NodeJS.ProcessEnv) => { expect(env['Path']).toBeUndefined(); }; +const waitForDataEventCount = async ( + onOutputEventMock: Mock<(event: ShellOutputEvent) => void>, + expectedCount: number, +) => { + for (let attempt = 0; attempt < 20; attempt++) { + const dataEvents = onOutputEventMock.mock.calls.filter( + ([event]) => event.type === 'data', + ); + if (dataEvents.length >= expectedCount) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 0)); + } +}; + describe('ShellExecutionService', () => { let mockPtyProcess: EventEmitter & { pid: number; @@ -835,6 +850,47 @@ describe('ShellExecutionService', () => { ); }); + it('does not re-emit default plain live output when only soft-wrap segmentation changes', async () => { + const abortController = new AbortController(); + const handle = await ShellExecutionService.execute( + 'narrow-output', + '/test/dir', + onOutputEventMock, + abortController.signal, + true, + { + ...shellExecutionConfig, + terminalWidth: 4, + terminalHeight: 4, + showColor: false, + disableDynamicLineTrimming: false, + }, + ); + + await new Promise((resolve) => process.nextTick(resolve)); + mockPtyProcess.onData.mock.calls[0][0]('abcdefgh'); + await waitForDataEventCount(onOutputEventMock, 1); + + ShellExecutionService.resizePty(handle.pid!, 2, 4); + mockPtyProcess.onData.mock.calls[0][0]('\r'); + mockPtyProcess.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); + await handle.result; + + const dataEvents = onOutputEventMock.mock.calls.filter( + ([event]) => event.type === 'data', + ); + expect(dataEvents).toHaveLength(1); + const firstDataEvent = dataEvents[0][0]; + if (firstDataEvent.type !== 'data') { + throw new Error('Expected a shell data event.'); + } + const chunk = firstDataEvent.chunk as AnsiOutput; + expect(chunk.map((line) => line[0]?.text).filter(Boolean)).toEqual([ + 'abcd', + 'efgh', + ]); + }); + it('should handle multi-line output correctly when showColor is false', async () => { await simulateExecution( 'ls --color=auto', diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index 5db37045c..06a628881 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -205,6 +205,40 @@ const areAnsiOutputsEqual = ( }); }; +const createPlainAnsiLine = (text: string) => [ + { + text, + bold: false, + italic: false, + underline: false, + dim: false, + inverse: false, + fg: '', + bg: '', + }, +]; + +const serializePlainViewportToAnsiOutput = ( + terminal: pkg.Terminal, + unwrapWrappedLines = false, +): AnsiOutput => { + const buffer = terminal.buffer.active; + const lines: AnsiOutput = []; + + for (let y = 0; y < terminal.rows; y++) { + const line = buffer.getLine(buffer.viewportY + y); + const lineContent = line ? line.translateToString(true) : ''; + + if (unwrapWrappedLines && line?.isWrapped && lines.length > 0) { + lines[lines.length - 1][0].text += lineContent; + } else { + lines.push(createPlainAnsiLine(lineContent)); + } + } + + return lines; +}; + interface ProcessCleanupStrategy { killPty(pid: number, pty: ActivePty): void; killChildProcesses(pids: Set): void; @@ -615,26 +649,11 @@ export class ShellExecutionService { { unwrapWrappedLines: true }, ); } else { - const buffer = headlessTerminal.buffer.active; - const lines: AnsiOutput = []; - for (let y = 0; y < headlessTerminal.rows; y++) { - const line = buffer.getLine(buffer.viewportY + y); - const lineContent = line ? line.translateToString(true) : ''; - lines.push([ - { - text: lineContent, - bold: false, - italic: false, - underline: false, - dim: false, - inverse: false, - fg: '', - bg: '', - }, - ]); - } - newOutput = lines; - newOutputComparison = lines; + newOutput = serializePlainViewportToAnsiOutput(headlessTerminal); + newOutputComparison = serializePlainViewportToAnsiOutput( + headlessTerminal, + true, + ); } const trimmedOutput = trimTrailingEmptyAnsiLines(newOutput);