From dc264e0fec9081933e1147818e767a90656376c0 Mon Sep 17 00:00:00 2001 From: Alex Bradley Date: Thu, 5 Feb 2026 10:05:23 -0500 Subject: [PATCH] use execa --- package.json | 1 + pnpm-lock.yaml | 76 +++++++++++++++ src/methods/build-function.ts | 125 ++++++++++--------------- src/methods/get-function-info.ts | 92 +++++++----------- test/methods/build-function.test.ts | 66 +++++-------- test/methods/get-function-info.test.ts | 100 ++++++++------------ 6 files changed, 221 insertions(+), 239 deletions(-) diff --git a/package.json b/package.json index 06cf210..32e16f8 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ }, "dependencies": { "core-js": "^3.46.0", + "execa": "^7.2.0", "graphql": "^16.11.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52cdc77..3a3dd6c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: core-js: specifier: ^3.46.0 version: 3.46.0 + execa: + specifier: ^7.2.0 + version: 7.2.0 graphql: specifier: ^16.11.0 version: 16.11.0 @@ -1768,6 +1771,10 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + execa@7.2.0: + resolution: {integrity: sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA==} + engines: {node: ^14.18.0 || ^16.14.0 || >=18.0.0} + expect-type@1.2.2: resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} engines: {node: '>=12.0.0'} @@ -1894,6 +1901,10 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + get-symbol-description@1.1.0: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} @@ -2017,6 +2028,10 @@ packages: resolution: {integrity: sha512-v/J+4Z/1eIJovEBdlV5TYj1IR+ZiohcYGRY+qN/oC9dAfKzVT023N/Bgw37hrKCoVRBvk3bqyzpr2PP5YeTMSg==} hasBin: true + human-signals@4.3.1: + resolution: {integrity: sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==} + engines: {node: '>=14.18.0'} + iconv-lite@0.7.0: resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} engines: {node: '>=0.10.0'} @@ -2167,6 +2182,10 @@ packages: resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} engines: {node: '>= 0.4'} + is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + is-string@1.1.1: resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} engines: {node: '>= 0.4'} @@ -2364,6 +2383,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -2385,6 +2407,10 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} + mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + minimatch@10.1.1: resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} engines: {node: 20 || >=22} @@ -2450,6 +2476,10 @@ packages: resolution: {integrity: sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==} engines: {node: '>=0.10.0'} + npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + nullthrows@1.1.1: resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==} @@ -2485,6 +2515,10 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -2564,6 +2598,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -2899,6 +2937,10 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -5406,6 +5448,18 @@ snapshots: esutils@2.0.3: {} + execa@7.2.0: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 4.3.1 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 3.0.7 + strip-final-newline: 3.0.0 + expect-type@1.2.2: {} extendable-error@0.1.7: {} @@ -5546,6 +5600,8 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + get-stream@6.0.1: {} + get-symbol-description@1.1.0: dependencies: call-bound: 1.0.4 @@ -5678,6 +5734,8 @@ snapshots: human-id@4.1.2: {} + human-signals@4.3.1: {} + iconv-lite@0.7.0: dependencies: safer-buffer: 2.1.2 @@ -5838,6 +5896,8 @@ snapshots: dependencies: call-bound: 1.0.4 + is-stream@3.0.0: {} + is-string@1.1.1: dependencies: call-bound: 1.0.4 @@ -6028,6 +6088,8 @@ snapshots: math-intrinsics@1.1.0: {} + merge-stream@2.0.0: {} + merge2@1.4.1: {} meros@1.3.2(@types/node@22.18.13): @@ -6041,6 +6103,8 @@ snapshots: mimic-fn@2.1.0: {} + mimic-fn@4.0.0: {} + minimatch@10.1.1: dependencies: '@isaacs/brace-expansion': 5.0.0 @@ -6090,6 +6154,10 @@ snapshots: dependencies: remove-trailing-separator: 1.1.0 + npm-run-path@5.3.0: + dependencies: + path-key: 4.0.0 + nullthrows@1.1.1: {} object-assign@4.1.1: {} @@ -6132,6 +6200,10 @@ snapshots: dependencies: mimic-fn: 2.1.0 + onetime@6.0.0: + dependencies: + mimic-fn: 4.0.0 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -6229,6 +6301,8 @@ snapshots: path-key@3.1.1: {} + path-key@4.0.0: {} + path-parse@1.0.7: {} path-root-regex@0.1.2: {} @@ -6615,6 +6689,8 @@ snapshots: strip-bom@3.0.0: {} + strip-final-newline@3.0.0: {} + strip-json-comments@3.1.1: {} strip-literal@3.1.0: diff --git a/src/methods/build-function.ts b/src/methods/build-function.ts index c66dc37..19d79c6 100644 --- a/src/methods/build-function.ts +++ b/src/methods/build-function.ts @@ -2,10 +2,11 @@ * Build a function run payload from a cart and options */ -import { spawn } from "child_process"; import path from "path"; import { fileURLToPath } from "url"; +import { execa } from "execa"; + /** * Interface for the build function result */ @@ -26,86 +27,56 @@ const __dirname = path.dirname(__filename); export async function buildFunction( functionPath?: string, ): Promise { - try { - let functionDir; - let appRootDir; - let functionName; + let functionDir; + let appRootDir; + let functionName; - if (functionPath) { - // 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); - } + if (functionPath) { + // 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", "build", "--path", functionName], - { - cwd: appRootDir, - stdio: ["pipe", "pipe", "pipe"], - env: { - ...process.env, - SHOPIFY_INVOKED_BY: "shopify-function-test-helpers", - }, + try { + const result = await execa( + "shopify", + ["app", "function", "build", "--path", functionName], + { + cwd: appRootDir, + env: { + ...process.env, + SHOPIFY_INVOKED_BY: "shopify-function-test-helpers", }, - ); + }, + ); - 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) { - reject( - new Error(`Build command failed with exit code ${code}: ${stderr}`), - ); - return; - } - - resolve({ - success: true, - output: stdout.trim(), - error: null, - }); - }); - - shopifyProcess.on("error", (error) => { - reject( - new Error(`Failed to start shopify build command: ${error.message}`), - ); - }); - }); - } catch (error) { - if (error instanceof Error) { - return { - success: false, - output: null, - error: error.message, - }; - } else { - return { - success: false, - output: null, - error: "Unknown error occurred during build", - }; + return { + success: true, + output: result.stdout.trim(), + error: null, + }; + } catch (error: any) { + // Handle command execution errors (non-zero exit code) + if (error?.exitCode !== undefined) { + const stderr = error?.stderr || ""; + throw new Error( + `Build command failed with exit code ${error.exitCode}${stderr ? `: ${stderr}` : ""}`, + ); } + + // Handle spawn/process errors (command not found, etc.) + throw new Error( + `Failed to start shopify build command: ${error?.message || String(error)}`, + ); } } diff --git a/src/methods/get-function-info.ts b/src/methods/get-function-info.ts index c531d44..34a0c35 100644 --- a/src/methods/get-function-info.ts +++ b/src/methods/get-function-info.ts @@ -2,9 +2,10 @@ * Retrieve function information from the Shopify CLI */ -import { spawn } from "child_process"; import path from "path"; +import { execa } from "execa"; + /** * Information about a Shopify function */ @@ -28,13 +29,12 @@ export async function getFunctionInfo( const appRootDir = path.dirname(resolvedFunctionDir); const functionName = path.basename(resolvedFunctionDir); - return new Promise((resolve, reject) => { - const shopifyProcess = spawn( + try { + const result = await execa( "shopify", ["app", "function", "info", "--json", "--path", functionName], { cwd: appRootDir, - stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, SHOPIFY_INVOKED_BY: "shopify-function-test-helpers", @@ -42,61 +42,37 @@ export async function getFunctionInfo( }, ); - 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; - } + const functionInfo = JSON.parse(result.stdout.trim()) as FunctionInfo; + return functionInfo; + } catch (error: any) { + // Handle JSON parsing errors + if (error instanceof SyntaxError) { + throw new Error(`Failed to parse function info JSON: ${error.message}`); + } - 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}`, - ), - ); - } - }); + // Check if the error is due to the command not being found + const stderr = error?.stderr || ""; + if ( + stderr.includes("Command app function info not found") || + stderr.includes("command not found") + ) { + throw 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", + ); + } - shopifyProcess.on("error", (error) => { - reject( - new Error( - `Failed to start shopify function info command: ${error.message}`, - ), + // Handle command execution errors + if (error?.exitCode !== undefined) { + throw new Error( + `Function info command failed with exit code ${error.exitCode}: ${stderr}`, ); - }); - }); + } + + // Generic error fallback (spawn/process errors) + throw new Error( + `Failed to start shopify function info command: ${error?.message || String(error)}`, + ); + } } diff --git a/test/methods/build-function.test.ts b/test/methods/build-function.test.ts index 5879710..f6a5174 100644 --- a/test/methods/build-function.test.ts +++ b/test/methods/build-function.test.ts @@ -1,46 +1,30 @@ -import { EventEmitter } from "events"; -import { spawn } from "child_process"; - -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, vi, afterEach } from "vitest"; +import { execa } from "execa"; import { buildFunction } from "../../src/methods/build-function.ts"; -vi.mock("child_process", () => ({ - spawn: vi.fn(), +vi.mock("execa", () => ({ + execa: vi.fn(), })); describe("buildFunction", () => { - 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); - }); + const mockExeca = vi.mocked(execa); afterEach(() => { vi.clearAllMocks(); }); it("should build a function successfully", async () => { + mockExeca.mockResolvedValue({ + stdout: "Build completed successfully", + stderr: "", + exitCode: 0, + } as any); + const resultPromise = buildFunction( "test-app/extensions/cart-validation-js", ); - // Simulate successful build - setImmediate(() => { - mockProcess.stdout.emit( - "data", - Buffer.from("Build completed successfully"), - ); - mockProcess.emit("close", 0); - }); - const result = await resultPromise; expect(result).toBeDefined(); @@ -48,8 +32,8 @@ describe("buildFunction", () => { expect(result.output).toContain("Build completed successfully"); expect(result.error).toBeNull(); - // Verify spawn was called with correct arguments - expect(mockSpawn).toHaveBeenCalledWith( + // Verify execa was called with correct arguments + expect(mockExeca).toHaveBeenCalledWith( "shopify", ["app", "function", "build", "--path", expect.any(String)], expect.objectContaining({ @@ -57,41 +41,33 @@ describe("buildFunction", () => { env: expect.objectContaining({ SHOPIFY_INVOKED_BY: "shopify-function-test-helpers", }), - stdio: ["pipe", "pipe", "pipe"], }), ); }); it("should handle build failures", async () => { + const error: any = new Error("Command failed"); + error.exitCode = 1; + error.stderr = "Build failed: syntax error"; + mockExeca.mockRejectedValue(error); + const resultPromise = buildFunction( "test-app/extensions/cart-validation-js", ); - // Simulate build failure - setImmediate(() => { - mockProcess.stderr.emit( - "data", - Buffer.from("Build failed: syntax error"), - ); - mockProcess.emit("close", 1); - }); - await expect(resultPromise).rejects.toThrow( "Build command failed with exit code 1", ); }); it("should handle process spawn errors", async () => { + const error = new Error("ENOENT: command not found"); + mockExeca.mockRejectedValue(error); + const resultPromise = buildFunction( "test-app/extensions/cart-validation-js", ); - // Simulate spawn error - setImmediate(() => { - const error = new Error("ENOENT: command not found"); - mockProcess.emit("error", error); - }); - await expect(resultPromise).rejects.toThrow( "Failed to start shopify build command", ); diff --git a/test/methods/get-function-info.test.ts b/test/methods/get-function-info.test.ts index de7d4d4..e59b50c 100644 --- a/test/methods/get-function-info.test.ts +++ b/test/methods/get-function-info.test.ts @@ -1,28 +1,16 @@ -import { EventEmitter } from "events"; -import { spawn } from "child_process"; import path from "path"; -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, vi, afterEach } from "vitest"; +import { execa } from "execa"; import { getFunctionInfo } from "../../src/methods/get-function-info.ts"; -vi.mock("child_process", () => ({ - spawn: vi.fn(), +vi.mock("execa", () => ({ + execa: 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); - }); + const mockExeca = vi.mocked(execa); afterEach(() => { vi.clearAllMocks(); @@ -39,15 +27,15 @@ describe("getFunctionInfo", () => { }, }; + mockExeca.mockResolvedValue({ + stdout: JSON.stringify(mockFunctionInfo), + stderr: "", + exitCode: 0, + } as any); + const functionDir = "/path/to/extensions/my-function"; const promise = getFunctionInfo(functionDir); - // Simulate successful CLI response - setTimeout(() => { - mockProcess.stdout.emit("data", JSON.stringify(mockFunctionInfo)); - mockProcess.emit("close", 0); - }, 10); - const result = await promise; // Calculate expected cwd the same way the implementation does @@ -55,7 +43,7 @@ describe("getFunctionInfo", () => { const expectedCwd = path.dirname(resolvedFunctionDir); expect(result).toEqual(mockFunctionInfo); - expect(mockSpawn).toHaveBeenCalledWith( + expect(mockExeca).toHaveBeenCalledWith( "shopify", ["app", "function", "info", "--json", "--path", "my-function"], expect.objectContaining({ @@ -63,21 +51,17 @@ describe("getFunctionInfo", () => { env: expect.objectContaining({ SHOPIFY_INVOKED_BY: "shopify-function-test-helpers", }), - stdio: ["pipe", "pipe", "pipe"], }), ); }); it("should reject when CLI command is not found", async () => { - const promise = getFunctionInfo("/path/to/extensions/my-function"); + const error: any = new Error("Command failed"); + error.exitCode = 1; + error.stderr = "Error: Command app function info not found"; + mockExeca.mockRejectedValue(error); - setTimeout(() => { - mockProcess.stderr.emit( - "data", - "Error: Command app function info not found", - ); - mockProcess.emit("close", 1); - }, 10); + const promise = getFunctionInfo("/path/to/extensions/my-function"); await expect(promise).rejects.toThrow( 'The "shopify app function info" command is not available', @@ -88,12 +72,12 @@ describe("getFunctionInfo", () => { }); it("should reject when shopify command is not found", async () => { - const promise = getFunctionInfo("/path/to/extensions/my-function"); + const error: any = new Error("Command failed"); + error.exitCode = 127; + error.stderr = "shopify: command not found"; + mockExeca.mockRejectedValue(error); - setTimeout(() => { - mockProcess.stderr.emit("data", "shopify: command not found"); - mockProcess.emit("close", 127); - }, 10); + const promise = getFunctionInfo("/path/to/extensions/my-function"); await expect(promise).rejects.toThrow( 'The "shopify app function info" command is not available', @@ -101,12 +85,12 @@ describe("getFunctionInfo", () => { }); it("should reject when CLI command fails with non-zero exit code", async () => { - const promise = getFunctionInfo("/path/to/extensions/my-function"); + const error: any = new Error("Command failed"); + error.exitCode = 1; + error.stderr = "Error: Function not found\n"; + mockExeca.mockRejectedValue(error); - setTimeout(() => { - mockProcess.stderr.emit("data", "Error: Function not found\n"); - mockProcess.emit("close", 1); - }, 10); + const promise = getFunctionInfo("/path/to/extensions/my-function"); await expect(promise).rejects.toThrow( "Function info command failed with exit code 1", @@ -115,23 +99,23 @@ describe("getFunctionInfo", () => { }); it("should reject when JSON parsing fails", async () => { - const promise = getFunctionInfo("/path/to/extensions/my-function"); + mockExeca.mockResolvedValue({ + stdout: "Invalid JSON output", + stderr: "", + exitCode: 0, + } as any); - setTimeout(() => { - mockProcess.stdout.emit("data", "Invalid JSON output"); - mockProcess.emit("close", 0); - }, 10); + const promise = getFunctionInfo("/path/to/extensions/my-function"); 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"); + const error = new Error("ENOENT: spawn failed"); + mockExeca.mockRejectedValue(error); - setTimeout(() => { - mockProcess.emit("error", new Error("ENOENT: spawn failed")); - }, 10); + const promise = getFunctionInfo("/path/to/extensions/my-function"); await expect(promise).rejects.toThrow( "Failed to start shopify function info command", @@ -140,14 +124,12 @@ describe("getFunctionInfo", () => { }); it("should accumulate stderr output for error messages", async () => { - const promise = getFunctionInfo("/path/to/extensions/my-function"); + const error: any = new Error("Command failed"); + error.exitCode = 1; + error.stderr = "Error line 1\nError line 2\nError line 3"; + mockExeca.mockRejectedValue(error); - 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); + const promise = getFunctionInfo("/path/to/extensions/my-function"); await expect(promise).rejects.toThrow("Error line 1"); await expect(promise).rejects.toThrow("Error line 2");