Skip to content

Commit 4d5faaf

Browse files
use our new watchBulkOperation rendering when --watch is provided
1 parent 9973979 commit 4d5faaf

File tree

5 files changed

+158
-50
lines changed

5 files changed

+158
-50
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export default class Execute extends AppLinkedCommand {
3939
storeFqdn: store.shopDomain,
4040
query: flags.query,
4141
variables: flags.variables,
42+
watch: flags.watch,
4243
})
4344

4445
return {app: appContextResult.app}

packages/app/src/cli/flags.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,9 @@ export const bulkOperationFlags = {
5555
env: 'SHOPIFY_FLAG_STORE',
5656
parse: async (input) => normalizeStoreFqdn(input),
5757
}),
58+
watch: Flags.boolean({
59+
description: 'Wait for bulk operation results before exiting.',
60+
env: 'SHOPIFY_FLAG_WATCH',
61+
default: false,
62+
}),
5863
}

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

Lines changed: 103 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
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 {describe, test, expect, vi, beforeEach} from 'vitest'
811

912
vi.mock('./run-query.js')
1013
vi.mock('./run-mutation.js')
14+
vi.mock('./watch-bulk-operation.js')
1115
vi.mock('@shopify/cli-kit/node/ui')
1216
vi.mock('@shopify/cli-kit/node/session')
1317

