diff --git a/src/apphosting/localbuilds.spec.ts b/src/apphosting/localbuilds.spec.ts index ba0531f4651..ebc7ae13a52 100644 --- a/src/apphosting/localbuilds.spec.ts +++ b/src/apphosting/localbuilds.spec.ts @@ -1,9 +1,10 @@ import * as sinon from "sinon"; import { expect } from "chai"; -import { localBuild, runUniversalMaker } from "./localbuilds"; +import { localBuild, runUniversalMaker, validateLocalBuildNodeVersion } from "./localbuilds"; import * as secrets from "./secrets/index"; import { EnvMap } from "./yaml"; import * as childProcess from "child_process"; +import * as utils from "../utils"; import * as universalMakerDownload from "./universalMakerDownload"; import * as fsExtra from "fs-extra"; @@ -312,4 +313,180 @@ describe("localBuild", () => { sinon.assert.calledOnce(downloadStub); }); }); + + describe("validateLocalBuildNodeVersion", () => { + let logWarningStub: sinon.SinonStub; + let execSyncStub: sinon.SinonStub; + let readJsonStub: sinon.SinonStub; + + beforeEach(() => { + logWarningStub = sinon.stub(utils, "logLabeledWarning"); + execSyncStub = sinon.stub(childProcess, "execSync"); + readJsonStub = sinon.stub(fsExtra, "readJsonSync"); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("throws error if ABIU is disabled", () => { + const backend = { + name: "projects/my-project/locations/us-central1/backends/foo", + runtime: { value: "nodejs" }, + } as any; + + expect(() => validateLocalBuildNodeVersion(backend, "./")).to.throw( + "Local builds are only supported for backends with ABIU", + ); + }); + + it("logs warning and exits early if runtime version is not extractable", () => { + const backend = { + name: "projects/my-project/locations/us-central1/backends/foo", + runtime: { value: "invalid-runtime-string" }, + } as any; + + validateLocalBuildNodeVersion(backend, "./"); + + expect(logWarningStub).to.have.been.calledWith( + "apphosting", + sinon.match("Unable to extract Node.js major version from the backend runtime"), + ); + expect(execSyncStub).to.not.have.been.called; + }); + + it("warns about package.json engines not being used for local build execution", () => { + const backend = { + name: "projects/my-project/locations/us-central1/backends/foo", + runtime: { value: "nodejs22" }, + } as any; + + execSyncStub.returns("v22.15.0"); + readJsonStub.returns({ + engines: { node: "22" }, + }); + + validateLocalBuildNodeVersion(backend, "./"); + + expect(logWarningStub).to.have.been.calledOnceWith( + "apphosting", + sinon.match('local builds do NOT use the "engines" field'), + ); + }); + + it("warns if package.json engines range does not satisfy the target version", () => { + const backend = { + name: "projects/my-project/locations/us-central1/backends/foo", + runtime: { value: "nodejs22" }, + } as any; + + execSyncStub.returns("v22.15.0"); + readJsonStub.returns({ + engines: { node: "20" }, + }); + + validateLocalBuildNodeVersion(backend, "./"); + + expect(logWarningStub).to.have.been.calledTwice; + expect(logWarningStub.secondCall).to.have.been.calledWith( + "apphosting", + sinon.match("does not satisfy your backend's target ABIU runtime version"), + ); + }); + + it("does not warn on minor/patch constraints in engines if target major is satisfied", () => { + const backend = { + name: "projects/my-project/locations/us-central1/backends/foo", + runtime: { value: "nodejs22" }, + } as any; + + execSyncStub.returns("v22.15.0"); + readJsonStub.returns({ + engines: { node: "^22.15.0" }, + }); + + validateLocalBuildNodeVersion(backend, "./"); + + // Should only log the informational "engines not used for local build execution" warning + expect(logWarningStub).to.have.been.calledOnce; + expect(logWarningStub.firstCall).to.have.been.calledWith( + "apphosting", + sinon.match('local builds do NOT use the "engines" field'), + ); + }); + + it("handles complex logical OR engines ranges correctly", () => { + const backend = { + name: "projects/my-project/locations/us-central1/backends/foo", + runtime: { value: "nodejs22" }, + } as any; + + execSyncStub.returns("v22.15.0"); + + // Case 1: Overlapping OR range (18 || 22) - Should NOT warn + readJsonStub.returns({ + engines: { node: "18 || 22" }, + }); + validateLocalBuildNodeVersion(backend, "./"); + expect(logWarningStub).to.have.been.calledOnce; // Only informational warning + logWarningStub.resetHistory(); + + // Case 2: Non-overlapping OR range (18 || 20) - Should warn! + readJsonStub.returns({ + engines: { node: "18 || 20" }, + }); + validateLocalBuildNodeVersion(backend, "./"); + expect(logWarningStub).to.have.been.calledTwice; // Informational + mismatch warning + }); + + it("warns if local host Node version doesn't match the target version", () => { + const backend = { + name: "projects/my-project/locations/us-central1/backends/foo", + runtime: { value: "nodejs22" }, + } as any; + + execSyncStub.returns("v24.10.0"); + readJsonStub.returns({}); + + validateLocalBuildNodeVersion(backend, "./"); + + expect(logWarningStub).to.have.been.calledOnceWith( + "apphosting", + sinon.match( + "Local Node.js version (v24.10.0) does not match your backend's target Node.js version", + ), + ); + }); + + it("does not log warnings when all versions are aligned", () => { + const backend = { + name: "projects/my-project/locations/us-central1/backends/foo", + runtime: { value: "nodejs22" }, + } as any; + + execSyncStub.returns("v22.15.0"); + readJsonStub.returns({}); + + validateLocalBuildNodeVersion(backend, "./"); + + expect(logWarningStub).to.not.have.been.called; + }); + + it("warns if local Node.js version detection fails (e.g. node not in PATH)", () => { + const backend = { + name: "projects/my-project/locations/us-central1/backends/foo", + runtime: { value: "nodejs22" }, + } as any; + + execSyncStub.throws(new Error("command not found")); + readJsonStub.returns({}); + + validateLocalBuildNodeVersion(backend, "./"); + + expect(logWarningStub).to.have.been.calledOnceWith( + "apphosting", + sinon.match("Unable to detect your local Node.js version"), + ); + }); + }); }); diff --git a/src/apphosting/localbuilds.ts b/src/apphosting/localbuilds.ts index 2211145c023..0b0e88a2d2a 100644 --- a/src/apphosting/localbuilds.ts +++ b/src/apphosting/localbuilds.ts @@ -1,14 +1,15 @@ import * as childProcess from "child_process"; import * as fs from "fs-extra"; import * as path from "path"; -import { Availability, BuildConfig, Env } from "../gcp/apphosting"; +import * as semver from "semver"; +import { Availability, Backend, BuildConfig, Env } from "../gcp/apphosting"; import { EnvMap } from "./yaml"; import { loadSecret } from "./secrets/index"; import { confirm } from "../prompt"; import { FirebaseError, getErrMsg } from "../error"; import { logger } from "../logger"; -import { wrappedSafeLoad } from "../utils"; +import { wrappedSafeLoad, logLabeledWarning } from "../utils"; import { getOrDownloadUniversalMaker } from "./universalMakerDownload"; interface UniversalMakerOutput { @@ -266,3 +267,91 @@ async function toProcessEnv(projectId: string, env: EnvMap): Promise { backends: [ { name: "projects/my-project/locations/us-central1/backends/foo", + runtime: { value: "nodejs22" }, }, ], }); @@ -240,8 +241,14 @@ describe("apphosting", () => { listBackendsStub.onFirstCall().resolves({ backends: [ - { name: "projects/my-project/locations/us-central1/backends/backend-prod" }, - { name: "projects/my-project/locations/us-central1/backends/backend-staging" }, + { + name: "projects/my-project/locations/us-central1/backends/backend-prod", + runtime: { value: "nodejs22" }, + }, + { + name: "projects/my-project/locations/us-central1/backends/backend-staging", + runtime: { value: "nodejs22" }, + }, ], }); @@ -298,6 +305,7 @@ describe("apphosting", () => { { name: "projects/my-project/locations/us-central1/backends/foo", appId: "my-app-id", + runtime: { value: "nodejs22" }, }, ], }); @@ -357,6 +365,7 @@ describe("apphosting", () => { backends: [ { name: "projects/my-project/locations/us-central1/backends/foo", + runtime: { value: "nodejs22" }, }, ], }); @@ -432,6 +441,7 @@ describe("apphosting", () => { backends: [ { name: "projects/my-project/locations/us-central1/backends/foo", + runtime: { value: "nodejs22" }, }, ], }); @@ -466,6 +476,7 @@ describe("apphosting", () => { backends: [ { name: "projects/my-project/locations/us-central1/backends/foo", + runtime: { value: "nodejs22" }, }, ], }); @@ -630,6 +641,59 @@ describe("apphosting", () => { expect(assertEnabledStub).to.not.have.been.calledWith("apphostinglocalbuilds"); }); + + it("dynamically fetches the backend from the API if it is not found in the pre-fetched list (e.g., newly created)", async () => { + const optsWithLocalBuild = { + ...opts, + config: new Config({ + apphosting: { + backendId: "newly-created-backend", + rootDir: "/", + ignore: [], + localBuild: true, + }, + }), + }; + const context = initializeContext(); + + const buildConfig = { + runCommand: "npm run build:prod", + env: [], + }; + sinon.stub(localbuilds, "localBuild").resolves({ + outputFiles: ["./next/standalone"], + buildConfig, + }); + + listBackendsStub.onFirstCall().resolves({ + backends: [], + }); + + doSetupSourceDeployStub.resolves({ location: "us-central1" }); + confirmStub.resolves(true); + checkboxStub.resolves(["newly-created-backend"]); + + const getBackendStub = sinon.stub(apphosting, "getBackend").resolves({ + name: "projects/my-project/locations/us-central1/backends/newly-created-backend", + runtime: { value: "nodejs22" }, + } as any); + + await prepare(context, optsWithLocalBuild); + + expect(getBackendStub).to.have.been.calledOnceWith( + "my-project", + "us-central1", + "newly-created-backend", + ); + expect(context.backendLocalBuilds["newly-created-backend"]).to.deep.equal({ + outputFiles: ["./next/standalone"], + localBuildScratchDir: path.join( + os.tmpdir(), + `apphosting-local-build-newly-created-backend-${expectedPathHash}`, + ), + buildConfig, + }); + }); }); describe("getBackendConfigs", () => { diff --git a/src/deploy/apphosting/prepare.ts b/src/deploy/apphosting/prepare.ts index 7bc30ce40a1..359baebde36 100644 --- a/src/deploy/apphosting/prepare.ts +++ b/src/deploy/apphosting/prepare.ts @@ -28,7 +28,7 @@ import { needProjectId } from "../../projectUtils"; import { getProjectNumber } from "../../getProjectNumber"; import { checkbox, confirm } from "../../prompt"; import { logLabeledBullet, logLabeledWarning } from "../../utils"; -import { localBuild } from "../../apphosting/localbuilds"; +import { localBuild, validateLocalBuildNodeVersion } from "../../apphosting/localbuilds"; import { Context } from "./args"; import { FirebaseError } from "../../error"; import * as managementApps from "../../management/apps"; @@ -187,6 +187,28 @@ export default async function (context: Context, options: Options): Promise parseBackendName(b.name).id === cfg.backendId); + if (!backend) { + const location = context.backendLocations[cfg.backendId]; + if (location) { + const apphosting = await import("../../gcp/apphosting"); + try { + backend = await apphosting.getBackend(projectId, location, cfg.backendId); + } catch { + // Fall through to error handling below + } + } + } + if (!backend) { + throw new FirebaseError(`Backend ${cfg.backendId} not found.`); + } + + const rootDir = path.resolve(options.projectRoot || process.cwd()); + const appDir = path.join(rootDir, cfg.rootDir || ""); + + validateLocalBuildNodeVersion(backend, appDir); + logLabeledBullet("apphosting", `Starting local build for backend ${cfg.backendId}`); await injectEnvVarsFromApphostingConfig( @@ -196,8 +218,6 @@ export default async function (context: Context, options: Options): Promise