diff --git a/.changeset/function-prepare-variables-check.md b/.changeset/function-prepare-variables-check.md new file mode 100644 index 00000000000..4bff7ecb163 --- /dev/null +++ b/.changeset/function-prepare-variables-check.md @@ -0,0 +1,6 @@ +--- +'@shopify/app': minor +'@shopify/cli': minor +--- + +Run `@shopify/shopify_function`'s `check.js`, when the package has it, before building a JavaScript function. It fails the build when a prepare target returns variables its run target's input query doesn't accept. diff --git a/packages/app/src/cli/services/function/build.test.ts b/packages/app/src/cli/services/function/build.test.ts index c1bebcd99cd..457b029bb95 100644 --- a/packages/app/src/cli/services/function/build.test.ts +++ b/packages/app/src/cli/services/function/build.test.ts @@ -7,6 +7,7 @@ import { runWasmOpt, runTrampoline, buildJSFunction, + checkJSFunction, } from './build.js' import { javyBinary, @@ -479,6 +480,98 @@ describe('bundleExtension', () => { }) }) +describe('checkJSFunction', () => { + const config = { + name: 'test function', + type: 'function', + build: {wasm_opt: true}, + configuration_ui: false, + api_version: '2026-07', + targeting: [ + {target: 'cart.validations.generate.prepare', export: 'cart-validations-generate-prepare'}, + { + target: 'cart.validations.generate.run', + input_query: 'src/run.graphql', + export: 'cart-validations-generate-run', + }, + ], + } + + async function installCheck(tmpDir: string) { + const check = joinPath(tmpDir, 'node_modules/@shopify/shopify_function/check.js') + await mkdir(dirname(check)) + await writeFile(check, '') + return check + } + + test("runs @shopify/shopify_function's check.js with the function's targets", async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + const ourFunction = await testFunctionExtension({dir: tmpDir, entryPath: 'src/index.js', config}) + const check = await installCheck(tmpDir) + let functionJson: unknown + vi.mocked(exec).mockImplementation(async (_command, args) => { + functionJson = JSON.parse(await readFile(args.at(-1)!)) + }) + + // When + await checkJSFunction(ourFunction, {stdout, stderr, signal, app}) + + // Then + expect(exec).toHaveBeenCalledWith( + process.execPath, + [check, '--function', expect.stringMatching(/function\.json$/)], + {cwd: tmpDir, stderr, signal}, + ) + expect(functionJson).toEqual({ + handle: ourFunction.handle, + directory: tmpDir, + entry: 'src/index.js', + schema: joinPath(tmpDir, 'schema.graphql'), + targets: [ + {target: 'cart.validations.generate.prepare', export: 'cartValidationsGeneratePrepare'}, + { + target: 'cart.validations.generate.run', + export: 'cartValidationsGenerateRun', + inputQuery: joinPath(tmpDir, 'src/run.graphql'), + }, + ], + }) + }) + }) + + test('does nothing when @shopify/shopify_function has no check.js', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + const ourFunction = await testFunctionExtension({dir: tmpDir, entryPath: 'src/index.js', config}) + + // When + await checkJSFunction(ourFunction, {stdout, stderr, signal, app}) + + // Then + expect(exec).not.toHaveBeenCalled() + }) + }) + + test('fails the build when the check fails', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + const ourFunction = await testFunctionExtension({dir: tmpDir, entryPath: 'src/index.js', config}) + await installShopifyLibrary(tmpDir) + const check = await installCheck(tmpDir) + vi.mocked(exec).mockRejectedValueOnce(new Error('prepare variables mismatch')) + + // When + const got = buildJSFunction(ourFunction, {stdout, stderr, signal, app}) + + // Then + await expect(got).rejects.toThrow('prepare variables mismatch') + expect(exec).toHaveBeenCalledTimes(1) + expect(exec).toHaveBeenCalledWith(process.execPath, [check, '--function', expect.any(String)], expect.anything()) + }) + }) +}) + describe('runJavy', () => { test('captures compiler diagnostics in the dev session log in text mode', async () => { const {exec: execute} = diff --git a/packages/app/src/cli/services/function/build.ts b/packages/app/src/cli/services/function/build.ts index 57d09db0137..b4f24a7f69f 100644 --- a/packages/app/src/cli/services/function/build.ts +++ b/packages/app/src/cli/services/function/build.ts @@ -94,6 +94,7 @@ export async function buildJSFunction(fun: ExtensionInstance const javyBuilder: JavyBuilder = exports.length === 0 ? DefaultJavyBuilder : new ExportJavyBuilder(exports) const deps = await validateShopifyFunctionPackageVersion(fun) + await checkJSFunction(fun, options) if (options.useTasks) { return buildJSFunctionWithTasks(fun, options, javyBuilder, deps) @@ -102,6 +103,36 @@ export async function buildJSFunction(fun: ExtensionInstance } } +// Runs @shopify/shopify_function's check.js, when the package has it, before the function is built. +export async function checkJSFunction(fun: ExtensionInstance, options: JSFunctionBuildOptions) { + const check = await findPathUp('node_modules/@shopify/shopify_function/check.js', {type: 'file', cwd: fun.directory}) + if (!check) return + + await inTemporaryDirectory(async (temporaryDirectory) => { + // Each target's export is the function the bundle imports for it + const functionPath = joinPath(temporaryDirectory, 'function.json') + await writeFile( + functionPath, + JSON.stringify({ + handle: fun.handle, + directory: fun.directory, + entry: fun.entrySourceFilePath, + schema: joinPath(fun.directory, 'schema.graphql'), + targets: (fun.configuration.targeting ?? []).map((targeting) => ({ + target: targeting.target, + export: targeting.export && camelize(targeting.export), + inputQuery: targeting.input_query && joinPath(fun.directory, targeting.input_query), + })), + }), + ) + await exec(process.execPath, [check, '--function', functionPath], { + cwd: fun.directory, + stderr: options.stderr, + signal: options.signal, + }) + }) +} + async function buildJSFunctionWithoutTasks( fun: ExtensionInstance, options: JSFunctionBuildOptions,