Skip to content

Commit 709834e

Browse files
authored
Merge pull request #5925 from Shopify/shauns/06-02-introduce_singletask_component
Introduce SingleTask component
2 parents 7a63b68 + 9741e37 commit 709834e

File tree

5 files changed

+351
-0
lines changed

5 files changed

+351
-0
lines changed

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
renderSuccess,
1313
renderTable,
1414
renderTasks,
15+
renderSingleTask,
1516
renderTextPrompt,
1617
renderWarning,
1718
} from '../../src/public/node/ui.js'
@@ -21,6 +22,7 @@ import {AbortSignal} from '../../src/public/node/abort.js'
2122
import {Stdout} from '../../src/private/node/ui.js'
2223
import {Stdin, waitFor} from '../../src/private/node/testing/ui.js'
2324
import {Writable} from 'node:stream'
25+
import { sleep } from '../../src/public/node/system.js'
2426

2527
interface Example {
2628
type: 'static' | 'async' | 'prompt'
@@ -584,6 +586,25 @@ export const examples: {[key in string]: Example} = {
584586
return stdout.lastFrame()!
585587
},
586588
},
589+
renderSingleTask: {
590+
type: 'async',
591+
basic: async () => {
592+
const stdout = new Stdout({columns: TERMINAL_WIDTH})
593+
594+
await renderSingleTask({
595+
title: 'Loading app',
596+
taskPromise: async () => {
597+
await sleep(1)
598+
},
599+
}, {renderOptions: {stdout: stdout as any, debug: true}})
600+
601+
// Find the last frame that includes mention of "Loading"
602+
const loadingFrame = stdout.frames.findLast(frame => frame.includes('Loading'))
603+
604+
// Gives a frame where the loading bar is visible
605+
return loadingFrame ?? stdout.lastFrame()!
606+
},
607+
},
587608
renderTextPrompt: {
588609
type: 'prompt',
589610
basic: async () => {
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import {SingleTask} from './SingleTask.js'
2+
import {render} from '../../testing/ui.js'
3+
import React from 'react'
4+
import {describe, expect, test} from 'vitest'
5+
6+
describe('SingleTask', () => {
7+
test('unmounts when promise resolves successfully', async () => {
8+
// Given
9+
const title = 'Uploading files'
10+
let resolvePromise: (value: string) => void
11+
const taskPromise = new Promise<string>((resolve) => {
12+
resolvePromise = resolve
13+
})
14+
15+
// When
16+
const renderInstance = render(<SingleTask title={title} taskPromise={taskPromise} />)
17+
18+
// Wait for initial render
19+
await new Promise((resolve) => setTimeout(resolve, 10))
20+
21+
// Resolve the promise
22+
resolvePromise!('success')
23+
24+
// Wait for component to update and unmount
25+
await renderInstance.waitUntilExit()
26+
27+
// Then - component should unmount cleanly
28+
expect(renderInstance.lastFrame()).toBeDefined()
29+
})
30+
31+
test('unmounts when promise rejects', async () => {
32+
// Given
33+
const title = 'Failed task'
34+
let rejectPromise: (error: Error) => void
35+
const taskPromise = new Promise<string>((resolve, reject) => {
36+
rejectPromise = reject
37+
})
38+
39+
// When
40+
const renderInstance = render(<SingleTask title={title} taskPromise={taskPromise} />)
41+
42+
// Wait for initial render
43+
await new Promise((resolve) => setTimeout(resolve, 10))
44+
45+
// Reject the promise and expect waitUntilExit to reject
46+
rejectPromise!(new Error('Task failed'))
47+
48+
// The component should exit with the error
49+
await expect(renderInstance.waitUntilExit()).rejects.toThrow('Task failed')
50+
})
51+
52+
test('handles promise that resolves immediately', async () => {
53+
// Given
54+
const title = 'Instant task'
55+
const taskPromise = Promise.resolve('immediate success')
56+
57+
// When
58+
const renderInstance = render(<SingleTask title={title} taskPromise={taskPromise} />)
59+
await renderInstance.waitUntilExit()
60+
61+
// Then - component should complete successfully
62+
expect(renderInstance.lastFrame()).toBeDefined()
63+
})
64+
65+
test('handles promise that rejects immediately', async () => {
66+
// Given
67+
const title = 'Instant failure'
68+
const taskPromise = Promise.reject(new Error('Immediate error'))
69+
70+
// When
71+
const renderInstance = render(<SingleTask title={title} taskPromise={taskPromise} />)
72+
73+
// Then - should exit with error
74+
await expect(renderInstance.waitUntilExit()).rejects.toThrow('Immediate error')
75+
})
76+
77+
test('handles different types of promise return values', async () => {
78+
// Test with string
79+
const stringTask = Promise.resolve('task completed')
80+
const stringRender = render(<SingleTask title="String task" taskPromise={stringTask} />)
81+
await stringRender.waitUntilExit()
82+
expect(stringRender.lastFrame()).toBeDefined()
83+
84+
// Test with object
85+
const objectTask = Promise.resolve({id: 1, name: 'test'})
86+
const objectRender = render(<SingleTask title="Object task" taskPromise={objectTask} />)
87+
await objectRender.waitUntilExit()
88+
expect(objectRender.lastFrame()).toBeDefined()
89+
90+
// Test with number
91+
const numberTask = Promise.resolve(42)
92+
const numberRender = render(<SingleTask title="Number task" taskPromise={numberTask} />)
93+
await numberRender.waitUntilExit()
94+
expect(numberRender.lastFrame()).toBeDefined()
95+
96+
// Test with boolean
97+
const booleanTask = Promise.resolve(true)
98+
const booleanRender = render(<SingleTask title="Boolean task" taskPromise={booleanTask} />)
99+
await booleanRender.waitUntilExit()
100+
expect(booleanRender.lastFrame()).toBeDefined()
101+
})
102+
103+
test('handles promise with delayed resolution', async () => {
104+
// Given
105+
const title = 'Delayed task'
106+
const taskPromise = new Promise<string>((resolve) => {
107+
setTimeout(() => resolve('completed'), 100)
108+
})
109+
110+
// When
111+
const renderInstance = render(<SingleTask title={title} taskPromise={taskPromise} />)
112+
113+
// Wait for completion
114+
await renderInstance.waitUntilExit()
115+
116+
// Then
117+
expect(renderInstance.lastFrame()).toBeDefined()
118+
})
119+
120+
test('handles promise with delayed rejection', async () => {
121+
// Given
122+
const title = 'Delayed failure'
123+
const taskPromise = new Promise<string>((resolve, reject) => {
124+
setTimeout(() => reject(new Error('delayed error')), 100)
125+
})
126+
127+
// When
128+
const renderInstance = render(<SingleTask title={title} taskPromise={taskPromise} />)
129+
130+
// Wait for completion - should throw error
131+
await expect(renderInstance.waitUntilExit()).rejects.toThrow('delayed error')
132+
})
133+
134+
test('preserves error types and messages', async () => {
135+
// Test with custom error
136+
class CustomError extends Error {
137+
constructor(message: string, public code: string) {
138+
super(message)
139+
this.name = 'CustomError'
140+
}
141+
}
142+
143+
const customError = new CustomError('Custom error message', 'CUSTOM_CODE')
144+
const taskPromise = Promise.reject(customError)
145+
146+
// When
147+
const renderInstance = render(<SingleTask title="Custom error task" taskPromise={taskPromise} />)
148+
149+
// Then - should preserve the exact error
150+
await expect(renderInstance.waitUntilExit()).rejects.toThrow('Custom error message')
151+
})
152+
153+
test('handles concurrent promise operations', async () => {
154+
// 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))
157+
158+
// When
159+
const fastRender = render(<SingleTask title="Fast task" taskPromise={fastPromise} />)
160+
const slowRender = render(<SingleTask title="Slow task" taskPromise={slowPromise} />)
161+
162+
// Then - Both should complete successfully
163+
await fastRender.waitUntilExit()
164+
await slowRender.waitUntilExit()
165+
166+
expect(fastRender.lastFrame()).toBeDefined()
167+
expect(slowRender.lastFrame()).toBeDefined()
168+
})
169+
170+
test('passes noColor prop to LoadingBar component', async () => {
171+
// Given
172+
const title = 'No color task'
173+
const taskPromise = Promise.resolve()
174+
175+
// When - Test that noColor prop doesn't break the component
176+
const renderInstance = render(<SingleTask title={title} taskPromise={taskPromise} noColor />)
177+
await renderInstance.waitUntilExit()
178+
179+
// Then - Component should complete successfully with noColor prop
180+
expect(renderInstance.lastFrame()).toBeDefined()
181+
})
182+
})
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import {LoadingBar} from './LoadingBar.js'
2+
import {useExitOnCtrlC} from '../hooks/use-exit-on-ctrl-c.js'
3+
import React, {useEffect, useState} from 'react'
4+
import {useApp} from 'ink'
5+
6+
interface SingleTaskProps {
7+
title: string
8+
taskPromise: Promise<unknown>
9+
noColor?: boolean
10+
}
11+
12+
const SingleTask = ({taskPromise, title, noColor}: React.PropsWithChildren<SingleTaskProps>) => {
13+
const [isDone, setIsDone] = useState(false)
14+
const {exit: unmountInk} = useApp()
15+
useExitOnCtrlC()
16+
17+
useEffect(() => {
18+
taskPromise
19+
.then(() => {
20+
setIsDone(true)
21+
unmountInk()
22+
})
23+
.catch((error) => {
24+
setIsDone(true)
25+
unmountInk(error)
26+
})
27+
}, [taskPromise, unmountInk])
28+
29+
if (isDone) {
30+
// clear things once done
31+
return null
32+
}
33+
34+
return <LoadingBar title={title} noColor={noColor} />
35+
}
36+
37+
export {SingleTask}

packages/cli-kit/src/public/node/ui.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
renderSuccess,
77
renderTasks,
88
renderWarning,
9+
renderSingleTask,
910
} from './ui.js'
1011
import {AbortSignal} from './abort.js'
1112
import {BugError, FatalError, AbortError, FatalErrorType} from './error.js'
@@ -419,3 +420,84 @@ describe('keypress', async () => {
419420
expect(rejected).toEqual(true)
420421
})
421422
})
423+
424+
describe('renderSingleTask', async () => {
425+
test('returns promise result when task resolves successfully', async () => {
426+
// Given
427+
const expectedResult = {id: 123, name: 'test-result'}
428+
const taskPromise = Promise.resolve(expectedResult)
429+
const title = 'Processing data'
430+
431+
// When
432+
const result = await renderSingleTask({title, taskPromise})
433+
434+
// Then
435+
expect(result).toEqual(expectedResult)
436+
})
437+
438+
test('returns function result when function resolves successfully', async () => {
439+
// Given
440+
const expectedResult = {id: 123, name: 'test-result'}
441+
const taskPromise = () => Promise.resolve(expectedResult)
442+
const title = 'Processing data'
443+
444+
// When
445+
const result = await renderSingleTask({title, taskPromise})
446+
447+
// Then
448+
expect(result).toEqual(expectedResult)
449+
})
450+
451+
test('returns undefined when task resolves with undefined', async () => {
452+
// Given
453+
const taskPromise = Promise.resolve(undefined)
454+
const title = 'Void task'
455+
456+
// When
457+
const result = await renderSingleTask({title, taskPromise})
458+
459+
// Then
460+
expect(result).toBeUndefined()
461+
})
462+
463+
test('throws error when task promise rejects', async () => {
464+
// Given
465+
const expectedError = new Error('Task failed with error')
466+
const taskPromise = Promise.reject(expectedError)
467+
const title = 'Failing task'
468+
469+
// When & Then
470+
await expect(renderSingleTask({title, taskPromise})).rejects.toThrow('Task failed with error')
471+
})
472+
473+
test('handles slow promise rejection', async () => {
474+
// Given
475+
const expectedError = new Error('Delayed failure')
476+
const taskPromise = new Promise((resolve, reject) => {
477+
setTimeout(() => reject(expectedError), 100)
478+
})
479+
const title = 'Slow failing task'
480+
481+
// When & Then
482+
await expect(renderSingleTask({title, taskPromise})).rejects.toThrow('Delayed failure')
483+
})
484+
485+
test('handles concurrent single tasks', async () => {
486+
// Given
487+
const task1Promise = new Promise((resolve) => setTimeout(() => resolve('result1'), 50))
488+
const task2Promise = new Promise((resolve) => setTimeout(() => resolve('result2'), 100))
489+
const task3Promise = new Promise((resolve) => setTimeout(() => resolve('result3'), 25))
490+
491+
// When
492+
const [result1, result2, result3] = await Promise.all([
493+
renderSingleTask({title: 'Task 1', taskPromise: task1Promise}),
494+
renderSingleTask({title: 'Task 2', taskPromise: task2Promise}),
495+
renderSingleTask({title: 'Task 3', taskPromise: task3Promise}),
496+
])
497+
498+
// Then
499+
expect(result1).toBe('result1')
500+
expect(result2).toBe('result2')
501+
expect(result3).toBe('result3')
502+
})
503+
})

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {TextPrompt, TextPromptProps} from '../../private/node/ui/components/Text
3030
import {AutocompletePromptProps, AutocompletePrompt} from '../../private/node/ui/components/AutocompletePrompt.js'
3131
import {InfoTableSection} from '../../private/node/ui/components/Prompts/InfoTable.js'
3232
import {InfoMessageProps} from '../../private/node/ui/components/Prompts/InfoMessage.js'
33+
import {SingleTask} from '../../private/node/ui/components/SingleTask.js'
3334
import React from 'react'
3435
import {Key as InkKey, RenderOptions} from 'ink'
3536

