Skip to content

Commit fa74da9

Browse files
committed
quick-watch-for-cli
1 parent 23c136c commit fa74da9

File tree

4 files changed

+357
-10
lines changed

4 files changed

+357
-10
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, quickWatchBulkOperation} 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'
@@ -60,6 +60,7 @@ describe('executeBulkOperation', () => {
6060

6161
beforeEach(() => {
6262
vi.mocked(ensureAuthenticatedAdminAsApp).mockResolvedValue(mockAdminSession)
63+
vi.mocked(quickWatchBulkOperation).mockResolvedValue(createdBulkOperation)
6364
})
6465

6566
afterEach(() => {
@@ -288,7 +289,7 @@ describe('executeBulkOperation', () => {
288289
})
289290
})
290291

291-
test('waits for operation to finish and renders success when watch is provided and operation finishes with COMPLETED status', async () => {
292+
test('uses watchBulkOperation (not quickWatchBulkOperation) when watch flag is true', async () => {
292293
const query = '{ products { edges { node { id } } } }'
293294
const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
294295
bulkOperation: createdBulkOperation,
@@ -303,7 +304,9 @@ describe('executeBulkOperation', () => {
303304

304305
vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse)
305306
vi.mocked(watchBulkOperation).mockResolvedValue(completedOperation)
306-
vi.mocked(downloadBulkOperationResults).mockResolvedValue('{"id":"gid://shopify/Product/123"}')
307+
vi.mocked(downloadBulkOperationResults).mockResolvedValue(
308+
'{"data":{"products":{"edges":[{"node":{"id":"gid://shopify/Product/123"}}],"userErrors":[]}},"__lineNumber":0}',
309+
)
307310

308311
await executeBulkOperation({
309312
remoteApp: mockRemoteApp,
@@ -312,6 +315,13 @@ describe('executeBulkOperation', () => {
312315
watch: true,
313316
})
314317

318+
expect(watchBulkOperation).toHaveBeenCalledWith(
319+
mockAdminSession,
320+
createdBulkOperation.id,
321+
expect.any(Object),
322+
expect.any(Function),
323+
)
324+
expect(quickWatchBulkOperation).not.toHaveBeenCalled()
315325
expect(renderSuccess).toHaveBeenCalledWith(
316326
expect.objectContaining({
317327
headline: expect.stringContaining('Bulk operation succeeded:'),
@@ -351,10 +361,62 @@ describe('executeBulkOperation', () => {
351361
expect(downloadBulkOperationResults).not.toHaveBeenCalled()
352362
})
353363

364+
test('uses quickWatchBulkOperation (not watchBulkOperation) when watch flag is false', async () => {
365+
const query = '{ products { edges { node { id } } } }'
366+
const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
367+
bulkOperation: createdBulkOperation,
368+
userErrors: [],
369+
}
370+
371+
vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse)
372+
vi.mocked(quickWatchBulkOperation).mockResolvedValue(createdBulkOperation)
373+
374+
await executeBulkOperation({
375+
remoteApp: mockRemoteApp,
376+
storeFqdn,
377+
query,
378+
watch: false,
379+
})
380+
381+
expect(quickWatchBulkOperation).toHaveBeenCalledWith(mockAdminSession, createdBulkOperation.id)
382+
expect(watchBulkOperation).not.toHaveBeenCalled()
383+
})
384+
385+
test('renders info message when quickWatchBulkOperation returns RUNNING status', async () => {
386+
const query = '{ products { edges { node { id } } } }'
387+
const runningOperation = {
388+
...createdBulkOperation,
389+
status: 'RUNNING' as const,
390+
objectCount: '50',
391+
}
392+
const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
393+
bulkOperation: createdBulkOperation,
394+
userErrors: [],
395+
}
396+
397+
vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse)
398+
vi.mocked(quickWatchBulkOperation).mockResolvedValue(runningOperation)
399+
400+
await executeBulkOperation({
401+
remoteApp: mockRemoteApp,
402+
storeFqdn,
403+
query,
404+
watch: false,
405+
})
406+
407+
expect(renderSuccess).toHaveBeenCalledWith(
408+
expect.objectContaining({
409+
headline: 'Bulk operation is running.',
410+
body: ['Monitor its progress with:', {command: expect.stringContaining('shopify app bulk status')}],
411+
}),
412+
)
413+
})
414+
354415
test('writes results to file when --output-file flag is provided', async () => {
355416
const query = '{ products { edges { node { id } } } }'
356417
const outputFile = '/tmp/results.jsonl'
357-
const resultsContent = '{"id":"gid://shopify/Product/123"}\n{"id":"gid://shopify/Product/456"}'
418+
const resultsContent =
419+
'{"data":{"productCreate":{"product":{"id":"gid://shopify/Product/123"},"userErrors":[]}},"__lineNumber":0}\n{"data":{"productCreate":{"product":{"id":"gid://shopify/Product/456"},"userErrors":[]}},"__lineNumber":1}'
358420

359421
const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
360422
bulkOperation: createdBulkOperation,
@@ -384,7 +446,8 @@ describe('executeBulkOperation', () => {
384446

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

389452
const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
390453
bulkOperation: createdBulkOperation,
@@ -512,4 +575,110 @@ describe('executeBulkOperation', () => {
512575

513576
expect(validateApiVersion).not.toHaveBeenCalled()
514577
})
578+
579+
test('renders warning when completed operation results contain userErrors', async () => {
580+
const query = '{ products { edges { node { id } } } }'
581+
const resultsWithErrors = '{"data":{"productUpdate":{"userErrors":[{"message":"invalid input"}]}},"__lineNumber":0}'
582+
583+
const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
584+
bulkOperation: createdBulkOperation,
585+
userErrors: [],
586+
}
587+
const completedOperation = {
588+
...createdBulkOperation,
589+
status: 'COMPLETED' as const,
590+
url: 'https://example.com/download',
591+
objectCount: '1',
592+
}
593+
594+
vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse)
595+
vi.mocked(watchBulkOperation).mockResolvedValue(completedOperation)
596+
vi.mocked(downloadBulkOperationResults).mockResolvedValue(resultsWithErrors)
597+
598+
await executeBulkOperation({
599+
remoteApp: mockRemoteApp,
600+
storeFqdn,
601+
query,
602+
watch: true,
603+
})
604+
605+
expect(renderWarning).toHaveBeenCalledWith(
606+
expect.objectContaining({
607+
headline: 'Bulk operation completed with errors.',
608+
body: 'Check results for error details.',
609+
}),
610+
)
611+
expect(renderSuccess).not.toHaveBeenCalled()
612+
})
613+
614+
test('renders success when completed operation results have no userErrors', async () => {
615+
const query = '{ products { edges { node { id } } } }'
616+
const resultsWithoutErrors = '{"data":{"productUpdate":{"product":{"id":"123"},"userErrors":[]}},"__lineNumber":0}'
617+
618+
const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
619+
bulkOperation: createdBulkOperation,
620+
userErrors: [],
621+
}
622+
const completedOperation = {
623+
...createdBulkOperation,
624+
status: 'COMPLETED' as const,
625+
url: 'https://example.com/download',
626+
objectCount: '1',
627+
}
628+
629+
vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse)
630+
vi.mocked(watchBulkOperation).mockResolvedValue(completedOperation)
631+
vi.mocked(downloadBulkOperationResults).mockResolvedValue(resultsWithoutErrors)
632+
633+
await executeBulkOperation({
634+
remoteApp: mockRemoteApp,
635+
storeFqdn,
636+
query,
637+
watch: true,
638+
})
639+
640+
expect(renderSuccess).toHaveBeenCalledWith(
641+
expect.objectContaining({
642+
headline: expect.stringContaining('Bulk operation succeeded'),
643+
}),
644+
)
645+
expect(renderWarning).not.toHaveBeenCalled()
646+
})
647+
648+
test('renders warning when results written to file contain userErrors', async () => {
649+
const query = '{ products { edges { node { id } } } }'
650+
const outputFile = '/tmp/results.jsonl'
651+
const resultsWithErrors = '{"data":{"productUpdate":{"userErrors":[{"message":"invalid input"}]}},"__lineNumber":0}'
652+
653+
const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
654+
bulkOperation: createdBulkOperation,
655+
userErrors: [],
656+
}
657+
const completedOperation = {
658+
...createdBulkOperation,
659+
status: 'COMPLETED' as const,
660+
url: 'https://example.com/download',
661+
objectCount: '1',
662+
}
663+
664+
vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse)
665+
vi.mocked(watchBulkOperation).mockResolvedValue(completedOperation)
666+
vi.mocked(downloadBulkOperationResults).mockResolvedValue(resultsWithErrors)
667+
668+
await executeBulkOperation({
669+
remoteApp: mockRemoteApp,
670+
storeFqdn,
671+
query,
672+
watch: true,
673+
outputFile,
674+
})
675+
676+
expect(writeFile).toHaveBeenCalledWith(outputFile, resultsWithErrors)
677+
expect(renderWarning).toHaveBeenCalledWith(
678+
expect.objectContaining({
679+
headline: 'Bulk operation completed with errors.',
680+
body: `Results written to ${outputFile}. Check file for error details.`,
681+
}),
682+
)
683+
})
515684
})

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

Lines changed: 43 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 {createAdminSessionAsApp, validateSingleOperation, validateApiVersion} from '../graphql/common.js'
@@ -101,7 +101,8 @@ export async function executeBulkOperation(input: ExecuteBulkOperationInput): Pr
101101
await renderBulkOperationResult(operation, outputFile)
102102
}
103103
} else {
104-
await renderBulkOperationResult(createdOperation, outputFile)
104+
const operation = await quickWatchBulkOperation(adminSession, createdOperation.id)
105+
await renderBulkOperationResult(operation, outputFile)
105106
}
106107
} else {
107108
renderWarning({
@@ -133,17 +134,39 @@ async function renderBulkOperationResult(operation: BulkOperation, outputFile?:
133134
customSections,
134135
})
135136
break
137+
case 'RUNNING':
138+
renderSuccess({
139+
headline: 'Bulk operation is running.',
140+
body: statusCommandHelpMessage(operation.id),
141+
customSections,
142+
})
143+
break
136144
case 'COMPLETED':
137145
if (operation.url) {
138146
const results = await downloadBulkOperationResults(operation.url)
147+
const hasUserErrors = resultsContainUserErrors(results)
139148

140149
if (outputFile) {
141150
await writeFile(outputFile, results)
142-
renderSuccess({headline, body: [`Results written to ${outputFile}`], customSections})
143151
} else {
144-
renderSuccess({headline, customSections})
145152
outputResult(results)
146153
}
154+
155+
if (hasUserErrors) {
156+
renderWarning({
157+
headline: 'Bulk operation completed with errors.',
158+
body: outputFile
159+
? `Results written to ${outputFile}. Check file for error details.`
160+
: 'Check results for error details.',
161+
customSections,
162+
})
163+
} else {
164+
renderSuccess({
165+
headline,
166+
body: outputFile ? [`Results written to ${outputFile}`] : undefined,
167+
customSections,
168+
})
169+
}
147170
} else {
148171
renderSuccess({headline, customSections})
149172
}
@@ -154,6 +177,22 @@ async function renderBulkOperationResult(operation: BulkOperation, outputFile?:
154177
}
155178
}
156179

180+
function resultsContainUserErrors(results: string): boolean {
181+
const lines = results.trim().split('\n')
182+
183+
return lines.some((line) => {
184+
try {
185+
const parsed = JSON.parse(line)
186+
if (!parsed.data) return false
187+
188+
const result = Object.values(parsed.data)[0] as any
189+
return result?.userErrors?.length > 0
190+
} catch {
191+
return true
192+
}
193+
})
194+
}
195+
157196
function validateGraphQLDocument(graphqlOperation: string, variablesJsonl?: string): void {
158197
validateSingleOperation(graphqlOperation)
159198

0 commit comments

Comments
 (0)