Skip to content

Commit c59e0b5

Browse files
committed
quick-watch-for-cli
1 parent 06d8de3 commit c59e0b5

File tree

4 files changed

+358
-18
lines changed

4 files changed

+358
-18
lines changed

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

Lines changed: 174 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,62 @@ 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+
remoteApp: mockRemoteApp,
395+
storeFqdn,
396+
query,
397+
watch: false,
398+
})
399+
400+
expect(shortBulkOperationPoll).toHaveBeenCalledWith(mockAdminSession, createdBulkOperation.id)
401+
expect(watchBulkOperation).not.toHaveBeenCalled()
402+
})
403+
404+
test('renders info message when quickWatchBulkOperation returns RUNNING status', async () => {
405+
const query = '{ products { edges { node { id } } } }'
406+
const runningOperation = {
407+
...createdBulkOperation,
408+
status: 'RUNNING' as const,
409+
objectCount: '50',
410+
}
411+
const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
412+
bulkOperation: createdBulkOperation,
413+
userErrors: [],
414+
}
415+
416+
vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse)
417+
vi.mocked(shortBulkOperationPoll).mockResolvedValue(runningOperation)
418+
419+
await executeBulkOperation({
420+
remoteApp: mockRemoteApp,
421+
storeFqdn,
422+
query,
423+
watch: false,
424+
})
425+
426+
expect(renderSuccess).toHaveBeenCalledWith(
427+
expect.objectContaining({
428+
headline: 'Bulk operation is running.',
429+
body: ['Monitor its progress with:', {command: expect.stringContaining('shopify app bulk status')}],
430+
}),
431+
)
432+
})
433+
373434
test('writes results to file when --output-file flag is provided', async () => {
374435
const query = '{ products { edges { node { id } } } }'
375436
const outputFile = '/tmp/results.jsonl'
376-
const resultsContent = '{"id":"gid://shopify/Product/123"}\n{"id":"gid://shopify/Product/456"}'
437+
const resultsContent =
438+
'{"data":{"productCreate":{"product":{"id":"gid://shopify/Product/123"},"userErrors":[]}},"__lineNumber":0}\n{"data":{"productCreate":{"product":{"id":"gid://shopify/Product/456"},"userErrors":[]}},"__lineNumber":1}'
377439

378440
const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
379441
bulkOperation: createdBulkOperation,
@@ -404,7 +466,8 @@ describe('executeBulkOperation', () => {
404466

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

409472
const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
410473
bulkOperation: createdBulkOperation,
@@ -537,4 +600,110 @@ describe('executeBulkOperation', () => {
537600

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

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 {
@@ -103,7 +103,8 @@ export async function executeBulkOperation(input: ExecuteBulkOperationInput): Pr
103103
await renderBulkOperationResult(operation, outputFile)
104104
}
105105
} else {
106-
await renderBulkOperationResult(createdOperation, outputFile)
106+
const operation = await shortBulkOperationPoll(adminSession, createdOperation.id)
107+
await renderBulkOperationResult(operation, outputFile)
107108
}
108109
} else {
109110
renderWarning({
@@ -135,17 +136,39 @@ async function renderBulkOperationResult(operation: BulkOperation, outputFile?:
135136
customSections,
136137
})
137138
break
139+
case 'RUNNING':
140+
renderSuccess({
141+
headline: 'Bulk operation is running.',
142+
body: statusCommandHelpMessage(operation.id),
143+
customSections,
144+
})
145+
break
138146
case 'COMPLETED':
139147
if (operation.url) {
140148
const results = await downloadBulkOperationResults(operation.url)
149+
const hasUserErrors = resultsContainUserErrors(results)
141150

142151
if (outputFile) {
143152
await writeFile(outputFile, results)
144-
renderSuccess({headline, body: [`Results written to ${outputFile}`], customSections})
145153
} else {
146-
renderSuccess({headline, customSections})
147154
outputResult(results)
148155
}
156+
157+
if (hasUserErrors) {
158+
renderWarning({
159+
headline: 'Bulk operation completed with errors.',
160+
body: outputFile
161+
? `Results written to ${outputFile}. Check file for error details.`
162+
: 'Check results for error details.',
163+
customSections,
164+
})
165+
} else {
166+
renderSuccess({
167+
headline,
168+
body: outputFile ? [`Results written to ${outputFile}`] : undefined,
169+
customSections,
170+
})
171+
}
149172
} else {
150173
renderSuccess({headline, customSections})
151174
}
@@ -156,6 +179,17 @@ async function renderBulkOperationResult(operation: BulkOperation, outputFile?:
156179
}
157180
}
158181

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

0 commit comments

Comments
 (0)