Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/* eslint-disable @typescript-eslint/consistent-type-definitions, @typescript-eslint/no-redundant-type-constituents */
import * as Types from './types.js'

import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core'

export type GetBulkOperationByIdQueryVariables = Types.Exact<{
id: Types.Scalars['ID']['input']
}>

export type GetBulkOperationByIdQuery = {
bulkOperation?: {
completedAt?: unknown | null
createdAt: unknown
errorCode?: Types.BulkOperationErrorCode | null
id: string
objectCount: unknown
status: Types.BulkOperationStatus
url?: string | null
} | null
}

export const GetBulkOperationById = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: {kind: 'Name', value: 'GetBulkOperationById'},
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {kind: 'Variable', name: {kind: 'Name', value: 'id'}},
type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'ID'}}},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: {kind: 'Name', value: 'bulkOperation'},
arguments: [
{
kind: 'Argument',
name: {kind: 'Name', value: 'id'},
value: {kind: 'Variable', name: {kind: 'Name', value: 'id'}},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{kind: 'Field', name: {kind: 'Name', value: 'completedAt'}},
{kind: 'Field', name: {kind: 'Name', value: 'createdAt'}},
{kind: 'Field', name: {kind: 'Name', value: 'errorCode'}},
{kind: 'Field', name: {kind: 'Name', value: 'id'}},
{kind: 'Field', name: {kind: 'Name', value: 'objectCount'}},
{kind: 'Field', name: {kind: 'Name', value: 'status'}},
{kind: 'Field', name: {kind: 'Name', value: 'url'}},
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
],
},
},
],
},
},
],
} as unknown as DocumentNode<GetBulkOperationByIdQuery, GetBulkOperationByIdQueryVariables>
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
query GetBulkOperationById($id: ID!) {
bulkOperation(id: $id) {
completedAt
createdAt
errorCode
id
objectCount
status
url
}
}
1 change: 1 addition & 0 deletions packages/app/src/cli/commands/app/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export default class Execute extends AppLinkedCommand {
query: flags.query,
variables: flags.variables,
variableFile: flags['variable-file'],
watch: flags.watch,
})

