diff --git a/.changeset/function-run-json-contract.md b/.changeset/function-run-json-contract.md new file mode 100644 index 00000000000..9ab700a86bb --- /dev/null +++ b/.changeset/function-run-json-contract.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli': major +--- + +Validate native `app function run --json` results, emit runner diagnostics on stderr, and avoid duplicate failure documents. diff --git a/packages/app/src/cli/commands/app/function/run.test.ts b/packages/app/src/cli/commands/app/function/run.test.ts new file mode 100644 index 00000000000..29ee4cc5b99 --- /dev/null +++ b/packages/app/src/cli/commands/app/function/run.test.ts @@ -0,0 +1,154 @@ +import FunctionRun from './run.js' +import {chooseFunction} from '../../../services/function/common.js' +import {localAppContext} from '../../../services/app-context.js' +import {testApp, testFunctionExtension, testProject} from '../../../models/app/app.test-data.js' +import {functionRunJsonOutputSchema} from '../../../services/function/runner/types.js' +import {Config} from '@oclif/core' +import {afterEach, expect, test, vi} from 'vitest' +import {captureOutputWithExitCode, exec} from '@shopify/cli-kit/node/system' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events' +import {renderAutocompletePrompt} from '@shopify/cli-kit/node/ui' +import {isTerminalInteractive} from '@shopify/cli-kit/node/context/local' +import {ExternalError, handler} from '@shopify/cli-kit/node/error' + +vi.mock('../../../services/app-context.js') +vi.mock('@shopify/cli-kit/node/system') +vi.mock('../../../services/function/common.js', async (importOriginal) => ({ + ...(await importOriginal()), + chooseFunction: vi.fn(), + getOrGenerateSchemaPath: vi.fn(), +})) +vi.mock('../../../services/function/binaries.js', async (importOriginal) => ({ + ...(await importOriginal()), + downloadBinary: vi.fn(), +})) +vi.mock('@shopify/cli-kit/node/context/local', async (importOriginal) => ({ + ...(await importOriginal()), + isTerminalInteractive: vi.fn(), +})) +vi.mock('@shopify/cli-kit/node/ui', async (importOriginal) => ({ + ...(await importOriginal()), + renderAutocompletePrompt: vi.fn(), +})) + +afterEach(() => vi.unstubAllEnvs()) + +const nativeResult = { + name: 'function.wasm', + size: 1, + memory_usage: 64, + instructions: 1000, + logs: '', + input: {}, + output: {operations: []}, + success: true, +} + +async function command(argv: string[], targets = [{target: 'run', export: 'run'}]) { + vi.mocked(localAppContext).mockResolvedValue({app: testApp(), project: testProject()}) + const extension = await testFunctionExtension() + extension.configuration.targeting = targets + vi.mocked(chooseFunction).mockResolvedValue(extension) + const instance = new FunctionRun(argv, await Config.load()) + return instance +} + +test('writes the native result once and routes runner diagnostics to stderr', async () => { + vi.mocked(captureOutputWithExitCode).mockResolvedValue({ + stdout: JSON.stringify(nativeResult), + stderr: 'Runner warning', + exitCode: 0, + }) + const instance = await command(['--json', '--input', 'input.json', '--export', 'custom']) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEventsForCommand(instance.argv, () => instance.run()) + expect(JSON.parse(stdout())).toEqual(nativeResult) + expect(JSON.parse(stderr())).toMatchObject({type: 'diagnostic', message: 'Runner warning'}) + }) + expect(captureOutputWithExitCode).toHaveBeenCalledWith( + expect.any(String), + expect.arrayContaining(['--input', 'input.json', '--export', 'custom', '--json']), + expect.objectContaining({stdin: 'inherit'}), + ) +}) + +test.each([{argv: ['--json']}, {argv: []}])( + 'keeps target selection interactive independently of JSON: $argv', + async ({argv}) => { + vi.mocked(isTerminalInteractive).mockReturnValue(true) + vi.mocked(renderAutocompletePrompt).mockResolvedValue('second') + vi.mocked(captureOutputWithExitCode).mockResolvedValue({ + stdout: JSON.stringify(nativeResult), + stderr: '', + exitCode: 0, + }) + const instance = await command(argv, [ + {target: 'first', export: 'first'}, + {target: 'second', export: 'second'}, + ]) + await runWithCommandEventsForCommand(argv, () => instance.run()) + expect(renderAutocompletePrompt).toHaveBeenCalledOnce() + const runner = argv.includes('--json') ? captureOutputWithExitCode : exec + expect(runner).toHaveBeenCalledWith( + expect.any(String), + expect.arrayContaining(['--export', 'second']), + expect.any(Object), + ) + }, +) + +test.each([{argv: ['--json', '--no-input']}, {argv: ['--no-input']}])( + 'does not prompt with --no-input: $argv', + async ({argv}) => { + vi.mocked(isTerminalInteractive).mockReturnValue(true) + vi.mocked(captureOutputWithExitCode).mockResolvedValue({ + stdout: JSON.stringify(nativeResult), + stderr: '', + exitCode: 0, + }) + const instance = await command(argv, [ + {target: 'first', export: 'first'}, + {target: 'second', export: 'second'}, + ]) + await runWithCommandEventsForCommand(argv, () => instance.run()) + expect(renderAutocompletePrompt).not.toHaveBeenCalled() + }, +) + +test('retains the existing runner text presentation', async () => { + const instance = await command([]) + await instance.run() + expect(captureOutputWithExitCode).not.toHaveBeenCalled() + expect(exec).toHaveBeenCalledWith( + expect.any(String), + expect.not.arrayContaining(['--json']), + expect.objectContaining({stdout: 'inherit', stderr: 'inherit'}), + ) +}) + +test('prints a single standard fatal document for an infrastructure failure', async () => { + vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + vi.mocked(captureOutputWithExitCode).mockResolvedValue({stdout: '', stderr: 'Invalid WASM', exitCode: 1}) + const instance = await command(['--json']) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEventsForCommand(['--json'], async () => { + try { + await instance.run() + expect.unreachable('The command should fail') + } catch (error) { + if (!(error instanceof ExternalError)) throw error + await handler(error) + } + }) + expect(JSON.parse(stdout())).toMatchObject({ + error: {type: 'external', details: {exitCode: 1, stderr: 'Invalid WASM'}}, + }) + expect(stderr()).toBe('') + }) +}) + +test('exposes the native schema in help', () => { + expect(FunctionRun.jsonOutputSchema).toBe(functionRunJsonOutputSchema) + expect(FunctionRun.descriptionForHelp()).toContain('FunctionRunResult') +}) diff --git a/packages/app/src/cli/commands/app/function/run.ts b/packages/app/src/cli/commands/app/function/run.ts index 8ca2eaca737..2b3d1f2c306 100644 --- a/packages/app/src/cli/commands/app/function/run.ts +++ b/packages/app/src/cli/commands/app/function/run.ts @@ -1,19 +1,28 @@ import {chooseFunction, functionFlags, getOrGenerateSchemaPath} from '../../../services/function/common.js' -import {runFunction} from '../../../services/function/runner.js' +import {runFunction, executeFunction} from '../../../services/function/runner.js' +import {functionRunJsonOutputSchema} from '../../../services/function/runner/types.js' +import {presentFunctionExecution} from '../../../services/function/runner/result.js' import {appFlags} from '../../../flags.js' import AppUnlinkedCommand, {AppUnlinkedCommandOutput} from '../../../utilities/app-unlinked-command.js' import {localAppContext} from '../../../services/app-context.js' import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' import {Flags} from '@oclif/core' -import {renderAutocompletePrompt, isTTY} from '@shopify/cli-kit/node/ui' +import {renderAutocompletePrompt} from '@shopify/cli-kit/node/ui' +import {isTerminalInteractive} from '@shopify/cli-kit/node/context/local' import {outputDebug} from '@shopify/cli-kit/node/output' const DEFAULT_FUNCTION_EXPORT = '_start' export default class FunctionRun extends AppUnlinkedCommand { + static get jsonOutputSchema() { + return functionRunJsonOutputSchema + } + static summary = 'Run a function locally for testing.' - static descriptionWithMarkdown = `Runs the function from your current directory for [testing purposes](https://shopify.dev/docs/apps/functions/testing-and-debugging). To learn how you can monitor and debug functions when errors occur, refer to [Shopify Functions error handling](https://shopify.dev/docs/api/functions/errors).` + static descriptionWithMarkdown = `Runs the function from your current directory for [testing purposes](https://shopify.dev/docs/apps/functions/testing-and-debugging). To learn how you can monitor and debug functions when errors occur, refer to [Shopify Functions error handling](https://shopify.dev/docs/api/functions/errors). + +JSON output preserves the native Function runner 7.x and 9.x format, including \`memory_usage\` and arbitrary input/output fields. Profiling files retain the native Speedscope format.` static description = this.descriptionForHelp() @@ -58,7 +67,7 @@ export default class FunctionRun extends AppUnlinkedCommand { } else if (ourFunction.configuration.targeting !== undefined && ourFunction.configuration.targeting.length > 0) { const targeting = ourFunction.configuration.targeting - if (targeting.length > 1 && isTTY({})) { + if (targeting.length > 1 && !flags['no-input'] && isTerminalInteractive()) { const targets = targeting.map((target) => ({ label: target.target, value: target.export || DEFAULT_FUNCTION_EXPORT, // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- empty export should use default @@ -90,16 +99,20 @@ export default class FunctionRun extends AppUnlinkedCommand { flags.config, ) - await runFunction({ + const options = { functionExtension: ourFunction, - json: flags.json, inputPath: flags.input, export: functionExport, - stdin: 'inherit', + stdin: 'inherit' as const, schemaPath, queryPath, profile: flags.profile, - }) + } + if (flags.json) { + presentFunctionExecution(await executeFunction(options)) + } else { + await runFunction(options) + } return {app} } diff --git a/packages/app/src/cli/services/function/runner.test.ts b/packages/app/src/cli/services/function/runner.test.ts index 8e6b5697f5a..ddca7cd7b76 100644 --- a/packages/app/src/cli/services/function/runner.test.ts +++ b/packages/app/src/cli/services/function/runner.test.ts @@ -1,8 +1,8 @@ -import {runFunction} from './runner.js' +import {runFunction, executeFunction} from './runner.js' import {functionRunnerBinary, downloadBinary} from './binaries.js' import {testFunctionExtension} from '../../models/app/app.test-data.js' import {describe, test, vi, expect} from 'vitest' -import {exec} from '@shopify/cli-kit/node/system' +import {exec, captureOutputWithExitCode} from '@shopify/cli-kit/node/system' import {dirname, joinPath} from '@shopify/cli-kit/node/path' import {inTemporaryDirectory, mkdir, writeFile} from '@shopify/cli-kit/node/fs' import {renderWarning} from '@shopify/cli-kit/node/ui' @@ -183,3 +183,88 @@ describe('runFunction', () => { }) }) }) + +const nativeRunResult = { + name: 'function.wasm', + size: 1, + memory_usage: 64, + instructions: 100, + logs: '', + input: {}, + output: {}, + success: true, +} + +describe('executeFunction', () => { + test('captures the native runner protocol as typed data', async () => { + const functionExtension = await testFunctionExtension() + vi.mocked(captureOutputWithExitCode).mockResolvedValue({ + stdout: JSON.stringify(nativeRunResult), + stderr: 'Runner diagnostic', + exitCode: 0, + }) + await expect(executeFunction({functionExtension, input: '{}', export: 'run'})).resolves.toEqual({ + state: 'completed', + result: nativeRunResult, + diagnostics: ['Runner diagnostic'], + exitCode: 0, + }) + expect(captureOutputWithExitCode).toHaveBeenCalledWith( + functionRunnerBinary().path, + ['-f', functionExtension.outputPath, '--export', 'run', '--json'], + {cwd: functionExtension.directory, stdin: undefined, input: '{}'}, + ) + }) + + test('returns completed Function failures without throwing a second fatal error', async () => { + const functionExtension = await testFunctionExtension() + vi.mocked(captureOutputWithExitCode).mockResolvedValue({ + stdout: JSON.stringify({...nativeRunResult, success: false}), + stderr: 'Execution failed', + exitCode: 1, + }) + await expect(executeFunction({functionExtension})).resolves.toMatchObject({ + state: 'completed', + result: {success: false}, + exitCode: 1, + }) + }) + + test.each([ + {stdout: '', exitCode: 1}, + {stdout: '{invalid', exitCode: 0}, + {stdout: '{}', exitCode: 0}, + {stdout: JSON.stringify(nativeRunResult), exitCode: 1}, + ])('classifies infrastructure or invalid-output failure: %j', async ({stdout, exitCode}) => { + const functionExtension = await testFunctionExtension() + vi.mocked(captureOutputWithExitCode).mockResolvedValue({stdout, stderr: 'Failure details', exitCode}) + await expect(executeFunction({functionExtension})).resolves.toMatchObject({ + state: 'failed', + exitCode, + stderr: 'Failure details', + }) + }) + + test('reports profile warnings as data and preserves the profile flag', async () => { + await inTemporaryDirectory(async (tempDir) => { + const functionExtension = await testFunctionExtension({dir: tempDir}) + await mkdir(dirname(functionExtension.outputPath)) + await writeFile(functionExtension.outputPath, Buffer.from([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00])) + vi.mocked(captureOutputWithExitCode).mockResolvedValue({ + stdout: JSON.stringify(nativeRunResult), + stderr: '', + exitCode: 0, + }) + await expect(executeFunction({functionExtension, profile: true})).resolves.toMatchObject({ + state: 'completed', + diagnostics: [expect.stringContaining('profile')], + }) + expect(renderWarning).not.toHaveBeenCalled() + expect(captureOutputWithExitCode).toHaveBeenCalledWith( + expect.any(String), + expect.arrayContaining(['--profile']), + expect.any(Object), + ) + }) + }) +}) diff --git a/packages/app/src/cli/services/function/runner.ts b/packages/app/src/cli/services/function/runner.ts index bf1516cdd1d..ceb9b6939bd 100644 --- a/packages/app/src/cli/services/function/runner.ts +++ b/packages/app/src/cli/services/function/runner.ts @@ -1,8 +1,9 @@ import {functionRunnerBinary, downloadBinary} from './binaries.js' import {validateShopifyFunctionPackageVersion} from './build.js' +import {functionRunJsonOutputSchema, type FunctionRunResult} from './runner/types.js' import {ExtensionInstance} from '../../models/extensions/extension-instance.js' import {FunctionConfigType} from '../../models/extensions/specifications/function.js' -import {exec} from '@shopify/cli-kit/node/system' +import {exec, captureOutputWithExitCode} from '@shopify/cli-kit/node/system' import {joinPath} from '@shopify/cli-kit/node/path' import {fileExists, readFileSync} from '@shopify/cli-kit/node/fs' import {renderWarning} from '@shopify/cli-kit/node/ui' @@ -37,30 +38,41 @@ function getFunctionPath(ext: ExtensionInstance) { return ext.outputPath } -async function warnIfProfileWillNotContainFunctionNames( +const profileWarningHeadline = "The profile won't contain names for your function." +const javaScriptProfileWarning = + "JavaScript functions built with Javy don't include a WebAssembly function name section, regardless of the wasm_opt setting. Function names will appear as in the profile." +const wasmProfileWarning = + "The built WebAssembly module doesn't contain a function name section. The default wasm-opt step removes this section, and the function compiler can also omit it. To preserve function names, set wasm_opt = false under [extensions.build] in shopify.extension.toml, configure the compiler to emit function names, and rebuild the function." + +async function profileWarningType( ext: ExtensionInstance, functionPath: string, -): Promise { +): Promise<'javascript' | 'wasm' | undefined> { try { if (!(await fileExists(functionPath))) return - const moduleBytes = readFileSync(functionPath) as Uint8Array if (!WebAssembly.validate(moduleBytes)) return - const module = new WebAssembly.Module(moduleBytes) - const hasFunctionNames = WebAssembly.Module.customSections(module, 'name').length > 0 - if (hasFunctionNames) return - - if (ext.isJavaScript) { - renderWarning({ - headline: "The profile won't contain names for your function.", - body: "JavaScript functions built with Javy don't include a WebAssembly function name section, regardless of the wasm_opt setting. Function names will appear as in the profile.", - }) - return - } + if (WebAssembly.Module.customSections(module, 'name').length > 0) return + return ext.isJavaScript ? 'javascript' : 'wasm' + // eslint-disable-next-line no-catch-all/no-catch-all + } catch { + // Inspecting function names is best-effort and must never prevent the function from running. + return undefined + } +} +async function warnIfProfileWillNotContainFunctionNames( + ext: ExtensionInstance, + functionPath: string, +): Promise { + const warningType = await profileWarningType(ext, functionPath) + if (!warningType) return + if (warningType === 'javascript') { + renderWarning({headline: profileWarningHeadline, body: javaScriptProfileWarning}) + } else { renderWarning({ - headline: "The profile won't contain names for your function.", + headline: profileWarningHeadline, body: [ "The built WebAssembly module doesn't contain a function name section. The default wasm-opt step removes this section, and the function compiler can also omit it. To preserve function names, set ", {userInput: 'wasm_opt = false'}, @@ -69,9 +81,6 @@ async function warnIfProfileWillNotContainFunctionNames( ' in shopify.extension.toml, configure the compiler to emit function names, and rebuild the function.', ], }) - // eslint-disable-next-line no-catch-all/no-catch-all - } catch { - // Inspecting function names is best-effort and must never prevent the function from running. } } @@ -81,6 +90,23 @@ export async function runFunction(options: FunctionRunnerOptions) { const functionRunner = await getFunctionRunnerBinary(ext) await downloadBinary(functionRunner) + const args = functionRunnerArguments(options) + + const functionPath = getFunctionPath(ext) + if (options.profile) { + await warnIfProfileWillNotContainFunctionNames(ext, functionPath) + } + + return exec(functionRunner.path, ['-f', functionPath, ...args], { + cwd: options.functionExtension.directory, + stdin: options.stdin, + stdout: options.stdout ?? 'inherit', + stderr: options.stderr ?? 'inherit', + input: options.input, + }) +} + +function functionRunnerArguments(options: FunctionRunnerOptions): string[] { const args: string[] = [] if (options.inputPath) { args.push('--input', options.inputPath) @@ -99,16 +125,65 @@ export async function runFunction(options: FunctionRunnerOptions) { args.push('--query-path', options.queryPath) } - const functionPath = getFunctionPath(ext) - if (options.profile) { - await warnIfProfileWillNotContainFunctionNames(ext, functionPath) - } + return args +} - return exec(functionRunner.path, ['-f', functionPath, ...args], { +export type FunctionExecution = + | {state: 'completed'; result: FunctionRunResult; exitCode: number; diagnostics: string[]} + | {state: 'failed'; message: string; command: string; args: string[]; exitCode: number; stderr: string} + +/** Runs the native JSON protocol without writing the child process's output to the terminal. */ +export async function executeFunction( + options: Omit, +): Promise { + const binary = await getFunctionRunnerBinary(options.functionExtension) + await downloadBinary(binary) + const functionPath = getFunctionPath(options.functionExtension) + const args = ['-f', functionPath, ...functionRunnerArguments({...options, json: true})] + const warningType = options.profile ? await profileWarningType(options.functionExtension, functionPath) : undefined + const diagnostics = warningType + ? [`${profileWarningHeadline} ${warningType === 'javascript' ? javaScriptProfileWarning : wasmProfileWarning}`] + : [] + const execution = await captureOutputWithExitCode(binary.path, args, { cwd: options.functionExtension.directory, stdin: options.stdin, - stdout: options.stdout ?? 'inherit', - stderr: options.stderr ?? 'inherit', input: options.input, }) + + let value: unknown + try { + value = JSON.parse(execution.stdout) + } catch (error) { + if (!(error instanceof SyntaxError)) throw error + return { + state: 'failed', + message: + execution.exitCode === 0 + ? 'Function runner returned invalid JSON.' + : `Function runner failed with exit code ${execution.exitCode}.`, + command: binary.path, + args, + exitCode: execution.exitCode, + stderr: execution.stderr, + } + } + const parsed = functionRunJsonOutputSchema.schema.safeParse(value) + if (!parsed.success || (execution.exitCode !== 0 && parsed.data.success)) { + return { + state: 'failed', + message: parsed.success + ? `Function runner failed with exit code ${execution.exitCode}.` + : 'Function runner returned an invalid result.', + command: binary.path, + args, + exitCode: execution.exitCode, + stderr: execution.stderr, + } + } + return { + state: 'completed', + result: parsed.data, + exitCode: execution.exitCode || Number(!parsed.data.success), + diagnostics: execution.stderr.trim() ? [...diagnostics, execution.stderr.trim()] : diagnostics, + } } diff --git a/packages/app/src/cli/services/function/runner/result.test.ts b/packages/app/src/cli/services/function/runner/result.test.ts new file mode 100644 index 00000000000..32cd463c653 --- /dev/null +++ b/packages/app/src/cli/services/function/runner/result.test.ts @@ -0,0 +1,89 @@ +import {presentFunctionExecution} from './result.js' +import {functionRunJsonOutputSchema, type FunctionRunResult} from './types.js' +import {expect, test} from 'vitest' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events' +import {ExternalError} from '@shopify/cli-kit/node/error' + +function result(): FunctionRunResult { + return { + name: 'discount.wasm', + size: 12, + memory_usage: 64, + instructions: 1000, + logs: '', + input: {cart: {lines: []}}, + output: {operations: []}, + success: true, + } +} + +test('preserves native keys and payloads through the real encoder and stdout writer', async () => { + const value = {...result(), extra_native_field: {nested_key: true}} + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEventsForCommand(['--json'], () => + presentFunctionExecution({state: 'completed', result: value, exitCode: 0, diagnostics: ['Runner warning']}), + ) + expect(stdout()).toBe(`${functionRunJsonOutputSchema.encode(value)}\n`) + expect(JSON.parse(stdout())).toEqual(value) + expect(JSON.parse(stderr())).toMatchObject({type: 'diagnostic', level: 'warning', message: 'Runner warning'}) + }) +}) + +test('prints one failed execution result and retains its nonzero exit code', async () => { + const previousExitCode = process.exitCode + try { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + presentFunctionExecution({ + state: 'completed', + result: {...result(), success: false}, + exitCode: 1, + diagnostics: [], + }) + expect(JSON.parse(stdout())).toEqual({...result(), success: false}) + expect(stderr()).toBe('') + expect(process.exitCode).toBe(1) + }) + } finally { + // Tests run serially and restore the process state after the captured callback. + // eslint-disable-next-line require-atomic-updates + process.exitCode = previousExitCode + } +}) + +test('throws an external failure before writing a result', async () => { + await withCapturedStandardStreams(async ({stdout}) => { + expect(() => + presentFunctionExecution({ + state: 'failed', + message: 'Invalid WASM', + command: '/bin/runner', + args: ['--json'], + exitCode: 1, + stderr: 'Invalid WASM', + }), + ).toThrow(ExternalError) + expect(stdout()).toBe('') + }) +}) + +test.each( + [null, {}, [], {error: 'Invalid output', stdout: 'bad output'}, {snake_case: [true, 0]}].map((output) => ({output})), +)('preserves native output values: %j', ({output}) => { + expect(JSON.parse(functionRunJsonOutputSchema.encode({...result(), output})).output).toEqual(output) +}) + +test.each([ + {field: 'instructions', value: -1}, + {field: 'size', value: 1.5}, + {field: 'memory_usage', value: -1}, + {field: 'success', value: 'false'}, + {field: 'input', value: undefined}, + {field: 'output', value: undefined}, +])('rejects invalid native $field values', ({field, value}) => { + expect(() => functionRunJsonOutputSchema.validate({...result(), [field]: value})).toThrow() +}) + +test('requires input and output in its discoverable schema', () => { + expect(functionRunJsonOutputSchema.jsonSchema).toMatchObject({required: expect.arrayContaining(['input', 'output'])}) +}) diff --git a/packages/app/src/cli/services/function/runner/result.ts b/packages/app/src/cli/services/function/runner/result.ts new file mode 100644 index 00000000000..44bac279ce2 --- /dev/null +++ b/packages/app/src/cli/services/function/runner/result.ts @@ -0,0 +1,15 @@ +import {functionRunJsonOutputSchema} from './types.js' +import {type FunctionExecution} from '../runner.js' +import {ExternalError} from '@shopify/cli-kit/node/error' +import {outputResult, outputWarn} from '@shopify/cli-kit/node/output' + +export function presentFunctionExecution(execution: FunctionExecution): void { + if (execution.state === 'failed') { + const error = new ExternalError(execution.message, execution.command, execution.args) + error.details = {exitCode: execution.exitCode, stderr: execution.stderr} + throw error + } + for (const diagnostic of execution.diagnostics) outputWarn(diagnostic) + outputResult(functionRunJsonOutputSchema.encode(execution.result)) + if (execution.exitCode !== 0) process.exitCode = execution.exitCode +} diff --git a/packages/app/src/cli/services/function/runner/types.ts b/packages/app/src/cli/services/function/runner/types.ts new file mode 100644 index 00000000000..f7dd048016d --- /dev/null +++ b/packages/app/src/cli/services/function/runner/types.ts @@ -0,0 +1,32 @@ +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' + +const nativeValueSchema = zod.unknown().refine((value) => value !== undefined, 'The native value must be present.') + +export const functionRunJsonOutputSchema = defineJsonOutputSchema({ + name: 'FunctionRunResult', + // Native Function runner 7.x/9.x protocol. Keep upstream keys and arbitrary payloads intact. + schema: zod + .object({ + name: zod.string(), + size: zod.number().int().nonnegative().describe('Module size in kilobytes, as reported by Function runner.'), + memory_usage: zod + .number() + .int() + .nonnegative() + .describe('Linear memory usage in kilobytes, as reported by Function runner.'), + instructions: zod.number().int().nonnegative(), + logs: zod.string(), + input: nativeValueSchema.describe('Native Function input; query keys and values are preserved.'), + output: nativeValueSchema.describe( + 'Native Function output, including the runner-specific invalid-output representation.', + ), + success: zod + .boolean() + .describe('False for a completed Function execution that failed; the command exits nonzero.'), + }) + .passthrough() + .describe('The native JSON object produced by Function runner 7.x and 9.x. Extra upstream fields are preserved.'), +}) + +export type FunctionRunResult = InferJsonOutputSchema diff --git a/packages/cli/README.md b/packages/cli/README.md index e280b2d2310..b0e93edb96a 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1468,6 +1468,66 @@ DESCRIPTION Runs the function from your current directory for "testing purposes" (https://shopify.dev/docs/apps/functions/testing-and-debugging). To learn how you can monitor and debug functions when errors occur, refer to "Shopify Functions error handling" (https://shopify.dev/docs/api/functions/errors). + + JSON output preserves the native Function runner 7.x and 9.x format, including `memory_usage` and arbitrary + input/output fields. Profiling files retain the native Speedscope format. + + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `FunctionRunResult` schema. + + ```json + { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "size": { + "type": "integer", + "minimum": 0, + "description": "Module size in kilobytes, as reported by Function runner." + }, + "memory_usage": { + "type": "integer", + "minimum": 0, + "description": "Linear memory usage in kilobytes, as reported by Function runner." + }, + "instructions": { + "type": "integer", + "minimum": 0 + }, + "logs": { + "type": "string" + }, + "input": { + "description": "Native Function input; query keys and values are preserved." + }, + "output": { + "$ref": "#/definitions/FunctionRunResult/properties/input", + "description": "Native Function output, including the runner-specific invalid-output representation." + }, + "success": { + "type": "boolean", + "description": "False for a completed Function execution that failed; the command exits nonzero." + } + }, + "required": [ + "name", + "size", + "memory_usage", + "instructions", + "logs", + "input", + "output", + "success" + ], + "additionalProperties": true, + "description": "The native JSON object produced by Function runner 7.x and 9.x. Extra upstream fields are preserved.", + "title": "FunctionRunResult", + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` ``` ## `shopify app function schema` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index e6a61339488..7568715a328 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -2184,8 +2184,8 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Runs the function from your current directory for \"testing purposes\" (https://shopify.dev/docs/apps/functions/testing-and-debugging). To learn how you can monitor and debug functions when errors occur, refer to \"Shopify Functions error handling\" (https://shopify.dev/docs/api/functions/errors).", - "descriptionWithMarkdown": "Runs the function from your current directory for [testing purposes](https://shopify.dev/docs/apps/functions/testing-and-debugging). To learn how you can monitor and debug functions when errors occur, refer to [Shopify Functions error handling](https://shopify.dev/docs/api/functions/errors).", + "description": "Runs the function from your current directory for \"testing purposes\" (https://shopify.dev/docs/apps/functions/testing-and-debugging). To learn how you can monitor and debug functions when errors occur, refer to \"Shopify Functions error handling\" (https://shopify.dev/docs/api/functions/errors).\n\nJSON output preserves the native Function runner 7.x and 9.x format, including `memory_usage` and arbitrary input/output fields. Profiling files retain the native Speedscope format.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `FunctionRunResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\n \"type\": \"string\"\n },\n \"size\": {\n \"type\": \"integer\",\n \"minimum\": 0,\n \"description\": \"Module size in kilobytes, as reported by Function runner.\"\n },\n \"memory_usage\": {\n \"type\": \"integer\",\n \"minimum\": 0,\n \"description\": \"Linear memory usage in kilobytes, as reported by Function runner.\"\n },\n \"instructions\": {\n \"type\": \"integer\",\n \"minimum\": 0\n },\n \"logs\": {\n \"type\": \"string\"\n },\n \"input\": {\n \"description\": \"Native Function input; query keys and values are preserved.\"\n },\n \"output\": {\n \"$ref\": \"#/definitions/FunctionRunResult/properties/input\",\n \"description\": \"Native Function output, including the runner-specific invalid-output representation.\"\n },\n \"success\": {\n \"type\": \"boolean\",\n \"description\": \"False for a completed Function execution that failed; the command exits nonzero.\"\n }\n },\n \"required\": [\n \"name\",\n \"size\",\n \"memory_usage\",\n \"instructions\",\n \"logs\",\n \"input\",\n \"output\",\n \"success\"\n ],\n \"additionalProperties\": true,\n \"description\": \"The native JSON object produced by Function runner 7.x and 9.x. Extra upstream fields are preserved.\",\n \"title\": \"FunctionRunResult\",\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "descriptionWithMarkdown": "Runs the function from your current directory for [testing purposes](https://shopify.dev/docs/apps/functions/testing-and-debugging). To learn how you can monitor and debug functions when errors occur, refer to [Shopify Functions error handling](https://shopify.dev/docs/api/functions/errors).\n\nJSON output preserves the native Function runner 7.x and 9.x format, including `memory_usage` and arbitrary input/output fields. Profiling files retain the native Speedscope format.", "flags": { "auth-alias": { "description": "Alias of the Shopify account to use for authentication.", diff --git a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js index 0111f5d7cc8..764a8f60378 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -17,7 +17,6 @@ const commandExceptions = [ 'packages/app/src/cli/commands/app/execute.ts', 'packages/app/src/cli/commands/app/function/build.ts', 'packages/app/src/cli/commands/app/function/replay.ts', - 'packages/app/src/cli/commands/app/function/run.ts', 'packages/app/src/cli/commands/app/function/schema.ts', 'packages/app/src/cli/commands/app/function/typegen.ts', 'packages/app/src/cli/commands/app/generate/extension.ts',