diff --git a/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.test.ts b/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.test.ts index 7b22761862..9d84c405e4 100644 --- a/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.test.ts +++ b/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.test.ts @@ -6,7 +6,7 @@ import {downloadBulkOperationResults} from './download-bulk-operation-results.js 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 {OrganizationApp} from '../../models/organization.js' -import {renderSuccess, renderWarning, renderError} from '@shopify/cli-kit/node/ui' +import {renderSuccess, renderWarning, renderError, renderInfo} from '@shopify/cli-kit/node/ui' import {ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session' import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs' import {joinPath} from '@shopify/cli-kit/node/path' @@ -355,7 +355,6 @@ describe('executeBulkOperation', () => { watch: true, }) - expect(watchBulkOperation).toHaveBeenCalledWith(mockAdminSession, createdBulkOperation.id) expect(renderSuccess).toHaveBeenCalledWith( expect.objectContaining({ headline: expect.stringContaining('Bulk operation succeeded:'), @@ -363,6 +362,38 @@ describe('executeBulkOperation', () => { ) }) + test('renders help message in an info banner when watch is provided and user aborts', async () => { + const query = '{ products { edges { node { id } } } }' + const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = { + bulkOperation: createdBulkOperation, + userErrors: [], + } + const runningOperation = { + ...createdBulkOperation, + status: 'RUNNING' as const, + objectCount: '100', + } + + vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse) + vi.mocked(watchBulkOperation).mockImplementation(async (_session, _id, signal, onAbort) => { + onAbort() + return runningOperation + }) + + await executeBulkOperation({ + remoteApp: mockRemoteApp, + storeFqdn, + query, + watch: true, + }) + + expect(renderInfo).toHaveBeenCalledWith({ + headline: `Bulk operation ${createdBulkOperation.id} is still running in the background.`, + body: ['Monitor its progress with:', {command: expect.stringContaining('shopify app bulk status')}], + }) + expect(downloadBulkOperationResults).not.toHaveBeenCalled() + }) + test('writes results to file when --output-file flag is provided', async () => { const query = '{ products { edges { node { id } } } }' const outputFile = '/tmp/results.jsonl' @@ -450,7 +481,6 @@ describe('executeBulkOperation', () => { watch: true, }) - expect(watchBulkOperation).toHaveBeenCalledWith(mockAdminSession, createdBulkOperation.id) expect(renderError).toHaveBeenCalledWith( expect.objectContaining({ headline: expect.any(String), diff --git a/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.ts b/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.ts index 28929a5acd..ed51bcb4f0 100644 --- a/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.ts +++ b/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.ts @@ -4,10 +4,11 @@ import {watchBulkOperation, type BulkOperation} from './watch-bulk-operation.js' import {formatBulkOperationStatus} from './format-bulk-operation-status.js' import {downloadBulkOperationResults} from './download-bulk-operation-results.js' import {OrganizationApp} from '../../models/organization.js' -import {renderSuccess, renderInfo, renderError, renderWarning} from '@shopify/cli-kit/node/ui' +import {renderSuccess, renderInfo, renderError, renderWarning, TokenItem} from '@shopify/cli-kit/node/ui' import {outputContent, outputToken, outputResult} from '@shopify/cli-kit/node/output' import {ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session' import {AbortError, BugError} from '@shopify/cli-kit/node/error' +import {AbortController} from '@shopify/cli-kit/node/abort' import {parse} from 'graphql' import {readFile, writeFile, fileExists} from '@shopify/cli-kit/node/fs' @@ -76,8 +77,19 @@ export async function executeBulkOperation(input: ExecuteBulkOperationInput): Pr const createdOperation = bulkOperationResponse?.bulkOperation if (createdOperation) { if (watch) { - const finishedOperation = await watchBulkOperation(adminSession, createdOperation.id) - await renderBulkOperationResult(finishedOperation, outputFile) + const abortController = new AbortController() + const operation = await watchBulkOperation(adminSession, createdOperation.id, abortController.signal, () => + abortController.abort(), + ) + + if (abortController.signal.aborted) { + renderInfo({ + headline: `Bulk operation ${operation.id} is still running in the background.`, + body: statusCommandHelpMessage(operation.id), + }) + } else { + await renderBulkOperationResult(operation, outputFile) + } } else { await renderBulkOperationResult(createdOperation, outputFile) } @@ -105,7 +117,11 @@ async function renderBulkOperationResult(operation: BulkOperation, outputFile?: switch (operation.status) { case 'CREATED': - renderSuccess({headline: 'Bulk operation started.', customSections}) + renderSuccess({ + headline: 'Bulk operation started.', + body: statusCommandHelpMessage(operation.id), + customSections, + }) break case 'COMPLETED': if (operation.url) { @@ -147,6 +163,10 @@ function validateGraphQLDocument(graphqlOperation: string, variablesJsonl?: stri } } +function statusCommandHelpMessage(operationId: string): TokenItem { + return ['Monitor its progress with:', {command: `shopify app bulk status --id="${operationId}}"`}] +} + function isMutation(graphqlOperation: string): boolean { const document = parse(graphqlOperation) const operation = document.definitions.find((def) => def.kind === 'OperationDefinition') diff --git a/packages/app/src/cli/services/bulk-operations/watch-bulk-operation.test.ts b/packages/app/src/cli/services/bulk-operations/watch-bulk-operation.test.ts index 7a94c77151..510f48633e 100644 --- a/packages/app/src/cli/services/bulk-operations/watch-bulk-operation.test.ts +++ b/packages/app/src/cli/services/bulk-operations/watch-bulk-operation.test.ts @@ -5,6 +5,7 @@ import {sleep} from '@shopify/cli-kit/node/system' import {renderSingleTask} from '@shopify/cli-kit/node/ui' import {describe, test, expect, vi, beforeEach} from 'vitest' import {outputContent} from '@shopify/cli-kit/node/output' +import {AbortController} from '@shopify/cli-kit/node/abort' vi.mock('./format-bulk-operation-status.js') vi.mock('@shopify/cli-kit/node/api/admin') @@ -14,6 +15,7 @@ vi.mock('@shopify/cli-kit/node/ui') describe('watchBulkOperation', () => { const mockAdminSession = {token: 'test-token', storeFqdn: 'test.myshopify.com'} const operationId = 'gid://shopify/BulkOperation/123' + let abortController: AbortController const runningOperation = { id: operationId, @@ -30,8 +32,13 @@ describe('watchBulkOperation', () => { } beforeEach(() => { + abortController = new AbortController() vi.mocked(sleep).mockResolvedValue() vi.mocked(formatBulkOperationStatus).mockReturnValue(outputContent`formatted status`) + vi.mocked(renderSingleTask).mockImplementation(async ({task, onAbort}) => { + if (onAbort) onAbort() + return task(() => {}) + }) }) test('polls until operation completes and returns the final operation', async () => { @@ -40,11 +47,7 @@ describe('watchBulkOperation', () => { .mockResolvedValueOnce({bulkOperation: runningOperation}) .mockResolvedValueOnce({bulkOperation: completedOperation}) - vi.mocked(renderSingleTask).mockImplementation(async ({task}) => { - return task(() => {}) - }) - - const result = await watchBulkOperation(mockAdminSession, operationId) + const result = await watchBulkOperation(mockAdminSession, operationId, abortController.signal, () => {}) expect(result).toEqual(completedOperation) expect(adminRequestDoc).toHaveBeenCalledTimes(3) @@ -65,11 +68,7 @@ describe('watchBulkOperation', () => { .mockResolvedValueOnce({bulkOperation: runningOperation}) .mockResolvedValueOnce({bulkOperation: terminalOperation}) - vi.mocked(renderSingleTask).mockImplementation(async ({task}) => { - return task(() => {}) - }) - - const result = await watchBulkOperation(mockAdminSession, operationId) + const result = await watchBulkOperation(mockAdminSession, operationId, abortController.signal, () => {}) expect(result).toEqual(terminalOperation) expect(adminRequestDoc).toHaveBeenCalledTimes(3) @@ -97,7 +96,7 @@ describe('watchBulkOperation', () => { return task(mockUpdateStatus) }) - await watchBulkOperation(mockAdminSession, operationId) + await watchBulkOperation(mockAdminSession, operationId, abortController.signal, () => {}) expect(mockUpdateStatus).toHaveBeenNthCalledWith(1, outputContent`processed 10 objects`) expect(mockUpdateStatus).toHaveBeenNthCalledWith(2, outputContent`processed 20 objects`) @@ -107,10 +106,34 @@ describe('watchBulkOperation', () => { test('throws when operation not found', async () => { vi.mocked(adminRequestDoc).mockResolvedValue({bulkOperation: null}) - vi.mocked(renderSingleTask).mockImplementation(async ({task}) => { - return task(() => {}) + await expect(watchBulkOperation(mockAdminSession, operationId, abortController.signal, () => {})).rejects.toThrow( + 'bulk operation not found', + ) + }) + + describe('when signal is aborted during polling', () => { + beforeEach(() => { + let callCount = 0 + vi.mocked(adminRequestDoc).mockImplementation(async () => { + callCount++ + if (callCount === 2) { + abortController.abort() + } + return {bulkOperation: runningOperation} + }) }) - await expect(watchBulkOperation(mockAdminSession, operationId)).rejects.toThrow('bulk operation not found') + test('returns current state of the operation, even if it is not terminal', async () => { + const result = await watchBulkOperation(mockAdminSession, operationId, abortController.signal, () => {}) + + expect(result.status).toBe('RUNNING') + expect(result).toEqual(runningOperation) + }) + + test('calls the onAbort callback', async () => { + const onAbort = vi.fn() + await watchBulkOperation(mockAdminSession, operationId, abortController.signal, onAbort) + expect(onAbort).toHaveBeenCalled() + }) }) }) diff --git a/packages/app/src/cli/services/bulk-operations/watch-bulk-operation.ts b/packages/app/src/cli/services/bulk-operations/watch-bulk-operation.ts index e69e9e0c1b..2988b54b0c 100644 --- a/packages/app/src/cli/services/bulk-operations/watch-bulk-operation.ts +++ b/packages/app/src/cli/services/bulk-operations/watch-bulk-operation.ts @@ -8,6 +8,7 @@ import {sleep} from '@shopify/cli-kit/node/system' import {AdminSession} from '@shopify/cli-kit/node/session' import {outputContent} from '@shopify/cli-kit/node/output' import {renderSingleTask} from '@shopify/cli-kit/node/ui' +import {AbortSignal} from '@shopify/cli-kit/node/abort' const TERMINAL_STATUSES = ['COMPLETED', 'FAILED', 'CANCELED', 'EXPIRED'] const POLL_INTERVAL_SECONDS = 5 @@ -15,11 +16,16 @@ const API_VERSION = '2026-01' export type BulkOperation = NonNullable -export async function watchBulkOperation(adminSession: AdminSession, operationId: string): Promise { +export async function watchBulkOperation( + adminSession: AdminSession, + operationId: string, + abortSignal: AbortSignal, + onAbort: () => void, +): Promise { return renderSingleTask({ title: outputContent`Polling bulk operation...`, task: async (updateStatus) => { - const poller = pollBulkOperation(adminSession, operationId) + const poller = pollBulkOperation(adminSession, operationId, abortSignal) while (true) { // eslint-disable-next-line no-await-in-loop @@ -31,12 +37,14 @@ export async function watchBulkOperation(adminSession: AdminSession, operationId } } }, + onAbort, }) } async function* pollBulkOperation( adminSession: AdminSession, operationId: string, + abortSignal: AbortSignal, ): AsyncGenerator { while (true) { // eslint-disable-next-line no-await-in-loop @@ -48,14 +56,17 @@ async function* pollBulkOperation( const latestOperationState = response.bulkOperation - if (TERMINAL_STATUSES.includes(latestOperationState.status)) { + if (TERMINAL_STATUSES.includes(latestOperationState.status) || abortSignal.aborted) { return latestOperationState } else { yield latestOperationState } // eslint-disable-next-line no-await-in-loop - await sleep(POLL_INTERVAL_SECONDS) + await Promise.race([ + sleep(POLL_INTERVAL_SECONDS), + new Promise((resolve) => abortSignal.addEventListener('abort', resolve)), + ]) } } diff --git a/packages/cli-kit/src/private/node/ui/components/SingleTask.tsx b/packages/cli-kit/src/private/node/ui/components/SingleTask.tsx index 3dbc0ec356..563f4f4001 100644 --- a/packages/cli-kit/src/private/node/ui/components/SingleTask.tsx +++ b/packages/cli-kit/src/private/node/ui/components/SingleTask.tsx @@ -1,21 +1,33 @@ import {LoadingBar} from './LoadingBar.js' -import {useExitOnCtrlC} from '../hooks/use-exit-on-ctrl-c.js' +import {handleCtrlC} from '../../ui.js' import {TokenizedString} from '../../../../public/node/output.js' import React, {useEffect, useState} from 'react' -import {useApp} from 'ink' +import {useApp, useInput, useStdin} from 'ink' interface SingleTaskProps { title: TokenizedString task: (updateStatus: (status: TokenizedString) => void) => Promise onComplete?: (result: T) => void + onAbort?: () => void noColor?: boolean } -const SingleTask = ({task, title, onComplete, noColor}: SingleTaskProps) => { +const SingleTask = ({task, title, onComplete, onAbort, noColor}: SingleTaskProps) => { const [status, setStatus] = useState(title) const [isDone, setIsDone] = useState(false) const {exit: unmountInk} = useApp() - useExitOnCtrlC() + const {isRawModeSupported} = useStdin() + + useInput( + (input, key) => { + if (onAbort) { + handleCtrlC(input, key, onAbort) + } else { + handleCtrlC(input, key) + } + }, + {isActive: Boolean(isRawModeSupported)}, + ) useEffect(() => { task(setStatus) diff --git a/packages/cli-kit/src/public/node/ui.tsx b/packages/cli-kit/src/public/node/ui.tsx index c57de9d013..6a44d696a1 100644 --- a/packages/cli-kit/src/public/node/ui.tsx +++ b/packages/cli-kit/src/public/node/ui.tsx @@ -490,6 +490,7 @@ export async function renderTasks( export interface RenderSingleTaskOptions { title: TokenizedString task: (updateStatus: (status: TokenizedString) => void) => Promise + onAbort?: () => void renderOptions?: RenderOptions } @@ -504,10 +505,15 @@ export interface RenderSingleTaskOptions { * ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ * Loading app ... */ -export async function renderSingleTask({title, task, renderOptions}: RenderSingleTaskOptions): Promise { +export async function renderSingleTask({ + title, + task, + onAbort, + renderOptions, +}: RenderSingleTaskOptions): Promise { // eslint-disable-next-line max-params return new Promise((resolve, reject) => { - render(, { + render(, { ...renderOptions, exitOnCtrlC: false, }).catch(reject)