Skip to content

Commit 850191c

Browse files
Merge pull request #6678 from Shopify/jtv/ctrl-c-shows-how-to-resume
Guide users to use `bulk status` when an operation is still running in the background
2 parents 3969017 + df935ac commit 850191c

File tree

6 files changed

+133
-31
lines changed

6 files changed

+133
-31
lines changed

packages/app/src/cli/services/bulk-operations/execute-bulk-operation.test.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {downloadBulkOperationResults} from './download-bulk-operation-results.js
66
import {BulkOperationRunQueryMutation} from '../../api/graphql/bulk-operations/generated/bulk-operation-run-query.js'
77
import {BulkOperationRunMutationMutation} from '../../api/graphql/bulk-operations/generated/bulk-operation-run-mutation.js'
88
import {OrganizationApp} from '../../models/organization.js'
9-
import {renderSuccess, renderWarning, renderError} from '@shopify/cli-kit/node/ui'
9+
import {renderSuccess, renderWarning, renderError, renderInfo} from '@shopify/cli-kit/node/ui'
1010
import {ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session'
1111
import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs'
1212
import {joinPath} from '@shopify/cli-kit/node/path'
@@ -355,14 +355,45 @@ describe('executeBulkOperation', () => {
355355
watch: true,
356356
})
357357

358-
expect(watchBulkOperation).toHaveBeenCalledWith(mockAdminSession, createdBulkOperation.id)
359358
expect(renderSuccess).toHaveBeenCalledWith(
360359
expect.objectContaining({
361360
headline: expect.stringContaining('Bulk operation succeeded:'),
362361
}),
363362
)
364363
})
365364

