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
1 change: 1 addition & 0 deletions packages/app/src/cli/commands/app/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export default class Execute extends AppLinkedCommand {
variables: flags.variables,
variableFile: flags['variable-file'],
watch: flags.watch,
outputFile: flags['output-file'],
})

return {app: appContextResult.app}
Expand Down
4 changes: 4 additions & 0 deletions packages/app/src/cli/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,8 @@ export const bulkOperationFlags = {
env: 'SHOPIFY_FLAG_WATCH',
default: false,
}),
'output-file': Flags.string({
description: 'The file path where results should be written. If not specified, results will be written to STDOUT.',
env: 'SHOPIFY_FLAG_OUTPUT_FILE',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In theory, if you add this it will throw an error when used without watch

Suggested change
env: 'SHOPIFY_FLAG_OUTPUT_FILE',
env: 'SHOPIFY_FLAG_OUTPUT_FILE',
compatible: ['watch'],

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting. I tried this and it didn't work for me:

pnpm shopify app execute --path="../my-second-test-app" --query="{ products { edges { node { id variants { edges { node { id } } } } } } }" --output-file='hello.jsonl'
image

but maybe I'm missing something?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me know and I'm happy to add this in a followup though :)

}),
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {downloadBulkOperationResults} from './download-bulk-operation-results.js'
import {fetch} from '@shopify/cli-kit/node/http'
import {describe, test, expect, vi} from 'vitest'

vi.mock('@shopify/cli-kit/node/http')

