diff --git a/packages/app/src/cli/services/execute-operation.test.ts b/packages/app/src/cli/services/execute-operation.test.ts index 03af22d7d2..4e78c1f976 100644 --- a/packages/app/src/cli/services/execute-operation.test.ts +++ b/packages/app/src/cli/services/execute-operation.test.ts @@ -1,4 +1,4 @@ -import {executeOperation} from './execute-operation.js' +import {executeOperation, runGraphQLExecution} from './execute-operation.js' import {createAdminSessionAsApp, resolveApiVersion, validateMutationStore} from './graphql/common.js' import {OrganizationApp, OrganizationSource, OrganizationStore} from '../models/organization.js' import {renderSuccess, renderError, renderSingleTask} from '@shopify/cli-kit/node/ui' @@ -360,3 +360,97 @@ describe('executeOperation', () => { expect(adminRequestDoc).not.toHaveBeenCalled() }) }) + +describe('runGraphQLExecution', () => { + const mockAdminSession = {token: 'test-token', storeFqdn: 'test-store.myshopify.com'} + + beforeEach(() => { + vi.mocked(renderSingleTask).mockImplementation(async ({task}) => { + return task(() => {}) + }) + }) + + afterEach(() => { + mockAndCaptureOutput().clear() + }) + + test('executes GraphQL operation and renders success', async () => { + const query = 'query { shop { name } }' + const mockResult = {data: {shop: {name: 'Test Shop'}}} + vi.mocked(adminRequestDoc).mockResolvedValue(mockResult) + + await runGraphQLExecution({ + adminSession: mockAdminSession, + query, + version: '2024-07', + }) + + expect(adminRequestDoc).toHaveBeenCalledWith({ + query: expect.any(Object), + session: mockAdminSession, + variables: undefined, + version: '2024-07', + responseOptions: {handleErrors: false}, + }) + expect(renderSuccess).toHaveBeenCalledWith(expect.objectContaining({headline: 'Operation succeeded.'})) + }) + + test('parses variables from flag', async () => { + const query = 'query { shop { name } }' + const variables = '{"key":"value"}' + vi.mocked(adminRequestDoc).mockResolvedValue({}) + + await runGraphQLExecution({ + adminSession: mockAdminSession, + query, + variables, + version: '2024-07', + }) + + expect(adminRequestDoc).toHaveBeenCalledWith(expect.objectContaining({variables: {key: 'value'}})) + }) + + test('writes output to file when outputFile specified', async () => { + const query = 'query { shop { name } }' + const mockResult = {data: {shop: {name: 'Test Shop'}}} + vi.mocked(adminRequestDoc).mockResolvedValue(mockResult) + + await runGraphQLExecution({ + adminSession: mockAdminSession, + query, + outputFile: '/tmp/results.json', + version: '2024-07', + }) + + expect(writeFile).toHaveBeenCalledWith('/tmp/results.json', JSON.stringify(mockResult, null, 2)) + }) + + test('handles ClientError gracefully', async () => { + const query = 'query { invalidField }' + const graphqlErrors = [{message: 'Field not found'}] + const clientError = new ClientError({errors: graphqlErrors} as any, {query: '', variables: {}}) + ;(clientError as any).response = {errors: graphqlErrors} + vi.mocked(adminRequestDoc).mockRejectedValue(clientError) + + await runGraphQLExecution({ + adminSession: mockAdminSession, + query, + version: '2024-07', + }) + + expect(renderError).toHaveBeenCalledWith(expect.objectContaining({headline: 'GraphQL operation failed.'})) + }) + + test('propagates non-ClientError errors', async () => { + const query = 'query { shop { name } }' + vi.mocked(adminRequestDoc).mockRejectedValue(new Error('Network error')) + + await expect( + runGraphQLExecution({ + adminSession: mockAdminSession, + query, + version: '2024-07', + }), + ).rejects.toThrow('Network error') + }) +}) diff --git a/packages/app/src/cli/services/execute-operation.ts b/packages/app/src/cli/services/execute-operation.ts index 00807b1607..cc97adf66b 100644 --- a/packages/app/src/cli/services/execute-operation.ts +++ b/packages/app/src/cli/services/execute-operation.ts @@ -25,6 +25,15 @@ interface ExecuteOperationInput { version?: string } +interface RunGraphQLExecutionInput { + adminSession: AdminSession + query: string + variables?: string + variableFile?: string + outputFile?: string + version: string +} + async function parseVariables( variables?: string, variableFile?: string, @@ -61,23 +70,12 @@ async function parseVariables( return undefined } -export async function executeOperation(input: ExecuteOperationInput): Promise { - const {remoteApp, store, query, variables, variableFile, version: userSpecifiedVersion, outputFile} = input - - const {adminSession, version} = await renderSingleTask({ - title: outputContent`Authenticating`, - task: async (): Promise<{adminSession: AdminSession; version: string}> => { - const adminSession = await createAdminSessionAsApp(remoteApp, store.shopDomain) - const version = await resolveApiVersion({adminSession, userSpecifiedVersion}) - return {adminSession, version} - }, - renderOptions: {stdout: process.stderr}, - }) +export async function runGraphQLExecution(input: RunGraphQLExecutionInput): Promise { + const {adminSession, query, variables, variableFile, outputFile, version} = input const parsedVariables = await parseVariables(variables, variableFile) validateSingleOperation(query) - validateMutationStore(query, store) try { const result = await renderSingleTask({ @@ -126,3 +124,21 @@ export async function executeOperation(input: ExecuteOperationInput): Promise { + const {remoteApp, store, query, variables, variableFile, version: userSpecifiedVersion, outputFile} = input + + const {adminSession, version} = await renderSingleTask({ + title: outputContent`Authenticating`, + task: async (): Promise<{adminSession: AdminSession; version: string}> => { + const adminSession = await createAdminSessionAsApp(remoteApp, store.shopDomain) + const version = await resolveApiVersion({adminSession, userSpecifiedVersion}) + return {adminSession, version} + }, + renderOptions: {stdout: process.stderr}, + }) + + validateMutationStore(query, store) + + await runGraphQLExecution({adminSession, query, variables, variableFile, outputFile, version}) +} diff --git a/packages/app/src/cli/utilities/execute-command-helpers.test.ts b/packages/app/src/cli/utilities/execute-command-helpers.test.ts index 5d31edc237..1183c4baad 100644 --- a/packages/app/src/cli/utilities/execute-command-helpers.test.ts +++ b/packages/app/src/cli/utilities/execute-command-helpers.test.ts @@ -1,4 +1,4 @@ -import {prepareAppStoreContext, prepareExecuteContext} from './execute-command-helpers.js' +import {prepareAppStoreContext, prepareExecuteContext, loadQuery} from './execute-command-helpers.js' import {linkedAppContext} from '../services/app-context.js' import {storeContext} from '../services/store-context.js' import {validateSingleOperation} from '../services/graphql/common.js' @@ -89,6 +89,53 @@ describe('prepareAppStoreContext', () => { }) }) +describe('loadQuery', () => { + test('returns query from --query flag', async () => { + const result = await loadQuery({query: 'query { shop { name } }'}) + expect(result).toBe('query { shop { name } }') + }) + + test('throws AbortError when query flag is empty', async () => { + await expect(loadQuery({query: ''})).rejects.toThrow('--query flag value is empty') + }) + + test('throws AbortError when query flag is whitespace', async () => { + await expect(loadQuery({query: ' \n\t '})).rejects.toThrow('--query flag value is empty') + }) + + test('reads query from file', async () => { + const queryFileContent = 'query { shop { name } }' + vi.mocked(fileExists).mockResolvedValue(true) + vi.mocked(readFile).mockResolvedValue(queryFileContent as any) + + const result = await loadQuery({'query-file': '/path/to/query.graphql'}) + + expect(fileExists).toHaveBeenCalledWith('/path/to/query.graphql') + expect(readFile).toHaveBeenCalledWith('/path/to/query.graphql', {encoding: 'utf8'}) + expect(result).toBe(queryFileContent) + }) + + test('throws when query file does not exist', async () => { + vi.mocked(fileExists).mockResolvedValue(false) + await expect(loadQuery({'query-file': '/path/to/missing.graphql'})).rejects.toThrow('Query file not found') + }) + + test('throws when query file is empty', async () => { + vi.mocked(fileExists).mockResolvedValue(true) + vi.mocked(readFile).mockResolvedValue('' as any) + await expect(loadQuery({'query-file': '/path/to/empty.graphql'})).rejects.toThrow('is empty') + }) + + test('throws BugError when no query provided', async () => { + await expect(loadQuery({})).rejects.toThrow('exactlyOne constraint') + }) + + test('validates GraphQL syntax via validateSingleOperation', async () => { + await loadQuery({query: 'query { shop { name } }'}) + expect(validateSingleOperation).toHaveBeenCalledWith('query { shop { name } }') + }) +}) + describe('prepareExecuteContext', () => { const mockFlags = { path: '/test/path', diff --git a/packages/app/src/cli/utilities/execute-command-helpers.ts b/packages/app/src/cli/utilities/execute-command-helpers.ts index a4602022b6..6f4ccc129d 100644 --- a/packages/app/src/cli/utilities/execute-command-helpers.ts +++ b/packages/app/src/cli/utilities/execute-command-helpers.ts @@ -29,38 +29,13 @@ interface ExecuteContext extends AppStoreContext { } /** - * Prepares the app and store context for commands. - * Sets up app linking and store selection without query handling. + * Loads a GraphQL query from the --query flag or --query-file flag. + * Validates that the query is non-empty and has valid GraphQL syntax. * - * @param flags - Command flags containing configuration options. - * @returns Context object containing app context and store information. + * @param flags - Flags containing query or query-file. + * @returns The loaded GraphQL query string. */ -export async function prepareAppStoreContext(flags: AppStoreContextFlags): Promise { - const appContextResult = await linkedAppContext({ - directory: flags.path, - clientId: flags['client-id'], - forceRelink: flags.reset, - userProvidedConfigName: flags.config, - }) - - const store = await storeContext({ - appContextResult, - storeFqdn: flags.store, - forceReselectStore: flags.reset, - storeTypes: ['APP_DEVELOPMENT', 'DEVELOPMENT', 'DEVELOPMENT_SUPERSET', 'PRODUCTION'], - }) - - return {appContextResult, store} -} - -/** - * Prepares the execution context for GraphQL operations. - * Handles query input from flag or file, validates GraphQL syntax, and sets up app and store contexts. - * - * @param flags - Command flags containing configuration options. - * @returns Context object containing query, app context, and store information. - */ -export async function prepareExecuteContext(flags: ExecuteCommandFlags): Promise { +export async function loadQuery(flags: {query?: string; 'query-file'?: string}): Promise { let query: string | undefined if (flags.query !== undefined) { @@ -94,6 +69,43 @@ export async function prepareExecuteContext(flags: ExecuteCommandFlags): Promise // Validate GraphQL syntax and ensure single operation validateSingleOperation(query) + return query +} + +/** + * Prepares the app and store context for commands. + * Sets up app linking and store selection without query handling. + * + * @param flags - Command flags containing configuration options. + * @returns Context object containing app context and store information. + */ +export async function prepareAppStoreContext(flags: AppStoreContextFlags): Promise { + const appContextResult = await linkedAppContext({ + directory: flags.path, + clientId: flags['client-id'], + forceRelink: flags.reset, + userProvidedConfigName: flags.config, + }) + + const store = await storeContext({ + appContextResult, + storeFqdn: flags.store, + forceReselectStore: flags.reset, + storeTypes: ['APP_DEVELOPMENT', 'DEVELOPMENT', 'DEVELOPMENT_SUPERSET', 'PRODUCTION'], + }) + + return {appContextResult, store} +} + +/** + * Prepares the execution context for GraphQL operations. + * Handles query input from flag or file, validates GraphQL syntax, and sets up app and store contexts. + * + * @param flags - Command flags containing configuration options. + * @returns Context object containing query, app context, and store information. + */ +export async function prepareExecuteContext(flags: ExecuteCommandFlags): Promise { + const query = await loadQuery(flags) const {appContextResult, store} = await prepareAppStoreContext(flags) return {query, appContextResult, store}