diff --git a/src/methods/get-function-info.ts b/src/methods/get-function-info.ts new file mode 100644 index 0000000..2d15be7 --- /dev/null +++ b/src/methods/get-function-info.ts @@ -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; +} + +/** + * Retrieves function information from the Shopify CLI + * @param {string} functionDir - The directory path of the function + * @returns {Promise} 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 { + 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}`)); + }); + }); +} diff --git a/src/methods/run-function.ts b/src/methods/run-function.ts index e38e5cc..8772c5f 100644 --- a/src/methods/run-function.ts +++ b/src/methods/run-function.ts @@ -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 @@ -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, - functionPath?: string + fixture: FixtureData, + functionRunnerPath: string, + wasmPath: string, + queryPath: string, + schemaPath: string, ): Promise { 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 { @@ -129,4 +117,3 @@ export async function runFunction( } } } - diff --git a/src/wasm-testing-helpers.ts b/src/wasm-testing-helpers.ts index c3ed581..2f02140 100644 --- a/src/wasm-testing-helpers.ts +++ b/src/wasm-testing-helpers.ts @@ -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"; @@ -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, diff --git a/test-app/extensions/cart-validation-js/tests/default.test.js b/test-app/extensions/cart-validation-js/tests/default.test.js index aa39873..6fcbf25 100644 --- a/test-app/extensions/cart-validation-js/tests/default.test.js +++ b/test-app/extensions/cart-validation-js/tests/default.test.js @@ -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 @@ -28,6 +31,8 @@ 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({ @@ -35,20 +40,17 @@ describe("Default Integration Test", () => { 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; @@ -56,4 +58,4 @@ describe("Default Integration Test", () => { expect(result.output).toEqual(fixture.expectedOutput); }, 10000); }); -}); \ No newline at end of file +}); diff --git a/test-app/extensions/cart-validation-js/tests/fixtures/cda6d1.json b/test-app/extensions/cart-validation-js/tests/fixtures/cda6d1.json deleted file mode 100644 index fbd8f93..0000000 --- a/test-app/extensions/cart-validation-js/tests/fixtures/cda6d1.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "payload": { - "export": "cart-validations-generate-run", - "target": "cart.validations.generate.run", - "input": { - "cart": { - "lines": [ - { - "quantity": 1 - }, - { - "quantity": 1 - }, - { - "quantity": 1 - } - ] - } - }, - "output": { - "operations": [{ "validationAdd": { "errors": [] } }] - } - } -} \ No newline at end of file diff --git a/test-app/extensions/cart-validation-js/tests/fixtures/checkout-validation-valid-fixture.json b/test-app/extensions/cart-validation-js/tests/fixtures/checkout-validation-valid-fixture.json new file mode 100644 index 0000000..715e98c --- /dev/null +++ b/test-app/extensions/cart-validation-js/tests/fixtures/checkout-validation-valid-fixture.json @@ -0,0 +1,24 @@ +{ + "payload": { + "export": "cart-validations-generate-run", + "target": "cart.validations.generate.run", + "input": { + "cart": { + "lines": [ + { + "quantity": 1 + }, + { + "quantity": 1 + }, + { + "quantity": 1 + } + ] + } + }, + "output": { + "operations": [{ "validationAdd": { "errors": [] } }] + } + } +} \ No newline at end of file diff --git a/test-app/extensions/discount-function/tests/default.test.js b/test-app/extensions/discount-function/tests/default.test.js index 57b842f..6fcbf25 100644 --- a/test-app/extensions/discount-function/tests/default.test.js +++ b/test-app/extensions/discount-function/tests/default.test.js @@ -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_lines_discounts_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 @@ -28,6 +31,8 @@ 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({ @@ -35,20 +40,17 @@ describe("Default Integration Test", () => { 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; @@ -56,4 +58,4 @@ describe("Default Integration Test", () => { expect(result.output).toEqual(fixture.expectedOutput); }, 10000); }); -}); \ No newline at end of file +}); diff --git a/test-app/extensions/discount-function/tests/fixtures/discount-function-valid-fixture.json b/test-app/extensions/discount-function/tests/fixtures/cart-lines-valid-fixture.json similarity index 82% rename from test-app/extensions/discount-function/tests/fixtures/discount-function-valid-fixture.json rename to test-app/extensions/discount-function/tests/fixtures/cart-lines-valid-fixture.json index a932551..1dd0d5d 100644 --- a/test-app/extensions/discount-function/tests/fixtures/discount-function-valid-fixture.json +++ b/test-app/extensions/discount-function/tests/fixtures/cart-lines-valid-fixture.json @@ -1,8 +1,7 @@ { - "shopId": 55479926983, - "apiClientId": 282965573633, "payload": { "export": "cart_lines_discounts_generate_run", + "target": "cart.lines.discounts.generate.run", "input": { "cart": { "lines": [ @@ -76,14 +75,6 @@ "outputBytes": 399, "logs": [], "functionId": "0199721a-ba07-70fd-9b6f-63e9c35a8d19", - "fuelConsumed": 50920, - "target": "cart.lines.discounts.generate.run" - }, - "logType": "function_run", - "status": "success", - "source": "discount-function", - "sourceNamespace": "extensions", - "logTimestamp": "2025-09-22T15:57:24.476234Z", - "localTime": "2025-09-22 11:57:24", - "storeName": "lopert-scripts.myshopify.com" + "fuelConsumed": 50920 + } } diff --git a/test-app/extensions/discount-function/tests/fixtures/delivery-valid-fixture.json b/test-app/extensions/discount-function/tests/fixtures/delivery-valid-fixture.json new file mode 100644 index 0000000..2b2b0a9 --- /dev/null +++ b/test-app/extensions/discount-function/tests/fixtures/delivery-valid-fixture.json @@ -0,0 +1,50 @@ +{ + "payload": { + "export": "cart_delivery_options_discounts_generate_run", + "target": "cart.delivery-options.discounts.generate.run", + "input": { + "discount": { + "discountClasses": ["SHIPPING"] + }, + "cart": { + "deliveryGroups": [ + { + "id": "gid://shopify/CartDeliveryGroup/1" + } + ], + "cost": { + "subtotalAmount": { + "amount": "100.0" + } + } + } + }, + "output": { + "operations": [ + { + "deliveryDiscountsAdd": { + "candidates": [ + { + "associatedDiscountCode": null, + "message": "FREE DELIVERY", + "targets": [ + { + "deliveryGroup": { + "id": "gid://shopify/CartDeliveryGroup/1" + } + } + ], + "value": { + "percentage": { + "value": "100.0" + } + } + } + ], + "selectionStrategy": "ALL" + } + } + ] + } + } +} \ No newline at end of file diff --git a/test/methods/get-function-info.test.ts b/test/methods/get-function-info.test.ts new file mode 100644 index 0000000..cb86ebf --- /dev/null +++ b/test/methods/get-function-info.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { EventEmitter } from "events"; +import { spawn } from "child_process"; + +import { getFunctionInfo } from "../../src/methods/get-function-info.ts"; + +vi.mock("child_process", () => ({ + spawn: vi.fn(), +})); + +describe("getFunctionInfo", () => { + const mockSpawn = vi.mocked(spawn); + let mockProcess: any; + + beforeEach(() => { + // Create a mock process object that extends EventEmitter + mockProcess = new EventEmitter(); + mockProcess.stdout = new EventEmitter(); + mockProcess.stderr = new EventEmitter(); + + // Configure the mock to return our mock process + mockSpawn.mockReturnValue(mockProcess); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("should successfully retrieve function info from Shopify CLI", async () => { + const mockFunctionInfo = { + schemaPath: "/path/to/schema.graphql", + functionRunnerPath: "/path/to/function-runner.wasm", + wasmPath: "/path/to/function.wasm", + targeting: { + target: "purchase.payment-customization.run", + version: "2024-01", + }, + }; + + const promise = getFunctionInfo("/path/to/extensions/my-function"); + + // Simulate successful CLI response + setTimeout(() => { + mockProcess.stdout.emit("data", JSON.stringify(mockFunctionInfo)); + mockProcess.emit("close", 0); + }, 10); + + const result = await promise; + + expect(result).toEqual(mockFunctionInfo); + expect(mockSpawn).toHaveBeenCalledWith( + "shopify", + ["app", "function", "info", "--json", "--path", "my-function"], + { + cwd: "/path/to/extensions", + stdio: ["pipe", "pipe", "pipe"], + }, + ); + }); + + it("should reject when CLI command is not found", async () => { + const promise = getFunctionInfo("/path/to/extensions/my-function"); + + setTimeout(() => { + mockProcess.stderr.emit( + "data", + "Error: Command app function info not found", + ); + mockProcess.emit("close", 1); + }, 10); + + await expect(promise).rejects.toThrow( + 'The "shopify app function info" command is not available', + ); + await expect(promise).rejects.toThrow( + "Please upgrade to the latest version", + ); + }); + + it("should reject when shopify command is not found", async () => { + const promise = getFunctionInfo("/path/to/extensions/my-function"); + + setTimeout(() => { + mockProcess.stderr.emit("data", "shopify: command not found"); + mockProcess.emit("close", 127); + }, 10); + + await expect(promise).rejects.toThrow( + 'The "shopify app function info" command is not available', + ); + }); + + it("should reject when CLI command fails with non-zero exit code", async () => { + const promise = getFunctionInfo("/path/to/extensions/my-function"); + + setTimeout(() => { + mockProcess.stderr.emit("data", "Error: Function not found\n"); + mockProcess.emit("close", 1); + }, 10); + + await expect(promise).rejects.toThrow( + "Function info command failed with exit code 1", + ); + await expect(promise).rejects.toThrow("Error: Function not found"); + }); + + it("should reject when JSON parsing fails", async () => { + const promise = getFunctionInfo("/path/to/extensions/my-function"); + + setTimeout(() => { + mockProcess.stdout.emit("data", "Invalid JSON output"); + mockProcess.emit("close", 0); + }, 10); + + await expect(promise).rejects.toThrow("Failed to parse function info JSON"); + await expect(promise).rejects.toThrow("Invalid JSON output"); + }); + + it("should reject when spawn process emits an error", async () => { + const promise = getFunctionInfo("/path/to/extensions/my-function"); + + setTimeout(() => { + mockProcess.emit("error", new Error("ENOENT: spawn failed")); + }, 10); + + await expect(promise).rejects.toThrow( + "Failed to start shopify function info command", + ); + await expect(promise).rejects.toThrow("ENOENT: spawn failed"); + }); + + it("should accumulate stderr output for error messages", async () => { + const promise = getFunctionInfo("/path/to/extensions/my-function"); + + setTimeout(() => { + mockProcess.stderr.emit("data", "Error line 1\n"); + mockProcess.stderr.emit("data", "Error line 2\n"); + mockProcess.stderr.emit("data", "Error line 3"); + mockProcess.emit("close", 1); + }, 10); + + await expect(promise).rejects.toThrow("Error line 1"); + await expect(promise).rejects.toThrow("Error line 2"); + await expect(promise).rejects.toThrow("Error line 3"); + }); +}); diff --git a/test/methods/load-fixture.test.ts b/test/methods/load-fixture.test.ts index 552dfc1..df7d2c1 100644 --- a/test/methods/load-fixture.test.ts +++ b/test/methods/load-fixture.test.ts @@ -3,7 +3,7 @@ import { loadFixture } from '../../src/methods/load-fixture.ts'; describe('loadFixture', () => { it('should load fixture from a valid JSON file', async () => { - const fixture = await loadFixture('test-app/extensions/cart-validation-js/tests/fixtures/cda6d1.json'); + const fixture = await loadFixture('test-app/extensions/cart-validation-js/tests/fixtures/checkout-validation-valid-fixture.json'); expect(fixture).toBeDefined(); expect(fixture.export).toBe('cart-validations-generate-run'); expect(fixture.target).toBe('cart.validations.generate.run'); diff --git a/test/methods/run-function.test.ts b/test/methods/run-function.test.ts index a994680..7c88f97 100644 --- a/test/methods/run-function.test.ts +++ b/test/methods/run-function.test.ts @@ -1,41 +1,295 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EventEmitter } from 'events'; +import { Writable } from 'stream'; +import { spawn } from 'child_process'; import { runFunction } from '../../src/methods/run-function.ts'; -import { loadFixture } from '../../src/methods/load-fixture.ts'; +import { FixtureData } from '../../src/methods/load-fixture.ts'; + +vi.mock('child_process', () => ({ + spawn: vi.fn(), +})); describe('runFunction', () => { - it('should run a function using Shopify CLI', async () => { - const exportName = 'cart-validations-generate-run'; - const input = { + const mockSpawn = vi.mocked(spawn); + let mockStdin: Writable & { end: ReturnType }; + let mockStdout: EventEmitter; + let mockStderr: EventEmitter; + let mockProcess: EventEmitter & { + stdin: typeof mockStdin; + stdout: typeof mockStdout; + stderr: typeof mockStderr; + }; + + beforeEach(() => { + // Create mock stdin with write and end methods + mockStdin = new Writable() as Writable & { end: ReturnType }; + mockStdin.write = vi.fn().mockReturnValue(true); + mockStdin.end = vi.fn(); + + // Create mock stdout and stderr + mockStdout = new EventEmitter(); + mockStderr = new EventEmitter(); + + // Create mock process + mockProcess = new EventEmitter() as typeof mockProcess; + mockProcess.stdin = mockStdin; + mockProcess.stdout = mockStdout; + mockProcess.stderr = mockStderr; + + // Configure the mock to return our mock process + mockSpawn.mockReturnValue(mockProcess as any); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should run a function successfully and return result', async () => { + const fixture: FixtureData = { + export: 'cart-validations-generate-run', + input: { cart: { lines: [{ quantity: 1 }] } - }; - - const result = await runFunction(exportName, input, 'test-app/extensions/cart-validation-js'); - - expect(result).toBeDefined(); - expect(result).toHaveProperty('result'); - expect(result).toHaveProperty('error'); + }, + expectedOutput: { + operations: [] + }, + target: 'cart.validations.generate.run' + }; + + const functionRunnerPath = '/path/to/function-runner'; + const wasmPath = '/path/to/function.wasm'; + const inputQueryPath = '/path/to/query.graphql'; + const schemaPath = '/path/to/schema.graphql'; + + const resultPromise = runFunction( + fixture, + functionRunnerPath, + wasmPath, + inputQueryPath, + schemaPath + ); + + // Simulate successful function execution + const expectedOutput = { + output: { + operations: [] + } + }; + setImmediate(() => { + mockStdout.emit('data', Buffer.from(JSON.stringify(expectedOutput))); + mockProcess.emit('close', 0); }); - it('should handle function execution errors gracefully', async () => { - const exportName = 'invalid_export'; - const input = { cart: { lines: [] } }; - - const result = await runFunction(exportName, input, 'test-app/extensions/cart-validation-js'); - - expect(result).toBeDefined(); - expect(result).toHaveProperty('result'); - expect(result).toHaveProperty('error'); + const result = await resultPromise; + + expect(result).toBeDefined(); + expect(result.error).toBeNull(); + expect(result.result).toEqual(expectedOutput); + + // Verify spawn was called with correct arguments + expect(mockSpawn).toHaveBeenCalledWith( + functionRunnerPath, + [ + '-f', wasmPath, + '--export', fixture.export, + '--query-path', inputQueryPath, + '--schema-path', schemaPath, + '--json', + ], + { stdio: ['pipe', 'pipe', 'pipe'] } + ); + + // Verify input was written to stdin + expect(mockStdin.write).toHaveBeenCalledWith( + JSON.stringify(fixture.input) + ); + expect(mockStdin.end).toHaveBeenCalled(); + }); + + it('should handle function execution errors with non-zero exit code', async () => { + const fixture: FixtureData = { + export: 'invalid_export', + input: { cart: { lines: [] } }, + expectedOutput: {}, + target: 'cart.validations.generate.run' + }; + + const functionRunnerPath = '/path/to/function-runner'; + const wasmPath = '/path/to/function.wasm'; + const inputQueryPath = '/path/to/query.graphql'; + const schemaPath = '/path/to/schema.graphql'; + + const resultPromise = runFunction( + fixture, + functionRunnerPath, + wasmPath, + inputQueryPath, + schemaPath + ); + + // Simulate function-runner error + setImmediate(() => { + mockStderr.emit('data', Buffer.from('Error: Export not found')); + mockProcess.emit('close', 1); }); - it('should work with fixture data', async () => { - const fixture = await loadFixture('test-app/extensions/cart-validation-js/tests/fixtures/cda6d1.json'); - - const result = await runFunction(fixture.export, fixture.input, 'test-app/extensions/cart-validation-js'); - - expect(result).toBeDefined(); - expect(result).toHaveProperty('result'); - expect(result).toHaveProperty('error'); + const result = await resultPromise; + + expect(result).toBeDefined(); + expect(result.error).toContain('function-runner failed with exit code 1'); + expect(result.error).toContain('Error: Export not found'); + expect(result.result).toBeNull(); + }); + + it('should handle process spawn errors', async () => { + const fixture: FixtureData = { + export: 'cart-validations-generate-run', + input: { cart: { lines: [] } }, + expectedOutput: {}, + target: 'cart.validations.generate.run' + }; + + const functionRunnerPath = '/path/to/nonexistent-runner'; + const wasmPath = '/path/to/function.wasm'; + const inputQueryPath = '/path/to/query.graphql'; + const schemaPath = '/path/to/schema.graphql'; + + const resultPromise = runFunction( + fixture, + functionRunnerPath, + wasmPath, + inputQueryPath, + schemaPath + ); + + // Simulate spawn error + setImmediate(() => { + const error = new Error('ENOENT: no such file or directory'); + mockProcess.emit('error', error); }); - }); \ No newline at end of file + + const result = await resultPromise; + + expect(result).toBeDefined(); + expect(result.error).toContain('Failed to start function-runner'); + expect(result.error).toContain('ENOENT'); + expect(result.result).toBeNull(); + }); + + it('should handle invalid JSON output from function-runner', async () => { + const fixture: FixtureData = { + export: 'cart-validations-generate-run', + input: { cart: { lines: [] } }, + expectedOutput: {}, + target: 'cart.validations.generate.run' + }; + + const functionRunnerPath = '/path/to/function-runner'; + const wasmPath = '/path/to/function.wasm'; + const inputQueryPath = '/path/to/query.graphql'; + const schemaPath = '/path/to/schema.graphql'; + + const resultPromise = runFunction( + fixture, + functionRunnerPath, + wasmPath, + inputQueryPath, + schemaPath + ); + + // Simulate invalid JSON output + setImmediate(() => { + mockStdout.emit('data', Buffer.from('invalid json {{{')); + mockProcess.emit('close', 0); + }); + + const result = await resultPromise; + + expect(result).toBeDefined(); + expect(result.error).toContain('Failed to parse function-runner output'); + expect(result.result).toBeNull(); + }); + + it('should handle multiple stdout/stderr chunks', async () => { + const fixture: FixtureData = { + export: 'cart-validations-generate-run', + input: { cart: { lines: [] } }, + expectedOutput: {}, + target: 'cart.validations.generate.run' + }; + + const functionRunnerPath = '/path/to/function-runner'; + const wasmPath = '/path/to/function.wasm'; + const inputQueryPath = '/path/to/query.graphql'; + const schemaPath = '/path/to/schema.graphql'; + + const resultPromise = runFunction( + fixture, + functionRunnerPath, + wasmPath, + inputQueryPath, + schemaPath + ); + + // Simulate output in multiple chunks + const outputPart1 = '{"output":'; + const outputPart2 = '{"operations":[]}'; + const outputPart3 = '}'; + + setImmediate(() => { + mockStdout.emit('data', Buffer.from(outputPart1)); + mockStdout.emit('data', Buffer.from(outputPart2)); + mockStdout.emit('data', Buffer.from(outputPart3)); + mockProcess.emit('close', 0); + }); + + const result = await resultPromise; + + expect(result).toBeDefined(); + expect(result.error).toBeNull(); + expect(result.result).toEqual({ + output: { + operations: [] + } + }); + }); + + it('should reject output without explicit output wrapper', async () => { + const fixture: FixtureData = { + export: 'cart-validations-generate-run', + input: { cart: { lines: [] } }, + expectedOutput: {}, + target: 'cart.validations.generate.run' + }; + + const functionRunnerPath = '/path/to/function-runner'; + const wasmPath = '/path/to/function.wasm'; + const inputQueryPath = '/path/to/query.graphql'; + const schemaPath = '/path/to/schema.graphql'; + + const resultPromise = runFunction( + fixture, + functionRunnerPath, + wasmPath, + inputQueryPath, + schemaPath + ); + + // Simulate output without "output" wrapper + setImmediate(() => { + mockStdout.emit('data', Buffer.from(JSON.stringify({ + operations: [] + }))); + mockProcess.emit('close', 0); + }); + + const result = await resultPromise; + + expect(result).toBeDefined(); + expect(result.error).toContain('function-runner returned unexpected format'); + expect(result.error).toContain('missing \'output\' field'); + expect(result.result).toBeNull(); + }); +});