365+
test('renders help message in an info banner when watch is provided and user aborts', async () => {
366+
const query = '{ products { edges { node { id } } } }'
367+
const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
368+
bulkOperation: createdBulkOperation,
369+
userErrors: [],
370+
}
371+
const runningOperation = {
372+
...createdBulkOperation,
373+
status: 'RUNNING' as const,
374+
objectCount: '100',
375+
}
376+
377+
vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse)
378+
vi.mocked(watchBulkOperation).mockImplementation(async (_session, _id, signal, onAbort) => {
379+
onAbort()
380+
return runningOperation
381+
})
382+
383+
await executeBulkOperation({
384+
remoteApp: mockRemoteApp,
385+
storeFqdn,
386+
query,
387+
watch: true,
388+
})
389+
390+
expect(renderInfo).toHaveBeenCalledWith({
391+
headline: `Bulk operation ${createdBulkOperation.id} is still running in the background.`,
392+
body: ['Monitor its progress with:', {command: expect.stringContaining('shopify app bulk status')}],
393+
})
394+
expect(downloadBulkOperationResults).not.toHaveBeenCalled()
395+
})
396+
366397
test('writes results to file when --output-file flag is provided', async () => {
367398
const query = '{ products { edges { node { id } } } }'
368399
const outputFile = '/tmp/results.jsonl'
@@ -450,7 +481,6 @@ describe('executeBulkOperation', () => {
450481
watch: true,
451482
})
452483

453-
expect(watchBulkOperation).toHaveBeenCalledWith(mockAdminSession, createdBulkOperation.id)
454484
expect(renderError).toHaveBeenCalledWith(
455485
expect.objectContaining({
456486
headline: expect.any(String),

packages/app/src/cli/services/bulk-operations/execute-bulk-operation.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ import {watchBulkOperation, type BulkOperation} from './watch-bulk-operation.js'
44
import {formatBulkOperationStatus} from './format-bulk-operation-status.js'
55
import {downloadBulkOperationResults} from './download-bulk-operation-results.js'
66
import {OrganizationApp} from '../../models/organization.js'
7-
import {renderSuccess, renderInfo, renderError, renderWarning} from '@shopify/cli-kit/node/ui'
7+
import {renderSuccess, renderInfo, renderError, renderWarning, TokenItem} from '@shopify/cli-kit/node/ui'
88
import {outputContent, outputToken, outputResult} from '@shopify/cli-kit/node/output'
99
import {ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session'
1010
import {AbortError, BugError} from '@shopify/cli-kit/node/error'
11+
import {AbortController} from '@shopify/cli-kit/node/abort'
1112
import {parse} from 'graphql'
1213
import {readFile, writeFile, fileExists} from '@shopify/cli-kit/node/fs'
1314

@@ -76,8 +77,19 @@ export async function executeBulkOperation(input: ExecuteBulkOperationInput): Pr
7677
const createdOperation = bulkOperationResponse?.bulkOperation
7778
if (createdOperation) {
7879
if (watch) {
79-
const finishedOperation = await watchBulkOperation(adminSession, createdOperation.id)
80-
await renderBulkOperationResult(finishedOperation, outputFile)
80+
const abortController = new AbortController()
81+
const operation = await watchBulkOperation(adminSession, createdOperation.id, abortController.signal, () =>
82+
abortController.abort(),
83+
)
84+
85+
if (abortController.signal.aborted) {
86+
renderInfo({
87+
headline: `Bulk operation ${operation.id} is still running in the background.`,
88+
body: statusCommandHelpMessage(operation.id),
89+
})
90+
} else {
91+
await renderBulkOperationResult(operation, outputFile)
92+
}
8193
} else {
8294
await renderBulkOperationResult(createdOperation, outputFile)
8395
}
@@ -105,7 +117,11 @@ async function renderBulkOperationResult(operation: BulkOperation, outputFile?:
105117

106118
switch (operation.status) {
107119
case 'CREATED':
108-
renderSuccess({headline: 'Bulk operation started.', customSections})
120+
renderSuccess({
121+
headline: 'Bulk operation started.',
122+
body: statusCommandHelpMessage(operation.id),
123+
customSections,
124+
})
109125
break
110126
case 'COMPLETED':
111127
if (operation.url) {
@@ -147,6 +163,10 @@ function validateGraphQLDocument(graphqlOperation: string, variablesJsonl?: stri
147163
}
148164
}
149165

166+
function statusCommandHelpMessage(operationId: string): TokenItem {
167+
return ['Monitor its progress with:', {command: `shopify app bulk status --id="${operationId}}"`}]
168+
}
169+
150170
function isMutation(graphqlOperation: string): boolean {
151171
const document = parse(graphqlOperation)
152172
const operation = document.definitions.find((def) => def.kind === 'OperationDefinition')

packages/app/src/cli/services/bulk-operations/watch-bulk-operation.test.ts

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {sleep} from '@shopify/cli-kit/node/system'
55
import {renderSingleTask} from '@shopify/cli-kit/node/ui'
66
import {describe, test, expect, vi, beforeEach} from 'vitest'
77
import {outputContent} from '@shopify/cli-kit/node/output'
8+
import {AbortController} from '@shopify/cli-kit/node/abort'
89

910
vi.mock('./format-bulk-operation-status.js')
1011
vi.mock('@shopify/cli-kit/node/api/admin')
@@ -14,6 +15,7 @@ vi.mock('@shopify/cli-kit/node/ui')
1415
describe('watchBulkOperation', () => {
1516
const mockAdminSession = {token: 'test-token', storeFqdn: 'test.myshopify.com'}
1617
const operationId = 'gid://shopify/BulkOperation/123'
18+
let abortController: AbortController
1719

1820
const runningOperation = {
1921
id: operationId,
@@ -30,8 +32,13 @@ describe('watchBulkOperation', () => {
3032
}
3133

3234
beforeEach(() => {
35+
abortController = new AbortController()
3336
vi.mocked(sleep).mockResolvedValue()
3437
vi.mocked(formatBulkOperationStatus).mockReturnValue(outputContent`formatted status`)
38+
vi.mocked(renderSingleTask).mockImplementation(async ({task, onAbort}) => {
39+
if (onAbort) onAbort()
40+
return task(() => {})
41+
})
3542
})
3643

3744
test('polls until operation completes and returns the final operation', async () => {
@@ -40,11 +47,7 @@ describe('watchBulkOperation', () => {
4047
.mockResolvedValueOnce({bulkOperation: runningOperation})
4148
.mockResolvedValueOnce({bulkOperation: completedOperation})
4249

43-
vi.mocked(renderSingleTask).mockImplementation(async ({task}) => {
44-
return task(() => {})
45-
})
46-
47-
const result = await watchBulkOperation(mockAdminSession, operationId)
50+
const result = await watchBulkOperation(mockAdminSession, operationId, abortController.signal, () => {})
4851

4952
expect(result).toEqual(completedOperation)
5053
expect(adminRequestDoc).toHaveBeenCalledTimes(3)
@@ -65,11 +68,7 @@ describe('watchBulkOperation', () => {
6568
.mockResolvedValueOnce({bulkOperation: runningOperation})
6669
.mockResolvedValueOnce({bulkOperation: terminalOperation})
6770

68-
vi.mocked(renderSingleTask).mockImplementation(async ({task}) => {
69-
return task(() => {})
70-
})
71-
72-
const result = await watchBulkOperation(mockAdminSession, operationId)
71+
const result = await watchBulkOperation(mockAdminSession, operationId, abortController.signal, () => {})
7372

7473
expect(result).toEqual(terminalOperation)
7574
expect(adminRequestDoc).toHaveBeenCalledTimes(3)
@@ -97,7 +96,7 @@ describe('watchBulkOperation', () => {
9796
return task(mockUpdateStatus)
9897
})
9998

100-
await watchBulkOperation(mockAdminSession, operationId)
99+
await watchBulkOperation(mockAdminSession, operationId, abortController.signal, () => {})
101100

102101
expect(mockUpdateStatus).toHaveBeenNthCalledWith(1, outputContent`processed 10 objects`)
103102
expect(mockUpdateStatus).toHaveBeenNthCalledWith(2, outputContent`processed 20 objects`)
@@ -107,10 +106,34 @@ describe('watchBulkOperation', () => {
107106
test('throws when operation not found', async () => {
108107
vi.mocked(adminRequestDoc).mockResolvedValue({bulkOperation: null})
109108

110-
vi.mocked(renderSingleTask).mockImplementation(async ({task}) => {
111-
return task(() => {})
109+
await expect(watchBulkOperation(mockAdminSession, operationId, abortController.signal, () => {})).rejects.toThrow(
110+
'bulk operation not found',
111+
)
112+
})
113+
114+
describe('when signal is aborted during polling', () => {
115+
beforeEach(() => {
116+
let callCount = 0
117+
vi.mocked(adminRequestDoc).mockImplementation(async () => {
118+
callCount++
119+
if (callCount === 2) {
120+
abortController.abort()
121+
}
122+
return {bulkOperation: runningOperation}
123+
})
112124
})
113125

114-
await expect(watchBulkOperation(mockAdminSession, operationId)).rejects.toThrow('bulk operation not found')
126+
test('returns current state of the operation, even if it is not terminal', async () => {
127+
const result = await watchBulkOperation(mockAdminSession, operationId, abortController.signal, () => {})
128+
129+
expect(result.status).toBe('RUNNING')
130+
expect(result).toEqual(runningOperation)
131+
})
132+
133+
test('calls the onAbort callback', async () => {
134+
const onAbort = vi.fn()
135+
await watchBulkOperation(mockAdminSession, operationId, abortController.signal, onAbort)
136+
expect(onAbort).toHaveBeenCalled()
137+
})
115138
})
116139
})

packages/app/src/cli/services/bulk-operations/watch-bulk-operation.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,24 @@ import {sleep} from '@shopify/cli-kit/node/system'
88
import {AdminSession} from '@shopify/cli-kit/node/session'
99
import {outputContent} from '@shopify/cli-kit/node/output'
1010
import {renderSingleTask} from '@shopify/cli-kit/node/ui'
11+
import {AbortSignal} from '@shopify/cli-kit/node/abort'
1112

1213
const TERMINAL_STATUSES = ['COMPLETED', 'FAILED', 'CANCELED', 'EXPIRED']
1314
const POLL_INTERVAL_SECONDS = 5
1415
const API_VERSION = '2026-01'
1516

1617
export type BulkOperation = NonNullable<GetBulkOperationByIdQuery['bulkOperation']>
1718

18-
export async function watchBulkOperation(adminSession: AdminSession, operationId: string): Promise<BulkOperation> {
19+
export async function watchBulkOperation(
20+
adminSession: AdminSession,
21+
operationId: string,
22+
abortSignal: AbortSignal,
23+
onAbort: () => void,
24+
): Promise<BulkOperation> {
1925
return renderSingleTask<BulkOperation>({
2026
title: outputContent`Polling bulk operation...`,
2127
task: async (updateStatus) => {
22-
const poller = pollBulkOperation(adminSession, operationId)
28+
const poller = pollBulkOperation(adminSession, operationId, abortSignal)
2329

2430
while (true) {
2531
// eslint-disable-next-line no-await-in-loop
@@ -31,12 +37,14 @@ export async function watchBulkOperation(adminSession: AdminSession, operationId
3137
}
3238
}
3339
},
40+
onAbort,
3441
})
3542
}
3643

3744
async function* pollBulkOperation(
3845
adminSession: AdminSession,
3946
operationId: string,
47+
abortSignal: AbortSignal,
4048
): AsyncGenerator<BulkOperation, BulkOperation> {
4149
while (true) {
4250
// eslint-disable-next-line no-await-in-loop
@@ -48,14 +56,17 @@ async function* pollBulkOperation(
4856

4957
const latestOperationState = response.bulkOperation
5058

51-
if (TERMINAL_STATUSES.includes(latestOperationState.status)) {
59+
if (TERMINAL_STATUSES.includes(latestOperationState.status) || abortSignal.aborted) {
5260
return latestOperationState
5361
} else {
5462
yield latestOperationState
5563
}
5664

5765
// eslint-disable-next-line no-await-in-loop
58-
await sleep(POLL_INTERVAL_SECONDS)
66+
await Promise.race([
67+
sleep(POLL_INTERVAL_SECONDS),
68+
new Promise((resolve) => abortSignal.addEventListener('abort', resolve)),
69+
])
5970
}
6071
}
6172

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

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,33 @@
11
import {LoadingBar} from './LoadingBar.js'
2-
import {useExitOnCtrlC} from '../hooks/use-exit-on-ctrl-c.js'
2+
import {handleCtrlC} from '../../ui.js'
33
import {TokenizedString} from '../../../../public/node/output.js'
44
import React, {useEffect, useState} from 'react'
5-
import {useApp} from 'ink'
5+
import {useApp, useInput, useStdin} from 'ink'
66

77
interface SingleTaskProps<T> {
88
title: TokenizedString
99
task: (updateStatus: (status: TokenizedString) => void) => Promise<T>
1010
onComplete?: (result: T) => void
11+
onAbort?: () => void
1112
noColor?: boolean
1213
}
1314

14-
const SingleTask = <T,>({task, title, onComplete, noColor}: SingleTaskProps<T>) => {
15+
const SingleTask = <T,>({task, title, onComplete, onAbort, noColor}: SingleTaskProps<T>) => {
1516
const [status, setStatus] = useState(title)
1617
const [isDone, setIsDone] = useState(false)
1718
const {exit: unmountInk} = useApp()
18-
useExitOnCtrlC()
19+
const {isRawModeSupported} = useStdin()
20+
21+
useInput(
22+
(input, key) => {
23+
if (onAbort) {
24+
handleCtrlC(input, key, onAbort)
25+
} else {
26+
handleCtrlC(input, key)
27+
}
28+
},
29+
{isActive: Boolean(isRawModeSupported)},
30+
)
1931

2032
useEffect(() => {
2133
task(setStatus)

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,7 @@ export async function renderTasks<TContext>(
490490
export interface RenderSingleTaskOptions<T> {
491491
title: TokenizedString
492492
task: (updateStatus: (status: TokenizedString) => void) => Promise<T>
493+
onAbort?: () => void
493494
renderOptions?: RenderOptions
494495
}
495496

@@ -504,10 +505,15 @@ export interface RenderSingleTaskOptions<T> {
504505
* ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
505506
* Loading app ...
506507
*/
507-
export async function renderSingleTask<T>({title, task, renderOptions}: RenderSingleTaskOptions<T>): Promise<T> {
508+
export async function renderSingleTask<T>({
509+
title,
510+
task,
511+
onAbort,
512+
renderOptions,
513+
}: RenderSingleTaskOptions<T>): Promise<T> {
508514
// eslint-disable-next-line max-params
509515
return new Promise<T>((resolve, reject) => {
510-
render(<SingleTask title={title} task={task} onComplete={resolve} />, {
516+
render(<SingleTask title={title} task={task} onComplete={resolve} onAbort={onAbort} />, {
511517
...renderOptions,
512518
exitOnCtrlC: false,
513519
}).catch(reject)

0 commit comments

Comments
 (0)