Skip to content

Commit 283cb69

Browse files
introduce watchBulkOperation function to poll the bulk operation and print live progress updates
1 parent 650aa9a commit 283cb69

File tree

2 files changed

+185
-0
lines changed

2 files changed

+185
-0
lines changed
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import {watchBulkOperation} from './watch-bulk-operation.js'
2+
import {formatBulkOperationStatus} from './format-bulk-operation-status.js'
3+
import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin'
4+
import {sleep} from '@shopify/cli-kit/node/system'
5+
import {renderSingleTask} from '@shopify/cli-kit/node/ui'
6+
import {describe, test, expect, vi, beforeEach} from 'vitest'
7+
import {outputContent} from '@shopify/cli-kit/node/output'
8+
9+
vi.mock('./format-bulk-operation-status.js')
10+
vi.mock('@shopify/cli-kit/node/api/admin')
11+
vi.mock('@shopify/cli-kit/node/system')
12+
vi.mock('@shopify/cli-kit/node/ui')
13+
14+
describe('watchBulkOperation', () => {
15+
const mockAdminSession = {token: 'test-token', storeFqdn: 'test.myshopify.com'}
16+
const operationId = 'gid://shopify/BulkOperation/123'
17+
18+
const runningOperation = {
19+
id: operationId,
20+
status: 'RUNNING',
21+
objectCount: '50',
22+
url: null,
23+
}
24+
25+
const completedOperation = {
26+
id: operationId,
27+
status: 'COMPLETED',
28+
objectCount: '100',
29+
url: 'https://example.com/download',
30+
}
31+
32+
beforeEach(() => {
33+
vi.mocked(sleep).mockResolvedValue()
34+
vi.mocked(formatBulkOperationStatus).mockReturnValue(outputContent`formatted status`)
35+
})
36+
37+
test('polls until operation completes and returns the final operation', async () => {
38+
vi.mocked(adminRequestDoc)
39+
.mockResolvedValueOnce({bulkOperation: runningOperation})
40+
.mockResolvedValueOnce({bulkOperation: runningOperation})
41+
.mockResolvedValueOnce({bulkOperation: completedOperation})
42+
43+
vi.mocked(renderSingleTask).mockImplementation(async ({task}) => {
44+
return task(() => {})
45+
})
46+
47+
const result = await watchBulkOperation(mockAdminSession, operationId)
48+
49+
expect(result).toEqual(completedOperation)
50+
expect(adminRequestDoc).toHaveBeenCalledTimes(3)
51+
})
52+
53+
test.each(['FAILED', 'CANCELED', 'EXPIRED'])(
54+
'stops polling and returns when operation status is %s',
55+
async (status) => {
56+
const terminalOperation = {
57+
id: operationId,
58+
status,
59+
objectCount: '25',
60+
url: null,
61+
}
62+
63+
vi.mocked(adminRequestDoc)
64+
.mockResolvedValueOnce({bulkOperation: runningOperation})
65+
.mockResolvedValueOnce({bulkOperation: runningOperation})
66+
.mockResolvedValueOnce({bulkOperation: terminalOperation})
67+
68+
vi.mocked(renderSingleTask).mockImplementation(async ({task}) => {
69+
return task(() => {})
70+
})
71+
72+
const result = await watchBulkOperation(mockAdminSession, operationId)
73+
74+
expect(result).toEqual(terminalOperation)
75+
expect(adminRequestDoc).toHaveBeenCalledTimes(3)
76+
},
77+
)
78+
79+
test('updates the UI with latest operation status as polling progresses', async () => {
80+
const runningOperation1 = {...runningOperation, objectCount: '10'}
81+
const runningOperation2 = {...runningOperation, objectCount: '20'}
82+
const runningOperation3 = {...runningOperation, objectCount: '30'}
83+
84+
vi.mocked(formatBulkOperationStatus)
85+
.mockReturnValueOnce(outputContent`processed 10 objects`)
86+
.mockReturnValueOnce(outputContent`processed 20 objects`)
87+
.mockReturnValueOnce(outputContent`processed 30 objects`)
88+
89+
vi.mocked(adminRequestDoc)
90+
.mockResolvedValueOnce({bulkOperation: runningOperation1})
91+
.mockResolvedValueOnce({bulkOperation: runningOperation2})
92+
.mockResolvedValueOnce({bulkOperation: runningOperation3})
93+
.mockResolvedValueOnce({bulkOperation: completedOperation})
94+
95+
const mockUpdateStatus = vi.fn()
96+
vi.mocked(renderSingleTask).mockImplementation(async ({task}) => {
97+
return task(mockUpdateStatus)
98+
})
99+
100+
await watchBulkOperation(mockAdminSession, operationId)
101+
102+
expect(mockUpdateStatus).toHaveBeenNthCalledWith(1, outputContent`processed 10 objects`)
103+
expect(mockUpdateStatus).toHaveBeenNthCalledWith(2, outputContent`processed 20 objects`)
104+
expect(mockUpdateStatus).toHaveBeenNthCalledWith(3, outputContent`processed 30 objects`)
105+
})
106+
107+
test('throws when operation not found', async () => {
108+
vi.mocked(adminRequestDoc).mockResolvedValue({bulkOperation: null})
109+
110+
vi.mocked(renderSingleTask).mockImplementation(async ({task}) => {
111+
return task(() => {})
112+
})
113+
114+
await expect(watchBulkOperation(mockAdminSession, operationId)).rejects.toThrow('bulk operation not found')
115+
})
116+
})
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import {formatBulkOperationStatus} from './format-bulk-operation-status.js'
2+
import {
3+
GetBulkOperationById,
4+
GetBulkOperationByIdQuery,
5+
} from '../../api/graphql/bulk-operations/generated/get-bulk-operation-by-id.js'
6+
import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin'
7+
import {sleep} from '@shopify/cli-kit/node/system'
8+
import {AdminSession} from '@shopify/cli-kit/node/session'
9+
import {outputContent} from '@shopify/cli-kit/node/output'
10+
import {renderSingleTask} from '@shopify/cli-kit/node/ui'
11+
12+
const TERMINAL_STATUSES = ['COMPLETED', 'FAILED', 'CANCELED', 'EXPIRED']
13+
const POLL_INTERVAL_SECONDS = 5
14+
const API_VERSION = '2026-01'
15+
16+
export type BulkOperation = NonNullable<GetBulkOperationByIdQuery['bulkOperation']>
17+
18+
export async function watchBulkOperation(adminSession: AdminSession, operationId: string): Promise<BulkOperation> {
19+
return renderSingleTask<BulkOperation>({
20+
title: outputContent`Polling bulk operation...`,
21+
task: async (updateStatus) => {
22+
const poller = pollBulkOperation(adminSession, operationId)
23+
24+
while (true) {
25+
// eslint-disable-next-line no-await-in-loop
26+
const {value: latestOperationState, done} = await poller.next()
27+
if (done) {
28+
return latestOperationState
29+
} else {
30+
updateStatus(formatBulkOperationStatus(latestOperationState))
31+
}
32+
}
33+
},
34+
})
35+
}
36+
37+
async function* pollBulkOperation(
38+
adminSession: AdminSession,
39+
operationId: string,
40+
): AsyncGenerator<BulkOperation, BulkOperation> {
41+
while (true) {
42+
// eslint-disable-next-line no-await-in-loop
43+
const response = await fetchBulkOperation(adminSession, operationId)
44+
45+
if (!response.bulkOperation) {
46+
throw new Error('bulk operation not found')
47+
}
48+
49+
const latestOperationState = response.bulkOperation
50+
51+
if (TERMINAL_STATUSES.includes(latestOperationState.status)) {
52+
return latestOperationState
53+
} else {
54+
yield latestOperationState
55+
}
56+
57+
// eslint-disable-next-line no-await-in-loop
58+
await sleep(POLL_INTERVAL_SECONDS)
59+
}
60+
}
61+
62+
async function fetchBulkOperation(adminSession: AdminSession, operationId: string): Promise<GetBulkOperationByIdQuery> {
63+
return adminRequestDoc<GetBulkOperationByIdQuery, {id: string}>({
64+
query: GetBulkOperationById,
65+
session: adminSession,
66+
variables: {id: operationId},
67+
version: API_VERSION,
68+
})
69+
}

0 commit comments

Comments
 (0)