Skip to content

Commit fe2d0bb

Browse files
committed
quick-watch-for-cli
1 parent dec24f5 commit fe2d0bb

File tree

4 files changed

+299
-7
lines changed

4 files changed

+299
-7
lines changed

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

Lines changed: 137 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {executeBulkOperation} from './execute-bulk-operation.js'
22
import {runBulkOperationQuery} from './run-query.js'
33
import {runBulkOperationMutation} from './run-mutation.js'
4-
import {watchBulkOperation} from './watch-bulk-operation.js'
4+
import {watchBulkOperation, quickWatchBulkOperation} from './watch-bulk-operation.js'
55
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'
@@ -52,6 +52,7 @@ describe('executeBulkOperation', () => {
5252

5353
beforeEach(() => {
5454
vi.mocked(ensureAuthenticatedAdminAsApp).mockResolvedValue(mockAdminSession)
55+
vi.mocked(quickWatchBulkOperation).mockResolvedValue(createdBulkOperation)
5556
})
5657

5758
afterEach(() => {
@@ -331,7 +332,7 @@ describe('executeBulkOperation', () => {
331332
})
332333
})
333334

334-
test('waits for operation to finish and renders success when watch is provided and operation finishes with COMPLETED status', async () => {
335+
test('uses watchBulkOperation (not quickWatchBulkOperation) when watch flag is true', async () => {
335336
const query = '{ products { edges { node { id } } } }'
336337
const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
337338
bulkOperation: createdBulkOperation,
@@ -355,6 +356,13 @@ describe('executeBulkOperation', () => {
355356
watch: true,
356357
})
357358

359+
expect(watchBulkOperation).toHaveBeenCalledWith(
360+
mockAdminSession,
361+
createdBulkOperation.id,
362+
expect.any(Object),
363+
expect.any(Function),
364+
)
365+
expect(quickWatchBulkOperation).not.toHaveBeenCalled()
358366
expect(renderSuccess).toHaveBeenCalledWith(
359367
expect.objectContaining({
360368
headline: expect.stringContaining('Bulk operation succeeded:'),
@@ -394,6 +402,27 @@ describe('executeBulkOperation', () => {
394402
expect(downloadBulkOperationResults).not.toHaveBeenCalled()
395403
})
396404

405+
test('uses quickWatchBulkOperation (not watchBulkOperation) when watch flag is false', async () => {
406+
const query = '{ products { edges { node { id } } } }'
407+
const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
408+
bulkOperation: createdBulkOperation,
409+
userErrors: [],
410+
}
411+
412+
vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse)
413+
vi.mocked(quickWatchBulkOperation).mockResolvedValue(createdBulkOperation)
414+
415+
await executeBulkOperation({
416+
remoteApp: mockRemoteApp,
417+
storeFqdn,
418+
query,
419+
watch: false,
420+
})
421+
422+
expect(quickWatchBulkOperation).toHaveBeenCalledWith(mockAdminSession, createdBulkOperation.id)
423+
expect(watchBulkOperation).not.toHaveBeenCalled()
424+
})
425+
397426
test('writes results to file when --output-file flag is provided', async () => {
398427
const query = '{ products { edges { node { id } } } }'
399428
const outputFile = '/tmp/results.jsonl'
@@ -512,4 +541,110 @@ describe('executeBulkOperation', () => {
512541

513542
expect(renderSuccess).not.toHaveBeenCalled()
514543
})
544+
545+
test('renders warning when completed operation results contain userErrors', async () => {
546+
const query = '{ products { edges { node { id } } } }'
547+
const resultsWithErrors = '{"data":{"productUpdate":{"userErrors":[{"message":"invalid input"}]}},"__lineNumber":0}'
548+
549+
const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
550+
bulkOperation: createdBulkOperation,
551+
userErrors: [],
552+
}
553+
const completedOperation = {
554+
...createdBulkOperation,
555+
status: 'COMPLETED' as const,
556+
url: 'https://example.com/download',
557+
objectCount: '1',
558+
}
559+
560+
vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse)
561+
vi.mocked(watchBulkOperation).mockResolvedValue(completedOperation)
562+
vi.mocked(downloadBulkOperationResults).mockResolvedValue(resultsWithErrors)
563+
564+
await executeBulkOperation({
565+
remoteApp: mockRemoteApp,
566+
storeFqdn,
567+
query,
568+
watch: true,
569+
})
570+
571+
expect(renderWarning).toHaveBeenCalledWith(
572+
expect.objectContaining({
573+
headline: 'Bulk operation completed with errors.',
574+
body: 'Check results for error details.',
575+
}),
576+
)
577+
expect(renderSuccess).not.toHaveBeenCalled()
578+
})
579+
580+
test('renders success when completed operation results have no userErrors', async () => {
581+
const query = '{ products { edges { node { id } } } }'
582+
const resultsWithoutErrors = '{"data":{"productUpdate":{"product":{"id":"123"},"userErrors":[]}},"__lineNumber":0}'
583+
584+
const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
585+
bulkOperation: createdBulkOperation,
586+
userErrors: [],
587+
}
588+
const completedOperation = {
589+
...createdBulkOperation,
590+
status: 'COMPLETED' as const,
591+
url: 'https://example.com/download',
592+
objectCount: '1',
593+
}
594+
595+
vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse)
596+
vi.mocked(watchBulkOperation).mockResolvedValue(completedOperation)
597+
vi.mocked(downloadBulkOperationResults).mockResolvedValue(resultsWithoutErrors)
598+
599+
await executeBulkOperation({
600+
remoteApp: mockRemoteApp,
601+
storeFqdn,
602+
query,
603+
watch: true,
604+
})
605+
606+
expect(renderSuccess).toHaveBeenCalledWith(
607+
expect.objectContaining({
608+
headline: expect.stringContaining('Bulk operation succeeded'),
609+
}),
610+
)
611+
expect(renderWarning).not.toHaveBeenCalled()
612+
})
613+
614+
test('renders warning when results written to file contain userErrors', async () => {
615+
const query = '{ products { edges { node { id } } } }'
616+
const outputFile = '/tmp/results.jsonl'
617+
const resultsWithErrors = '{"data":{"productUpdate":{"userErrors":[{"message":"invalid input"}]}},"__lineNumber":0}'
618+
619+
const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
620+
bulkOperation: createdBulkOperation,
621+
userErrors: [],
622+
}
623+
const completedOperation = {
624+
...createdBulkOperation,
625+
status: 'COMPLETED' as const,
626+
url: 'https://example.com/download',
627+
objectCount: '1',
628+
}
629+
630+
vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse)
631+
vi.mocked(watchBulkOperation).mockResolvedValue(completedOperation)
632+
vi.mocked(downloadBulkOperationResults).mockResolvedValue(resultsWithErrors)
633+
634+
await executeBulkOperation({
635+
remoteApp: mockRemoteApp,
636+
storeFqdn,
637+
query,
638+
watch: true,
639+
outputFile,
640+
})
641+
642+
expect(writeFile).toHaveBeenCalledWith(outputFile, resultsWithErrors)
643+
expect(renderWarning).toHaveBeenCalledWith(
644+
expect.objectContaining({
645+
headline: 'Bulk operation completed with errors.',
646+
body: `Results written to ${outputFile}. Check file for error details.`,
647+
}),
648+
)
649+
})
515650
})

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
@@ -1,6 +1,6 @@
11
import {runBulkOperationQuery} from './run-query.js'
22
import {runBulkOperationMutation} from './run-mutation.js'
3-
import {watchBulkOperation, type BulkOperation} from './watch-bulk-operation.js'
3+
import {watchBulkOperation, quickWatchBulkOperation, 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'
@@ -91,7 +91,8 @@ export async function executeBulkOperation(input: ExecuteBulkOperationInput): Pr
9191
await renderBulkOperationResult(operation, outputFile)
9292
}
9393
} else {
94-
await renderBulkOperationResult(createdOperation, outputFile)
94+
const operation = await quickWatchBulkOperation(adminSession, createdOperation.id)
95+
await renderBulkOperationResult(operation, outputFile)
9596
}
9697
} else {
9798
renderWarning({
@@ -126,13 +127,28 @@ async function renderBulkOperationResult(operation: BulkOperation, outputFile?:
126127
case 'COMPLETED':
127128
if (operation.url) {
128129
const results = await downloadBulkOperationResults(operation.url)
130+
const hasUserErrors = resultsContainUserErrors(results)
129131

130132
if (outputFile) {
131133
await writeFile(outputFile, results)
132-
renderSuccess({headline, body: [`Results written to ${outputFile}`], customSections})
133134
} else {
134135
outputResult(results)
135-
renderSuccess({headline, customSections})
136+
}
137+
138+
if (hasUserErrors) {
139+
renderWarning({
140+
headline: 'Bulk operation completed with errors.',
141+
body: outputFile
142+
? `Results written to ${outputFile}. Check file for error details.`
143+
: 'Check results for error details.',
144+
customSections,
145+
})
146+
} else {
147+
renderSuccess({
148+
headline,
149+
body: outputFile ? [`Results written to ${outputFile}`] : undefined,
150+
customSections,
151+
})
136152
}
137153
} else {
138154
renderSuccess({headline, customSections})
@@ -144,6 +160,10 @@ async function renderBulkOperationResult(operation: BulkOperation, outputFile?:
144160
}
145161
}
146162

163+
function resultsContainUserErrors(results: string): boolean {
164+
return results.includes('"userErrors":[{')
165+
}
166+
147167
function validateGraphQLDocument(graphqlOperation: string, variablesJsonl?: string): void {
148168
const document = parse(graphqlOperation)
149169
const operationDefinitions = document.definitions.filter((def) => def.kind === 'OperationDefinition')

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

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import {watchBulkOperation} from './watch-bulk-operation.js'
1+
import {
2+
watchBulkOperation,
3+
quickWatchBulkOperation,
4+
QUICK_WATCH_POLL_INTERVAL_MS,
5+
QUICK_WATCH_TIMEOUT_MS,
6+
} from './watch-bulk-operation.js'
27
import {formatBulkOperationStatus} from './format-bulk-operation-status.js'
38
import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin'
49
import {sleep} from '@shopify/cli-kit/node/system'
@@ -137,3 +142,110 @@ describe('watchBulkOperation', () => {
137142
})
138143
})
139144
})
145+
146+
describe('quickWatchBulkOperation', () => {
147+
const mockAdminSession = {token: 'test-token', storeFqdn: 'test.myshopify.com'}
148+
const operationId = 'gid://shopify/BulkOperation/123'
149+
150+
const createdOperation = {
151+
id: operationId,
152+
status: 'CREATED',
153+
objectCount: '0',
154+
url: null,
155+
}
156+
157+
const runningOperation = {
158+
id: operationId,
159+
status: 'RUNNING',
160+
objectCount: '50',
161+
url: null,
162+
}
163+
164+
const completedOperation = {
165+
id: operationId,
166+
status: 'COMPLETED',
167+
objectCount: '100',
168+
url: 'https://example.com/download',
169+
}
170+
171+
const failedOperation = {
172+
id: operationId,
173+
status: 'FAILED',
174+
objectCount: '25',
175+
url: null,
176+
errorCode: 'INTERNAL_SERVER_ERROR',
177+
}
178+
179+
beforeEach(() => {
180+
vi.mocked(sleep).mockResolvedValue()
181+
})
182+
183+
test('returns immediately when operation is already completed', async () => {
184+
vi.mocked(adminRequestDoc).mockResolvedValue({bulkOperation: completedOperation})
185+
186+
const result = await quickWatchBulkOperation(mockAdminSession, operationId)
187+
188+
expect(result).toEqual(completedOperation)
189+
expect(adminRequestDoc).toHaveBeenCalledTimes(1)
190+
})
191+
192+
test('returns immediately when operation has failed', async () => {
193+
vi.mocked(adminRequestDoc).mockResolvedValue({bulkOperation: failedOperation})
194+
195+
const result = await quickWatchBulkOperation(mockAdminSession, operationId)
196+
197+
expect(result).toEqual(failedOperation)
198+
expect(adminRequestDoc).toHaveBeenCalledTimes(1)
199+
})
200+
201+
test.each(['FAILED', 'CANCELED', 'EXPIRED'])(
202+
'returns when operation reaches %s status within timeout',
203+
async (status) => {
204+
const terminalOperation = {
205+
id: operationId,
206+
status,
207+
objectCount: '25',
208+
url: null,
209+
}
210+
211+
vi.mocked(adminRequestDoc)
212+
.mockResolvedValueOnce({bulkOperation: runningOperation})
213+
.mockResolvedValueOnce({bulkOperation: terminalOperation})
214+
215+
const result = await quickWatchBulkOperation(mockAdminSession, operationId)
216+
217+
expect(result).toEqual(terminalOperation)
218+
},
219+
)
220+
221+
test('polls multiple times before returning terminal status', async () => {
222+
vi.mocked(adminRequestDoc)
223+
.mockResolvedValueOnce({bulkOperation: createdOperation})
224+
.mockResolvedValueOnce({bulkOperation: runningOperation})
225+
.mockResolvedValueOnce({bulkOperation: completedOperation})
226+
227+
const result = await quickWatchBulkOperation(mockAdminSession, operationId)
228+
229+
expect(result).toEqual(completedOperation)
230+
expect(adminRequestDoc).toHaveBeenCalledTimes(3)
231+
expect(sleep).toHaveBeenCalledWith(QUICK_WATCH_POLL_INTERVAL_MS / 1000)
232+
})
233+
234+
test('returns latest state when timeout is reached without terminal status', async () => {
235+
const originalDateNow = Date.now
236+
let mockTime = 0
237+
vi.spyOn(Date, 'now').mockImplementation(() => {
238+
const currentTime = mockTime
239+
mockTime += QUICK_WATCH_TIMEOUT_MS + 1
240+
return currentTime
241+
})
242+
243+
vi.mocked(adminRequestDoc).mockResolvedValue({bulkOperation: runningOperation})
244+
245+
const result = await quickWatchBulkOperation(mockAdminSession, operationId)
246+
247+
expect(result.status).toBe('RUNNING')
248+
249+
vi.spyOn(Date, 'now').mockImplementation(originalDateNow)
250+
})
251+
})

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,33 @@ const TERMINAL_STATUSES = ['COMPLETED', 'FAILED', 'CANCELED', 'EXPIRED']
1414
const POLL_INTERVAL_SECONDS = 5
1515
const API_VERSION = '2026-01'
1616

17+
export const QUICK_WATCH_TIMEOUT_MS = 3000
18+
export const QUICK_WATCH_POLL_INTERVAL_MS = 300
19+
1720
export type BulkOperation = NonNullable<GetBulkOperationByIdQuery['bulkOperation']>
1821

22+
export async function quickWatchBulkOperation(adminSession: AdminSession, operationId: string): Promise<BulkOperation> {
23+
const startTime = Date.now()
24+
25+
while (Date.now() - startTime < QUICK_WATCH_TIMEOUT_MS) {
26+
// eslint-disable-next-line no-await-in-loop
27+
const {bulkOperation} = await fetchBulkOperation(adminSession, operationId)
28+
29+
if (bulkOperation && TERMINAL_STATUSES.includes(bulkOperation.status)) {
30+
return bulkOperation
31+
}
32+
33+
// eslint-disable-next-line no-await-in-loop
34+
await sleep(QUICK_WATCH_POLL_INTERVAL_MS / 1000)
35+
}
36+
37+
const {bulkOperation} = await fetchBulkOperation(adminSession, operationId)
38+
if (!bulkOperation) {
39+
throw new Error('bulk operation not found')
40+
}
41+
return bulkOperation
42+
}
43+
1944
export async function watchBulkOperation(
2045
adminSession: AdminSession,
2146
operationId: string,

0 commit comments

Comments
 (0)