return {app: appContextResult.app}
Expand Down
5 changes: 5 additions & 0 deletions packages/app/src/cli/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,9 @@ export const bulkOperationFlags = {
env: 'SHOPIFY_FLAG_STORE',
parse: async (input) => normalizeStoreFqdn(input),
}),
watch: Flags.boolean({
description: 'Wait for bulk operation results before exiting.',
env: 'SHOPIFY_FLAG_WATCH',
default: false,
}),
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import {executeBulkOperation} from './execute-bulk-operation.js'
import {runBulkOperationQuery} from './run-query.js'
import {runBulkOperationMutation} from './run-mutation.js'
import {watchBulkOperation} from './watch-bulk-operation.js'
import {AppLinkedInterface} from '../../models/app/app.js'
import {renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui'
import {BulkOperationRunQueryMutation} from '../../api/graphql/bulk-operations/generated/bulk-operation-run-query.js'
import {BulkOperationRunMutationMutation} from '../../api/graphql/bulk-operations/generated/bulk-operation-run-mutation.js'
import {renderSuccess, renderWarning, renderError} from '@shopify/cli-kit/node/ui'
import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session'
import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs'
import {joinPath} from '@shopify/cli-kit/node/path'
import {describe, test, expect, vi, beforeEach} from 'vitest'

vi.mock('./run-query.js')
vi.mock('./run-mutation.js')
vi.mock('./watch-bulk-operation.js')
vi.mock('@shopify/cli-kit/node/ui')
vi.mock('@shopify/cli-kit/node/session')

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

const successfulBulkOperation = {
const createdBulkOperation: NonNullable<
NonNullable<BulkOperationRunQueryMutation['bulkOperationRunQuery']>['bulkOperation']
> = {
id: 'gid://shopify/BulkOperation/123',
status: 'CREATED',
errorCode: null,
createdAt: '2024-01-01T00:00:00Z',
objectCount: '0',
fileSize: '0',
url: null,
query: '{ products { edges { node { id } } } }',
rootObjectCount: '0',
type: 'QUERY',
completedAt: null,
partialDataUrl: null,
}

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

test('runs query operation when GraphQL document starts with query', async () => {
const query = 'query { products { edges { node { id } } } }'
const mockResponse = {
bulkOperation: successfulBulkOperation,
const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
bulkOperation: createdBulkOperation,
userErrors: [],
}
vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse as any)
vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse)

await executeBulkOperation({
app: mockApp,
Expand All @@ -58,11 +69,11 @@ describe('executeBulkOperation', () => {

test('runs query operation when GraphQL document starts with curly brace', async () => {
const query = '{ products { edges { node { id } } } }'
const mockResponse = {
bulkOperation: successfulBulkOperation,
const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
bulkOperation: createdBulkOperation,
userErrors: [],
}
vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse as any)
vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse)

await executeBulkOperation({
app: mockApp,
Expand All @@ -79,11 +90,11 @@ describe('executeBulkOperation', () => {

test('runs mutation operation when GraphQL document starts with mutation', async () => {
const mutation = 'mutation productUpdate($input: ProductInput!) { productUpdate(input: $input) { product { id } } }'
const mockResponse = {
bulkOperation: successfulBulkOperation,
const mockResponse: BulkOperationRunMutationMutation['bulkOperationRunMutation'] = {
bulkOperation: createdBulkOperation,
userErrors: [],
}
vi.mocked(runBulkOperationMutation).mockResolvedValue(mockResponse as any)
vi.mocked(runBulkOperationMutation).mockResolvedValue(mockResponse)

await executeBulkOperation({
app: mockApp,
Expand All @@ -102,11 +113,11 @@ describe('executeBulkOperation', () => {
test('passes variables parameter to runBulkOperationMutation when variables are provided', async () => {
const mutation = 'mutation productUpdate($input: ProductInput!) { productUpdate(input: $input) { product { id } } }'
const variables = ['{"input":{"id":"gid://shopify/Product/123","tags":["test"]}}']
const mockResponse = {
bulkOperation: successfulBulkOperation,
const mockResponse: BulkOperationRunMutationMutation['bulkOperationRunMutation'] = {
bulkOperation: createdBulkOperation,
userErrors: [],
}
vi.mocked(runBulkOperationMutation).mockResolvedValue(mockResponse as any)
vi.mocked(runBulkOperationMutation).mockResolvedValue(mockResponse)

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

test('renders success message when bulk operation returns without user errors', async () => {
const query = '{ products { edges { node { id } } } }'
const mockResponse = {
bulkOperation: successfulBulkOperation,
const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
bulkOperation: createdBulkOperation,
userErrors: [],
}
vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse as any)
vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse)
await executeBulkOperation({
app: mockApp,
storeFqdn,
query,
})

expect(renderSuccess).toHaveBeenCalledWith({
headline: 'Bulk operation started successfully!',
body: 'Congrats!',
})
expect(renderSuccess).toHaveBeenCalledWith(
expect.objectContaining({
headline: 'Bulk operation started.',
}),
)
})

test('renders warning with formatted field errors when bulk operation returns user errors', async () => {
const query = '{ products { edges { node { id } } } }'
const mockResponse = {
const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
bulkOperation: null,
userErrors: [
{field: ['query'], message: 'Invalid query syntax'},
{field: null, message: 'Another error'},
{field: ['query'], message: 'Invalid query syntax', code: null},
{field: null, message: 'Another error', code: null},
],
}
vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse as any)
vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse)

await executeBulkOperation({
app: mockApp,
Expand Down Expand Up @@ -229,7 +241,7 @@ describe('executeBulkOperation', () => {
const mutation =
'mutation productUpdate($input: ProductInput!) { productUpdate(input: $input) { product { id } } }'
const mockResponse = {
bulkOperation: successfulBulkOperation,
bulkOperation: createdBulkOperation,
userErrors: [],
}
vi.mocked(runBulkOperationMutation).mockResolvedValue(mockResponse as any)
Expand Down Expand Up @@ -306,4 +318,70 @@ describe('executeBulkOperation', () => {
expect(runBulkOperationMutation).not.toHaveBeenCalled()
})
})

test('waits for operation to finish and renders success when watch is provided and operation finishes with COMPLETED status', async () => {
const query = '{ products { edges { node { id } } } }'
const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
bulkOperation: createdBulkOperation,
userErrors: [],
}
const completedOperation = {
...createdBulkOperation,
status: 'COMPLETED' as const,
url: 'https://example.com/download',
objectCount: '650',
}

vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse)
vi.mocked(watchBulkOperation).mockResolvedValue(completedOperation)

await executeBulkOperation({
app: mockApp,
storeFqdn,
query,
watch: true,
})

expect(watchBulkOperation).toHaveBeenCalledWith(mockAdminSession, createdBulkOperation.id)
expect(renderSuccess).toHaveBeenCalledWith(
expect.objectContaining({
headline: expect.stringContaining('Bulk operation succeeded:'),
body: expect.arrayContaining([expect.stringContaining('https://example.com/download')]),
}),
)
})

test.each(['FAILED', 'CANCELED', 'EXPIRED'] as const)(
'waits for operation to finish and renders error when watch is provided and operation finishes with %s status',
async (status) => {
const query = '{ products { edges { node { id } } } }'
const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
bulkOperation: createdBulkOperation,
userErrors: [],
}
const finishedOperation = {
...createdBulkOperation,
status,
objectCount: '100',
}

vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse)
vi.mocked(watchBulkOperation).mockResolvedValue(finishedOperation)

await executeBulkOperation({
app: mockApp,
storeFqdn,
query,
watch: true,
})

expect(watchBulkOperation).toHaveBeenCalledWith(mockAdminSession, createdBulkOperation.id)
expect(renderError).toHaveBeenCalledWith(
expect.objectContaining({
headline: expect.any(String),
customSections: expect.any(Array),
}),
)
},
)
})
Loading