Skip to content

Commit 7b8c779

Browse files
Merge pull request #6631 from Shopify/single_task_with_status_update
Refactor renderSingleTask to allow an async process to update current status
2 parents 7f53b75 + 4ecf988 commit 7b8c779

File tree

6 files changed

+212
-98
lines changed

6 files changed

+212
-98
lines changed

packages/cli-kit/bin/documentation/examples.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
renderTextPrompt,
1717
renderWarning,
1818
} from '../../src/public/node/ui.js'
19-
import {unstyled} from '../../src/public/node/output.js'
19+
import {outputContent, unstyled} from '../../src/public/node/output.js'
2020
import {AbortError, BugError} from '../../src/public/node/error.js'
2121
import {AbortSignal} from '../../src/public/node/abort.js'
2222
import {Stdout} from '../../src/private/node/ui.js'
@@ -592,11 +592,12 @@ export const examples: {[key in string]: Example} = {
592592
const stdout = new Stdout({columns: TERMINAL_WIDTH})
593593

594594
await renderSingleTask({
595-
title: 'Loading app',
596-
taskPromise: async () => {
595+
title: outputContent`Loading app`,
596+
task: async () => {
597597
await sleep(1)
598598
},
599-
}, {renderOptions: {stdout: stdout as any, debug: true}})
599+
renderOptions: {stdout: stdout as any, debug: true}
600+
})
600601

601602
// Find the last frame that includes mention of "Loading"
602603
const loadingFrame = stdout.frames.findLast(frame => frame.includes('Loading'))
Lines changed: 134 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
11
import {SingleTask} from './SingleTask.js'
22
import {render} from '../../testing/ui.js'
3+
import {TokenizedString} from '../../../../public/node/output.js'
34
import React from 'react'
45
import {describe, expect, test} from 'vitest'
56

