Skip to content
78 changes: 78 additions & 0 deletions src/methods/get-function-info.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* Retrieve function information from the Shopify CLI
*/

import { spawn } from 'child_process';
import path from 'path';

/**
* Information about a Shopify function
*/
export interface FunctionInfo {
schemaPath: string;
functionRunnerPath: string;
wasmPath: string;
targeting: Record<string, any>;
}

/**
* Retrieves function information from the Shopify CLI
* @param {string} functionDir - The directory path of the function
* @returns {Promise<FunctionInfo>} Function information including schemaPath, functionRunnerPath, wasmPath, and targeting
* @throws {Error} If the CLI command is not available or fails
*/
export async function getFunctionInfo(functionDir: string): Promise<FunctionInfo> {
const resolvedFunctionDir = path.resolve(functionDir);
const appRootDir = path.dirname(resolvedFunctionDir);
const functionName = path.basename(resolvedFunctionDir);

return new Promise((resolve, reject) => {
const shopifyProcess = spawn('shopify', [
'app', 'function', 'info',
'--json',
'--path', functionName
], {
cwd: appRootDir,
stdio: ['pipe', 'pipe', 'pipe']
});

let stdout = '';
let stderr = '';

shopifyProcess.stdout.on('data', (data) => {
stdout += data.toString();
});

shopifyProcess.stderr.on('data', (data) => {
stderr += data.toString();
});

shopifyProcess.on('close', (code) => {
if (code !== 0) {
// Check if the error is due to the command not being found
if (stderr.includes('Command app function info not found') || stderr.includes('command not found')) {
reject(new Error(
'The "shopify app function info" command is not available in your CLI version.\n' +
'Please upgrade to the latest version:\n' +
' npm install -g @shopify/cli@latest\n\n'
));
return;
}
reject(new Error(`Function info command failed with exit code ${code}: ${stderr}`));
return;
}

try {
const functionInfo = JSON.parse(stdout.trim()) as FunctionInfo;
resolve(functionInfo);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
reject(new Error(`Failed to parse function info JSON: ${errorMessage}\nOutput: ${stdout}`));
}
});

shopifyProcess.on('error', (error) => {
reject(new Error(`Failed to start shopify function info command: ${error.message}`));
});
});
}
109 changes: 48 additions & 61 deletions src/methods/run-function.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@
*/

import { spawn } from 'child_process';
import path from 'path';
import { fileURLToPath } from 'url';
import { FixtureData } from './load-fixture.js';

/**
* Interface for the run function result
Expand All @@ -14,112 +13,101 @@ export interface RunFunctionResult {
error: string | null;
}

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

/**
* Run a function with the given payload and return the result
* @param {String} exportName - The function run payload
* @param {String} input - The actual function implementation to test
* @param {String} [functionPath] - Optional path to the function directory
* Run a function using Shopify CLI's function runner command
*
* This function:
* - Uses function-runner binary directly to run the function.
* @param {String} functionRunnerPath - Path to the function runner binary
* @param {String} wasmPath - Path to the WASM file
* @param {FixtureData} fixture - The fixture data containing export, input, and target
* @param {String} queryPath - Path to the input query file
* @param {String} schemaPath - Path to the schema file
* @returns {Object} The function run result
*/