@@ -482,6 +483,34 @@ export async function renderTasks<TContext>(tasks: Task<TContext>[], {renderOpti
482483
})
483484
}
484485

486+
/**
487+
* Awaits a single promise and displays a loading bar while it's in progress. The promise's result is returned.
488+
* @param options - Configuration object
489+
* @param options.title - The title to display with the loading bar
490+
* @param options.taskPromise - The promise to track
491+
* @param renderOptions - Optional render configuration
492+
* @returns The result of the promise
493+
* @example
494+
* ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
495+
* Loading app ...
496+
*/
497+
// eslint-disable-next-line max-params
498+
export async function renderSingleTask<T>(
499+
{title, taskPromise}: {title: string; taskPromise: Promise<T> | (() => Promise<T>)},
500+
{renderOptions}: RenderTasksOptions = {},
501+
) {
502+
const promise = typeof taskPromise === 'function' ? taskPromise() : taskPromise
503+
const [_renderResult, taskResult] = await Promise.all([
504+
render(<SingleTask title={title} taskPromise={promise} />, {
505+
...renderOptions,
506+
exitOnCtrlC: false,
507+
}),
508+
promise,
509+
])
510+
511+
return taskResult
512+
}
513+
485514
export interface RenderTextPromptOptions extends Omit<TextPromptProps, 'onSubmit'> {
486515
renderOptions?: RenderOptions
487516
}

0 commit comments

Comments
 (0)