67
describe('SingleTask', () => {
78
test('unmounts when promise resolves successfully', async () => {
89
// Given
9-
const title = 'Uploading files'
10+
const title = new TokenizedString('Uploading files')
1011
let resolvePromise: (value: string) => void
11-
const taskPromise = new Promise<string>((resolve) => {
12-
resolvePromise = resolve
13-
})
12+
const task = () =>
13+
new Promise<string>((resolve) => {
14+
resolvePromise = resolve
15+
})
1416

1517
// When
16-
const renderInstance = render(<SingleTask title={title} taskPromise={taskPromise} />)
18+
const renderInstance = render(<SingleTask title={title} task={task} />)
1719

1820
// Wait for initial render
1921
await new Promise((resolve) => setTimeout(resolve, 10))
@@ -30,14 +32,15 @@ describe('SingleTask', () => {
3032

3133
test('unmounts when promise rejects', async () => {
3234
// Given
33-
const title = 'Failed task'
35+
const title = new TokenizedString('Failed task')
3436
let rejectPromise: (error: Error) => void
35-
const taskPromise = new Promise<string>((resolve, reject) => {
36-
rejectPromise = reject
37-
})
37+
const task = () =>
38+
new Promise<string>((resolve, reject) => {
39+
rejectPromise = reject
40+
})
3841

3942
// When
40-
const renderInstance = render(<SingleTask title={title} taskPromise={taskPromise} />)
43+
const renderInstance = render(<SingleTask title={title} task={task} />)
4144

4245
// Wait for initial render
4346
await new Promise((resolve) => setTimeout(resolve, 10))
@@ -51,11 +54,11 @@ describe('SingleTask', () => {
5154

5255
test('handles promise that resolves immediately', async () => {
5356
// Given
54-
const title = 'Instant task'
55-
const taskPromise = Promise.resolve('immediate success')
57+
const title = new TokenizedString('Instant task')
58+
const task = () => Promise.resolve('immediate success')
5659

5760
// When
58-
const renderInstance = render(<SingleTask title={title} taskPromise={taskPromise} />)
61+
const renderInstance = render(<SingleTask title={title} task={task} />)
5962
await renderInstance.waitUntilExit()
6063

6164
// Then - component should complete successfully
@@ -64,51 +67,83 @@ describe('SingleTask', () => {
6467

6568
test('handles promise that rejects immediately', async () => {
6669
// Given
67-
const title = 'Instant failure'
68-
const taskPromise = Promise.reject(new Error('Immediate error'))
70+
const title = new TokenizedString('Instant failure')
71+
const task = () => Promise.reject(new Error('Immediate error'))
6972

7073
// When
71-
const renderInstance = render(<SingleTask title={title} taskPromise={taskPromise} />)
74+
const renderInstance = render(<SingleTask title={title} task={task} />)
7275

7376
// Then - should exit with error
7477
await expect(renderInstance.waitUntilExit()).rejects.toThrow('Immediate error')
7578
})
7679

7780
test('handles different types of promise return values', async () => {
7881
// Test with string
79-
const stringTask = Promise.resolve('task completed')
80-
const stringRender = render(<SingleTask title="String task" taskPromise={stringTask} />)
82+
let stringResult: string | undefined
83+
const stringTask = () => Promise.resolve('task completed')
84+
const stringRender = render(
85+
<SingleTask
86+
title={new TokenizedString('String task')}
87+
task={stringTask}
88+
onComplete={(result) => (stringResult = result)}
89+
/>,
90+
)
8191
await stringRender.waitUntilExit()
8292
expect(stringRender.lastFrame()).toBeDefined()
93+
expect(stringResult).toBe('task completed')
8394

8495
// Test with object
85-
const objectTask = Promise.resolve({id: 1, name: 'test'})
86-
const objectRender = render(<SingleTask title="Object task" taskPromise={objectTask} />)
96+
let objectResult: {id: number; name: string} | undefined
97+
const objectTask = () => Promise.resolve({id: 1, name: 'test'})
98+
const objectRender = render(
99+
<SingleTask
100+
title={new TokenizedString('Object task')}
101+
task={objectTask}
102+
onComplete={(result) => (objectResult = result)}
103+
/>,
104+
)
87105
await objectRender.waitUntilExit()
88106
expect(objectRender.lastFrame()).toBeDefined()
89107

90108
// Test with number
91-
const numberTask = Promise.resolve(42)
92-
const numberRender = render(<SingleTask title="Number task" taskPromise={numberTask} />)
109+
let numberResult: number | undefined
110+
const numberTask = () => Promise.resolve(42)
111+
const numberRender = render(
112+
<SingleTask
113+
title={new TokenizedString('Number task')}
114+
task={numberTask}
115+
onComplete={(result) => (numberResult = result)}
116+
/>,
117+
)
93118
await numberRender.waitUntilExit()
94119
expect(numberRender.lastFrame()).toBeDefined()
120+
expect(numberResult).toBe(42)
95121

96122
// Test with boolean
97-
const booleanTask = Promise.resolve(true)
98-
const booleanRender = render(<SingleTask title="Boolean task" taskPromise={booleanTask} />)
123+
let booleanResult: boolean | undefined
124+
const booleanTask = () => Promise.resolve(true)
125+
const booleanRender = render(
126+
<SingleTask
127+
title={new TokenizedString('Boolean task')}
128+
task={booleanTask}
129+
onComplete={(result) => (booleanResult = result)}
130+
/>,
131+
)
99132
await booleanRender.waitUntilExit()
100133
expect(booleanRender.lastFrame()).toBeDefined()
134+
expect(booleanResult).toBe(true)
101135
})
102136

103137
test('handles promise with delayed resolution', async () => {
104138
// Given
105-
const title = 'Delayed task'
106-
const taskPromise = new Promise<string>((resolve) => {
107-
setTimeout(() => resolve('completed'), 100)
108-
})
139+
const title = new TokenizedString('Delayed task')
140+
const task = () =>
141+
new Promise<string>((resolve) => {
142+
setTimeout(() => resolve('completed'), 100)
143+
})
109144

110145
// When
111-
const renderInstance = render(<SingleTask title={title} taskPromise={taskPromise} />)
146+
const renderInstance = render(<SingleTask title={title} task={task} />)
112147

113148
// Wait for completion
114149
await renderInstance.waitUntilExit()
@@ -119,13 +154,14 @@ describe('SingleTask', () => {
119154

120155
test('handles promise with delayed rejection', async () => {
121156
// Given
122-
const title = 'Delayed failure'
123-
const taskPromise = new Promise<string>((resolve, reject) => {
124-
setTimeout(() => reject(new Error('delayed error')), 100)
125-
})
157+
const title = new TokenizedString('Delayed failure')
158+
const task = () =>
159+
new Promise<string>((resolve, reject) => {
160+
setTimeout(() => reject(new Error('delayed error')), 100)
161+
})
126162

127163
// When
128-
const renderInstance = render(<SingleTask title={title} taskPromise={taskPromise} />)
164+
const renderInstance = render(<SingleTask title={title} task={task} />)
129165

130166
// Wait for completion - should throw error
131167
await expect(renderInstance.waitUntilExit()).rejects.toThrow('delayed error')
@@ -141,23 +177,23 @@ describe('SingleTask', () => {
141177
}
142178

143179
const customError = new CustomError('Custom error message', 'CUSTOM_CODE')
144-
const taskPromise = Promise.reject(customError)
180+
const task = () => Promise.reject(customError)
145181

146182
// When
147-
const renderInstance = render(<SingleTask title="Custom error task" taskPromise={taskPromise} />)
183+
const renderInstance = render(<SingleTask title={new TokenizedString('Custom error task')} task={task} />)
148184

149185
// Then - should preserve the exact error
150186
await expect(renderInstance.waitUntilExit()).rejects.toThrow('Custom error message')
151187
})
152188

153189
test('handles concurrent promise operations', async () => {
154190
// Given - Multiple SingleTask components with different promises
155-
const fastPromise = new Promise((resolve) => setTimeout(() => resolve('fast'), 50))
156-
const slowPromise = new Promise((resolve) => setTimeout(() => resolve('slow'), 150))
191+
const fastPromise = () => new Promise((resolve) => setTimeout(() => resolve('fast'), 50))
192+
const slowPromise = () => new Promise((resolve) => setTimeout(() => resolve('slow'), 150))
157193

158194
// When
159-
const fastRender = render(<SingleTask title="Fast task" taskPromise={fastPromise} />)
160-
const slowRender = render(<SingleTask title="Slow task" taskPromise={slowPromise} />)
195+
const fastRender = render(<SingleTask title={new TokenizedString('Fast task')} task={fastPromise} />)
196+
const slowRender = render(<SingleTask title={new TokenizedString('Slow task')} task={slowPromise} />)
161197

162198
// Then - Both should complete successfully
163199
await fastRender.waitUntilExit()
@@ -169,14 +205,69 @@ describe('SingleTask', () => {
169205

170206
test('passes noColor prop to LoadingBar component', async () => {
171207
// Given
172-
const title = 'No color task'
173-
const taskPromise = Promise.resolve()
208+
const title = new TokenizedString('No color task')
209+
const task = () => Promise.resolve()
174210

175211
// When - Test that noColor prop doesn't break the component
176-
const renderInstance = render(<SingleTask title={title} taskPromise={taskPromise} noColor />)
212+
const renderInstance = render(<SingleTask title={title} task={task} noColor />)
177213
await renderInstance.waitUntilExit()
178214

179215
// Then - Component should complete successfully with noColor prop
180216
expect(renderInstance.lastFrame()).toBeDefined()
181217
})
218+
219+
test('updates status message during task execution', async () => {
220+
// Given
221+
const initialTitle = new TokenizedString('Starting task')
222+
let step1Resolve: () => void
223+
let step2Resolve: () => void
224+
let step3Resolve: () => void
225+
226+
const step1Promise = new Promise<void>((resolve) => {
227+
step1Resolve = resolve
228+
})
229+
const step2Promise = new Promise<void>((resolve) => {
230+
step2Resolve = resolve
231+
})
232+
const step3Promise = new Promise<void>((resolve) => {
233+
step3Resolve = resolve
234+
})
235+
236+
const task = async (updateStatus: (status: TokenizedString) => void) => {
237+
updateStatus(new TokenizedString('Running (1 complete)...'))
238+
await step1Promise
239+
240+
updateStatus(new TokenizedString('Running (2 complete)...'))
241+
await step2Promise
242+
243+
updateStatus(new TokenizedString('Running (3 complete)...'))
244+
await step3Promise
245+
246+
return 'completed'
247+
}
248+
249+
// When
250+
const renderInstance = render(<SingleTask title={initialTitle} task={task} />)
251+
252+
// Wait for component to render with first status
253+
await new Promise((resolve) => setTimeout(resolve, 10))
254+
const frame1 = renderInstance.lastFrame()
255+
expect(frame1).toContain('1 complete')
256+
257+
// Progress to step 2
258+
step1Resolve!()
259+
await new Promise((resolve) => setTimeout(resolve, 10))
260+
const frame2 = renderInstance.lastFrame()
261+
expect(frame2).toContain('2 complete')
262+
263+
// Progress to step 3
264+
step2Resolve!()
265+
await new Promise((resolve) => setTimeout(resolve, 10))
266+
const frame3 = renderInstance.lastFrame()
267+
expect(frame3).toContain('3 complete')
268+
269+
// Complete the task
270+
step3Resolve!()
271+
await renderInstance.waitUntilExit()
272+
})
182273
})
Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,41 @@
11
import {LoadingBar} from './LoadingBar.js'
22
import {useExitOnCtrlC} from '../hooks/use-exit-on-ctrl-c.js'
3+
import {TokenizedString} from '../../../../public/node/output.js'
34
import React, {useEffect, useState} from 'react'
45
import {useApp} from 'ink'
56

6-
interface SingleTaskProps {
7-
title: string
8-
taskPromise: Promise<unknown>
7+
interface SingleTaskProps<T> {
8+
title: TokenizedString
9+
task: (updateStatus: (status: TokenizedString) => void) => Promise<T>
10+
onComplete?: (result: T) => void
911
noColor?: boolean
1012
}
1113

12-
const SingleTask = ({taskPromise, title, noColor}: React.PropsWithChildren<SingleTaskProps>) => {
14+
const SingleTask = <T,>({task, title, onComplete, noColor}: SingleTaskProps<T>) => {
15+
const [status, setStatus] = useState(title)
1316
const [isDone, setIsDone] = useState(false)
1417
const {exit: unmountInk} = useApp()
1518
useExitOnCtrlC()
1619

1720
useEffect(() => {
18-
taskPromise
19-
.then(() => {
21+
task(setStatus)
22+
.then((result) => {
2023
setIsDone(true)
24+
onComplete?.(result)
2125
unmountInk()
2226
})
2327
.catch((error) => {
2428
setIsDone(true)
2529
unmountInk(error)
2630
})
27-
}, [taskPromise, unmountInk])
31+
}, [task, unmountInk, onComplete])
2832

2933
if (isDone) {
3034
// clear things once done
3135
return null
3236
}
3337

34-
return <LoadingBar title={title} noColor={noColor} />
38+
return <LoadingBar title={status.value} noColor={noColor} />
3539
}
3640

3741
export {SingleTask}

0 commit comments

Comments
 (0)