Skip to content

Commit c34ea88

Browse files
committed
refactor renderSingleTask to allow an async process to update current status
1 parent 7f53b75 commit c34ea88

File tree

3 files changed

+136
-61
lines changed

3 files changed

+136
-61
lines changed

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

Lines changed: 110 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@ describe('SingleTask', () => {
88
// Given
99
const title = 'Uploading files'
1010
let resolvePromise: (value: string) => void
11-
const taskPromise = new Promise<string>((resolve) => {
12-
resolvePromise = resolve
13-
})
11+
const task = () =>
12+
new Promise<string>((resolve) => {
13+
resolvePromise = resolve
14+
})
1415

1516
// When
16-
const renderInstance = render(<SingleTask title={title} taskPromise={taskPromise} />)
17+
const renderInstance = render(<SingleTask title={title} task={task} />)
1718

1819
// Wait for initial render
1920
await new Promise((resolve) => setTimeout(resolve, 10))
@@ -32,12 +33,13 @@ describe('SingleTask', () => {
3233
// Given
3334
const title = 'Failed task'
3435
let rejectPromise: (error: Error) => void
35-
const taskPromise = new Promise<string>((resolve, reject) => {
36-
rejectPromise = reject
37-
})
36+
const task = () =>
37+
new Promise<string>((resolve, reject) => {
38+
rejectPromise = reject
39+
})
3840

3941
// When
40-
const renderInstance = render(<SingleTask title={title} taskPromise={taskPromise} />)
42+
const renderInstance = render(<SingleTask title={title} task={task} />)
4143

4244
// Wait for initial render
4345
await new Promise((resolve) => setTimeout(resolve, 10))
@@ -52,10 +54,10 @@ describe('SingleTask', () => {
5254
test('handles promise that resolves immediately', async () => {
5355
// Given
5456
const title = 'Instant task'
55-
const taskPromise = Promise.resolve('immediate success')
57+
const task = () => Promise.resolve('immediate success')
5658

5759
// When
58-
const renderInstance = render(<SingleTask title={title} taskPromise={taskPromise} />)
60+
const renderInstance = render(<SingleTask title={title} task={task} />)
5961
await renderInstance.waitUntilExit()
6062

6163
// Then - component should complete successfully
@@ -65,50 +67,66 @@ describe('SingleTask', () => {
6567
test('handles promise that rejects immediately', async () => {
6668
// Given
6769
const title = 'Instant failure'
68-
const taskPromise = Promise.reject(new Error('Immediate error'))
70+
const task = () => Promise.reject(new Error('Immediate error'))
6971

7072
// When
71-
const renderInstance = render(<SingleTask title={title} taskPromise={taskPromise} />)
73+
const renderInstance = render(<SingleTask title={title} task={task} />)
7274

7375
// Then - should exit with error
7476
await expect(renderInstance.waitUntilExit()).rejects.toThrow('Immediate error')
7577
})
7678

7779
test('handles different types of promise return values', async () => {
7880
// Test with string
79-
const stringTask = Promise.resolve('task completed')
80-
const stringRender = render(<SingleTask title="String task" taskPromise={stringTask} />)
81+
let stringResult: string | undefined
82+
const stringTask = () => Promise.resolve('task completed')
83+
const stringRender = render(
84+
<SingleTask title="String task" task={stringTask} onComplete={(result) => (stringResult = result)} />,
85+
)
8186
await stringRender.waitUntilExit()
8287
expect(stringRender.lastFrame()).toBeDefined()
88+
expect(stringResult).toBe('task completed')
8389

8490
// Test with object
85-
const objectTask = Promise.resolve({id: 1, name: 'test'})
86-
const objectRender = render(<SingleTask title="Object task" taskPromise={objectTask} />)
91+
let objectResult: {id: number; name: string} | undefined
92+
const objectTask = () => Promise.resolve({id: 1, name: 'test'})
93+
const objectRender = render(
94+
<SingleTask title="Object task" task={objectTask} onComplete={(result) => (objectResult = result)} />,
95+
)
8796
await objectRender.waitUntilExit()
8897
expect(objectRender.lastFrame()).toBeDefined()
8998

9099
// Test with number
91-
const numberTask = Promise.resolve(42)
92-
const numberRender = render(<SingleTask title="Number task" taskPromise={numberTask} />)
100+
let numberResult: number | undefined
101+
const numberTask = () => Promise.resolve(42)
102+
const numberRender = render(
103+
<SingleTask title="Number task" task={numberTask} onComplete={(result) => (numberResult = result)} />,
104+
)
93105
await numberRender.waitUntilExit()
94106
expect(numberRender.lastFrame()).toBeDefined()
107+
expect(numberResult).toBe(42)
95108

96109
// Test with boolean
97-
const booleanTask = Promise.resolve(true)
98-
const booleanRender = render(<SingleTask title="Boolean task" taskPromise={booleanTask} />)
110+
let booleanResult: boolean | undefined
111+
const booleanTask = () => Promise.resolve(true)
112+
const booleanRender = render(
113+
<SingleTask title="Boolean task" task={booleanTask} onComplete={(result) => (booleanResult = result)} />,
114+
)
99115
await booleanRender.waitUntilExit()
100116
expect(booleanRender.lastFrame()).toBeDefined()
117+
expect(booleanResult).toBe(true)
101118
})
102119

103120
test('handles promise with delayed resolution', async () => {
104121
// Given
105122
const title = 'Delayed task'
106-
const taskPromise = new Promise<string>((resolve) => {
107-
setTimeout(() => resolve('completed'), 100)
108-
})
123+
const task = () =>
124+
new Promise<string>((resolve) => {
125+
setTimeout(() => resolve('completed'), 100)
126+
})
109127

110128
// When
111-
const renderInstance = render(<SingleTask title={title} taskPromise={taskPromise} />)
129+
const renderInstance = render(<SingleTask title={title} task={task} />)
112130

113131
// Wait for completion
114132
await renderInstance.waitUntilExit()
@@ -120,12 +138,13 @@ describe('SingleTask', () => {
120138
test('handles promise with delayed rejection', async () => {
121139
// Given
122140
const title = 'Delayed failure'
123-
const taskPromise = new Promise<string>((resolve, reject) => {
124-
setTimeout(() => reject(new Error('delayed error')), 100)
125-
})
141+
const task = () =>
142+
new Promise<string>((resolve, reject) => {
143+
setTimeout(() => reject(new Error('delayed error')), 100)
144+
})
126145

127146
// When
128-
const renderInstance = render(<SingleTask title={title} taskPromise={taskPromise} />)
147+
const renderInstance = render(<SingleTask title={title} task={task} />)
129148

130149
// Wait for completion - should throw error
131150
await expect(renderInstance.waitUntilExit()).rejects.toThrow('delayed error')
@@ -141,23 +160,23 @@ describe('SingleTask', () => {
141160
}
142161

143162
const customError = new CustomError('Custom error message', 'CUSTOM_CODE')
144-
const taskPromise = Promise.reject(customError)
163+
const task = () => Promise.reject(customError)
145164

146165
// When
147-
const renderInstance = render(<SingleTask title="Custom error task" taskPromise={taskPromise} />)
166+
const renderInstance = render(<SingleTask title="Custom error task" task={task} />)
148167

149168
// Then - should preserve the exact error
150169
await expect(renderInstance.waitUntilExit()).rejects.toThrow('Custom error message')
151170
})
152171

153172
test('handles concurrent promise operations', async () => {
154173
// 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))
174+
const fastPromise = () => new Promise((resolve) => setTimeout(() => resolve('fast'), 50))
175+
const slowPromise = () => new Promise((resolve) => setTimeout(() => resolve('slow'), 150))
157176

158177
// When
159-
const fastRender = render(<SingleTask title="Fast task" taskPromise={fastPromise} />)
160-
const slowRender = render(<SingleTask title="Slow task" taskPromise={slowPromise} />)
178+
const fastRender = render(<SingleTask title="Fast task" task={fastPromise} />)
179+
const slowRender = render(<SingleTask title="Slow task" task={slowPromise} />)
161180

162181
// Then - Both should complete successfully
163182
await fastRender.waitUntilExit()
@@ -170,13 +189,68 @@ describe('SingleTask', () => {
170189
test('passes noColor prop to LoadingBar component', async () => {
171190
// Given
172191
const title = 'No color task'
173-
const taskPromise = Promise.resolve()
192+
const task = () => Promise.resolve()
174193

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

179198
// Then - Component should complete successfully with noColor prop
180199
expect(renderInstance.lastFrame()).toBeDefined()
181200
})
201+
202+
test('updates status message during task execution', async () => {
203+
// Given
204+
const initialTitle = 'Starting task'
205+
let step1Resolve: () => void
206+
let step2Resolve: () => void
207+
let step3Resolve: () => void
208+
209+
const step1Promise = new Promise<void>((resolve) => {
210+
step1Resolve = resolve
211+
})
212+
const step2Promise = new Promise<void>((resolve) => {
213+
step2Resolve = resolve
214+
})
215+
const step3Promise = new Promise<void>((resolve) => {
216+
step3Resolve = resolve
217+
})
218+
219+
const task = async (updateStatus: (status: string) => void) => {
220+
updateStatus('Running (1 complete)...')
221+
await step1Promise
222+
223+
updateStatus('Running (2 complete)...')
224+
await step2Promise
225+
226+
updateStatus('Running (3 complete)...')
227+
await step3Promise
228+
229+
return 'completed'
230+
}
231+
232+
// When
233+
const renderInstance = render(<SingleTask title={initialTitle} task={task} />)
234+
235+
// Wait for component to render with first status
236+
await new Promise((resolve) => setTimeout(resolve, 10))
237+
const frame1 = renderInstance.lastFrame()
238+
expect(frame1).toContain('1 complete')
239+
240+
// Progress to step 2
241+
step1Resolve!()
242+
await new Promise((resolve) => setTimeout(resolve, 10))
243+
const frame2 = renderInstance.lastFrame()
244+
expect(frame2).toContain('2 complete')
245+
246+
// Progress to step 3
247+
step2Resolve!()
248+
await new Promise((resolve) => setTimeout(resolve, 10))
249+
const frame3 = renderInstance.lastFrame()
250+
expect(frame3).toContain('3 complete')
251+
252+
// Complete the task
253+
step3Resolve!()
254+
await renderInstance.waitUntilExit()
255+
})
182256
})

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

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,35 +3,38 @@ import {useExitOnCtrlC} from '../hooks/use-exit-on-ctrl-c.js'
33
import React, {useEffect, useState} from 'react'
44
import {useApp} from 'ink'
55

6-
interface SingleTaskProps {
6+
interface SingleTaskProps<T> {
77
title: string
8-
taskPromise: Promise<unknown>
8+
task: (updateStatus: (status: string) => void) => Promise<T>
9+
onComplete?: (result: T) => void
910
noColor?: boolean
1011
}
1112

12-
const SingleTask = ({taskPromise, title, noColor}: React.PropsWithChildren<SingleTaskProps>) => {
13+
const SingleTask = <T,>({task, title, onComplete, noColor}: SingleTaskProps<T>) => {
14+
const [status, setStatus] = useState(title)
1315
const [isDone, setIsDone] = useState(false)
1416
const {exit: unmountInk} = useApp()
1517
useExitOnCtrlC()
1618

1719
useEffect(() => {
18-
taskPromise
19-
.then(() => {
20+
task(setStatus)
21+
.then((result) => {
2022
setIsDone(true)
23+
onComplete?.(result)
2124
unmountInk()
2225
})
2326
.catch((error) => {
2427
setIsDone(true)
2528
unmountInk(error)
2629
})
27-
}, [taskPromise, unmountInk])
30+
}, [task, unmountInk, onComplete])
2831

2932
if (isDone) {
3033
// clear things once done
3134
return null
3235
}
3336

34-
return <LoadingBar title={title} noColor={noColor} />
37+
return <LoadingBar title={status} noColor={noColor} />
3538
}
3639

3740
export {SingleTask}

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

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -487,32 +487,30 @@ export async function renderTasks<TContext>(
487487
})
488488
}
489489

490+
export interface RenderSingleTaskOptions<T> {
491+
title: string
492+
task: (updateStatus: (status: string) => void) => Promise<T>
493+
renderOptions?: RenderOptions
494+
}
495+
490496
/**
491-
* Awaits a single promise and displays a loading bar while it's in progress. The promise's result is returned.
492-
* @param options - Configuration object
493-
* @param options.title - The title to display with the loading bar
494-
* @param options.taskPromise - The promise to track
497+
* Awaits a single task and displays a loading bar while it's in progress. The task's result is returned.
498+
* @param title - The initial title to display with the loading bar
499+
* @param task - The async task to execute. Receives an updateStatus callback to change the displayed title.
495500
* @param renderOptions - Optional render configuration
496-
* @returns The result of the promise
501+
* @returns The result of the task
497502
* @example
498503
* ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
499504
* Loading app ...
500505
*/
501-
// eslint-disable-next-line max-params
502-
export async function renderSingleTask<T>(
503-
{title, taskPromise}: {title: string; taskPromise: Promise<T> | (() => Promise<T>)},
504-
{renderOptions}: RenderTasksOptions = {},
505-
) {
506-
const promise = typeof taskPromise === 'function' ? taskPromise() : taskPromise
507-
const [_renderResult, taskResult] = await Promise.all([
508-
render(<SingleTask title={title} taskPromise={promise} />, {
506+
export async function renderSingleTask<T>({title, task, renderOptions}: RenderSingleTaskOptions<T>): Promise<T> {
507+
// eslint-disable-next-line max-params
508+
return new Promise<T>((resolve, reject) => {
509+
render(<SingleTask title={title} task={task} onComplete={resolve} />, {
509510
...renderOptions,
510511
exitOnCtrlC: false,
511-
}),
512-
promise,
513-
])
514-
515-
return taskResult
512+
}).catch(reject)
513+
})
516514
}
517515

518516
export interface RenderTextPromptOptions extends Omit<TextPromptProps, 'onSubmit'> {

0 commit comments

Comments
 (0)