Skip to content

Commit 65302ac

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

4 files changed

Lines changed: 363 additions & 18 deletions

File tree

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:', {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 {
@@ -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)