Skip to content

Commit 8f8b496

Browse files
authored
Merge pull request #6687 from Shopify/12-04-cli-quick-watch-feature
Quick-watch: Catch early bulk operation errors without `--watch`
2 parents 1e65e20 + 910f6b0 commit 8f8b496

File tree

4 files changed

+399
-22
lines changed

4 files changed

+399
-22
lines changed

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

Lines changed: 179 additions & 5 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, shortBulkOperationPoll} from './watch-bulk-operation.js'
55
import {downloadBulkOperationResults} from './download-bulk-operation-results.js'
66
import {validateApiVersion} from '../graphql/common.js'
77
import {BulkOperationRunQueryMutation} from '../../api/graphql/bulk-operations/generated/bulk-operation-run-query.js'
@@ -67,6 +67,7 @@ describe('executeBulkOperation', () => {
6767

6868
beforeEach(() => {
6969
vi.mocked(ensureAuthenticatedAdminAsApp).mockResolvedValue(mockAdminSession)
70+
vi.mocked(shortBulkOperationPoll).mockResolvedValue(createdBulkOperation)
7071
})
7172

7273
afterEach(() => {
@@ -305,7 +306,7 @@ describe('executeBulkOperation', () => {
305306
})
306307
})
307308

308-
test('waits for operation to finish and renders success when watch is provided and operation finishes with COMPLETED status', async () => {
309+
test('uses watchBulkOperation (not quickWatchBulkOperation) when watch flag is true', async () => {
309310
const query = '{ products { edges { node { id } } } }'
310311
const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
311312
bulkOperation: createdBulkOperation,
@@ -320,7 +321,9 @@ describe('executeBulkOperation', () => {
320321

321322
vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse)
322323
vi.mocked(watchBulkOperation).mockResolvedValue(completedOperation)
323-
vi.mocked(downloadBulkOperationResults).mockResolvedValue('{"id":"gid://shopify/Product/123"}')
324+
vi.mocked(downloadBulkOperationResults).mockResolvedValue(
325+
'{"data":{"products":{"edges":[{"node":{"id":"gid://shopify/Product/123"}}],"userErrors":[]}},"__lineNumber":0}',
326+
)
324327

325328
await executeBulkOperation({
326329
organization: mockOrganization,
@@ -330,6 +333,13 @@ describe('executeBulkOperation', () => {
330333
watch: true,
331334
})
332335

336+
expect(watchBulkOperation).toHaveBeenCalledWith(
337+
mockAdminSession,
338+
createdBulkOperation.id,
339+
expect.any(Object),
340+
expect.any(Function),
341+
)
342+
expect(shortBulkOperationPoll).not.toHaveBeenCalled()
333343
expect(renderSuccess).toHaveBeenCalledWith(
334344
expect.objectContaining({
335345
headline: expect.stringContaining('Bulk operation succeeded:'),
@@ -370,10 +380,64 @@ describe('executeBulkOperation', () => {
370380
expect(downloadBulkOperationResults).not.toHaveBeenCalled()
371381
})
372382

383+
test('uses quickWatchBulkOperation (not watchBulkOperation) when watch flag is false', async () => {
384+
const query = '{ products { edges { node { id } } } }'
385+
const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
386+
bulkOperation: createdBulkOperation,
387+
userErrors: [],
388+
}
389+
390+
vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse)
391+
vi.mocked(shortBulkOperationPoll).mockResolvedValue(createdBulkOperation)
392+
393+
await executeBulkOperation({
394+
organization: mockOrganization,
395+
remoteApp: mockRemoteApp,
396+
storeFqdn,
397+
query,
398+
watch: false,
399+
})
400+
401+
expect(shortBulkOperationPoll).toHaveBeenCalledWith(mockAdminSession, createdBulkOperation.id)
402+
expect(watchBulkOperation).not.toHaveBeenCalled()
403+
})
404+
405+
test('renders info message when quickWatchBulkOperation returns RUNNING status', async () => {
406+
const query = '{ products { edges { node { id } } } }'
407+
const runningOperation = {
408+
...createdBulkOperation,
409+
status: 'RUNNING' as const,
410+
objectCount: '50',
411+
}
412+
const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
413+
bulkOperation: createdBulkOperation,
414+
userErrors: [],
415+
}
416+
417+
vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse)
418+
vi.mocked(shortBulkOperationPoll).mockResolvedValue(runningOperation)
419+
420+
await executeBulkOperation({
421+
organization: mockOrganization,
422+
remoteApp: mockRemoteApp,
423+
storeFqdn,
424+
query,
425+
watch: false,
426+
})
427+
428+
expect(renderSuccess).toHaveBeenCalledWith(
429+
expect.objectContaining({
430+
headline: 'Bulk operation is running.',
431+
body: ['Monitor its progress with:\n', {command: expect.stringContaining('shopify app bulk status')}],
432+
}),
433+
)
434+
})
435+
373436
test('writes results to file when --output-file flag is provided', async () => {
374437
const query = '{ products { edges { node { id } } } }'
375438
const outputFile = '/tmp/results.jsonl'
376-
const resultsContent = '{"id":"gid://shopify/Product/123"}\n{"id":"gid://shopify/Product/456"}'
439+
const resultsContent =
440+
'{"data":{"productCreate":{"product":{"id":"gid://shopify/Product/123"},"userErrors":[]}},"__lineNumber":0}\n{"data":{"productCreate":{"product":{"id":"gid://shopify/Product/456"},"userErrors":[]}},"__lineNumber":1}'
377441

378442
const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
379443
bulkOperation: createdBulkOperation,
@@ -404,7 +468,8 @@ describe('executeBulkOperation', () => {
404468

405469
test('writes results to stdout when --output-file flag is not provided', async () => {
406470
const query = '{ products { edges { node { id } } } }'
407-
const resultsContent = '{"id":"gid://shopify/Product/123"}\n{"id":"gid://shopify/Product/456"}'
471+
const resultsContent =
472+
'{"data":{"productCreate":{"product":{"id":"gid://shopify/Product/123"},"userErrors":[]}},"__lineNumber":0}\n{"data":{"productCreate":{"product":{"id":"gid://shopify/Product/456"},"userErrors":[]}},"__lineNumber":1}'
408473

409474
const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
410475
bulkOperation: createdBulkOperation,
@@ -537,4 +602,113 @@ describe('executeBulkOperation', () => {
537602

538603
expect(validateApiVersion).not.toHaveBeenCalled()
539604
})
605+
606+
test('renders warning when completed operation results contain userErrors', async () => {
607+
const query = '{ products { edges { node { id } } } }'
608+
const resultsWithErrors = '{"data":{"productUpdate":{"userErrors":[{"message":"invalid input"}]}},"__lineNumber":0}'
609+
610+
const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
611+
bulkOperation: createdBulkOperation,
612+
userErrors: [],
613+
}
614+
const completedOperation = {
615+
...createdBulkOperation,
616+
status: 'COMPLETED' as const,
617+
url: 'https://example.com/download',
618+
objectCount: '1',
619+
}
620+
621+
vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse)
622+
vi.mocked(watchBulkOperation).mockResolvedValue(completedOperation)
623+
vi.mocked(downloadBulkOperationResults).mockResolvedValue(resultsWithErrors)
624+
625+
await executeBulkOperation({
626+
organization: mockOrganization,
627+
remoteApp: mockRemoteApp,
628+
storeFqdn,
629+
query,
630+
watch: true,
631+
})
632+
633+
expect(renderWarning).toHaveBeenCalledWith(
634+
expect.objectContaining({
635+
headline: 'Bulk operation completed with errors.',
636+
body: 'Check results for error details.',
637+
}),
638+
)
639+
expect(renderSuccess).not.toHaveBeenCalled()
640+
})
641+
642+
test('renders success when completed operation results have no userErrors', async () => {
643+
const query = '{ products { edges { node { id } } } }'
644+
const resultsWithoutErrors = '{"data":{"productUpdate":{"product":{"id":"123"},"userErrors":[]}},"__lineNumber":0}'
645+
646+
const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
647+
bulkOperation: createdBulkOperation,
648+
userErrors: [],
649+
}
650+
const completedOperation = {
651+
...createdBulkOperation,
652+
status: 'COMPLETED' as const,
653+
url: 'https://example.com/download',
654+
objectCount: '1',
655+
}
656+
657+
vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse)
658+
vi.mocked(watchBulkOperation).mockResolvedValue(completedOperation)
659+
vi.mocked(downloadBulkOperationResults).mockResolvedValue(resultsWithoutErrors)
660+
661+
await executeBulkOperation({
662+
organization: mockOrganization,
663+
remoteApp: mockRemoteApp,
664+
storeFqdn,
665+
query,
666+
watch: true,
667+
})
668+
669+
expect(renderSuccess).toHaveBeenCalledWith(
670+
expect.objectContaining({
671+
headline: expect.stringContaining('Bulk operation succeeded'),
672+
}),
673+
)
674+
expect(renderWarning).not.toHaveBeenCalled()
675+
})
676+
677+
test('renders warning when results written to file contain userErrors', async () => {
678+
const query = '{ products { edges { node { id } } } }'
679+
const outputFile = '/tmp/results.jsonl'
680+
const resultsWithErrors = '{"data":{"productUpdate":{"userErrors":[{"message":"invalid input"}]}},"__lineNumber":0}'
681+
682+
const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
683+
bulkOperation: createdBulkOperation,
684+
userErrors: [],
685+
}
686+
const completedOperation = {
687+
...createdBulkOperation,
688+
status: 'COMPLETED' as const,
689+
url: 'https://example.com/download',
690+
objectCount: '1',
691+
}
692+
693+
vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse)
694+
vi.mocked(watchBulkOperation).mockResolvedValue(completedOperation)
695+
vi.mocked(downloadBulkOperationResults).mockResolvedValue(resultsWithErrors)
696+
697+
await executeBulkOperation({
698+
organization: mockOrganization,
699+
remoteApp: mockRemoteApp,
700+
storeFqdn,
701+
query,
702+
watch: true,
703+
outputFile,
704+
})
705+
706+
expect(writeFile).toHaveBeenCalledWith(outputFile, resultsWithErrors)
707+
expect(renderWarning).toHaveBeenCalledWith(
708+
expect.objectContaining({
709+
headline: 'Bulk operation completed with errors.',
710+
body: `Results written to ${outputFile}. Check file for error details.`,
711+
}),
712+
)
713+
})
540714
})

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

Lines changed: 38 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, shortBulkOperationPoll, 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 {extractBulkOperationId} from './bulk-operation-status.js'
@@ -104,7 +104,8 @@ export async function executeBulkOperation(input: ExecuteBulkOperationInput): Pr
104104
await renderBulkOperationResult(operation, outputFile)
105105
}
106106
} else {
107-
await renderBulkOperationResult(createdOperation, outputFile)
107+
const operation = await shortBulkOperationPoll(adminSession, createdOperation.id)
108+
await renderBulkOperationResult(operation, outputFile)
108109
}
109110
} else {
110111
renderWarning({
@@ -136,17 +137,39 @@ async function renderBulkOperationResult(operation: BulkOperation, outputFile?:
136137
customSections,
137138
})
138139
break
140+
case 'RUNNING':
141+
renderSuccess({
142+
headline: 'Bulk operation is running.',
143+
body: statusCommandHelpMessage(operation.id),
144+
customSections,
145+
})
146+
break
139147
case 'COMPLETED':
140148
if (operation.url) {
141149
const results = await downloadBulkOperationResults(operation.url)
150+
const hasUserErrors = resultsContainUserErrors(results)
142151

143152
if (outputFile) {
144153
await writeFile(outputFile, results)
145-
renderSuccess({headline, body: [`Results written to ${outputFile}`], customSections})
146154
} else {
147-
renderSuccess({headline, customSections})
148155
outputResult(results)
149156
}
157+
158+
if (hasUserErrors) {
159+
renderWarning({
160+
headline: 'Bulk operation completed with errors.',
161+
body: outputFile
162+
? `Results written to ${outputFile}. Check file for error details.`
163+
: 'Check results for error details.',
164+
customSections,
165+
})
166+
} else {
167+
renderSuccess({
168+
headline,
169+
body: outputFile ? [`Results written to ${outputFile}`] : undefined,
170+
customSections,
171+
})
172+
}
150173
} else {
151174
renderSuccess({headline, customSections})
152175
}
@@ -157,6 +180,17 @@ async function renderBulkOperationResult(operation: BulkOperation, outputFile?:
157180
}
158181
}
159182

183+
function resultsContainUserErrors(results: string): boolean {
184+
const lines = results.trim().split('\n')
185+
186+
return lines.some((line) => {
187+
const parsed = JSON.parse(line)
188+
if (!parsed.data) return false
189+
const result = Object.values(parsed.data)[0] as {userErrors?: unknown[]} | undefined
190+
return result?.userErrors !== undefined && result.userErrors.length > 0
191+
})
192+
}
193+
160194
function validateGraphQLDocument(graphqlOperation: string, variablesJsonl?: string): void {
161195
validateSingleOperation(graphqlOperation)
162196

0 commit comments

Comments
 (0)