@@ -19,14 +23,21 @@ describe('executeBulkOperation', () => {
1923
const storeFqdn = 'test-store.myshopify.com'
2024
const mockAdminSession = {token: 'test-token', storeFqdn}
2125

22-
const successfulBulkOperation = {
26+
const createdBulkOperation: NonNullable<
27+
NonNullable<BulkOperationRunQueryMutation['bulkOperationRunQuery']>['bulkOperation']
28+
> = {
2329
id: 'gid://shopify/BulkOperation/123',
2430
status: 'CREATED',
2531
errorCode: null,
2632
createdAt: '2024-01-01T00:00:00Z',
2733
objectCount: '0',
2834
fileSize: '0',
2935
url: null,
36+
query: '{ products { edges { node { id } } } }',
37+
rootObjectCount: '0',
38+
type: 'QUERY',
39+
completedAt: null,
40+
partialDataUrl: null,
3041
}
3142

3243
beforeEach(() => {
@@ -35,11 +46,11 @@ describe('executeBulkOperation', () => {
3546

3647
test('runs query operation when GraphQL document starts with query', async () => {
3748
const query = 'query { products { edges { node { id } } } }'
38-
const mockResponse = {
39-
bulkOperation: successfulBulkOperation,
49+
const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
50+
bulkOperation: createdBulkOperation,
4051
userErrors: [],
4152
}
42-
vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse as any)
53+
vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse)
4354

4455
await executeBulkOperation({
4556
app: mockApp,
@@ -57,11 +68,11 @@ describe('executeBulkOperation', () => {
5768

5869
test('runs query operation when GraphQL document starts with curly brace', async () => {
5970
const query = '{ products { edges { node { id } } } }'
60-
const mockResponse = {
61-
bulkOperation: successfulBulkOperation,
71+
const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
72+
bulkOperation: createdBulkOperation,
6273
userErrors: [],
6374
}
64-
vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse as any)
75+
vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse)
6576

6677
await executeBulkOperation({
6778
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 to mutation when provided with `--variables` flag', 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 is created', 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 when user errors are present', 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,
@@ -165,4 +177,70 @@ describe('executeBulkOperation', () => {
165177

166178
expect(renderSuccess).not.toHaveBeenCalled()
167179
})
180+
181+
test('waits for operation to finish and renders success when watch is provided and operation finishes with COMPLETED status', async () => {
182+
const query = '{ products { edges { node { id } } } }'
183+
const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
184+
bulkOperation: createdBulkOperation,
185+
userErrors: [],
186+
}
187+
const completedOperation = {
188+
...createdBulkOperation,
189+
status: 'COMPLETED' as const,
190+
url: 'https://example.com/download',
191+
objectCount: '650',
192+
}
193+
194+
vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse)
195+
vi.mocked(watchBulkOperation).mockResolvedValue(completedOperation)
196+
197+
await executeBulkOperation({
198+
app: mockApp,
199+
storeFqdn,
200+
query,
201+
watch: true,
202+
})
203+
204+
expect(watchBulkOperation).toHaveBeenCalledWith(mockAdminSession, createdBulkOperation.id)
205+
expect(renderSuccess).toHaveBeenCalledWith(
206+
expect.objectContaining({
207+
headline: expect.stringContaining('Bulk operation succeeded.'),
208+
body: expect.arrayContaining([expect.stringContaining('https://example.com/download')]),
209+
}),
210+
)
211+
})
212+
213+
test.each(['FAILED', 'CANCELED', 'EXPIRED'] as const)(
214+
'waits for operation to finish and renders error when watch is provided and operation finishes with %s status',
215+
async (status) => {
216+
const query = '{ products { edges { node { id } } } }'
217+
const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
218+
bulkOperation: createdBulkOperation,
219+
userErrors: [],
220+
}
221+
const finishedOperation = {
222+
...createdBulkOperation,
223+
status,
224+
objectCount: '100',
225+
}
226+
227+
vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse)
228+
vi.mocked(watchBulkOperation).mockResolvedValue(finishedOperation)
229+
230+
await executeBulkOperation({
231+
app: mockApp,
232+
storeFqdn,
233+
query,
234+
watch: true,
235+
})
236+
237+
expect(watchBulkOperation).toHaveBeenCalledWith(mockAdminSession, createdBulkOperation.id)
238+
expect(renderError).toHaveBeenCalledWith(
239+
expect.objectContaining({
240+
headline: expect.any(String),
241+
customSections: expect.any(Array),
242+
}),
243+
)
244+
},
245+
)
168246
})

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

Lines changed: 42 additions & 25 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'
@@ -12,10 +14,11 @@ interface ExecuteBulkOperationInput {
1214
storeFqdn: string
1315
query: string
1416
variables?: string[]
17+
watch?: boolean
1518
}
1619

1720
export async function executeBulkOperation(input: ExecuteBulkOperationInput): Promise<void> {
18-
const {app, storeFqdn, query, variables} = input
21+
const {app, storeFqdn, query, variables, watch = false} = input
1922

2023
renderInfo({
2124
headline: 'Starting bulk operation.',
@@ -49,31 +52,45 @@ export async function executeBulkOperation(input: ExecuteBulkOperationInput): Pr
4952
return
5053
}
5154

52-
const result = bulkOperationResponse?.bulkOperation
53-
if (result) {
54-
const infoSections = [
55-
{
56-
title: 'Bulk Operation Created',
57-
body: [
58-
{
59-
list: {
60-
items: [
61-
outputContent`ID: ${outputToken.cyan(result.id)}`.value,
62-
outputContent`Status: ${outputToken.yellow(result.status)}`.value,
63-
outputContent`Created: ${outputToken.gray(String(result.createdAt))}`.value,
64-
],
65-
},
66-
},
67-
],
68-
},
69-
]
55+
const createdOperation = bulkOperationResponse?.bulkOperation
56+
if (createdOperation) {
57+
if (watch) {
58+
const finishedOperation = await watchBulkOperation(adminSession, createdOperation.id)
59+
renderBulkOperationResult(finishedOperation)
60+
} else {
61+
renderBulkOperationResult(createdOperation)
62+
}
63+
}
64+
}
7065

71-
renderInfo({customSections: infoSections})
66+
function renderBulkOperationResult(operation: BulkOperation): void {
67+
const headline = formatBulkOperationStatus(operation).value
68+
const items = [
69+
outputContent`ID: ${outputToken.cyan(operation.id)}`.value,
70+
outputContent`Status: ${outputToken.yellow(operation.status)}`.value,
71+
outputContent`Created at: ${outputToken.gray(String(operation.createdAt))}`.value,
72+
...(operation.completedAt
73+
? [outputContent`Completed at: ${outputToken.gray(String(operation.completedAt))}`.value]
74+
: []),
75+
]
7276

73-
renderSuccess({
74-
headline: 'Bulk operation started successfully!',
75-
body: 'Congrats!',
76-
})
77+
const customSections = [{body: [{list: {items}}]}]
78+
79+
switch (operation.status) {
80+
case 'CREATED':
81+
renderSuccess({headline: 'Bulk operation started.', customSections})
82+
break
83+
case 'COMPLETED':
84+
if (operation.url) {
85+
const downloadMessage = outputContent`Download results ${outputToken.link('here.', operation.url)}`.value
86+
renderSuccess({headline, body: [downloadMessage], customSections})
87+
} else {
88+
renderSuccess({headline, customSections})
89+
}
90+
break
91+
default:
92+
renderError({headline, customSections})
93+
break
7794
}
7895
}
7996

packages/cli/oclif.manifest.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -901,6 +901,13 @@
901901
"hidden": false,
902902
"name": "verbose",
903903
"type": "boolean"
904+
},
905+
"watch": {
906+
"allowNo": false,
907+
"description": "Wait for bulk operation results before exiting.",
908+
"env": "SHOPIFY_FLAG_WATCH",
909+
"name": "watch",
910+
"type": "boolean"
904911
}
905912
},
906913
"hasDynamicHelp": false,

0 commit comments

Comments
 (0)