From 34939d69c868fa2ea4d96cbeae5212f836f9dd28 Mon Sep 17 00:00:00 2001 From: Long Hao Date: Thu, 9 Apr 2026 09:48:19 +0000 Subject: [PATCH 1/3] fix: handle StaticSitesClient stderr and non-zero exit codes The deploy command silently ignored failures from the StaticSitesClient binary. When the binary crashed (e.g., missing native dependencies on slim Docker images), the CLI reported success and exited with code 0. This fix: - Adds stderr handler to capture binary error output - Adds else branch for non-zero exit codes with diagnostic message - Calls process.exit(1) on failure so CI/CD detects it Fixes #536, #594 Refs: ICM 21000000909499 --- src/cli/commands/deploy/deploy.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/cli/commands/deploy/deploy.ts b/src/cli/commands/deploy/deploy.ts index 0f0d96444..70fc4630e 100644 --- a/src/cli/commands/deploy/deploy.ts +++ b/src/cli/commands/deploy/deploy.ts @@ -315,6 +315,13 @@ export async function deploy(options: SWACLIConfig) { }); }); + child.stderr!.on("data", (data) => { + const stderrOutput = data.toString().trim(); + if (stderrOutput) { + logger.error(stderrOutput); + } + }); + child.on("error", (error) => { logger.error(error.toString()); }); @@ -325,6 +332,12 @@ export async function deploy(options: SWACLIConfig) { if (code === 0) { spinner.succeed(chalk.green(`Project deployed to ${chalk.underline(projectUrl)} 🚀`)); logger.log(``); + } else { + spinner.fail(chalk.red(`Deployment failed with exit code ${code}`)); + logger.error(`The deployment binary exited with code ${code}.`); + logger.error(`If you are running in a minimal container image, ensure native dependencies are installed.`); + logger.error(`Run ${chalk.cyan(`ldd ${binary}`)} to check for missing shared libraries.`); + process.exit(1); } }); } From 2529d6bbedaaffef1f99d0ef0bb1c39a8292039b Mon Sep 17 00:00:00 2001 From: Long Hao Date: Thu, 9 Apr 2026 10:15:41 +0000 Subject: [PATCH 2/3] test: add tests for StaticSitesClient exit code and stderr handling - stderr is captured and passed to logger.error - Non-zero exit code triggers spinner.fail and error message - process.exit(1) is called on binary failure - Success path (exit code 0) remains unchanged --- src/cli/commands/deploy/deploy.spec.ts | 111 +++++++++++++++++++++---- 1 file changed, 97 insertions(+), 14 deletions(-) diff --git a/src/cli/commands/deploy/deploy.spec.ts b/src/cli/commands/deploy/deploy.spec.ts index ba149559f..aab719e4a 100644 --- a/src/cli/commands/deploy/deploy.spec.ts +++ b/src/cli/commands/deploy/deploy.spec.ts @@ -1,5 +1,7 @@ import "../../../../tests/_mocks/fs.js"; -import child_process from "node:child_process"; +import child_process, { spawn } from "node:child_process"; +import { EventEmitter } from "node:events"; +import path from "node:path"; import { logger } from "../../../core/utils/logger.js"; import { vol } from "memfs"; import * as accountModule from "../../../core/account.js"; @@ -10,6 +12,26 @@ import { loadPackageJson } from "../../../core/utils/json.js"; const pkg = loadPackageJson(); +// Prevent transitive jsonwebtoken/buffer-equal-constant-time loading error +// by fully mocking modules that pull in Azure SDK → jsonwebtoken chain +vi.mock("../../../core/account.js", () => ({ + chooseOrCreateProjectDetails: vi.fn(() => Promise.resolve({ resourceGroup: "mock-rg", staticSiteName: "mock-site" })), + getStaticSiteDeployment: vi.fn(() => Promise.resolve({})), + authenticateWithAzureIdentity: vi.fn(), + listSubscriptions: vi.fn(), + listTenants: vi.fn(), +})); + +vi.mock("../login/login.js", () => ({ + login: vi.fn(() => + Promise.resolve({ + credentialChain: {}, + subscriptionId: "mock-subscription-id", + }), + ), + loginCommand: vi.fn(), +})); + vi.mock("../../../core/utils/logger", () => { return { logger: { @@ -22,8 +44,15 @@ vi.mock("../../../core/utils/logger", () => { }; }); -//vi.spyOn(process, "exit").mockImplementation(() => {}); -vi.spyOn(child_process, "spawn").mockImplementation(vi.fn()); +vi.mock("node:child_process", async (importOriginal) => { + const actual: typeof child_process = await importOriginal(); + return { + ...actual, + default: { ...actual, spawn: vi.fn() }, + spawn: vi.fn(), + }; +}); + vi.spyOn(deployClientModule, "getDeployClientPath").mockImplementation(() => { return Promise.resolve({ binary: "mock-binary", @@ -32,17 +61,6 @@ vi.spyOn(deployClientModule, "getDeployClientPath").mockImplementation(() => { }); vi.spyOn(deployClientModule, "cleanUp").mockImplementation(() => {}); -vi.spyOn(accountModule, "getStaticSiteDeployment").mockImplementation(() => Promise.resolve({})); - -vi.spyOn(loginModule, "login").mockImplementation(() => { - return Promise.resolve({ - credentialChain: {} as any, - subscriptionId: "mock-subscription-id", - resourceGroup: "mock-resource-group-name", - staticSiteName: "mock-static-site-name", - }); -}); - describe("deploy", () => { const OLD_ENV = process.env; @@ -177,4 +195,69 @@ describe("deploy", () => { }, }); }); + + describe("StaticSitesClient process handling", () => { + let mockChild: EventEmitter & { stdout: EventEmitter; stderr: EventEmitter }; + let exitSpy: ReturnType; + + beforeEach(() => { + // Create mock child process with stdout/stderr EventEmitters + const stdout = new EventEmitter(); + const stderr = new EventEmitter(); + mockChild = Object.assign(new EventEmitter(), { stdout, stderr }); + + // Set up spawn mock to return the mock child process + vi.mocked(spawn).mockReturnValue(mockChild as any); + vi.spyOn(deployClientModule, "getDeployClientPath").mockResolvedValue({ + binary: "mock-binary", + buildId: "0.0.0", + }); + vi.spyOn(deployClientModule, "cleanUp").mockImplementation(() => {}); + + // Mock process.exit to prevent test runner from exiting + exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as any); + + // Provide deployment token via env to skip login flow + process.env.SWA_CLI_DEPLOYMENT_TOKEN = "test-token"; + + // Create required filesystem structure in memfs + const cwd = process.cwd(); + vol.fromJSON({ + [path.join("/test-output", "index.html")]: "hello", + [path.join(cwd, "placeholder")]: "", + }); + }); + + it("should capture stderr and pass to logger.error", async () => { + await deploy({ outputLocation: "/test-output", dryRun: false }); + + mockChild.stderr.emit("data", Buffer.from("some error from binary")); + + expect(logger.error).toHaveBeenCalledWith("some error from binary"); + }); + + it("should fail spinner and log error on non-zero exit code", async () => { + await deploy({ outputLocation: "/test-output", dryRun: false }); + + mockChild.emit("close", 1); + + expect(logger.error).toHaveBeenCalledWith("The deployment binary exited with code 1."); + }); + + it("should call process.exit(1) on non-zero exit code", async () => { + await deploy({ outputLocation: "/test-output", dryRun: false }); + + mockChild.emit("close", 127); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("should succeed without calling process.exit on exit code 0", async () => { + await deploy({ outputLocation: "/test-output", dryRun: false }); + + mockChild.emit("close", 0); + + expect(exitSpy).not.toHaveBeenCalled(); + }); + }); }); From 5a1fb498a6d623c2a00a13ea3db46c454661de74 Mon Sep 17 00:00:00 2001 From: Long Hao Date: Thu, 9 Apr 2026 10:33:05 +0000 Subject: [PATCH 3/3] fix: resolve CI type error in process.exit mock The ReturnType didn't match the 'never' return type of process.exit, causing tsc to fail in CI format check. --- src/cli/commands/deploy/deploy.spec.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/cli/commands/deploy/deploy.spec.ts b/src/cli/commands/deploy/deploy.spec.ts index aab719e4a..e2b431c57 100644 --- a/src/cli/commands/deploy/deploy.spec.ts +++ b/src/cli/commands/deploy/deploy.spec.ts @@ -198,7 +198,8 @@ describe("deploy", () => { describe("StaticSitesClient process handling", () => { let mockChild: EventEmitter & { stdout: EventEmitter; stderr: EventEmitter }; - let exitSpy: ReturnType; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let exitSpy: any; beforeEach(() => { // Create mock child process with stdout/stderr EventEmitters @@ -215,7 +216,7 @@ describe("deploy", () => { vi.spyOn(deployClientModule, "cleanUp").mockImplementation(() => {}); // Mock process.exit to prevent test runner from exiting - exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as any); + exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as unknown as () => never); // Provide deployment token via env to skip login flow process.env.SWA_CLI_DEPLOYMENT_TOKEN = "test-token";