Skip to content

Commit 0770c28

Browse files
authored
Merge pull request #7295 from Shopify/ac/hide-loading-bar-non-tty
Render task progress bars to stderr by default
2 parents dba5a8f + 63fed48 commit 0770c28

4 files changed

Lines changed: 78 additions & 109 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/cli-kit': patch
3+
---
4+
5+
Render task progress bars to stderr to reduce output noise in non-TTY environments
Lines changed: 60 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {LoadingBar} from './LoadingBar.js'
2+
import {Stdout} from '../../ui.js'
23
import {render} from '../../testing/ui.js'
34
import {shouldDisplayColors, unstyled} from '../../../../public/node/output.js'
45
import useLayout from '../hooks/use-layout.js'
@@ -16,7 +17,6 @@ vi.mock('../../../../public/node/output.js', async () => {
1617
})
1718

1819
beforeEach(() => {
19-
// Default terminal width
2020
vi.mocked(useLayout).mockReturnValue({
2121
twoThirds: 53,
2222
oneThird: 27,
@@ -25,196 +25,151 @@ beforeEach(() => {
2525
vi.mocked(shouldDisplayColors).mockReturnValue(true)
2626
})
2727

28+
/**
29+
* Creates a Stdout test double simulating a TTY stream.
30+
* On real Node streams, isTTY is only present as an own property when the
31+
* stream IS a TTY.
32+
*/
33+
function createTTYStdout(columns = 100) {
34+
const stdout = new Stdout({columns}) as Stdout & {isTTY: boolean}
35+
stdout.isTTY = true
36+
return stdout
37+
}
38+
39+
/**
40+
* Renders LoadingBar with a TTY stdout so the animated progress bar renders.
41+
*/
42+
function renderWithTTY(element: React.ReactElement) {
43+
const stdout = createTTYStdout()
44+
const instance = render(element, {stdout})
45+
return {lastFrame: stdout.lastFrame, unmount: instance.unmount}
46+
}
47+
2848
describe('LoadingBar', () => {
2949
test('renders loading bar with default colored characters', async () => {
30-
// Given
31-
const title = 'Loading content'
32-
33-
// When
34-
const {lastFrame} = render(<LoadingBar title={title} />)
50+
const {lastFrame} = renderWithTTY(<LoadingBar title="Loading content" />)
3551

36-
// Then
3752
expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
3853
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
3954
Loading content ..."
4055
`)
4156
})
4257

4358
test('renders loading bar with hill pattern when noColor prop is true', async () => {
44-
// Given
45-
const title = 'Processing files'
46-
47-
// When
48-
const {lastFrame} = render(<LoadingBar title={title} noColor />)
59+
const {lastFrame} = renderWithTTY(<LoadingBar title="Processing files" noColor />)
4960

50-
// Then
5161
expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
5262
"▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅▄▄▃▃▂▂▁▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅
5363
Processing files ..."
5464
`)
5565
})
5666

5767
test('renders loading bar with hill pattern when shouldDisplayColors returns false', async () => {
58-
// Given
5968
vi.mocked(shouldDisplayColors).mockReturnValue(false)
60-
const title = 'Downloading packages'
69+
const {lastFrame} = renderWithTTY(<LoadingBar title="Downloading packages" />)
6170

62-
// When
63-
const {lastFrame} = render(<LoadingBar title={title} />)
64-
65-
// Then
6671
expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
6772
"▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅▄▄▃▃▂▂▁▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅
6873
Downloading packages ..."
6974
`)
7075
})
7176

7277
test('handles narrow terminal width correctly', async () => {
73-
// Given
74-
vi.mocked(useLayout).mockReturnValue({
75-
twoThirds: 20,
76-
oneThird: 10,
77-
fullWidth: 30,
78-
})
79-
const title = 'Building app'
80-
81-
// When
82-
const {lastFrame} = render(<LoadingBar title={title} />)
83-
84-
// Then
78+
vi.mocked(useLayout).mockReturnValue({twoThirds: 20, oneThird: 10, fullWidth: 30})
79+
const {lastFrame} = renderWithTTY(<LoadingBar title="Building app" />)
80+
8581
expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
8682
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
8783
Building app ..."
8884
`)
8985
})
9086

9187
test('handles narrow terminal width correctly in no-color mode', async () => {
92-
// Given
93-
vi.mocked(useLayout).mockReturnValue({
94-
twoThirds: 15,
95-
oneThird: 8,
96-
fullWidth: 23,
97-
})
98-
const title = 'Installing'
99-
100-
// When
101-
const {lastFrame} = render(<LoadingBar title={title} noColor />)
102-
103-
// Then
88+
vi.mocked(useLayout).mockReturnValue({twoThirds: 15, oneThird: 8, fullWidth: 23})
89+
const {lastFrame} = renderWithTTY(<LoadingBar title="Installing" noColor />)
90+
10491
expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
10592
"▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇
10693
Installing ..."
10794
`)
10895
})
10996

11097
test('handles very narrow terminal width in no-color mode', async () => {
111-
// Given
112-
vi.mocked(useLayout).mockReturnValue({
113-
twoThirds: 5,
114-
oneThird: 3,
115-
fullWidth: 8,
116-
})
117-
const title = 'Wait'
118-
119-
// When
120-
const {lastFrame} = render(<LoadingBar title={title} noColor />)
121-
122-
// Then
98+
vi.mocked(useLayout).mockReturnValue({twoThirds: 5, oneThird: 3, fullWidth: 8})
99+
const {lastFrame} = renderWithTTY(<LoadingBar title="Wait" noColor />)
100+
123101
expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
124102
"▁▁▁▂▂
125103
Wait ..."
126104
`)
127105
})
128106

129107
test('handles wide terminal width correctly', async () => {
130-
// Given
131-
vi.mocked(useLayout).mockReturnValue({
132-
twoThirds: 100,
133-
oneThird: 50,
134-
fullWidth: 150,
135-
})
136-
const title = 'Synchronizing data'
137-
138-
// When
139-
const {lastFrame} = render(<LoadingBar title={title} />)
140-
141-
// Then
108+
vi.mocked(useLayout).mockReturnValue({twoThirds: 100, oneThird: 50, fullWidth: 150})
109+
const {lastFrame} = renderWithTTY(<LoadingBar title="Synchronizing data" />)
110+
142111
expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
143112
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
144113
Synchronizing data ..."
145114
`)
146115
})
147116

148117
test('handles wide terminal width correctly in no-color mode with pattern repetition', async () => {
149-
// Given
150-
vi.mocked(useLayout).mockReturnValue({
151-
twoThirds: 90,
152-
oneThird: 45,
153-
fullWidth: 135,
154-
})
155-
const title = 'Analyzing dependencies'
156-
157-
// When
158-
const {lastFrame} = render(<LoadingBar title={title} noColor />)
159-
160-
// Then
118+
vi.mocked(useLayout).mockReturnValue({twoThirds: 90, oneThird: 45, fullWidth: 135})
119+
const {lastFrame} = renderWithTTY(<LoadingBar title="Analyzing dependencies" noColor />)
120+
161121
expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
162122
"▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅▄▄▃▃▂▂▁▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅▄▄▃▃▂▂▁▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅▄▄▃▃▂▂▁
163123
Analyzing dependencies ..."
164124
`)
165125
})
166126

167127
test('renders correctly with empty title', async () => {
168-
// Given
169-
const title = ''
170-
171-
// When
172-
const {lastFrame} = render(<LoadingBar title={title} />)
128+
const {lastFrame} = renderWithTTY(<LoadingBar title="" />)
173129

174-
// Then
175130
expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
176131
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
177132
..."
178133
`)
179134
})
180135

181136
test('noColor prop overrides shouldDisplayColors when both would show colors', async () => {
182-
// Given
183137
vi.mocked(shouldDisplayColors).mockReturnValue(true)
184-
const title = 'Testing override'
138+
const {lastFrame} = renderWithTTY(<LoadingBar title="Testing override" noColor />)
185139

186-
// When
187-
const {lastFrame} = render(<LoadingBar title={title} noColor />)
188-
189-
// Then
190140
expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
191141
"▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅▄▄▃▃▂▂▁▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅
192142
Testing override ..."
193143
`)
194144
})
195145

196146
test('renders consistently with same props', async () => {
197-
// Given
198-
const title = 'Consistent test'
199-
const props = {title, noColor: false}
200-
201-
// When
202-
const {lastFrame: frame1} = render(<LoadingBar {...props} />)
203-
const {lastFrame: frame2} = render(<LoadingBar {...props} />)
147+
const props = {title: 'Consistent test', noColor: false}
148+
const {lastFrame: frame1} = renderWithTTY(<LoadingBar {...props} />)
149+
const {lastFrame: frame2} = renderWithTTY(<LoadingBar {...props} />)
204150

205-
// Then
206151
expect(frame1()).toBe(frame2())
207152
})
208153

209154
test('hides progress bar when noProgressBar is true', async () => {
210-
// Given
211155
vi.mocked(shouldDisplayColors).mockReturnValue(true)
212-
const title = 'task 1'
213-
214-
// When
215-
const {lastFrame} = render(<LoadingBar title={title} noProgressBar />)
156+
const {lastFrame} = renderWithTTY(<LoadingBar title="task 1" noProgressBar />)
216157

217-
// Then
218158
expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`"task 1 ..."`)
219159
})
160+
161+
test('shows only static title text when output stream is not a TTY', async () => {
162+
// Default test Stdout has no isTTY property, simulating a non-TTY stream
163+
const {lastFrame} = render(<LoadingBar title="Installing dependencies" />)
164+
165+
expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`"Installing dependencies ..."`)
166+
})
167+
168+
test('shows animated progress bar when output stream is a TTY', async () => {
169+
const {lastFrame} = renderWithTTY(<LoadingBar title="Uploading theme" />)
170+
171+
const frame = unstyled(lastFrame()!)
172+
expect(frame).toContain('▀')
173+
expect(frame).toContain('Uploading theme ...')
174+
})
220175
})

