Skip to content
Draft
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
44 changes: 43 additions & 1 deletion packages/app/src/cli/commands/app/bulk/execute.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import BulkExecute from './execute.js'
import {executeBulkOperation} from '../../../services/bulk-operations/execute-bulk-operation.js'
import {prepareExecuteContext} from '../../../utilities/execute-command-helpers.js'
import {resolveBulkPlan} from '../../../services/bulk-operations/plan.js'
import {prepareAppStoreContext, prepareExecuteContext} from '../../../utilities/execute-command-helpers.js'
import {
testAppLinked,
testOrganization,
Expand All @@ -11,6 +12,7 @@ import {
import {describe, expect, test, vi, beforeEach} from 'vitest'

vi.mock('../../../services/bulk-operations/execute-bulk-operation.js')
vi.mock('../../../services/bulk-operations/plan.js')
vi.mock('../../../utilities/execute-command-helpers.js')

describe('app bulk execute command', () => {
Expand All @@ -33,6 +35,18 @@ describe('app bulk execute command', () => {
store,
query: 'query { shop { name } }',
})
vi.mocked(prepareAppStoreContext).mockResolvedValue({
appContextResult: {
app,
remoteApp,
developerPlatformClient: remoteApp.developerPlatformClient,
organization,
specifications: [],
project: testProject(),
activeConfig: {} as never,
},
store,
})
vi.mocked(executeBulkOperation).mockResolvedValue()
})

Expand All @@ -51,6 +65,7 @@ describe('app bulk execute command', () => {
organization,
remoteApp,
store,
validate: true,
query: 'query { shop { name } }',
variables: undefined,
variableFile: undefined,
Expand Down Expand Up @@ -78,6 +93,7 @@ describe('app bulk execute command', () => {
organization,
remoteApp,
store,
validate: true,
query: 'query { shop { name } }',
variables: ['{"key": "value"}'],
variableFile: undefined,
Expand All @@ -102,11 +118,37 @@ describe('app bulk execute command', () => {
organization,
remoteApp,
store,
validate: true,
query: 'query { shop { name } }',
variables: undefined,
variableFile: expect.stringContaining('variables.jsonl'),
watch: false,
outputFile: undefined,
})
})

test('resolves the plan file and calls executeBulkOperation with operations', async () => {
const operations = [
{mutation: 'mutation A($input: X!) { a(input: $input) { id } }', variablesJsonl: '{"$key":"k","input":{}}'},
{mutation: 'mutation B($id: ID!) { b(id: $id) { id } }', variablesJsonl: '{"id":"$ref:A[k].id"}'},
]
vi.mocked(resolveBulkPlan).mockResolvedValue(operations)

// When
await BulkExecute.run(['--plan-file', 'plan.json', '--store', 'shop.myshopify.com', '--watch'])

// Then
expect(resolveBulkPlan).toHaveBeenCalledWith(expect.stringContaining('plan.json'))
expect(prepareAppStoreContext).toHaveBeenCalled()
expect(prepareExecuteContext).not.toHaveBeenCalled()
expect(executeBulkOperation).toHaveBeenCalledWith({
organization,
remoteApp,
store,
validate: true,
operations,
watch: true,
outputFile: undefined,
})
})
})
24 changes: 23 additions & 1 deletion packages/app/src/cli/commands/app/bulk/execute.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {appFlags, bulkOperationFlags} from '../../../flags.js'
import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js'
import {executeBulkOperation} from '../../../services/bulk-operations/execute-bulk-operation.js'
import {prepareExecuteContext} from '../../../utilities/execute-command-helpers.js'
import {resolveBulkPlan} from '../../../services/bulk-operations/plan.js'
import {prepareAppStoreContext, prepareExecuteContext} from '../../../utilities/execute-command-helpers.js'
import {globalFlags} from '@shopify/cli-kit/node/cli'

export default class BulkExecute extends AppLinkedCommand {
Expand All @@ -24,6 +25,26 @@ export default class BulkExecute extends AppLinkedCommand {
async run(): Promise<AppLinkedCommandOutput> {
const {flags} = await this.parse(BulkExecute)

// A `--plan-file` runs an ordered set of named mutations as one plan (bulkOperationRunMutations).
// Everything else keeps the single-operation path (query or single mutation).
if (flags['plan-file']) {
const operations = await resolveBulkPlan(flags['plan-file'])
const {appContextResult, store} = await prepareAppStoreContext(flags)

await executeBulkOperation({
organization: appContextResult.organization,
remoteApp: appContextResult.remoteApp,
store,
operations,
validate: flags.validate,
watch: flags.watch ?? false,
outputFile: flags['output-file'],
...(flags.version && {version: flags.version}),
})

return {app: appContextResult.app}
}

const {query, appContextResult, store} = await prepareExecuteContext(flags)

await executeBulkOperation({
Expand All @@ -33,6 +54,7 @@ export default class BulkExecute extends AppLinkedCommand {
query,
variables: flags.variables,
variableFile: flags['variable-file'],
validate: flags.validate,
watch: flags.watch ?? false,
outputFile: flags['output-file'],
...(flags.version && {version: flags.version}),
Expand Down
23 changes: 19 additions & 4 deletions packages/app/src/cli/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,35 +41,50 @@ export const bulkOperationFlags = {
description: 'The GraphQL query or mutation to run as a bulk operation.',
env: 'SHOPIFY_FLAG_QUERY',
required: false,
exactlyOne: ['query', 'query-file'],
exactlyOne: ['query', 'query-file', 'plan-file'],
}),
'query-file': Flags.string({
description: "Path to a file containing the GraphQL query or mutation. Can't be used with --query.",
env: 'SHOPIFY_FLAG_QUERY_FILE',
parse: async (input) => resolvePath(input),
exactlyOne: ['query', 'query-file'],
exactlyOne: ['query', 'query-file', 'plan-file'],
}),
'plan-file': Flags.string({
description:
'Path to a JSON file describing an ordered plan of named mutation operations to run together as a single bulk operation. Each entry is {"mutationFile": "...", "variableFile": "..."} (inline "mutation"/"variables" are also accepted). Runs via bulkOperationRunMutations.',
env: 'SHOPIFY_FLAG_PLAN_FILE',
parse: async (input) => resolvePath(input),
exactlyOne: ['query', 'query-file', 'plan-file'],
exclusive: ['variables', 'variable-file'],
}),
variables: Flags.string({
char: 'v',
description:
'The values for any GraphQL variables in your mutation, in JSON format. Can be specified multiple times.',
env: 'SHOPIFY_FLAG_VARIABLES',
multiple: true,
exclusive: ['variable-file'],
exclusive: ['variable-file', 'plan-file'],
}),
'variable-file': Flags.string({
description:
"Path to a file containing GraphQL variables in JSONL format (one JSON object per line). Can't be used with --variables.",
env: 'SHOPIFY_FLAG_VARIABLE_FILE',
parse: async (input) => resolvePath(input),
exclusive: ['variables'],
exclusive: ['variables', 'plan-file'],
}),
store: Flags.string({
char: 's',
description: 'The store domain. Must be an existing dev store.',
env: 'SHOPIFY_FLAG_STORE',
parse: async (input) => normalizeStoreFqdn(input),
}),
validate: Flags.boolean({
description:
"Validate operations against the store's Admin schema before submitting. Enabled by default; use --no-validate to skip.",
env: 'SHOPIFY_FLAG_VALIDATE',
default: true,
allowNo: true,
}),
watch: Flags.boolean({
description: 'Wait for bulk operation results before exiting. Defaults to false.',
env: 'SHOPIFY_FLAG_WATCH',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {OrganizationApp, OrganizationSource, OrganizationStore} from '../../mode
import {
runBulkOperationQuery,
runBulkOperationMutation,
runBulkOperationMutations,
validateBulkOperations,
watchBulkOperation,
shortBulkOperationPoll,
downloadBulkOperationResults,
Expand All @@ -23,6 +25,8 @@ vi.mock('@shopify/cli-kit/node/api/bulk-operations', async () => {
...actual,
runBulkOperationQuery: vi.fn(),
runBulkOperationMutation: vi.fn(),
runBulkOperationMutations: vi.fn(),
validateBulkOperations: vi.fn(),
watchBulkOperation: vi.fn(),
shortBulkOperationPoll: vi.fn(),
downloadBulkOperationResults: vi.fn(),
Expand Down Expand Up @@ -98,6 +102,7 @@ describe('executeBulkOperation', () => {
vi.mocked(ensureAuthenticatedAdminAsApp).mockResolvedValue(mockAdminSession)
vi.mocked(shortBulkOperationPoll).mockResolvedValue(createdBulkOperation)
vi.mocked(resolveApiVersion).mockResolvedValue(BULK_OPERATIONS_MIN_API_VERSION)
vi.mocked(validateBulkOperations).mockResolvedValue([])
})

afterEach(() => {
Expand Down Expand Up @@ -201,6 +206,144 @@ describe('executeBulkOperation', () => {
})
})

test('runs a single-operation plan via runBulkOperationMutation (not the plural mutation)', async () => {
const mutation = 'mutation SetProducts($input: ProductSetInput!) { productSet(input: $input) { product { id } } }'
const mockResponse: Awaited<ReturnType<typeof runBulkOperationMutation>> = {
bulkOperation: createdBulkOperation,
userErrors: [],
}
vi.mocked(runBulkOperationMutation).mockResolvedValue(mockResponse)

await executeBulkOperation({
organization: mockOrganization,
remoteApp: mockRemoteApp,
store: mockStore,
operations: [{mutation, variablesJsonl: '{"input":{}}'}],
})

expect(runBulkOperationMutation).toHaveBeenCalledWith({
adminSession: mockAdminSession,
query: mutation,
variablesJsonl: '{"input":{}}',
version: BULK_OPERATIONS_MIN_API_VERSION,
})
expect(runBulkOperationMutations).not.toHaveBeenCalled()
})

test('runs a multi-operation plan via runBulkOperationMutations', async () => {
const operations = [
{
mutation: 'mutation SetProducts($input: ProductSetInput!) { productSet(input: $input) { product { id } } }',
variablesJsonl: '{"$key":"a","input":{}}',
},
{
mutation:
'mutation Publish($id: ID!) { publishablePublish(id: $id, input: []) { publishable { publishedOnCurrentPublication } } }',
variablesJsonl: '{"id":"$ref:SetProducts[a].product.id"}',
},
]
const mockResponse: Awaited<ReturnType<typeof runBulkOperationMutations>> = {
bulkOperation: createdBulkOperation,
userErrors: [],
}
vi.mocked(runBulkOperationMutations).mockResolvedValue(mockResponse)

await executeBulkOperation({
organization: mockOrganization,
remoteApp: mockRemoteApp,
store: mockStore,
operations,
})

expect(runBulkOperationMutations).toHaveBeenCalledWith({
adminSession: mockAdminSession,
operations,
version: BULK_OPERATIONS_MIN_API_VERSION,
})
expect(runBulkOperationMutation).not.toHaveBeenCalled()
})

test('renders plan user errors with the error code prefixed', async () => {
const operations = [
{
mutation: 'mutation A($input: ProductSetInput!) { productSet(input: $input) { product { id } } }',
variablesJsonl: '{"input":{}}',
},
{
mutation:
'mutation B($id: ID!) { publishablePublish(id: $id, input: []) { publishable { publishedOnCurrentPublication } } }',
variablesJsonl: '{"id":"$ref:A[missing].product.id"}',
},
]
const mockResponse: Awaited<ReturnType<typeof runBulkOperationMutations>> = {
bulkOperation: null,
userErrors: [
{code: 'UNKNOWN_KEY_REF', field: ['operations', 'B'], message: 'unknown $key'},
{code: null, field: null, message: 'plain error'},
],
}
vi.mocked(runBulkOperationMutations).mockResolvedValue(mockResponse)

await executeBulkOperation({
organization: mockOrganization,
remoteApp: mockRemoteApp,
store: mockStore,
operations,
})

expect(renderError).toHaveBeenCalledWith({
headline: 'Error creating bulk operation.',
body: {
list: {
items: ['[UNKNOWN_KEY_REF] operations.B: unknown $key', 'plain error'],
},
},
})
expect(renderSuccess).not.toHaveBeenCalled()
})

test('aborts before submitting when client-side validation fails', async () => {
vi.mocked(validateBulkOperations).mockResolvedValue([
{label: 'operation 1', errors: ["Field 'bogusField' doesn't exist on type 'ProductSetInput'"]},
])

await executeBulkOperation({
organization: mockOrganization,
remoteApp: mockRemoteApp,
store: mockStore,
operations: [
{
mutation: 'mutation A($input: ProductSetInput!) { productSet(input: $input) { product { id } } }',
variablesJsonl: '{"input":{}}',
},
],
})

expect(renderError).toHaveBeenCalledWith(expect.objectContaining({headline: 'Bulk operation validation failed.'}))
expect(runBulkOperationMutation).not.toHaveBeenCalled()
expect(runBulkOperationMutations).not.toHaveBeenCalled()
})

test('skips client-side validation when validate is false', async () => {
const mockResponse: Awaited<ReturnType<typeof runBulkOperationMutation>> = {
bulkOperation: createdBulkOperation,
userErrors: [],
}
vi.mocked(runBulkOperationMutation).mockResolvedValue(mockResponse)

await executeBulkOperation({
organization: mockOrganization,
remoteApp: mockRemoteApp,
store: mockStore,
query: 'mutation A($input: ProductSetInput!) { productSet(input: $input) { product { id } } }',
variables: ['{"input":{}}'],
validate: false,
})

expect(validateBulkOperations).not.toHaveBeenCalled()
expect(runBulkOperationMutation).toHaveBeenCalled()
})

test('renders running message when bulk operation returns without user errors', async () => {
const query = '{ products { edges { node { id } } } }'
const mockResponse: Awaited<ReturnType<typeof runBulkOperationQuery>> = {
Expand Down
Loading
Loading