-
Notifications
You must be signed in to change notification settings - Fork 7
fix(staged): improve action output with PTY support and carriage return handling #557
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
117 changes: 117 additions & 0 deletions
117
apps/staged/src/lib/features/actions/processOutput.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| import { describe, it, expect } from 'vitest'; | ||
| import { processChunksToLines } from './processOutput'; | ||
| import type { OutputChunk } from './actions'; | ||
|
|
||
| /** Helper to build an OutputChunk. */ | ||
| function chunk(text: string, stream: 'stdout' | 'stderr' = 'stdout'): OutputChunk { | ||
| return { chunk: text, stream, timestamp: 0 }; | ||
| } | ||
|
|
||
| /** Helper to extract just the text from result lines. */ | ||
| function texts(chunks: OutputChunk[]): string[] { | ||
| return processChunksToLines(chunks).map((l) => l.text); | ||
| } | ||
|
|
||
| describe('processChunksToLines', () => { | ||
| // --------------------------------------------------------------------------- | ||
| // Basic newline handling | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| it('splits on \\n', () => { | ||
| expect(texts([chunk('hello\nworld\n')])).toEqual(['hello', 'world']); | ||
| }); | ||
|
|
||
| it('keeps a trailing line that has no terminating newline', () => { | ||
| expect(texts([chunk('hello\nworld')])).toEqual(['hello', 'world']); | ||
| }); | ||
|
|
||
| it('handles empty input', () => { | ||
| expect(texts([])).toEqual([]); | ||
| }); | ||
|
|
||
| it('handles a single chunk with no newlines', () => { | ||
| expect(texts([chunk('hello')])).toEqual(['hello']); | ||
| }); | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // \\r\\n (CRLF) handling | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| it('treats \\r\\n as a single newline', () => { | ||
| expect(texts([chunk('hello\r\nworld\r\n')])).toEqual(['hello', 'world']); | ||
| }); | ||
|
|
||
| it('handles \\r\\n split across two chunks', () => { | ||
| expect(texts([chunk('hello\r'), chunk('\nworld')])).toEqual(['hello', 'world']); | ||
| }); | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Bare \\r (carriage return) — progress bar behavior | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| it('bare \\r overwrites the current line (single chunk)', () => { | ||
| expect(texts([chunk('progress 50%\rprogress 100%\n')])).toEqual(['progress 100%']); | ||
| }); | ||
|
|
||
| it('bare \\r overwrites across multiple updates in one chunk', () => { | ||
| expect(texts([chunk('10%\r20%\r30%\r40%\n')])).toEqual(['40%']); | ||
| }); | ||
|
|
||
| it('bare \\r overwrites across separate chunks', () => { | ||
| expect(texts([chunk('downloading 50%\r'), chunk('downloading 100%\n')])).toEqual([ | ||
| 'downloading 100%', | ||
| ]); | ||
| }); | ||
|
|
||
| it('bare \\r at end of chunk followed by non-\\n text in next chunk overwrites', () => { | ||
| expect(texts([chunk('old text\r'), chunk('new text')])).toEqual(['new text']); | ||
| }); | ||
|
|
||
| it('multiple progress updates across separate chunks collapse correctly', () => { | ||
| expect(texts([chunk('10%\r'), chunk('20%\r'), chunk('30%\r'), chunk('done\n')])).toEqual([ | ||
| 'done', | ||
| ]); | ||
| }); | ||
|
|
||
| it('progress bar followed by normal output', () => { | ||
| expect(texts([chunk('building...\rprogress 50%\rprogress 100%\nSuccess!\n')])).toEqual([ | ||
| 'progress 100%', | ||
| 'Success!', | ||
| ]); | ||
| }); | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Mixed scenarios | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| it('handles normal lines before and after progress bars', () => { | ||
| expect( | ||
| texts([chunk('Starting build\n'), chunk('0%\r50%\r100%\n'), chunk('Build complete\n')]) | ||
| ).toEqual(['Starting build', '100%', 'Build complete']); | ||
| }); | ||
|
|
||
| it('handles interleaved stdout and stderr', () => { | ||
| const result = processChunksToLines([ | ||
| chunk('out line\n', 'stdout'), | ||
| chunk('err line\n', 'stderr'), | ||
| ]); | ||
| expect(result).toEqual([ | ||
| { text: 'out line', stream: 'stdout' }, | ||
| { text: 'err line', stream: 'stderr' }, | ||
| ]); | ||
| }); | ||
|
|
||
| it('bare \\r at the very end of all chunks produces no trailing line', () => { | ||
| // A progress bar that never finishes with \n — should show nothing | ||
| // because the \r resets the line and there's no further content. | ||
| expect(texts([chunk('in progress\r')])).toEqual([]); | ||
| }); | ||
|
|
||
| it('empty lines are preserved', () => { | ||
| expect(texts([chunk('a\n\nb\n')])).toEqual(['a', '', 'b']); | ||
| }); | ||
|
|
||
| it('handles chunk boundaries mid-text', () => { | ||
| expect(texts([chunk('hel'), chunk('lo\nwor'), chunk('ld\n')])).toEqual(['hello', 'world']); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| /** | ||
| * Terminal output processing utilities. | ||
| * | ||
| * Converts raw output chunks (as received from the backend) into display-ready | ||
| * terminal lines, handling carriage returns the way a real terminal would. | ||
| */ | ||
|
|
||
| import type { OutputChunk } from './actions'; | ||
|
|
||
| /** A processed terminal line ready for display. */ | ||
| export interface TerminalLine { | ||
| text: string; | ||
| stream: 'stdout' | 'stderr'; | ||
| } | ||
|
|
||
| /** | ||
| * Process raw output chunks into terminal lines, handling carriage returns. | ||
| * | ||
| * Terminal programs use \r (carriage return without newline) to overwrite the | ||
| * current line in-place — e.g. for progress bars. This function simulates | ||
| * that behavior: | ||
| * - \n finalizes the current line and starts a new one | ||
| * - \r\n is treated as a single newline | ||
| * - \r (not followed by \n) resets the cursor to the start of the current | ||
| * line so subsequent text overwrites it | ||
| * | ||
| * Because chunks arrive in arbitrary byte boundaries, \r\n may be split across | ||
| * two consecutive chunks. We track this with `pendingCR` so the \r at the end | ||
| * of one chunk and the \n at the start of the next are still treated as a | ||
| * single newline. | ||
| */ | ||
| export function processChunksToLines(chunks: OutputChunk[]): TerminalLine[] { | ||
| const lines: TerminalLine[] = []; | ||
| let currentText = ''; | ||
| let currentStream: 'stdout' | 'stderr' = 'stdout'; | ||
| let pendingCR = false; | ||
|
|
||
| for (const chunk of chunks) { | ||
| const raw = chunk.chunk; | ||
| const stream = chunk.stream; | ||
|
|
||
| for (let i = 0; i < raw.length; i++) { | ||
| const ch = raw[i]; | ||
|
|
||
| if (pendingCR) { | ||
| pendingCR = false; | ||
| if (ch === '\n') { | ||
| // \r\n split across chunks — treat as a single newline | ||
| lines.push({ text: currentText, stream: currentStream }); | ||
| currentText = ''; | ||
| currentStream = stream; | ||
| continue; | ||
| } else { | ||
| // The previous \r was a bare carriage return — reset the line | ||
| currentText = ''; | ||
| currentStream = stream; | ||
| } | ||
| } | ||
|
|
||
| if (ch === '\n') { | ||
| lines.push({ text: currentText, stream: currentStream }); | ||
| currentText = ''; | ||
| currentStream = stream; | ||
| } else if (ch === '\r') { | ||
| if (i + 1 < raw.length && raw[i + 1] === '\n') { | ||
| // \r\n within the same chunk | ||
| lines.push({ text: currentText, stream: currentStream }); | ||
| currentText = ''; | ||
| currentStream = stream; | ||
| i++; // skip the \n | ||
| } else if (i + 1 < raw.length) { | ||
| // Bare \r with more data in this chunk: reset cursor (overwrite) | ||
| currentText = ''; | ||
| currentStream = stream; | ||
| } else { | ||
| // \r at the very end of the chunk — defer decision until next chunk | ||
| pendingCR = true; | ||
| } | ||
| } else { | ||
| currentText += ch; | ||
| currentStream = stream; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // If the last chunk ended with a bare \r that was never resolved, treat it | ||
| // as a carriage return (reset the line). | ||
| if (pendingCR) { | ||
| currentText = ''; | ||
| } | ||
|
|
||
| // Don't forget the last in-progress line | ||
| if (currentText.length > 0) { | ||
| lines.push({ text: currentText, stream: currentStream }); | ||
| } | ||
|
|
||
| return lines; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| import { defineConfig } from 'vitest/config'; | ||
|
|
||
| export default defineConfig({ | ||
| test: { | ||
| include: ['src/**/*.test.ts'], | ||
| }, | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
pendingCRbranch currently treats any next\nas the completion of a prior\r, even when that\ncomes from a different stream chunk. In mixed stdout/stderr output (still possible when chunks are interleaved from separate streams), a stdout chunk ending in\rfollowed by a stderr chunk starting with\nwill be misinterpreted as CRLF and incorrectly preserve/finalize the stdout line instead of applying carriage-return overwrite semantics. The CRLF-bridging logic should only trigger when the continuation chunk is from the same stream as the pending\r.Useful? React with 👍 / 👎.