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
5 changes: 5 additions & 0 deletions .changeset/function-run-json-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/cli': major
---

Validate native `app function run --json` results, emit runner diagnostics on stderr, and avoid duplicate failure documents.
154 changes: 154 additions & 0 deletions packages/app/src/cli/commands/app/function/run.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('../../../services/function/common.js')>()),
chooseFunction: vi.fn(),
getOrGenerateSchemaPath: vi.fn(),
}))
vi.mock('../../../services/function/binaries.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../services/function/binaries.js')>()),
downloadBinary: vi.fn(),
}))
vi.mock('@shopify/cli-kit/node/context/local', async (importOriginal) => ({
...(await importOriginal<typeof import('@shopify/cli-kit/node/context/local')>()),
isTerminalInteractive: vi.fn(),
}))
vi.mock('@shopify/cli-kit/node/ui', async (importOriginal) => ({
...(await importOriginal<typeof import('@shopify/cli-kit/node/ui')>()),
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')
})
29 changes: 21 additions & 8 deletions packages/app/src/cli/commands/app/function/run.ts
Original file line number Diff line number Diff line change
@@ -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()

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
}
Expand Down
89 changes: 87 additions & 2 deletions packages/app/src/cli/services/function/runner.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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),
)
})
})
})
Loading
Loading