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
5 changes: 5 additions & 0 deletions .changeset/app-bulk-cancel-json.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/cli': minor
---

Add typed JSON output to app bulk cancel.
11 changes: 10 additions & 1 deletion docs-shopify.dev/generated/generated_docs_data_v2.json
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,15 @@
"isOptional": true,
"environmentValue": "SHOPIFY_FLAG_APP_CONFIG"
},
{
"filePath": "docs-shopify.dev/commands/interfaces/app-bulk-cancel.interface.ts",
"syntaxKind": "PropertySignature",
"name": "-j, --json",
"value": "''",
"description": "Output the result as JSON. Automatically disables color output.",
"isOptional": true,
"environmentValue": "SHOPIFY_FLAG_JSON"
},
{
"filePath": "docs-shopify.dev/commands/interfaces/app-bulk-cancel.interface.ts",
"syntaxKind": "PropertySignature",
Expand All @@ -206,7 +215,7 @@
"environmentValue": "SHOPIFY_FLAG_STORE"
}
],
"value": "export interface appbulkcancel {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias <value>'?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id <value>'?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config <value>'?: string\n\n /**\n * The bulk operation ID to cancel (numeric ID or full GID).\n * @environment SHOPIFY_FLAG_ID\n */\n '--id <value>': string\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path <value>'?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * The store domain. Must be an existing dev store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store <value>'?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
"value": "export interface appbulkcancel {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias <value>'?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id <value>'?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config <value>'?: string\n\n /**\n * The bulk operation ID to cancel (numeric ID or full GID).\n * @environment SHOPIFY_FLAG_ID\n */\n '--id <value>': string\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path <value>'?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * The store domain. Must be an existing dev store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store <value>'?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
}
},
"appbulkexecute": {
Expand Down
11 changes: 9 additions & 2 deletions packages/app/src/cli/commands/app/bulk/cancel.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import BulkCancel from './cancel.js'
import {renderCancelBulkOperationResult} from '../../../services/bulk-operations/cancel-result.js'
import {cancelBulkOperation} from '../../../services/bulk-operations/cancel-bulk-operation.js'
import {prepareAppStoreContext} from '../../../utilities/execute-command-helpers.js'
import {
Expand All @@ -12,6 +13,8 @@ import {describe, expect, test, vi, beforeEach} from 'vitest'

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

describe('app bulk cancel command', () => {
const app = testAppLinked()
Expand All @@ -31,7 +34,7 @@ describe('app bulk cancel command', () => {
},
store,
})
vi.mocked(cancelBulkOperation).mockResolvedValue()
vi.mocked(cancelBulkOperation).mockResolvedValue({operation: null, userErrors: []})
})

test('prepares app/store context and cancels bulk operation', async () => {
Expand All @@ -46,8 +49,12 @@ describe('app bulk cancel command', () => {
store: 'shop.myshopify.com',
}),
)
expect(renderCancelBulkOperationResult).toHaveBeenCalledWith(
{operation: null, userErrors: []},
'gid://shopify/BulkOperation/12345',
'text',
)
expect(cancelBulkOperation).toHaveBeenCalledWith({
organization: expect.any(Object),
storeFqdn: 'shop.myshopify.com',
operationId: 'gid://shopify/BulkOperation/12345',
remoteApp,
Expand Down
33 changes: 27 additions & 6 deletions packages/app/src/cli/commands/app/bulk/cancel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,24 @@ import {appFlags} from '../../../flags.js'
import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js'
import {prepareAppStoreContext} from '../../../utilities/execute-command-helpers.js'
import {cancelBulkOperation} from '../../../services/bulk-operations/cancel-bulk-operation.js'
import {cancelBulkOperationJsonOutputSchema} from '../../../services/bulk-operations/types.js'
import {renderCancelBulkOperationResult} from '../../../services/bulk-operations/cancel-result.js'
import {logBulkOperationStart} from '../../../services/bulk-operations/progress.js'
import {Flags} from '@oclif/core'
import {globalFlags} from '@shopify/cli-kit/node/cli'
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
import {normalizeBulkOperationId} from '@shopify/cli-kit/node/api/bulk-operations'
import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'

export default class BulkCancel extends AppLinkedCommand {
static summary = 'Cancel a bulk operation.'

static description = 'Cancels a running bulk operation by ID.'
static descriptionWithMarkdown = 'Cancels a running bulk operation by ID.'

static description = this.descriptionForHelp()

static flags = {
...globalFlags,
...jsonFlag,
...appFlags,
id: Flags.string({
description: 'The bulk operation ID to cancel (numeric ID or full GID).',
Expand All @@ -28,17 +34,32 @@ export default class BulkCancel extends AppLinkedCommand {
}),
}

static get jsonOutputSchema() {
return cancelBulkOperationJsonOutputSchema
}

async run(): Promise<AppLinkedCommandOutput> {
const {flags} = await this.parse(BulkCancel)

const {appContextResult, store} = await prepareAppStoreContext(flags)

await cancelBulkOperation({
organization: appContextResult.organization,
const format = flags.json ? 'json' : 'text'
const operationId = normalizeBulkOperationId(flags.id)
logBulkOperationStart(
'Canceling bulk operation.',
{
organization: appContextResult.organization,
remoteApp: appContextResult.remoteApp,
storeFqdn: store.shopDomain,
operationId,
},
format,
)
const result = await cancelBulkOperation({
storeFqdn: store.shopDomain,
operationId: normalizeBulkOperationId(flags.id),
operationId,
remoteApp: appContextResult.remoteApp,
})
renderCancelBulkOperationResult(result, operationId, format)

return {app: appContextResult.app}
}
Expand Down
86 changes: 74 additions & 12 deletions packages/app/src/cli/commands/app/bulk/output.test.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,33 @@
import BulkStatus from './status.js'
import BulkCancel from './cancel.js'
import {
testBulkOperation,
testBulkOperationContext,
} from '../../../services/bulk-operations/bulk-operation.test-data.js'
import {prepareAppStoreContext} from '../../../utilities/execute-command-helpers.js'
import {resolveApiVersion} from '../../../services/graphql/common.js'
import {bulkOperationStatusJsonOutputSchema} from '../../../services/bulk-operations/types.js'
import {fetchBulkOperationById, fetchRecentBulkOperations} from '@shopify/cli-kit/node/api/bulk-operations'
import {createAdminSessionAsApp, resolveApiVersion} from '../../../services/graphql/common.js'
import {
bulkOperationStatusJsonOutputSchema,
cancelBulkOperationJsonOutputSchema,
} from '../../../services/bulk-operations/types.js'
import {
fetchBulkOperationById,
fetchRecentBulkOperations,
cancelBulkOperationRequest,
} from '@shopify/cli-kit/node/api/bulk-operations'
import {ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session'
import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events'
import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output'
import {AbortError, handler} from '@shopify/cli-kit/node/error'
import {Config} from '@oclif/core'
import {expect, test, vi} from 'vitest'
import {afterEach, expect, test, vi} from 'vitest'
import type {BulkOperation} from '@shopify/cli-kit/node/api/bulk-operations'

vi.mock('../../../utilities/execute-command-helpers.js')

vi.mock('../../../services/graphql/common.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../services/graphql/common.js')>()),
createAdminSessionAsApp: vi.fn(),
resolveApiVersion: vi.fn(),
}))

Expand All @@ -30,8 +40,16 @@ vi.mock('@shopify/cli-kit/node/api/bulk-operations', async (importOriginal) => (
...(await importOriginal<typeof import('@shopify/cli-kit/node/api/bulk-operations')>()),
fetchBulkOperationById: vi.fn(),
fetchRecentBulkOperations: vi.fn(),
cancelBulkOperationRequest: vi.fn(),
}))

const originalExitCode = process.exitCode

afterEach(() => {
vi.unstubAllEnvs()
process.exitCode = originalExitCode
})

function bulkOperation(overrides: Partial<BulkOperation> = {}): BulkOperation {
return testBulkOperation({
objectCount: '900719925474099312345',
Expand All @@ -54,13 +72,12 @@ function expectedOperation() {
}
}

async function runCommand(Command: typeof BulkStatus, argv: string[]) {
async function runCommand(Command: typeof BulkStatus | typeof BulkCancel, argv: string[]) {
const {appContextResult, store} = testBulkOperationContext()
const remoteApp = appContextResult.remoteApp
vi.mocked(prepareAppStoreContext).mockResolvedValue({appContextResult, store})

const session = {storeFqdn: store.shopDomain, token: 'token'}

vi.mocked(createAdminSessionAsApp).mockResolvedValue(session)
vi.mocked(ensureAuthenticatedAdminAsApp).mockResolvedValue(session)
vi.mocked(resolveApiVersion).mockResolvedValue('2026-01')
const config = await Config.load()
Expand Down Expand Up @@ -118,14 +135,59 @@ test('a missing requested operation is distinct from an empty list', async () =>
operationGid: 'gid://shopify/BulkOperation/123',
operation: null,
})
expect(process.exitCode).toBe(originalExitCode)
})
})

test('exposes the status schema, JSON flag, and help', () => {
expect(BulkStatus.jsonOutputSchema).toBe(bulkOperationStatusJsonOutputSchema)
expect(BulkStatus.flags.json).toBeDefined()
expect(BulkStatus.baseFlags).toHaveProperty('json-schema')
expect(BulkStatus.descriptionForHelp()).toContain(bulkOperationStatusJsonOutputSchema.name)
test('cancellation emits one result through the real service, codec, presenter, and writer', async () => {
vi.mocked(cancelBulkOperationRequest).mockResolvedValue({
bulkOperation: {...bulkOperation(), query: 'query { shop { name } }', rootObjectCount: '2'},
userErrors: [],
})
await withCapturedStandardStreams(async ({stdout, stderr}) => {
await runCommand(BulkCancel, ['--id', '123', '--json'])
expect(JSON.parse(stdout())).toEqual({
storeDomain: 'shop.myshopify.com',
apiVersion: '2026-01',
status: 'success',
operation: expectedOperation(),
})
assertDiagnosticEvents(stderr(), 'Canceling bulk operation.')
})
})

test.each([{name: 'cancel', Command: BulkCancel}])(
'$name user errors reach the fatal envelope without a success document',
async ({Command}) => {
const userErrors = [{field: ['id'], message: 'Operation rejected'}]
vi.mocked(cancelBulkOperationRequest).mockResolvedValue({bulkOperation: null, userErrors})

vi.stubEnv('SHOPIFY_FLAG_JSON', '1')
await withCapturedStandardStreams(async ({stdout, stderr}) => {
const argv = ['--id', '123']
try {
await runCommand(Command, [...argv, '--json'])
expect.fail('The command must reject the operation.')
} catch (error) {
if (!(error instanceof AbortError)) throw error
expect(error).toMatchObject({details: {userErrors}})
expect(stdout()).toBe('')
await handler(error)
}
expect(JSON.parse(stdout())).toMatchObject({error: {type: 'abort', details: {userErrors}}})
assertDiagnosticEvents(stderr(), 'Canceling bulk operation.')
})
},
)

test.each([
{name: 'status', Command: BulkStatus, schema: bulkOperationStatusJsonOutputSchema},
{name: 'cancel', Command: BulkCancel, schema: cancelBulkOperationJsonOutputSchema},
] as const)('exposes the schema, JSON flag, and help for $name', ({Command, schema}) => {
expect(Command.jsonOutputSchema).toBe(schema)
expect(Command.flags.json).toBeDefined()
expect(Command.baseFlags).toHaveProperty('json-schema')
expect(Command.descriptionForHelp()).toContain(schema.name)
})
test.each([
{json: true, noInput: false},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import {cancelBulkOperation} from './cancel-bulk-operation.js'
import {renderCancelBulkOperationResult} from './cancel-result.js'
import {logBulkOperationStart} from './progress.js'
import {cancelBulkOperation as runCancelBulkOperation} from './cancel-bulk-operation.js'
import {createAdminSessionAsApp, formatOperationInfo} from '../graphql/common.js'
import {OrganizationApp, Organization, OrganizationSource} from '../../models/organization.js'
import {describe, test, expect, vi, beforeEach, afterEach} from 'vitest'
Expand Down Expand Up @@ -40,6 +42,25 @@ describe('cancelBulkOperation', () => {
mockAndCaptureOutput().clear()
})

test('returns the operation and user errors without presenting the result', async () => {
const operation = {id: operationId, status: 'CANCELING', createdAt: '2024-01-01T00:00:00Z', completedAt: null}
const userErrors = [{field: ['id'], message: 'Cannot cancel'}]
vi.mocked(adminRequestDoc).mockResolvedValue({bulkOperationCancel: {bulkOperation: operation, userErrors}})
const output = mockAndCaptureOutput()

await expect(runCancelBulkOperation({storeFqdn, operationId, remoteApp: mockRemoteApp})).resolves.toEqual({
store: storeFqdn,
apiVersion: '2026-01',
operation,
userErrors,
})
expect(output.output()).toBe('')
expect(renderInfo).not.toHaveBeenCalled()
expect(renderError).not.toHaveBeenCalled()
expect(renderSuccess).not.toHaveBeenCalled()
expect(renderWarning).not.toHaveBeenCalled()
})

test('renders initial info message with operation details', async () => {
vi.mocked(adminRequestDoc).mockResolvedValue({
bulkOperationCancel: {
Expand All @@ -53,7 +74,12 @@ describe('cancelBulkOperation', () => {
},
})

await cancelBulkOperation({organization: mockOrganization, storeFqdn, operationId, remoteApp: mockRemoteApp})
await cancelAndPresentBulkOperation({
organization: mockOrganization,
storeFqdn,
operationId,
remoteApp: mockRemoteApp,
})

expect(renderInfo).toHaveBeenCalledWith(
expect.objectContaining({
Expand All @@ -75,7 +101,12 @@ describe('cancelBulkOperation', () => {
},
})

await cancelBulkOperation({organization: mockOrganization, storeFqdn, operationId, remoteApp: mockRemoteApp})
await cancelAndPresentBulkOperation({
organization: mockOrganization,
storeFqdn,
operationId,
remoteApp: mockRemoteApp,
})

expect(adminRequestDoc).toHaveBeenCalledWith({
query: expect.any(Object),
Expand Down Expand Up @@ -119,7 +150,12 @@ describe('cancelBulkOperation', () => {
},
})

await cancelBulkOperation({organization: mockOrganization, storeFqdn, operationId, remoteApp: mockRemoteApp})
await cancelAndPresentBulkOperation({
organization: mockOrganization,
storeFqdn,
operationId,
remoteApp: mockRemoteApp,
})

const rendererFn = {renderSuccess, renderWarning, renderInfo}[renderer]
expect(rendererFn).toHaveBeenCalledWith(
Expand All @@ -137,7 +173,12 @@ describe('cancelBulkOperation', () => {
},
})

await cancelBulkOperation({organization: mockOrganization, storeFqdn, operationId, remoteApp: mockRemoteApp})
await cancelAndPresentBulkOperation({
organization: mockOrganization,
storeFqdn,
operationId,
remoteApp: mockRemoteApp,
})

expect(renderError).toHaveBeenCalledWith({
headline: 'Failed to cancel bulk operation.',
Expand All @@ -153,7 +194,12 @@ describe('cancelBulkOperation', () => {
},
})

await cancelBulkOperation({organization: mockOrganization, storeFqdn, operationId, remoteApp: mockRemoteApp})
await cancelAndPresentBulkOperation({
organization: mockOrganization,
storeFqdn,
operationId,
remoteApp: mockRemoteApp,
})

expect(renderError).toHaveBeenCalledWith(
expect.objectContaining({
Expand All @@ -162,3 +208,12 @@ describe('cancelBulkOperation', () => {
)
})
})

async function cancelAndPresentBulkOperation(
input: Parameters<typeof runCancelBulkOperation>[0] & {organization: Organization},
) {
logBulkOperationStart('Canceling bulk operation.', input, 'text')
const result = await runCancelBulkOperation(input)
renderCancelBulkOperationResult(result, input.operationId, 'text')
return result
}
Loading
Loading