From 6616f2e67394225b41f0b34da3f5cb4fed498637 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Tue, 6 Oct 2026 10:25:09 +0200 Subject: [PATCH 1/2] Add typed JSON output to app bulk cancel --- .changeset/app-bulk-cancel-json.md | 5 + .../generated/generated_docs_data_v2.json | 11 +- .../src/cli/commands/app/bulk/cancel.test.ts | 11 +- .../app/src/cli/commands/app/bulk/cancel.ts | 33 ++++- .../src/cli/commands/app/bulk/output.test.ts | 106 +++++++++----- .../cancel-bulk-operation.test.ts | 67 ++++++++- .../bulk-operations/cancel-bulk-operation.ts | 77 ++-------- .../services/bulk-operations/cancel-result.ts | 74 ++++++++++ .../services/bulk-operations/result.test.ts | 56 +++++++- .../services/bulk-operations/types.test.ts | 13 +- .../src/cli/services/bulk-operations/types.ts | 13 +- packages/cli/README.md | 134 +++++++++++++++++- packages/cli/oclif.manifest.json | 12 +- .../rules/json-output-command-exceptions.js | 1 - 14 files changed, 488 insertions(+), 125 deletions(-) create mode 100644 .changeset/app-bulk-cancel-json.md create mode 100644 packages/app/src/cli/services/bulk-operations/cancel-result.ts diff --git a/.changeset/app-bulk-cancel-json.md b/.changeset/app-bulk-cancel-json.md new file mode 100644 index 00000000000..6fe347d18a2 --- /dev/null +++ b/.changeset/app-bulk-cancel-json.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli': minor +--- + +Add typed JSON output to app bulk cancel. diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 3c454479b9a..194b4e02be4 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -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", @@ -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 '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * The bulk operation ID to cancel (numeric ID or full GID).\n * @environment SHOPIFY_FLAG_ID\n */\n '--id ': 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 '?: 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 '?: 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 '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * The bulk operation ID to cancel (numeric ID or full GID).\n * @environment SHOPIFY_FLAG_ID\n */\n '--id ': 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 '?: 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 '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, "appbulkexecute": { diff --git a/packages/app/src/cli/commands/app/bulk/cancel.test.ts b/packages/app/src/cli/commands/app/bulk/cancel.test.ts index cecdfc3ca40..ae8d85883e8 100644 --- a/packages/app/src/cli/commands/app/bulk/cancel.test.ts +++ b/packages/app/src/cli/commands/app/bulk/cancel.test.ts @@ -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 { @@ -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() @@ -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 () => { @@ -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, diff --git a/packages/app/src/cli/commands/app/bulk/cancel.ts b/packages/app/src/cli/commands/app/bulk/cancel.ts index 7cc9ee43687..466359a44ea 100644 --- a/packages/app/src/cli/commands/app/bulk/cancel.ts +++ b/packages/app/src/cli/commands/app/bulk/cancel.ts @@ -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).', @@ -28,17 +34,32 @@ export default class BulkCancel extends AppLinkedCommand { }), } + static get jsonOutputSchema() { + return cancelBulkOperationJsonOutputSchema + } + async run(): Promise { 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} } diff --git a/packages/app/src/cli/commands/app/bulk/output.test.ts b/packages/app/src/cli/commands/app/bulk/output.test.ts index d3bd76637e6..c1436f7c85e 100644 --- a/packages/app/src/cli/commands/app/bulk/output.test.ts +++ b/packages/app/src/cli/commands/app/bulk/output.test.ts @@ -1,23 +1,27 @@ +import {testBulkOperation, testBulkOperationContext} from '../../../services/bulk-operations/bulk-operation.test-data.js' import BulkStatus from './status.js' -import { - testBulkOperation, - testBulkOperationContext, -} from '../../../services/bulk-operations/bulk-operation.test-data.js' +import BulkCancel from './cancel.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()), + createAdminSessionAsApp: vi.fn(), resolveApiVersion: vi.fn(), })) @@ -30,14 +34,18 @@ vi.mock('@shopify/cli-kit/node/api/bulk-operations', async (importOriginal) => ( ...(await importOriginal()), fetchBulkOperationById: vi.fn(), fetchRecentBulkOperations: vi.fn(), + cancelBulkOperationRequest: vi.fn(), })) +const originalExitCode = process.exitCode + +afterEach(() => { + vi.unstubAllEnvs() + process.exitCode = originalExitCode +}) + function bulkOperation(overrides: Partial = {}): BulkOperation { - return testBulkOperation({ - objectCount: '900719925474099312345', - createdAt: '2026-09-01T02:00:00.789+02:00', - ...overrides, - }) +return testBulkOperation({objectCount: '900719925474099312345', createdAt: '2026-09-01T02:00:00.789+02:00', ...overrides}) } function expectedOperation() { @@ -54,13 +62,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() @@ -118,33 +125,68 @@ 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([ - {json: true, noInput: false}, - {json: true, noInput: true}, - {json: false, noInput: false}, - {json: false, noInput: true}, -])('status keeps output mode $json independent from input policy $noInput', async ({json, noInput}) => { + {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}, {json: true, noInput: true}, {json: false, noInput: false}, {json: false, noInput: true}])('status keeps output mode $json independent from input policy $noInput', async ({json, noInput}) => { vi.mocked(fetchBulkOperationById).mockResolvedValue(bulkOperation()) const argv = ['--id', '123', ...(json ? ['--json'] : []), ...(noInput ? ['--no-input'] : [])] await withCapturedStandardStreams(async ({stdout, stderr}) => { const result = await runCommand(BulkStatus, argv) expect(result.app).toBeDefined() if (json) { - expect(JSON.parse(stdout())).toEqual({ - storeDomain: 'shop.myshopify.com', - apiVersion: '2026-01', - operationGid: 'gid://shopify/BulkOperation/123', - operation: expectedOperation(), - }) + expect(JSON.parse(stdout())).toEqual({storeDomain: 'shop.myshopify.com', apiVersion: '2026-01', operationGid: 'gid://shopify/BulkOperation/123', operation: expectedOperation()}) assertDiagnosticEvents(stderr(), 'Checking bulk operation status.') } else { expect(stdout()).toBe('') diff --git a/packages/app/src/cli/services/bulk-operations/cancel-bulk-operation.test.ts b/packages/app/src/cli/services/bulk-operations/cancel-bulk-operation.test.ts index efee9318fe7..503b391e1cb 100644 --- a/packages/app/src/cli/services/bulk-operations/cancel-bulk-operation.test.ts +++ b/packages/app/src/cli/services/bulk-operations/cancel-bulk-operation.test.ts @@ -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' @@ -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: { @@ -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({ @@ -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), @@ -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( @@ -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.', @@ -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({ @@ -162,3 +208,12 @@ describe('cancelBulkOperation', () => { ) }) }) + +async function cancelAndPresentBulkOperation( + input: Parameters[0] & {organization: Organization}, +) { + logBulkOperationStart('Canceling bulk operation.', input, 'text') + const result = await runCancelBulkOperation(input) + renderCancelBulkOperationResult(result, input.operationId, 'text') + return result +} diff --git a/packages/app/src/cli/services/bulk-operations/cancel-bulk-operation.ts b/packages/app/src/cli/services/bulk-operations/cancel-bulk-operation.ts index f16311971f3..58c9716d0db 100644 --- a/packages/app/src/cli/services/bulk-operations/cancel-bulk-operation.ts +++ b/packages/app/src/cli/services/bulk-operations/cancel-bulk-operation.ts @@ -1,76 +1,23 @@ -import {formatOperationInfo, createAdminSessionAsApp} from '../graphql/common.js' -import {OrganizationApp, Organization} from '../../models/organization.js' -import { - cancelBulkOperationRequest, - renderBulkOperationUserErrors, - formatBulkOperationCancellationResult, - extractBulkOperationId, -} from '@shopify/cli-kit/node/api/bulk-operations' -import {renderInfo, renderError, renderSuccess, renderWarning, TokenItem} from '@shopify/cli-kit/node/ui' -import {outputContent, outputToken} from '@shopify/cli-kit/node/output' +import {createAdminSessionAsApp} from '../graphql/common.js' +import {OrganizationApp} from '../../models/organization.js' +import {BULK_OPERATIONS_MIN_API_VERSION, cancelBulkOperationRequest} from '@shopify/cli-kit/node/api/bulk-operations' +import type {CancelBulkOperationResult} from './types.js' interface CancelBulkOperationOptions { - organization: Organization storeFqdn: string operationId: string remoteApp: OrganizationApp } -export async function cancelBulkOperation(options: CancelBulkOperationOptions): Promise { - const {organization, storeFqdn, operationId, remoteApp} = options - - renderInfo({ - headline: 'Canceling bulk operation.', - body: [ - { - list: { - items: [`ID: ${operationId}`, ...formatOperationInfo({organization, remoteApp, storeFqdn})], - }, - }, - ], - }) - +export async function cancelBulkOperation(options: CancelBulkOperationOptions): Promise { + const {storeFqdn, operationId, remoteApp} = options const adminSession = await createAdminSessionAsApp(remoteApp, storeFqdn) + const response = await cancelBulkOperationRequest({adminSession, operationId}) - const bulkOperationCancel = await cancelBulkOperationRequest({adminSession, operationId}) - - if (bulkOperationCancel?.userErrors?.length) { - renderBulkOperationUserErrors(bulkOperationCancel.userErrors, 'Failed to cancel bulk operation.') - return - } - - const operation = bulkOperationCancel?.bulkOperation - if (operation) { - const result = formatBulkOperationCancellationResult(operation) - // The engine is command-agnostic; this command writes its own "check status" hint. - const body: TokenItem | undefined = - operation.status === 'CANCELING' - ? [ - 'This may take a few moments. Check the status with:\n', - {command: `shopify app bulk status --id=${extractBulkOperationId(operation.id)}`}, - ] - : result.body - const renderOptions = { - headline: result.headline, - ...(body && {body}), - ...(result.customSections && {customSections: result.customSections}), - } - - switch (result.renderType) { - case 'success': - renderSuccess(renderOptions) - break - case 'warning': - renderWarning(renderOptions) - break - case 'info': - renderInfo(renderOptions) - break - } - } else { - renderError({ - headline: 'Bulk operation not found or could not be canceled.', - body: outputContent`ID: ${outputToken.yellow(operationId)}`.value, - }) + return { + store: storeFqdn, + apiVersion: BULK_OPERATIONS_MIN_API_VERSION, + operation: response?.bulkOperation ?? null, + userErrors: response?.userErrors ?? [], } } diff --git a/packages/app/src/cli/services/bulk-operations/cancel-result.ts b/packages/app/src/cli/services/bulk-operations/cancel-result.ts new file mode 100644 index 00000000000..aed2831fc97 --- /dev/null +++ b/packages/app/src/cli/services/bulk-operations/cancel-result.ts @@ -0,0 +1,74 @@ +import {cancelBulkOperationJsonOutputSchema, type CancelBulkOperationResult} from './types.js' +import {bulkOperationJsonContext, toBulkOperationJson} from './json.js' +import { + renderBulkOperationUserErrors, + formatBulkOperationCancellationResult, + extractBulkOperationId, +} from '@shopify/cli-kit/node/api/bulk-operations' +import {renderInfo, renderError, renderSuccess, renderWarning, TokenItem} from '@shopify/cli-kit/node/ui' +import {outputContent, outputToken, outputResult} from '@shopify/cli-kit/node/output' +import {AbortError} from '@shopify/cli-kit/node/error' + +export function renderCancelBulkOperationResult( + result: CancelBulkOperationResult, + operationId: string, + format: 'text' | 'json', +): void { + if (format === 'json') { + if (result.userErrors.length || !result.operation) { + const error = new AbortError('Failed to cancel bulk operation.') + error.details = { + ...bulkOperationJsonContext(result), + operationGid: operationId, + userErrors: result.userErrors, + } + throw error + } + outputResult( + cancelBulkOperationJsonOutputSchema.encode({ + ...bulkOperationJsonContext(result), + status: 'success', + operation: toBulkOperationJson(result.operation), + }), + ) + return + } + if (result.userErrors.length) { + renderBulkOperationUserErrors(result.userErrors, 'Failed to cancel bulk operation.') + return + } + + const operation = result.operation + if (operation) { + const cancellation = formatBulkOperationCancellationResult(operation) + const body: TokenItem | undefined = + operation.status === 'CANCELING' + ? [ + 'This may take a few moments. Check the status with:\n', + {command: `shopify app bulk status --id=${extractBulkOperationId(operation.id)}`}, + ] + : cancellation.body + const renderOptions = { + headline: cancellation.headline, + ...(body && {body}), + ...(cancellation.customSections && {customSections: cancellation.customSections}), + } + + switch (cancellation.renderType) { + case 'success': + renderSuccess(renderOptions) + break + case 'warning': + renderWarning(renderOptions) + break + case 'info': + renderInfo(renderOptions) + break + } + } else { + renderError({ + headline: 'Bulk operation not found or could not be canceled.', + body: outputContent`ID: ${outputToken.yellow(operationId)}`.value, + }) + } +} diff --git a/packages/app/src/cli/services/bulk-operations/result.test.ts b/packages/app/src/cli/services/bulk-operations/result.test.ts index 6a22e838bae..ffea97d30ce 100644 --- a/packages/app/src/cli/services/bulk-operations/result.test.ts +++ b/packages/app/src/cli/services/bulk-operations/result.test.ts @@ -1,16 +1,68 @@ import {testBulkOperation} from './bulk-operation.test-data.js' +import {renderCancelBulkOperationResult} from './cancel-result.js' import {renderBulkOperationStatusResult} from './status-result.js' -import {expect, test, vi} from 'vitest' +import {afterEach, beforeEach, expect, test, vi} from 'vitest' +import {renderSuccess, renderError, renderTable, renderWarning} from '@shopify/cli-kit/node/ui' +import {AbortError} from '@shopify/cli-kit/node/error' import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' vi.mock('@shopify/cli-kit/node/ui') +const originalExitCode = process.exitCode + +beforeEach(() => { + mockAndCaptureOutput().clear() +}) + +afterEach(() => { + process.exitCode = originalExitCode +}) + +function completedResult() { + return { + operation: testBulkOperation({status: 'COMPLETED', completedAt: '2026-09-01T00:01:00Z', url: 'https://example.com/results.jsonl'}), + } +} + +test('outputs successful cancellation with the same operation projection', () => { + const output = mockAndCaptureOutput() + renderCancelBulkOperationResult( + {operation: completedResult().operation, userErrors: []}, + 'gid://shopify/BulkOperation/123', + 'json', + ) + expect(JSON.parse(output.output())).toEqual({ + storeDomain: null, + apiVersion: null, + status: 'success', + operation: expectedOperation(), + }) + expect(renderWarning).not.toHaveBeenCalled() +}) + +test('routes cancellation user errors through the global fatal-error path', () => { + expect(() => + renderCancelBulkOperationResult( + {operation: null, userErrors: [{message: 'Cannot cancel'}]}, + 'gid://shopify/BulkOperation/123', + 'json', + ), + ).toThrow(AbortError) + expect(mockAndCaptureOutput().output()).toBe('') +}) + test('does not invent a list operation type or claim completeness for a capped list', () => { const output = mockAndCaptureOutput() - const {type: _type, ...operation} = testBulkOperation() + const {type: _type, ...operation} = completedResult().operation! renderBulkOperationStatusResult({operations: Array.from({length: 100}, () => operation)}, 'json') const result = JSON.parse(output.output()) expect(result.operations[0]).not.toHaveProperty('type') expect(result.operations[0].gid).toBe(operation.id) expect(result.pageInfo).toEqual({hasNextPage: null}) }) + +test('keeps missing cancellation nonfatal in text mode', () => { + renderCancelBulkOperationResult({operation: null, userErrors: []}, '123', 'text') + expect(renderError).toHaveBeenCalledWith(expect.objectContaining({headline: 'Bulk operation not found or could not be canceled.'})) + expect(process.exitCode).toBe(originalExitCode) +}) diff --git a/packages/app/src/cli/services/bulk-operations/types.test.ts b/packages/app/src/cli/services/bulk-operations/types.test.ts index 09a10fb0bce..638f098ccba 100644 --- a/packages/app/src/cli/services/bulk-operations/types.test.ts +++ b/packages/app/src/cli/services/bulk-operations/types.test.ts @@ -1,16 +1,17 @@ -import {bulkOperationStatusJsonOutputSchema} from './types.js' +import {bulkOperationStatusJsonOutputSchema, cancelBulkOperationJsonOutputSchema} from './types.js' import {toBulkOperationJson} from './json.js' import {testBulkOperation} from './bulk-operation.test-data.js' import {expect, test} from 'vitest' test('status result rejects unselected fields and lists without pagination metadata', () => { const context = {storeDomain: 'shop.myshopify.com', apiVersion: '2026-01'} - const value = { - ...context, - operationGid: 'gid://shopify/BulkOperation/123', - operation: toBulkOperationJson(testBulkOperation()), - } + const value = {...context, operationGid: 'gid://shopify/BulkOperation/123', operation: toBulkOperationJson(testBulkOperation())} expect(() => bulkOperationStatusJsonOutputSchema.validate({...value, token: 'secret'})).toThrow() expect(() => bulkOperationStatusJsonOutputSchema.validate({...context, operations: []})).toThrow() expect(bulkOperationStatusJsonOutputSchema.jsonSchema.definitions?.BulkOperation).toBeDefined() }) + +test.each([{name: 'cancel', schema: cancelBulkOperationJsonOutputSchema}])('$name uses the common operation projection', ({schema}) => { + const value = {storeDomain: 'shop.myshopify.com', apiVersion: '2026-01', status: 'success' as const, operation: toBulkOperationJson(testBulkOperation())} + expect(JSON.parse(schema.encode(value))).toEqual(value) +}) diff --git a/packages/app/src/cli/services/bulk-operations/types.ts b/packages/app/src/cli/services/bulk-operations/types.ts index 1ca99816af9..63141c88265 100644 --- a/packages/app/src/cli/services/bulk-operations/types.ts +++ b/packages/app/src/cli/services/bulk-operations/types.ts @@ -1,7 +1,7 @@ import {BulkOperationGidSchema, BulkOperationSchema, BulkOperationContextSchema} from './common.js' +import type {BulkOperationContext} from './common.js' import {defineJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' import {zod} from '@shopify/cli-kit/node/schema' -import type {BulkOperationContext} from './common.js' import type {BulkOperation} from '@shopify/cli-kit/node/api/bulk-operations' const ListedBulkOperationSchema = BulkOperationSchema.omit({type: true}) @@ -33,3 +33,14 @@ export const bulkOperationStatusJsonOutputSchema = defineJsonOutputSchema({ export type BulkOperationStatusResult = BulkOperationContext & ({operationId: string; operation: BulkOperation | null} | {operations: Omit[]}) + +export const cancelBulkOperationJsonOutputSchema = defineJsonOutputSchema({ + name: 'CancelBulkOperationResult', + schema: BulkOperationContextSchema.extend({status: zod.literal('success'), operation: BulkOperationSchema}), + definitions: {BulkOperation: BulkOperationSchema}, +}) + +export interface CancelBulkOperationResult extends BulkOperationContext { + operation: BulkOperation | null + userErrors: {field?: string[] | null; message: string}[] +} diff --git a/packages/cli/README.md b/packages/cli/README.md index 360bbd1005e..68e94d93dc5 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -192,14 +192,18 @@ Cancel a bulk operation. ``` USAGE - $ shopify app bulk cancel --id [--auth-alias ] [--client-id | -c ] [--json-schema] - [--no-color] [--no-input] [--path ] [--reset | ] [-s ] [--verbose] + $ shopify app bulk cancel --id [--auth-alias ] [--client-id | -c ] [-j] + [--json-schema] [--no-color] [--no-input] [--path ] [--reset | ] [-s ] [--verbose] FLAGS -c, --config= The name of the app configuration. [env: SHOPIFY_FLAG_APP_CONFIG] + -j, --json + Output the result as JSON. Automatically disables color output. + [env: SHOPIFY_FLAG_JSON] + -s, --store= The store domain. Must be an existing dev store. [env: SHOPIFY_FLAG_STORE] @@ -244,6 +248,132 @@ DESCRIPTION Cancel a bulk operation. Cancels a running bulk operation by ID. + + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `CancelBulkOperationResult` schema. + + ```json + { + "type": "object", + "properties": { + "storeDomain": { + "anyOf": [ + { + "type": "string", + "pattern": "^[^.]+\\.myshopify\\.com$" + }, + { + "type": "null" + } + ] + }, + "apiVersion": { + "type": [ + "string", + "null" + ], + "description": "The API version selected for the request." + }, + "status": { + "type": "string", + "const": "success" + }, + "operation": { + "$ref": "#/definitions/BulkOperation" + } + }, + "required": [ + "storeDomain", + "apiVersion", + "status", + "operation" + ], + "additionalProperties": false, + "title": "CancelBulkOperationResult", + "definitions": { + "BulkOperation": { + "type": "object", + "properties": { + "gid": { + "type": "string", + "pattern": "^gid:\\/\\/shopify\\/BulkOperation\\/[^/]+$", + "description": "The Shopify global ID of a bulk operation." + }, + "type": { + "type": "string" + }, + "status": { + "type": "string" + }, + "errorCode": { + "type": [ + "string", + "null" + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$", + "description": "A UTC ISO 8601 instant with whole seconds and the Z timezone marker." + }, + "completedAt": { + "anyOf": [ + { + "$ref": "#/definitions/BulkOperation/properties/createdAt" + }, + { + "type": "null" + } + ], + "description": "A UTC ISO 8601 instant with whole seconds and the Z timezone marker." + }, + "objectCount": { + "type": "string", + "pattern": "^\\d+$", + "description": "A nonnegative decimal count, without loss of integer precision." + }, + "url": { + "anyOf": [ + { + "type": "string", + "format": "uri" + }, + { + "type": "null" + } + ] + }, + "partialDataUrl": { + "anyOf": [ + { + "type": "string", + "format": "uri" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "gid", + "type", + "status", + "errorCode", + "createdAt", + "completedAt", + "objectCount", + "url", + "partialDataUrl" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` ``` ## `shopify app bulk execute` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 004cc18efea..117f7c094c1 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -114,7 +114,8 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Cancels a running bulk operation by ID.", + "description": "Cancels a running bulk operation by ID.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `CancelBulkOperationResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"storeDomain\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"pattern\": \"^[^.]+\\\\.myshopify\\\\.com$\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"apiVersion\": {\n \"type\": [\n \"string\",\n \"null\"\n ],\n \"description\": \"The API version selected for the request.\"\n },\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"operation\": {\n \"$ref\": \"#/definitions/BulkOperation\"\n }\n },\n \"required\": [\n \"storeDomain\",\n \"apiVersion\",\n \"status\",\n \"operation\"\n ],\n \"additionalProperties\": false,\n \"title\": \"CancelBulkOperationResult\",\n \"definitions\": {\n \"BulkOperation\": {\n \"type\": \"object\",\n \"properties\": {\n \"gid\": {\n \"type\": \"string\",\n \"pattern\": \"^gid:\\\\/\\\\/shopify\\\\/BulkOperation\\\\/[^/]+$\",\n \"description\": \"The Shopify global ID of a bulk operation.\"\n },\n \"type\": {\n \"type\": \"string\"\n },\n \"status\": {\n \"type\": \"string\"\n },\n \"errorCode\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"createdAt\": {\n \"type\": \"string\",\n \"format\": \"date-time\",\n \"pattern\": \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}Z$\",\n \"description\": \"A UTC ISO 8601 instant with whole seconds and the Z timezone marker.\"\n },\n \"completedAt\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/BulkOperation/properties/createdAt\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"description\": \"A UTC ISO 8601 instant with whole seconds and the Z timezone marker.\"\n },\n \"objectCount\": {\n \"type\": \"string\",\n \"pattern\": \"^\\\\d+$\",\n \"description\": \"A nonnegative decimal count, without loss of integer precision.\"\n },\n \"url\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"uri\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"partialDataUrl\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"uri\"\n },\n {\n \"type\": \"null\"\n }\n ]\n }\n },\n \"required\": [\n \"gid\",\n \"type\",\n \"status\",\n \"errorCode\",\n \"createdAt\",\n \"completedAt\",\n \"objectCount\",\n \"url\",\n \"partialDataUrl\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "descriptionWithMarkdown": "Cancels a running bulk operation by ID.", "flags": { "auth-alias": { "description": "Alias of the Shopify account to use for authentication.", @@ -155,6 +156,15 @@ "required": true, "type": "option" }, + "json": { + "allowNo": false, + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "type": "boolean" + }, "json-schema": { "allowNo": false, "description": "Print the command's JSON schemas.", diff --git a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js index fc0d957f138..d496cf68c71 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -3,7 +3,6 @@ const commandExceptions = [ // Existing finite commands awaiting migration. Remove entries as they adopt typed JSON output. // Do not add new finite commands to this section. 'packages/app/src/cli/commands/app/build.ts', - 'packages/app/src/cli/commands/app/bulk/cancel.ts', 'packages/app/src/cli/commands/app/bulk/execute.ts', 'packages/app/src/cli/commands/app/config/link.ts', 'packages/app/src/cli/commands/app/config/pull.ts', From 67f1a19a5b5bf54991ece39917e26e1236eb0a7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Tue, 6 Oct 2026 11:55:30 +0200 Subject: [PATCH 2/2] Keep cancellation coverage focused on its own output behavior --- .../src/cli/commands/app/bulk/output.test.ts | 78 ++++++++++++------- .../services/bulk-operations/result.test.ts | 30 +++---- .../services/bulk-operations/types.test.ts | 22 ++++-- .../src/cli/services/bulk-operations/types.ts | 2 +- 4 files changed, 77 insertions(+), 55 deletions(-) diff --git a/packages/app/src/cli/commands/app/bulk/output.test.ts b/packages/app/src/cli/commands/app/bulk/output.test.ts index c1436f7c85e..ee930dc0dbd 100644 --- a/packages/app/src/cli/commands/app/bulk/output.test.ts +++ b/packages/app/src/cli/commands/app/bulk/output.test.ts @@ -1,14 +1,20 @@ -import {testBulkOperation, testBulkOperationContext} from '../../../services/bulk-operations/bulk-operation.test-data.js' 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 {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 { + 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' @@ -45,7 +51,11 @@ afterEach(() => { }) function bulkOperation(overrides: Partial = {}): BulkOperation { -return testBulkOperation({objectCount: '900719925474099312345', createdAt: '2026-09-01T02:00:00.789+02:00', ...overrides}) + return testBulkOperation({ + objectCount: '900719925474099312345', + createdAt: '2026-09-01T02:00:00.789+02:00', + ...overrides, + }) } function expectedOperation() { @@ -146,47 +156,57 @@ test('cancellation emits one result through the real service, codec, presenter, }) }) -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: '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}, {json: true, noInput: true}, {json: false, noInput: false}, {json: false, noInput: true}])('status keeps output mode $json independent from input policy $noInput', async ({json, noInput}) => { +test.each([ + {json: true, noInput: false}, + {json: true, noInput: true}, + {json: false, noInput: false}, + {json: false, noInput: true}, +])('status keeps output mode $json independent from input policy $noInput', async ({json, noInput}) => { vi.mocked(fetchBulkOperationById).mockResolvedValue(bulkOperation()) const argv = ['--id', '123', ...(json ? ['--json'] : []), ...(noInput ? ['--no-input'] : [])] await withCapturedStandardStreams(async ({stdout, stderr}) => { const result = await runCommand(BulkStatus, argv) expect(result.app).toBeDefined() if (json) { - expect(JSON.parse(stdout())).toEqual({storeDomain: 'shop.myshopify.com', apiVersion: '2026-01', operationGid: 'gid://shopify/BulkOperation/123', operation: expectedOperation()}) + expect(JSON.parse(stdout())).toEqual({ + storeDomain: 'shop.myshopify.com', + apiVersion: '2026-01', + operationGid: 'gid://shopify/BulkOperation/123', + operation: expectedOperation(), + }) assertDiagnosticEvents(stderr(), 'Checking bulk operation status.') } else { expect(stdout()).toBe('') diff --git a/packages/app/src/cli/services/bulk-operations/result.test.ts b/packages/app/src/cli/services/bulk-operations/result.test.ts index ffea97d30ce..24bcc561013 100644 --- a/packages/app/src/cli/services/bulk-operations/result.test.ts +++ b/packages/app/src/cli/services/bulk-operations/result.test.ts @@ -2,7 +2,7 @@ import {testBulkOperation} from './bulk-operation.test-data.js' import {renderCancelBulkOperationResult} from './cancel-result.js' import {renderBulkOperationStatusResult} from './status-result.js' import {afterEach, beforeEach, expect, test, vi} from 'vitest' -import {renderSuccess, renderError, renderTable, renderWarning} from '@shopify/cli-kit/node/ui' +import {renderError} from '@shopify/cli-kit/node/ui' import {AbortError} from '@shopify/cli-kit/node/error' import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' @@ -20,26 +20,14 @@ afterEach(() => { function completedResult() { return { - operation: testBulkOperation({status: 'COMPLETED', completedAt: '2026-09-01T00:01:00Z', url: 'https://example.com/results.jsonl'}), + operation: testBulkOperation({ + status: 'COMPLETED', + completedAt: '2026-09-01T00:01:00Z', + url: 'https://example.com/results.jsonl', + }), } } -test('outputs successful cancellation with the same operation projection', () => { - const output = mockAndCaptureOutput() - renderCancelBulkOperationResult( - {operation: completedResult().operation, userErrors: []}, - 'gid://shopify/BulkOperation/123', - 'json', - ) - expect(JSON.parse(output.output())).toEqual({ - storeDomain: null, - apiVersion: null, - status: 'success', - operation: expectedOperation(), - }) - expect(renderWarning).not.toHaveBeenCalled() -}) - test('routes cancellation user errors through the global fatal-error path', () => { expect(() => renderCancelBulkOperationResult( @@ -53,7 +41,7 @@ test('routes cancellation user errors through the global fatal-error path', () = test('does not invent a list operation type or claim completeness for a capped list', () => { const output = mockAndCaptureOutput() - const {type: _type, ...operation} = completedResult().operation! + const {type: _type, ...operation} = completedResult().operation renderBulkOperationStatusResult({operations: Array.from({length: 100}, () => operation)}, 'json') const result = JSON.parse(output.output()) expect(result.operations[0]).not.toHaveProperty('type') @@ -63,6 +51,8 @@ test('does not invent a list operation type or claim completeness for a capped l test('keeps missing cancellation nonfatal in text mode', () => { renderCancelBulkOperationResult({operation: null, userErrors: []}, '123', 'text') - expect(renderError).toHaveBeenCalledWith(expect.objectContaining({headline: 'Bulk operation not found or could not be canceled.'})) + expect(renderError).toHaveBeenCalledWith( + expect.objectContaining({headline: 'Bulk operation not found or could not be canceled.'}), + ) expect(process.exitCode).toBe(originalExitCode) }) diff --git a/packages/app/src/cli/services/bulk-operations/types.test.ts b/packages/app/src/cli/services/bulk-operations/types.test.ts index 638f098ccba..636f6db7e1a 100644 --- a/packages/app/src/cli/services/bulk-operations/types.test.ts +++ b/packages/app/src/cli/services/bulk-operations/types.test.ts @@ -5,13 +5,25 @@ import {expect, test} from 'vitest' test('status result rejects unselected fields and lists without pagination metadata', () => { const context = {storeDomain: 'shop.myshopify.com', apiVersion: '2026-01'} - const value = {...context, operationGid: 'gid://shopify/BulkOperation/123', operation: toBulkOperationJson(testBulkOperation())} + const value = { + ...context, + operationGid: 'gid://shopify/BulkOperation/123', + operation: toBulkOperationJson(testBulkOperation()), + } expect(() => bulkOperationStatusJsonOutputSchema.validate({...value, token: 'secret'})).toThrow() expect(() => bulkOperationStatusJsonOutputSchema.validate({...context, operations: []})).toThrow() expect(bulkOperationStatusJsonOutputSchema.jsonSchema.definitions?.BulkOperation).toBeDefined() }) -test.each([{name: 'cancel', schema: cancelBulkOperationJsonOutputSchema}])('$name uses the common operation projection', ({schema}) => { - const value = {storeDomain: 'shop.myshopify.com', apiVersion: '2026-01', status: 'success' as const, operation: toBulkOperationJson(testBulkOperation())} - expect(JSON.parse(schema.encode(value))).toEqual(value) -}) +test.each([{name: 'cancel', schema: cancelBulkOperationJsonOutputSchema}])( + '$name uses the common operation projection', + ({schema}) => { + const value = { + storeDomain: 'shop.myshopify.com', + apiVersion: '2026-01', + status: 'success' as const, + operation: toBulkOperationJson(testBulkOperation()), + } + expect(JSON.parse(schema.encode(value))).toEqual(value) + }, +) diff --git a/packages/app/src/cli/services/bulk-operations/types.ts b/packages/app/src/cli/services/bulk-operations/types.ts index 63141c88265..2796f336f6b 100644 --- a/packages/app/src/cli/services/bulk-operations/types.ts +++ b/packages/app/src/cli/services/bulk-operations/types.ts @@ -1,7 +1,7 @@ import {BulkOperationGidSchema, BulkOperationSchema, BulkOperationContextSchema} from './common.js' -import type {BulkOperationContext} from './common.js' import {defineJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' import {zod} from '@shopify/cli-kit/node/schema' +import type {BulkOperationContext} from './common.js' import type {BulkOperation} from '@shopify/cli-kit/node/api/bulk-operations' const ListedBulkOperationSchema = BulkOperationSchema.omit({type: true})