packages/cli-kit/src/private/node/ui/components/LoadingBar.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import useLayout from '../hooks/use-layout.js'
33
import {shouldDisplayColors} from '../../../../public/node/output.js'
44
import React from 'react'
55

6-
import {Box, Text} from 'ink'
6+
import {Box, Text, useStdout} from 'ink'
77

88
const loadingBarChar = '▀'
99
const hillString = '▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅▄▄▃▃▂▂▁▁'
@@ -16,14 +16,23 @@ interface LoadingBarProps {
1616

1717
const LoadingBar = ({title, noColor, noProgressBar}: React.PropsWithChildren<LoadingBarProps>) => {
1818
const {twoThirds} = useLayout()
19+
const {stdout} = useStdout()
20+
21+
// On real Node streams, isTTY is only present as an own property when the
22+
// stream IS a TTY. When Ink's output stream is not a TTY (e.g. AI agents
23+
// capturing stderr via 2>&1), the animated progress bar can't overwrite
24+
// previous frames and would flood the output. Show only the static title
25+
// in that case.
26+
const isTTY = Boolean((stdout as unknown as Record<string, unknown>).isTTY)
27+
1928
let loadingBar = new Array(twoThirds).fill(loadingBarChar).join('')
2029
if (noColor ?? !shouldDisplayColors()) {
2130
loadingBar = hillString.repeat(Math.ceil(twoThirds / hillString.length))
2231
}
2332

2433
return (
2534
<Box flexDirection="column">
26-
{!noProgressBar && <TextAnimation text={loadingBar} maxWidth={twoThirds} />}
35+
{isTTY && !noProgressBar && <TextAnimation text={loadingBar} maxWidth={twoThirds} />}
2736
<Text>{title} ...</Text>
2837
</Box>
2938
)

packages/cli-kit/src/public/node/ui.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -479,7 +479,6 @@ interface RenderTasksOptions {
479479
/**
480480
* Runs async tasks and displays their progress to the console.
481481
* @example
482-
* ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
483482
* Installing dependencies ...
484483
*/
485484

@@ -497,6 +496,7 @@ export async function renderTasks<TContext>(
497496
noProgressBar={noProgressBar}
498497
/>,
499498
{
499+
stdout: process.stderr as unknown as NodeJS.WriteStream,
500500
...renderOptions,
501501
exitOnCtrlC: false,
502502
},
@@ -519,7 +519,6 @@ export interface RenderSingleTaskOptions<T> {
519519
* @param options.renderOptions - Optional render configuration
520520
* @returns The result of the task
521521
* @example
522-
* ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
523522
* Loading app ...
524523
*/
525524
export async function renderSingleTask<T>({
@@ -539,6 +538,7 @@ export async function renderSingleTask<T>({
539538
onAbort={onAbort}
540539
/>,
541540
{
541+
stdout: process.stderr as unknown as NodeJS.WriteStream,
542542
...renderOptions,
543543
exitOnCtrlC: false,
544544
},

0 commit comments

Comments
 (0)