diff --git a/packages/app/src/cli/commands/app/function/info.ts b/packages/app/src/cli/commands/app/function/info.ts index 38e7da82bf1..28b72502b13 100644 --- a/packages/app/src/cli/commands/app/function/info.ts +++ b/packages/app/src/cli/commands/app/function/info.ts @@ -1,11 +1,12 @@ import {chooseFunction, functionFlags, getOrGenerateSchemaPath} from '../../../services/function/common.js' import {functionRunnerBinary, downloadBinary} from '../../../services/function/binaries.js' +import {functionInfo} from '../../../services/function/info.js' import {localAppContext} from '../../../services/app-context.js' import {appFlags} from '../../../flags.js' import AppUnlinkedCommand, {AppUnlinkedCommandOutput} from '../../../utilities/app-unlinked-command.js' import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' -import {outputContent, outputResult, outputToken} from '@shopify/cli-kit/node/output' -import {InlineToken, renderInfo} from '@shopify/cli-kit/node/ui' +import {outputResult} from '@shopify/cli-kit/node/output' +import {AlertCustomSection, renderInfo} from '@shopify/cli-kit/node/ui' export default class FunctionInfo extends AppUnlinkedCommand { static summary = 'Print basic information about your function.' @@ -41,16 +42,6 @@ export default class FunctionInfo extends AppUnlinkedCommand { const functionRunner = functionRunnerBinary() await downloadBinary(functionRunner) - const targeting: {[key: string]: {inputQueryPath?: string; export?: string}} = {} - ourFunction?.configuration.targeting?.forEach((target) => { - if (target.target) { - targeting[target.target] = { - ...(target.input_query && {inputQueryPath: `${ourFunction.directory}/${target.input_query}`}), - ...(target.export && {export: target.export}), - } - } - }) - const schemaPath = await getOrGenerateSchemaPath( ourFunction, flags.path, @@ -59,81 +50,17 @@ export default class FunctionInfo extends AppUnlinkedCommand { flags.config, ) + const result = functionInfo(ourFunction, { + format: flags.json ? 'json' : 'text', + functionRunnerPath: functionRunner.path, + schemaPath, + }) + if (flags.json) { - outputResult( - JSON.stringify( - { - handle: ourFunction.configuration.handle, - name: ourFunction.name, - apiVersion: ourFunction.configuration.api_version, - targeting, - schemaPath, - wasmPath: ourFunction.outputPath, - functionRunnerPath: functionRunner.path, - }, - null, - 2, - ), - ) + outputResult(result as string) } else { - const configData: InlineToken[][] = [ - ['Handle', ourFunction.configuration.handle ?? 'N/A'], - ['Name', ourFunction.name ?? 'N/A'], - ['API Version', ourFunction.configuration.api_version ?? 'N/A'], - ] - - const sections: {title: string; body: {tabularData: InlineToken[][]; firstColumnSubdued?: boolean}}[] = [ - { - title: 'CONFIGURATION\n', - body: { - tabularData: configData, - firstColumnSubdued: true, - }, - }, - ] - - if (Object.keys(targeting).length > 0) { - const targetingData: InlineToken[][] = [] - Object.entries(targeting).forEach(([target, config]) => { - targetingData.push([outputContent`${outputToken.cyan(target)}`.value, '']) - if (config.inputQueryPath) { - targetingData.push([{subdued: ' Input Query Path'}, {filePath: config.inputQueryPath}]) - } - if (config.export) { - targetingData.push([{subdued: ' Export'}, config.export]) - } - }) - - sections.push({ - title: '\nTARGETING\n', - body: { - tabularData: targetingData, - }, - }) - } - - sections.push( - { - title: '\nBUILD\n', - body: { - tabularData: [ - ['Schema Path', {filePath: schemaPath ?? 'N/A'}], - ['Wasm Path', {filePath: ourFunction.outputPath}], - ], - firstColumnSubdued: true, - }, - }, - { - title: '\nFUNCTION RUNNER\n', - body: { - tabularData: [['Path', {filePath: functionRunner.path}]], - firstColumnSubdued: true, - }, - }, - ) - renderInfo({ - customSections: sections, + customSections: result as AlertCustomSection[], }) } diff --git a/packages/app/src/cli/services/function/info.test.ts b/packages/app/src/cli/services/function/info.test.ts new file mode 100644 index 00000000000..8c44aa96cf0 --- /dev/null +++ b/packages/app/src/cli/services/function/info.test.ts @@ -0,0 +1,376 @@ +import { + functionInfo, + buildTargetingData, + formatAsJson, + buildConfigurationSection, + buildTargetingSection, + buildBuildSection, + buildFunctionRunnerSection, + buildTextFormatSections, +} from './info.js' +import {testFunctionExtension} from '../../models/app/app.test-data.js' +import {ExtensionInstance} from '../../models/extensions/extension-instance.js' +import {describe, expect, test, beforeEach} from 'vitest' +import {AlertCustomSection} from '@shopify/cli-kit/node/ui' + +describe('functionInfo', () => { + let ourFunction: ExtensionInstance + + beforeEach(async () => { + ourFunction = await testFunctionExtension({ + dir: '/path/to/function', + config: { + name: 'My Function', + type: 'function', + handle: 'my-function', + api_version: '2024-01', + configuration_ui: false, + }, + }) + }) + + describe('functionInfo integration', () => { + test('returns JSON string when format is json', async () => { + // Given + const options = { + format: 'json' as const, + functionRunnerPath: '/path/to/runner', + schemaPath: '/path/to/schema.graphql', + } + + // When + const result = functionInfo(ourFunction, options) + + // Then + expect(typeof result).toBe('string') + const parsed = JSON.parse(result as string) + expect(parsed).toHaveProperty('handle') + expect(parsed).toHaveProperty('name') + expect(parsed).toHaveProperty('apiVersion') + }) + + test('returns AlertCustomSection array when format is text', async () => { + // Given + const options = { + format: 'text' as const, + functionRunnerPath: '/path/to/runner', + schemaPath: '/path/to/schema.graphql', + } + + // When + const result = functionInfo(ourFunction, options) as AlertCustomSection[] + + // Then + expect(Array.isArray(result)).toBe(true) + expect(result.length).toBeGreaterThan(0) + }) + }) + + describe('buildTargetingData', () => { + test('transforms targeting configuration with multiple targets', () => { + // Given + const config = { + handle: 'test', + targeting: [ + { + target: 'purchase.payment-customization.run', + input_query: 'query1.graphql', + export: 'run', + }, + { + target: 'purchase.checkout.delivery-customization.run', + input_query: 'query2.graphql', + export: 'customize', + }, + ], + } + + // When + const result = buildTargetingData(config, '/path/to/function') + + // Then + expect(result).toEqual({ + 'purchase.payment-customization.run': { + inputQueryPath: '/path/to/function/query1.graphql', + export: 'run', + }, + 'purchase.checkout.delivery-customization.run': { + inputQueryPath: '/path/to/function/query2.graphql', + export: 'customize', + }, + }) + }) + + test('handles targets without input_query', () => { + // Given + const config = { + handle: 'test', + targeting: [ + { + target: 'purchase.payment-customization.run', + export: 'run', + }, + ], + } + + // When + const result = buildTargetingData(config, '/path/to/function') + + // Then + expect(result).toEqual({ + 'purchase.payment-customization.run': { + export: 'run', + }, + }) + }) + + test('handles targets without export', () => { + // Given + const config = { + handle: 'test', + targeting: [ + { + target: 'purchase.payment-customization.run', + input_query: 'query.graphql', + }, + ], + } + + // When + const result = buildTargetingData(config, '/path/to/function') + + // Then + expect(result).toEqual({ + 'purchase.payment-customization.run': { + inputQueryPath: '/path/to/function/query.graphql', + }, + }) + }) + }) + + describe('formatAsJson', () => { + test('returns correctly formatted JSON string', async () => { + // Given + const testFunc = await testFunctionExtension({ + dir: '/path/to/function', + config: { + name: 'My Function', + type: 'function', + handle: 'my-function', + api_version: '2024-01', + configuration_ui: false, + }, + }) + const config = { + handle: 'my-function', + name: 'My Function', + api_version: '2024-01', + } + const targeting = { + 'purchase.payment-customization.run': { + inputQueryPath: '/path/to/function/query.graphql', + export: 'run', + }, + } + + // When + const result = formatAsJson(testFunc, config, targeting, '/path/to/runner', '/path/to/schema.graphql') + + // Then + const parsed = JSON.parse(result) + expect(parsed).toEqual({ + handle: 'my-function', + name: 'My Function', + apiVersion: '2024-01', + targeting: { + 'purchase.payment-customization.run': { + inputQueryPath: '/path/to/function/query.graphql', + export: 'run', + }, + }, + schemaPath: '/path/to/schema.graphql', + wasmPath: testFunc.outputPath, + functionRunnerPath: '/path/to/runner', + }) + }) + + test('handles missing optional fields', async () => { + // Given + const testFunc = await testFunctionExtension({ + dir: '/path/to/function', + config: { + name: 'My Function', + type: 'function', + api_version: '2024-01', + configuration_ui: false, + }, + }) + const config = {} + const targeting = {} + + // When + const result = formatAsJson(testFunc, config, targeting, '/path/to/runner') + + // Then + const parsed = JSON.parse(result) + expect(parsed.handle).toBeUndefined() + expect(parsed.schemaPath).toBeUndefined() + expect(parsed.targeting).toEqual({}) + }) + }) + + describe('buildConfigurationSection', () => { + test('builds configuration section with all fields', () => { + // Given + const config = { + handle: 'my-function', + name: 'My Function', + api_version: '2024-01', + } + + // When + const result = buildConfigurationSection(config, 'My Function') + + // Then + expect(result.title).toBe('CONFIGURATION\n') + expect(result.body).toHaveProperty('tabularData') + expect(result.body).toHaveProperty('firstColumnSubdued', true) + expect((result.body as {tabularData: unknown[][]}).tabularData).toEqual([ + ['Handle', 'my-function'], + ['Name', 'My Function'], + ['API Version', '2024-01'], + ]) + }) + + test('uses N/A for missing fields', () => { + // Given + const config = {} + + // When + const result = buildConfigurationSection(config, undefined as unknown as string) + + // Then + expect((result.body as {tabularData: unknown[][]}).tabularData).toEqual([ + ['Handle', 'N/A'], + ['Name', 'N/A'], + ['API Version', 'N/A'], + ]) + }) + }) + + describe('buildTargetingSection', () => { + test('builds targeting section with multiple targets', () => { + // Given + const targeting = { + 'purchase.payment-customization.run': { + inputQueryPath: '/path/to/function/query1.graphql', + export: 'run', + }, + 'purchase.checkout.delivery-customization.run': { + inputQueryPath: '/path/to/function/query2.graphql', + export: 'customize', + }, + } + + // When + const result = buildTargetingSection(targeting) + + // Then + expect(result).not.toBeNull() + expect(result?.title).toBe('\nTARGETING\n') + const tabularData = (result?.body as {tabularData: unknown[][]})?.tabularData + // 2 targets × 3 rows each + expect(tabularData?.length).toBe(6) + }) + }) + + describe('buildBuildSection', () => { + test('builds build section with schema and wasm paths', () => { + // Given + const wasmPath = '/path/to/function.wasm' + const schemaPath = '/path/to/schema.graphql' + + // When + const result = buildBuildSection(wasmPath, schemaPath) + + // Then + expect(result.title).toBe('\nBUILD\n') + expect(result.body).toHaveProperty('tabularData') + expect(result.body).toHaveProperty('firstColumnSubdued', true) + expect((result.body as {tabularData: unknown[][]}).tabularData).toEqual([ + ['Schema Path', {filePath: schemaPath}], + ['Wasm Path', {filePath: wasmPath}], + ]) + }) + + test('uses N/A for missing schema path', () => { + // Given + const wasmPath = '/path/to/function.wasm' + + // When + const result = buildBuildSection(wasmPath) + + // Then + expect((result.body as {tabularData: unknown[][]}).tabularData).toEqual([ + ['Schema Path', {filePath: 'N/A'}], + ['Wasm Path', {filePath: wasmPath}], + ]) + }) + }) + + describe('buildFunctionRunnerSection', () => { + test('builds function runner section', () => { + // Given + const functionRunnerPath = '/path/to/runner' + + // When + const result = buildFunctionRunnerSection(functionRunnerPath) + + // Then + expect(result.title).toBe('\nFUNCTION RUNNER\n') + expect(result.body).toHaveProperty('tabularData') + expect(result.body).toHaveProperty('firstColumnSubdued', true) + expect((result.body as {tabularData: unknown[][]}).tabularData).toEqual([ + ['Path', {filePath: functionRunnerPath}], + ]) + }) + }) + + describe('buildTextFormatSections', () => { + test('includes all sections when targeting is present', async () => { + // Given + const testFunc = await testFunctionExtension({ + dir: '/path/to/function', + config: { + name: 'My Function', + type: 'function', + handle: 'my-function', + api_version: '2024-01', + configuration_ui: false, + }, + }) + const config = { + handle: 'my-function', + name: 'My Function', + api_version: '2024-01', + } + const targeting = { + 'purchase.payment-customization.run': { + inputQueryPath: '/path/to/function/query.graphql', + export: 'run', + }, + } + + // When + const result = buildTextFormatSections(testFunc, config, targeting, '/path/to/runner', '/path/to/schema.graphql') + + // Then + // configuration, targeting, build, function runner + expect(result.length).toBe(4) + expect(result[0]?.title).toContain('CONFIGURATION') + expect(result[1]?.title).toContain('TARGETING') + expect(result[2]?.title).toContain('BUILD') + expect(result[3]?.title).toContain('FUNCTION RUNNER') + }) + }) +}) diff --git a/packages/app/src/cli/services/function/info.ts b/packages/app/src/cli/services/function/info.ts new file mode 100644 index 00000000000..3abc7d3d5c5 --- /dev/null +++ b/packages/app/src/cli/services/function/info.ts @@ -0,0 +1,158 @@ +import {ExtensionInstance} from '../../models/extensions/extension-instance.js' +import {outputContent, outputToken} from '@shopify/cli-kit/node/output' +import {InlineToken, AlertCustomSection} from '@shopify/cli-kit/node/ui' + +type Format = 'json' | 'text' + +interface FunctionInfoOptions { + format: Format + functionRunnerPath: string + schemaPath?: string +} + +interface FunctionConfiguration { + handle?: string + name?: string + api_version?: string + targeting?: { + target: string + input_query?: string + export?: string + }[] +} + +export function buildTargetingData( + config: FunctionConfiguration, + functionDirectory: string, +): {[key: string]: {inputQueryPath?: string; export?: string}} { + const targeting: {[key: string]: {inputQueryPath?: string; export?: string}} = {} + config.targeting?.forEach((target) => { + if (target.target) { + targeting[target.target] = { + ...(target.input_query && {inputQueryPath: `${functionDirectory}/${target.input_query}`}), + ...(target.export && {export: target.export}), + } + } + }) + return targeting +} + +export function formatAsJson( + ourFunction: ExtensionInstance, + config: FunctionConfiguration, + targeting: {[key: string]: {inputQueryPath?: string; export?: string}}, + functionRunnerPath: string, + schemaPath?: string, +): string { + return JSON.stringify( + { + handle: config.handle, + name: ourFunction.name, + apiVersion: config.api_version, + targeting, + schemaPath, + wasmPath: ourFunction.outputPath, + functionRunnerPath, + }, + null, + 2, + ) +} + +export function buildConfigurationSection(config: FunctionConfiguration, functionName: string): AlertCustomSection { + return { + title: 'CONFIGURATION\n', + body: { + tabularData: [ + ['Handle', config.handle ?? 'N/A'], + ['Name', functionName ?? 'N/A'], + ['API Version', config.api_version ?? 'N/A'], + ], + firstColumnSubdued: true, + }, + } +} + +export function buildTargetingSection(targeting: { + [key: string]: {inputQueryPath?: string; export?: string} +}): AlertCustomSection | null { + if (Object.keys(targeting).length === 0) { + return null + } + + const targetingData: InlineToken[][] = [] + Object.entries(targeting).forEach(([target, config]) => { + targetingData.push([outputContent`${outputToken.cyan(target)}`.value, '']) + if (config.inputQueryPath) { + targetingData.push([{subdued: ' Input Query Path'}, {filePath: config.inputQueryPath}]) + } + if (config.export) { + targetingData.push([{subdued: ' Export'}, config.export]) + } + }) + + return { + title: '\nTARGETING\n', + body: { + tabularData: targetingData, + }, + } +} + +export function buildBuildSection(wasmPath: string, schemaPath?: string): AlertCustomSection { + return { + title: '\nBUILD\n', + body: { + tabularData: [ + ['Schema Path', {filePath: schemaPath ?? 'N/A'}], + ['Wasm Path', {filePath: wasmPath}], + ], + firstColumnSubdued: true, + }, + } +} + +export function buildFunctionRunnerSection(functionRunnerPath: string): AlertCustomSection { + return { + title: '\nFUNCTION RUNNER\n', + body: { + tabularData: [['Path', {filePath: functionRunnerPath}]], + firstColumnSubdued: true, + }, + } +} + +export function buildTextFormatSections( + ourFunction: ExtensionInstance, + config: FunctionConfiguration, + targeting: {[key: string]: {inputQueryPath?: string; export?: string}}, + functionRunnerPath: string, + schemaPath?: string, +): AlertCustomSection[] { + const sections: AlertCustomSection[] = [buildConfigurationSection(config, ourFunction.name)] + + const targetingSection = buildTargetingSection(targeting) + if (targetingSection) { + sections.push(targetingSection) + } + + sections.push(buildBuildSection(ourFunction.outputPath, schemaPath), buildFunctionRunnerSection(functionRunnerPath)) + + return sections +} + +export function functionInfo( + ourFunction: ExtensionInstance, + options: FunctionInfoOptions, +): string | AlertCustomSection[] { + const {format, functionRunnerPath, schemaPath} = options + const config = ourFunction.configuration as FunctionConfiguration + + const targeting = buildTargetingData(config, ourFunction.directory) + + if (format === 'json') { + return formatAsJson(ourFunction, config, targeting, functionRunnerPath, schemaPath) + } + + return buildTextFormatSections(ourFunction, config, targeting, functionRunnerPath, schemaPath) +}