Skip to content

Commit d3ffc48

Browse files
use our new watchBulkOperation rendering when --watch is provided
1 parent 9895d2f commit d3ffc48

File tree

5 files changed

+161
-53
lines changed

5 files changed

+161
-53
lines changed

packages/app/src/cli/commands/app/execute.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export default class Execute extends AppLinkedCommand {
4040
query: flags.query,
4141
variables: flags.variables,
4242
variableFile: flags['variable-file'],
43+
watch: flags.watch,
4344
})
4445

4546
return {app: appContextResult.app}

packages/app/src/cli/flags.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,4 +63,9 @@ export const bulkOperationFlags = {
6363
env: 'SHOPIFY_FLAG_STORE',
6464
parse: async (input) => normalizeStoreFqdn(input),
6565
}),
66+
watch: Flags.boolean({
67+
description: 'Wait for bulk operation results before exiting.',
68+
env: 'SHOPIFY_FLAG_WATCH',
69+
default: false,
70+
}),
6671
}

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

Lines changed: 104 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
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'
45
import {AppLinkedInterface} from '../../models/app/app.js'
5-
import {renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui'
6+
import {BulkOperationRunQueryMutation} from '../../api/graphql/bulk-operations/generated/bulk-operation-run-query.js'
7+
import {BulkOperationRunMutationMutation} from '../../api/graphql/bulk-operations/generated/bulk-operation-run-mutation.js'
8+
import {renderSuccess, renderWarning, renderError} from '@shopify/cli-kit/node/ui'
69
import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session'
710
import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs'
811
import {joinPath} from '@shopify/cli-kit/node/path'
912
import {describe, test, expect, vi, beforeEach} from 'vitest'
1013

1114
vi.mock('./run-query.js')
1215
vi.mock('./run-mutation.js')
16+
vi.mock('./watch-bulk-operation.js')
1317
vi.mock('@shopify/cli-kit/node/ui')
1418
vi.mock('@shopify/cli-kit/node/session')
1519

@@ -21,14 +25,21 @@ describe('executeBulkOperation', () => {
2125
const storeFqdn = 'test-store.myshopify.com'
2226
const mockAdminSession = {token: 'test-token', storeFqdn}
2327

24-
const successfulBulkOperation = {
28+
const createdBulkOperation: NonNullable<
29+
NonNullable<BulkOperationRunQueryMutation['bulkOperationRunQuery']>['bulkOperation']
30+
> = {
2531
id: 'gid://shopify/BulkOperation/123',
2632
status: 'CREATED',
2733
errorCode: null,
2834
createdAt: '2024-01-01T00:00:00Z',
2935
objectCount: '0',
3036
fileSize: '0',
3137
url: null,
38+
query: '{ products { edges { node { id } } } }',
39+
rootObjectCount: '0',
40+
type: 'QUERY',
41+
completedAt: null,
42+
partialDataUrl: null,
3243
}
3344

3445
beforeEach(() => {
@@ -37,11 +48,11 @@ describe('executeBulkOperation', () => {
3748

3849
test('runs query operation when GraphQL document starts with query', async () => {
3950
const query = 'query { products { edges { node { id } } } }'
40-
const mockResponse = {
41-
bulkOperation: successfulBulkOperation,
51+
const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
52+
bulkOperation: createdBulkOperation,
4253
userErrors: [],
4354
}
44-
vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse as any)
55+
vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse)
4556

4657
await executeBulkOperation({
4758
app: mockApp,
@@ -58,11 +69,11 @@ describe('executeBulkOperation', () => {
5869

5970
test('runs query operation when GraphQL document starts with curly brace', async () => {
6071
const query = '{ products { edges { node { id } } } }'
61-
const mockResponse = {
62-
bulkOperation: successfulBulkOperation,
72+
const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
73+
bulkOperation: createdBulkOperation,
6374
userErrors: [],
6475
}
65-
vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse as any)
76+
vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse)
6677

6778
await executeBulkOperation({
6879
app: mockApp,
@@ -79,11 +90,11 @@ describe('executeBulkOperation', () => {
7990

8091
test('runs mutation operation when GraphQL document starts with mutation', async () => {
8192
const mutation = 'mutation productUpdate($input: ProductInput!) { productUpdate(input: $input) { product { id } } }'
82-
const mockResponse = {
83-
bulkOperation: successfulBulkOperation,
93+
const mockResponse: BulkOperationRunMutationMutation['bulkOperationRunMutation'] = {
94+
bulkOperation: createdBulkOperation,
8495
userErrors: [],
8596
}
86-
vi.mocked(runBulkOperationMutation).mockResolvedValue(mockResponse as any)
97+
vi.mocked(runBulkOperationMutation).mockResolvedValue(mockResponse)
8798

8899
await executeBulkOperation({
89100
app: mockApp,
@@ -102,11 +113,11 @@ describe('executeBulkOperation', () => {
102113
test('passes variables parameter to runBulkOperationMutation when variables are provided', async () => {
103114
const mutation = 'mutation productUpdate($input: ProductInput!) { productUpdate(input: $input) { product { id } } }'
104115
const variables = ['{"input":{"id":"gid://shopify/Product/123","tags":["test"]}}']
105-
const mockResponse = {
106-
bulkOperation: successfulBulkOperation,
116+
const mockResponse: BulkOperationRunMutationMutation['bulkOperationRunMutation'] = {
117+
bulkOperation: createdBulkOperation,
107118
userErrors: [],
108119
}
109-
vi.mocked(runBulkOperationMutation).mockResolvedValue(mockResponse as any)
120+
vi.mocked(runBulkOperationMutation).mockResolvedValue(mockResponse)
110121

111122
await executeBulkOperation({
112123
app: mockApp,
@@ -124,33 +135,34 @@ describe('executeBulkOperation', () => {
124135

125136
test('renders success message when bulk operation returns without user errors', async () => {
126137
const query = '{ products { edges { node { id } } } }'
127-
const mockResponse = {
128-
bulkOperation: successfulBulkOperation,
138+
const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
139+
bulkOperation: createdBulkOperation,
129140
userErrors: [],
130141
}
131-
vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse as any)
142+
vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse)
132143
await executeBulkOperation({
133144
app: mockApp,
134145
storeFqdn,
135146
query,
136147
})
137148

138-
expect(renderSuccess).toHaveBeenCalledWith({
139-
headline: 'Bulk operation started successfully!',
140-
body: 'Congrats!',
141-
})
149+
expect(renderSuccess).toHaveBeenCalledWith(
150+
expect.objectContaining({
151+
headline: 'Bulk operation started.',
152+
}),
153+
)
142154
})
143155

144156
test('renders warning with formatted field errors when bulk operation returns user errors', async () => {
145157
const query = '{ products { edges { node { id } } } }'
146-
const mockResponse = {
158+
const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
147159
bulkOperation: null,
148160
userErrors: [
149-
{field: ['query'], message: 'Invalid query syntax'},
150-
{field: null, message: 'Another error'},
161+
{field: ['query'], message: 'Invalid query syntax', code: null},
162+
{field: null, message: 'Another error', code: null},
151163
],
152164
}
153-
vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse as any)
165+
vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse)
154166

155167
await executeBulkOperation({
156168
app: mockApp,
@@ -229,7 +241,7 @@ describe('executeBulkOperation', () => {
229241
const mutation =
230242
'mutation productUpdate($input: ProductInput!) { productUpdate(input: $input) { product { id } } }'
231243
const mockResponse = {
232-
bulkOperation: successfulBulkOperation,
244+
bulkOperation: createdBulkOperation,
233245
userErrors: [],
234246
}
235247
vi.mocked(runBulkOperationMutation).mockResolvedValue(mockResponse as any)
@@ -306,4 +318,70 @@ describe('executeBulkOperation', () => {
306318
expect(runBulkOperationMutation).not.toHaveBeenCalled()
307319
})
308320
})
321+
322+
test('waits for operation to finish and renders success when watch is provided and operation finishes with COMPLETED status', async () => {
323+
const query = '{ products { edges { node { id } } } }'
324+
const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
325+
bulkOperation: createdBulkOperation,
326+
userErrors: [],
327+
}
328+
const completedOperation = {
329+
...createdBulkOperation,
330+
status: 'COMPLETED' as const,
331+
url: 'https://example.com/download',
332+
objectCount: '650',
333+
}
334+
335+
vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse)
336+
vi.mocked(watchBulkOperation).mockResolvedValue(completedOperation)
337+
338+
await executeBulkOperation({
339+
app: mockApp,
340+
storeFqdn,
341+
query,
342+
watch: true,
343+
})
344+
345+
expect(watchBulkOperation).toHaveBeenCalledWith(mockAdminSession, createdBulkOperation.id)
346+
expect(renderSuccess).toHaveBeenCalledWith(
347+
expect.objectContaining({
348+
headline: expect.stringContaining('Bulk operation succeeded.'),
349+
body: expect.arrayContaining([expect.stringContaining('https://example.com/download')]),
350+
}),
351+
)
352+
})
353+
354+
test.each(['FAILED', 'CANCELED', 'EXPIRED'] as const)(
355+
'waits for operation to finish and renders error when watch is provided and operation finishes with %s status',
356+
async (status) => {
357+
const query = '{ products { edges { node { id } } } }'
358+
const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
359+
bulkOperation: createdBulkOperation,
360+
userErrors: [],
361+
}
362+
const finishedOperation = {
363+
...createdBulkOperation,
364+
status,
365+
objectCount: '100',
366+
}
367+
368+
vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse)
369+
vi.mocked(watchBulkOperation).mockResolvedValue(finishedOperation)
370+
371+
await executeBulkOperation({
372+
app: mockApp,
373+
storeFqdn,
374+
query,
375+
watch: true,
376+
})
377+
378+
expect(watchBulkOperation).toHaveBeenCalledWith(mockAdminSession, createdBulkOperation.id)
379+
expect(renderError).toHaveBeenCalledWith(
380+
expect.objectContaining({
381+
headline: expect.any(String),
382+
customSections: expect.any(Array),
383+
}),
384+
)
385+
},
386+
)
309387
})

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

Lines changed: 44 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import {runBulkOperationQuery} from './run-query.js'
22
import {runBulkOperationMutation} from './run-mutation.js'
3+
import {watchBulkOperation, type BulkOperation} from './watch-bulk-operation.js'
4+
import {formatBulkOperationStatus} from './format-bulk-operation-status.js'
35
import {AppLinkedInterface} from '../../models/app/app.js'
4-
import {renderSuccess, renderInfo, renderWarning} from '@shopify/cli-kit/node/ui'
6+
import {renderSuccess, renderInfo, renderError, renderWarning} from '@shopify/cli-kit/node/ui'
57
import {outputContent, outputToken} from '@shopify/cli-kit/node/output'
68
import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session'
79
import {AbortError} from '@shopify/cli-kit/node/error'
@@ -14,6 +16,7 @@ interface ExecuteBulkOperationInput {
1416
query: string
1517
variables?: string[]
1618
variableFile?: string
19+
watch?: boolean
1720
}
1821

1922
async function parseVariablesToJsonl(variables?: string[], variableFile?: string): Promise<string | undefined> {
@@ -34,7 +37,7 @@ async function parseVariablesToJsonl(variables?: string[], variableFile?: string
3437
}
3538

3639
export async function executeBulkOperation(input: ExecuteBulkOperationInput): Promise<void> {
37-
const {app, storeFqdn, query, variables, variableFile} = input
40+
const {app, storeFqdn, query, variables, variableFile, watch = false} = input
3841

3942
renderInfo({
4043
headline: 'Starting bulk operation.',
@@ -64,31 +67,45 @@ export async function executeBulkOperation(input: ExecuteBulkOperationInput): Pr
6467
return
6568
}
6669

67-
const result = bulkOperationResponse?.bulkOperation
68-
if (result) {
69-
const infoSections = [
70-
{
71-
title: 'Bulk Operation Created',
72-
body: [
73-
{
74-
list: {
75-
items: [
76-
outputContent`ID: ${outputToken.cyan(result.id)}`.value,
77-
outputContent`Status: ${outputToken.yellow(result.status)}`.value,
78-
outputContent`Created: ${outputToken.gray(String(result.createdAt))}`.value,
79-
],
80-
},
81-
},
82-
],
83-
},
84-
]
85-
86-
renderInfo({customSections: infoSections})
87-
88-
renderSuccess({
89-
headline: 'Bulk operation started successfully!',
90-
body: 'Congrats!',
91-
})
70+
const createdOperation = bulkOperationResponse?.bulkOperation
71+
if (createdOperation) {
72+
if (watch) {
73+
const finishedOperation = await watchBulkOperation(adminSession, createdOperation.id)
74+
renderBulkOperationResult(finishedOperation)
75+
} else {
76+
renderBulkOperationResult(createdOperation)
77+
}
78+
}
79+
}
80+
81+
function renderBulkOperationResult(operation: BulkOperation): void {
82+
const headline = formatBulkOperationStatus(operation).value
83+
const items = [
84+
outputContent`ID: ${outputToken.cyan(operation.id)}`.value,
85+
outputContent`Status: ${outputToken.yellow(operation.status)}`.value,
86+
outputContent`Created at: ${outputToken.gray(String(operation.createdAt))}`.value,
87+
...(operation.completedAt
88+
? [outputContent`Completed at: ${outputToken.gray(String(operation.completedAt))}`.value]
89+
: []),
90+
]
91+
92+
const customSections = [{body: [{list: {items}}]}]
93+
94+
switch (operation.status) {
95+
case 'CREATED':
96+
renderSuccess({headline: 'Bulk operation started.', customSections})
97+
break
98+
case 'COMPLETED':
99+
if (operation.url) {
100+
const downloadMessage = outputContent`Download results ${outputToken.link('here.', operation.url)}`.value
101+
renderSuccess({headline, body: [downloadMessage], customSections})
102+
} else {
103+
renderSuccess({headline, customSections})
104+
}
105+
break
106+
default:
107+
renderError({headline, customSections})
108+
break
92109
}
93110
}
94111

packages/cli/oclif.manifest.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -915,6 +915,13 @@
915915
"hidden": false,
916916
"name": "verbose",
917917
"type": "boolean"
918+
},
919+
"watch": {
920+
"allowNo": false,
921+
"description": "Wait for bulk operation results before exiting.",
922+
"env": "SHOPIFY_FLAG_WATCH",
923+
"name": "watch",
924+
"type": "boolean"
918925
}
919926
},
920927
"hasDynamicHelp": false,

0 commit comments

Comments
 (0)