Skip to content
Draft
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
96 changes: 95 additions & 1 deletion packages/app/src/cli/services/execute-operation.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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')
})
})
42 changes: 29 additions & 13 deletions packages/app/src/cli/services/execute-operation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -61,23 +70,12 @@ async function parseVariables(
return undefined
}

export async function executeOperation(input: ExecuteOperationInput): Promise<void> {
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<void> {
const {adminSession, query, variables, variableFile, outputFile, version} = input

const parsedVariables = await parseVariables(variables, variableFile)

validateSingleOperation(query)
validateMutationStore(query, store)

try {
const result = await renderSingleTask({
Expand Down Expand Up @@ -126,3 +124,21 @@ export async function executeOperation(input: ExecuteOperationInput): Promise<vo
throw error
}
}

export async function executeOperation(input: ExecuteOperationInput): Promise<void> {
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})
}
49 changes: 48 additions & 1 deletion packages/app/src/cli/utilities/execute-command-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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',
Expand Down
72 changes: 42 additions & 30 deletions packages/app/src/cli/utilities/execute-command-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppStoreContext> {
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<ExecuteContext> {
export async function loadQuery(flags: {query?: string; 'query-file'?: string}): Promise<string> {
let query: string | undefined

if (flags.query !== undefined) {
Expand Down Expand Up @@ -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<AppStoreContext> {
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<ExecuteContext> {
const query = await loadQuery(flags)
const {appContextResult, store} = await prepareAppStoreContext(flags)

return {query, appContextResult, store}
Expand Down
Loading