Skip to content

Commit cf26356

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

File tree

4 files changed

+353
-10
lines changed

4 files changed

+353
-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 {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,
@@ -346,7 +347,9 @@ describe('executeBulkOperation', () => {
346347

347348
vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse)
348349
vi.mocked(watchBulkOperation).mockResolvedValue(completedOperation)
349-
vi.mocked(downloadBulkOperationResults).mockResolvedValue('{"id":"gid://shopify/Product/123"}')
350+
vi.mocked(downloadBulkOperationResults).mockResolvedValue(
351+
'{"data":{"products":{"edges":[{"node":{"id":"gid://shopify/Product/123"}}],"userErrors":[]}},"__lineNumber":0}',
352+
)
350353

351354
await executeBulkOperation({
352355
remoteApp: mockRemoteApp,
@@ -355,6 +358,13 @@ describe('executeBulkOperation', () => {
355358
watch: true,
356359
})
357360

361+
expect(watchBulkOperation).toHaveBeenCalledWith(
362+
mockAdminSession,
363+
createdBulkOperation.id,
364+
expect.any(Object),
365+
expect.any(Function),
366+
)
367+
expect(quickWatchBulkOperation).not.toHaveBeenCalled()
358368
expect(renderSuccess).toHaveBeenCalledWith(
359369
expect.objectContaining({
360370
headline: expect.stringContaining('Bulk operation succeeded:'),
@@ -394,10 +404,62 @@ describe('executeBulkOperation', () => {
394404
expect(downloadBulkOperationResults).not.toHaveBeenCalled()
395405
})
396406

407+
test('uses quickWatchBulkOperation (not watchBulkOperation) when watch flag is false', async () => {
408+
const query = '{ products { edges { node { id } } } }'
409+
const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
410+
bulkOperation: createdBulkOperation,
411+
userErrors: [],
412+
}
413+
414+
vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse)
415+
vi.mocked(quickWatchBulkOperation).mockResolvedValue(createdBulkOperation)
416+
417+
await executeBulkOperation({
418+
remoteApp: mockRemoteApp,
419+
storeFqdn,
420+
query,
421+
watch: false,
422+
})
423+
424+
expect(quickWatchBulkOperation).toHaveBeenCalledWith(mockAdminSession, createdBulkOperation.id)
425+
expect(watchBulkOperation).not.toHaveBeenCalled()
426+
})
427+
428+
test('renders info message when quickWatchBulkOperation returns RUNNING status', async () => {
429+
const query = '{ products { edges { node { id } } } }'
430+
const runningOperation = {
431+
...createdBulkOperation,
432+
status: 'RUNNING' as const,
433+
objectCount: '50',
434+
}
435+
const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
436+
bulkOperation: createdBulkOperation,
437+
userErrors: [],
438+
}
439+
440+
vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse)
441+
vi.mocked(quickWatchBulkOperation).mockResolvedValue(runningOperation)
442+
443+
await executeBulkOperation({
444+
remoteApp: mockRemoteApp,
445+
storeFqdn,
446+
query,
447+
watch: false,
448+
})
449+
450+
expect(renderSuccess).toHaveBeenCalledWith(
451+
expect.objectContaining({
452+
headline: 'Bulk operation is running.',
453+
body: ['Monitor its progress with:', {command: expect.stringContaining('shopify app bulk status')}],
454+
}),
455+
)
456+
})
457+
397458
test('writes results to file when --output-file flag is provided', async () => {
398459
const query = '{ products { edges { node { id } } } }'
399460
const outputFile = '/tmp/results.jsonl'
400-
const resultsContent = '{"id":"gid://shopify/Product/123"}\n{"id":"gid://shopify/Product/456"}'
461+
const resultsContent =
462+
'{"data":{"productCreate":{"product":{"id":"gid://shopify/Product/123"},"userErrors":[]}},"__lineNumber":0}\n{"data":{"productCreate":{"product":{"id":"gid://shopify/Product/456"},"userErrors":[]}},"__lineNumber":1}'
401463

402464
const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
403465
bulkOperation: createdBulkOperation,
@@ -427,7 +489,8 @@ describe('executeBulkOperation', () => {
427489

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

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

513576
expect(renderSuccess).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: 41 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({
@@ -123,16 +124,38 @@ async function renderBulkOperationResult(operation: BulkOperation, outputFile?:
123124
customSections,
124125
})
125126
break
127+
case 'RUNNING':
128+
renderSuccess({
129+
headline: 'Bulk operation is running.',
130+
body: statusCommandHelpMessage(operation.id),
131+
customSections,
132+
})
133+
break
126134
case 'COMPLETED':
127135
if (operation.url) {
128136
const results = await downloadBulkOperationResults(operation.url)
137+
const hasUserErrors = resultsContainUserErrors(results)
129138

130139
if (outputFile) {
131140
await writeFile(outputFile, results)
132-
renderSuccess({headline, body: [`Results written to ${outputFile}`], customSections})
133141
} else {
134142
outputResult(results)
135-
renderSuccess({headline, customSections})
143+
}
144+
145+
if (hasUserErrors) {
146+
renderWarning({
147+
headline: 'Bulk operation completed with errors.',
148+
body: outputFile
149+
? `Results written to ${outputFile}. Check file for error details.`
150+
: 'Check results for error details.',
151+
customSections,
152+
})
153+
} else {
154+
renderSuccess({
155+
headline,
156+
body: outputFile ? [`Results written to ${outputFile}`] : undefined,
157+
customSections,
158+
})
136159
}
137160
} else {
138161
renderSuccess({headline, customSections})
@@ -144,6 +167,20 @@ async function renderBulkOperationResult(operation: BulkOperation, outputFile?:
144167
}
145168
}
146169

170+
function resultsContainUserErrors(results: string): boolean {
171+
const lines = results.trim().split('\n')
172+
173+
return lines.some((line) => {
174+
try {
175+
const parsed = JSON.parse(line)
176+
const operationResult = Object.values(parsed.data)[0] as any
177+
return Array.isArray(operationResult?.userErrors) && operationResult.userErrors.length > 0
178+
} catch {
179+
return true
180+
}
181+
})
182+
}
183+
147184
function validateGraphQLDocument(graphqlOperation: string, variablesJsonl?: string): void {
148185
const document = parse(graphqlOperation)
149186
const operationDefinitions = document.definitions.filter((def) => def.kind === 'OperationDefinition')

0 commit comments

Comments
 (0)