export async function runFunction(
exportName: string,
input: Record<string, any>,
functionPath?: string
fixture: FixtureData,
functionRunnerPath: string,
wasmPath: string,
queryPath: string,
schemaPath: string,
): Promise<RunFunctionResult> {
try {
const inputJson = JSON.stringify(input);

let functionDir, appRootDir, functionName;

if (functionPath !== undefined && functionPath !== null) {
// Use provided function path
functionDir = path.resolve(functionPath);
appRootDir = path.dirname(functionDir);
functionName = path.basename(functionDir);
} else {
// Calculate paths correctly for when used as a dependency:
// __dirname = /path/to/function/tests/node_modules/function-testing-helpers/src/methods
// Go up 5 levels to get to function directory: ../../../../../ = /path/to/function
functionDir = path.dirname(path.dirname(path.dirname(path.dirname(path.dirname(__dirname)))));
appRootDir = path.dirname(functionDir);
functionName = path.basename(functionDir);
}

return new Promise((resolve, reject) => {
const shopifyProcess = spawn('shopify', [
'app', 'function', 'run',
'--export', exportName,
const inputJson = JSON.stringify(fixture.input);

return new Promise((resolve) => {
const runnerProcess = spawn(functionRunnerPath, [
'-f', wasmPath,
'--export', fixture.export,
'--query-path', queryPath,
'--schema-path', schemaPath,
'--json',
'--path', functionName
], {
cwd: appRootDir,
stdio: ['pipe', 'pipe', 'pipe']
stdio: ['pipe', 'pipe', 'pipe'],
});

let stdout = '';
let stderr = '';

shopifyProcess.stdout.on('data', (data) => {
runnerProcess.stdout.on('data', (data) => {
stdout += data.toString();
});

shopifyProcess.stderr.on('data', (data) => {
runnerProcess.stderr.on('data', (data) => {
stderr += data.toString();
});

shopifyProcess.on('close', (code) => {
runnerProcess.on('close', (code) => {

if (code !== 0) {
resolve({
result: null,
error: `Command failed with exit code ${code}: ${stderr}`,
error: `function-runner failed with exit code ${code}: ${stderr}`
});
return;
}

let result;
try {
result = JSON.parse(stdout);

let actualOutput;
if (result?.output?.humanized) {
actualOutput = JSON.parse(result.output.humanized);
} else if (result?.output) {
actualOutput = result.output;
} else {
actualOutput = result;
const result = JSON.parse(stdout);

// function-runner output format: { output: {...} }
if (!result.output) {
resolve({
result: null,
error: `function-runner returned unexpected format - missing 'output' field. Received: ${JSON.stringify(result)}`
});
return;
}

resolve({
result: { output: actualOutput },
error: null,
result: { output: result.output },
error: null
});
} catch (parseError) {
resolve({
result: { output: stdout.trim() },
error: null,
result: null,
error: `Failed to parse function-runner output: ${parseError instanceof Error ? parseError.message : 'Unknown error'}`
});
}
});

shopifyProcess.on('error', (error) => {
runnerProcess.on('error', (error) => {
resolve({
result: null,
error: `Failed to start shopify command: ${error.message}`,
error: `Failed to start function-runner: ${error.message}`
});
});

shopifyProcess.stdin.write(inputJson);
shopifyProcess.stdin.end();
runnerProcess.stdin.write(inputJson);
runnerProcess.stdin.end();
});

} catch (error) {
if (error instanceof Error) {
return {
result: null,
error: error.message,
error: error.message
};
} else {
return {
Expand All @@ -129,4 +117,3 @@ export async function runFunction(
}
}
}

2 changes: 2 additions & 0 deletions src/wasm-testing-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export { loadSchema } from "./methods/load-schema.js";
export { loadInputQuery } from "./methods/load-input-query.js";
export { buildFunction } from "./methods/build-function.js";
export { runFunction } from "./methods/run-function.js";
export { getFunctionInfo } from "./methods/get-function-info.js";
export { validateTestAssets } from "./methods/validate-test-assets.js";
export { validateInputQuery } from "./methods/validate-input-query.js";
export { validateFixtureOutput } from "./methods/validate-fixture-output.js";
Expand All @@ -20,6 +21,7 @@ export { validateFixtureInput } from "./methods/validate-fixture-input.js";
export type { FixtureData } from "./methods/load-fixture.js";
export type { BuildFunctionResult } from "./methods/build-function.js";
export type { RunFunctionResult } from "./methods/run-function.js";
export type { FunctionInfo } from "./methods/get-function-info.js";
export type {
ValidateTestAssetsOptions,
CompleteValidationResult,
Expand Down
36 changes: 19 additions & 17 deletions test-app/extensions/cart-validation-js/tests/default.test.js
Original file line number Diff line number Diff line change
@@ -1,23 +1,26 @@
import path from "path";
import fs from "fs";
import { buildFunction, loadFixture, runFunction, validateTestAssets, loadSchema, loadInputQuery } from "@shopify/shopify-function-test-helpers";
import { buildFunction, loadFixture, runFunction, validateTestAssets, loadSchema, loadInputQuery, getFunctionInfo } from "@shopify/shopify-function-test-helpers";

describe("Default Integration Test", () => {
let schema;
let inputQueryAST;
let functionDir;
let schemaPath;
let targeting;
let functionRunnerPath;
let wasmPath;

beforeAll(async () => {
functionDir = path.dirname(__dirname);
await buildFunction(functionDir);
// Load schema and input query once since they don't change across fixtures
const schemaPath = path.join(functionDir, "schema.graphql");
const inputQueryPath = path.join(functionDir, "src/cart_validations_generate_run.graphql");

// Get function info from Shopify CLI
const functionInfo = await getFunctionInfo(functionDir);
({ schemaPath, functionRunnerPath, wasmPath, targeting } = functionInfo);

schema = await loadSchema(schemaPath);
inputQueryAST = await loadInputQuery(inputQueryPath);
}, 20000); // 20 second timeout for building the function
}, 20000); // 20 second timeout for building and obtaining information about the function

const fixturesDir = path.join(__dirname, "fixtures");
const fixtureFiles = fs
Expand All @@ -28,32 +31,31 @@ describe("Default Integration Test", () => {
fixtureFiles.forEach((fixtureFile) => {
test(`runs ${path.relative(fixturesDir, fixtureFile)}`, async () => {
const fixture = await loadFixture(fixtureFile);
const inputQueryPath = targeting[fixture.target].inputQueryPath;
inputQueryAST = await loadInputQuery(inputQueryPath);

// Validate fixture using our comprehensive validation system
const validationResult = await validateTestAssets({
schema,
fixture,
inputQueryAST
});

// Log validation results for debugging
// logValidationResults(fixtureFile, validationResult);

// Assert that all validation steps pass
expect(validationResult.inputQuery.errors).toHaveLength(0);
expect(validationResult.inputFixture.errors).toHaveLength(0);
expect(validationResult.outputFixture.errors).toHaveLength(0);

// Run the actual function
const runResult = await runFunction(
fixture.export,
fixture.input,
functionDir
fixture,
functionRunnerPath,
wasmPath,
inputQueryPath,
schemaPath
);

const { result, error } = runResult;
expect(error).toBeNull();
expect(result.output).toEqual(fixture.expectedOutput);
}, 10000);
});
});
});
24 changes: 0 additions & 24 deletions test-app/extensions/cart-validation-js/tests/fixtures/cda6d1.json

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just renamed this file, I dont know why it doesn't show as a rename.

"payload": {
"export": "cart-validations-generate-run",
"target": "cart.validations.generate.run",
"input": {
"cart": {
"lines": [
{
"quantity": 1
},
{
"quantity": 1
},
{
"quantity": 1
}
]
}
},
"output": {
"operations": [{ "validationAdd": { "errors": [] } }]
}
}
}
Loading