describe('downloadBulkOperationResults', () => {
test('returns text content when fetch is successful', async () => {
const mockUrl = 'https://example.com/results.jsonl'
const mockContent = '{"id":"gid://shopify/Product/123"}\n{"id":"gid://shopify/Product/456"}'

vi.mocked(fetch).mockResolvedValue({
ok: true,
text: async () => mockContent,
} as Awaited<ReturnType<typeof fetch>>)

const result = await downloadBulkOperationResults(mockUrl)

expect(fetch).toHaveBeenCalledWith(mockUrl)
expect(result).toBe(mockContent)
})

test('throws error when fetch fails', async () => {
const mockUrl = 'https://example.com/results.jsonl'

vi.mocked(fetch).mockResolvedValue({
ok: false,
statusText: 'Not Found',
} as Awaited<ReturnType<typeof fetch>>)

await expect(downloadBulkOperationResults(mockUrl)).rejects.toThrow(
'Failed to download bulk operation results: Not Found',
)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import {fetch} from '@shopify/cli-kit/node/http'
import {AbortError} from '@shopify/cli-kit/node/error'

export async function downloadBulkOperationResults(url: string): Promise<string> {
const response = await fetch(url)

if (!response.ok) {
throw new AbortError(`Failed to download bulk operation results: ${response.statusText}`)
}

return response.text()
}
Comment on lines +4 to +12
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why have this function in a different file? is just wrapping a call to fetch​ and a error? And is only used in a single place... best to put the call to fetch directly where it is needed. I'd say the tests don't add much value either (you are just testing an if​)

Copy link
Contributor Author

@jordanverasamy jordanverasamy Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah good question! It's really small and like you said, doesnt have any complexity to test. I waffled on whether it should be in its own file, and ultimately decided to, because we're going to want the same behaviour in the bulk status command (more info here) so may as well have it available to reuse.

I'm happy to just duplicate the function in both execute and status if you would prefer, since it's so simple, just a couple lines.

Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,24 @@ import {executeBulkOperation} from './execute-bulk-operation.js'
import {runBulkOperationQuery} from './run-query.js'
import {runBulkOperationMutation} from './run-mutation.js'
import {watchBulkOperation} from './watch-bulk-operation.js'
import {downloadBulkOperationResults} from './download-bulk-operation-results.js'
import {AppLinkedInterface} from '../../models/app/app.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 {renderSuccess, renderWarning, renderError} from '@shopify/cli-kit/node/ui'
import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session'
import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs'
import {joinPath} from '@shopify/cli-kit/node/path'
import {describe, test, expect, vi, beforeEach} from 'vitest'
import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output'
import {describe, test, expect, vi, beforeEach, afterEach} from 'vitest'

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

describe('executeBulkOperation', () => {
const mockApp = {
Expand Down Expand Up @@ -46,6 +50,10 @@ describe('executeBulkOperation', () => {
vi.mocked(ensureAuthenticatedAdmin).mockResolvedValue(mockAdminSession)
})

afterEach(() => {
mockAndCaptureOutput().clear()
})

test('runs query operation when GraphQL document starts with query', async () => {
const query = 'query { products { edges { node { id } } } }'
const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
Expand Down Expand Up @@ -334,6 +342,7 @@ describe('executeBulkOperation', () => {

vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse)
vi.mocked(watchBulkOperation).mockResolvedValue(completedOperation)
vi.mocked(downloadBulkOperationResults).mockResolvedValue('{"id":"gid://shopify/Product/123"}')

await executeBulkOperation({
app: mockApp,
Expand All @@ -346,11 +355,73 @@ describe('executeBulkOperation', () => {
expect(renderSuccess).toHaveBeenCalledWith(
expect.objectContaining({
headline: expect.stringContaining('Bulk operation succeeded:'),
body: expect.arrayContaining([expect.stringContaining('https://example.com/download')]),
}),
)
})

test('writes results to file when --output-file flag is provided', async () => {
const query = '{ products { edges { node { id } } } }'
const outputFile = '/tmp/results.jsonl'
const resultsContent = '{"id":"gid://shopify/Product/123"}\n{"id":"gid://shopify/Product/456"}'

const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
bulkOperation: createdBulkOperation,
userErrors: [],
}
const completedOperation = {
...createdBulkOperation,
status: 'COMPLETED' as const,
url: 'https://example.com/download',
objectCount: '2',
}

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

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

expect(writeFile).toHaveBeenCalledWith(outputFile, resultsContent)
})

test('writes results to stdout when --output-file flag is not provided', async () => {
const query = '{ products { edges { node { id } } } }'
const resultsContent = '{"id":"gid://shopify/Product/123"}\n{"id":"gid://shopify/Product/456"}'

const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
bulkOperation: createdBulkOperation,
userErrors: [],
}
const completedOperation = {
...createdBulkOperation,
status: 'COMPLETED' as const,
url: 'https://example.com/download',
objectCount: '2',
}

const mockOutput = mockAndCaptureOutput()

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

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

expect(mockOutput.info()).toContain(resultsContent)
expect(writeFile).not.toHaveBeenCalled()
})

test.each(['FAILED', 'CANCELED', 'EXPIRED'] as const)(
'waits for operation to finish and renders error when watch is provided and operation finishes with %s status',
async (status) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ import {runBulkOperationQuery} from './run-query.js'
import {runBulkOperationMutation} from './run-mutation.js'
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 {AppLinkedInterface} from '../../models/app/app.js'
import {renderSuccess, renderInfo, renderError, renderWarning} from '@shopify/cli-kit/node/ui'
import {outputContent, outputToken} from '@shopify/cli-kit/node/output'
import {outputContent, outputToken, outputResult} from '@shopify/cli-kit/node/output'
import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session'
import {AbortError} from '@shopify/cli-kit/node/error'
import {parse} from 'graphql'
import {readFile, fileExists} from '@shopify/cli-kit/node/fs'
import {readFile, writeFile, fileExists} from '@shopify/cli-kit/node/fs'

interface ExecuteBulkOperationInput {
app: AppLinkedInterface
Expand All @@ -17,6 +18,7 @@ interface ExecuteBulkOperationInput {
variables?: string[]
variableFile?: string
watch?: boolean
outputFile?: string
}

async function parseVariablesToJsonl(variables?: string[], variableFile?: string): Promise<string | undefined> {
Expand All @@ -37,7 +39,7 @@ async function parseVariablesToJsonl(variables?: string[], variableFile?: string
}

export async function executeBulkOperation(input: ExecuteBulkOperationInput): Promise<void> {
const {app, storeFqdn, query, variables, variableFile, watch = false} = input
const {app, storeFqdn, query, variables, variableFile, outputFile, watch = false} = input

renderInfo({
headline: 'Starting bulk operation.',
Expand Down Expand Up @@ -71,14 +73,14 @@ export async function executeBulkOperation(input: ExecuteBulkOperationInput): Pr
if (createdOperation) {
if (watch) {
const finishedOperation = await watchBulkOperation(adminSession, createdOperation.id)
renderBulkOperationResult(finishedOperation)
await renderBulkOperationResult(finishedOperation, outputFile)
} else {
renderBulkOperationResult(createdOperation)
await renderBulkOperationResult(createdOperation, outputFile)
}
}
}

function renderBulkOperationResult(operation: BulkOperation): void {
async function renderBulkOperationResult(operation: BulkOperation, outputFile?: string): Promise<void> {
const headline = formatBulkOperationStatus(operation).value
const items = [
outputContent`ID: ${outputToken.cyan(operation.id)}`.value,
Expand All @@ -97,8 +99,15 @@ function renderBulkOperationResult(operation: BulkOperation): void {
break
case 'COMPLETED':
if (operation.url) {
const downloadMessage = outputContent`Download results ${outputToken.link('here', operation.url)}.`.value
renderSuccess({headline, body: [downloadMessage], customSections})
const results = await downloadBulkOperationResults(operation.url)

if (outputFile) {
await writeFile(outputFile, results)
renderSuccess({headline, body: [`Results written to ${outputFile}`], customSections})
} else {
outputResult(results)
renderSuccess({headline, customSections})
}
} else {
renderSuccess({headline, customSections})
}
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -846,6 +846,14 @@
"name": "no-color",
"type": "boolean"
},
"output-file": {
"description": "The file path where results should be written. If not specified, results will be written to STDOUT.",
"env": "SHOPIFY_FLAG_OUTPUT_FILE",
"hasDynamicHelp": false,
"multiple": false,
"name": "output-file",
"type": "option"
},
"path": {
"description": "The path to your app directory.",
"env": "SHOPIFY_FLAG_PATH",
Expand Down