diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index f0722cc..80b7feb 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -4,6 +4,9 @@ on: - pull_request - push +permissions: + contents: read + env: CI: true @@ -15,22 +18,44 @@ jobs: node-version: - 22.x - 24.x + firebase-functions: + - pinned + - '7' steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@a37ce9120846195fa4ece8f58b268e6043cb2f26 # v3.7.0 + - uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3.9.1 with: node-version: ${{ matrix.node-version }} - name: Cache npm - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.npm key: ${{ runner.os }}-node-${{ matrix.node-version }}-${{ hashFiles('**/package-lock.json') }} - run: npm ci - - run: npm run lint - - run: npm run format - - run: npm run test + - name: Lint + if: matrix.firebase-functions == 'pinned' + run: npm run lint + - name: Format + if: matrix.firebase-functions == 'pinned' + run: npm run format + - name: Run tests (pinned firebase-functions) + if: matrix.firebase-functions == 'pinned' + run: npm run test + # Specs do not compile against firebase-functions v7 typings, so + # compile against the pinned version, swap in v7, and run the + # compiled output. This mirrors how users hit version drift: their + # compiled code running against a newer firebase-functions. + - name: Compile specs against pinned firebase-functions + if: matrix.firebase-functions == '7' + run: npx tsc + - name: Swap in firebase-functions@7 + if: matrix.firebase-functions == '7' + run: npm install --no-save firebase-functions@^7 + - name: Run tests (firebase-functions@7) + if: matrix.firebase-functions == '7' + run: npx mocha .tmp/spec/index.spec.js integration: runs-on: ubuntu-latest strategy: @@ -39,13 +64,13 @@ jobs: - 22.x - 24.x steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@a37ce9120846195fa4ece8f58b268e6043cb2f26 # v3.7.0 + - uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3.9.1 with: node-version: ${{ matrix.node-version }} - name: Cache npm - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.npm key: ${{ runner.os }}-node-${{ matrix.node-version }}-${{ hashFiles('**/package-lock.json') }} diff --git a/CHANGELOG.md b/CHANGELOG.md index c3cb8f4..58c0385 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1,2 @@ - chore: drop support for Node 18 and below (minimum supported version is now Node 20) +- fix: mockConfig() now throws a clear migration error when firebase-functions v7+ is installed, since functions.config() was removed upstream (#334) diff --git a/spec/lifecycle.spec.ts b/spec/lifecycle.spec.ts index 7d7b249..7be06c3 100644 --- a/spec/lifecycle.spec.ts +++ b/spec/lifecycle.spec.ts @@ -24,8 +24,20 @@ import { expect } from 'chai'; import { FirebaseFunctionsTest } from '../src/lifecycle'; import { mockConfig } from '../src/main'; +import { _isConfigRemoved } from '../src/v1'; import { afterEach } from 'mocha'; +// mockConfig() throws on firebase-functions v7+ because functions.config() +// was removed. These tests only care about CLOUD_RUNTIME_CONFIG being +// restored by cleanup(), so set the variable directly on v7+. +function setRuntimeConfig(conf: { [key: string]: { [key: string]: any } }) { + if (_isConfigRemoved()) { + process.env.CLOUD_RUNTIME_CONFIG = JSON.stringify(conf); + } else { + mockConfig(conf); + } +} + describe('lifecycle', () => { describe('#init', () => { let test; @@ -87,7 +99,7 @@ describe('lifecycle', () => { it('deletes all the env variables if they did not previously exist', () => { let test = new FirebaseFunctionsTest(); test.init(); - mockConfig({ foo: { bar: 'faz ' } }); + setRuntimeConfig({ foo: { bar: 'faz ' } }); test.cleanup(); expect(process.env.FIREBASE_CONFIG).to.be.undefined; expect(process.env.GCLOUD_PROJECT).to.be.undefined; @@ -103,7 +115,7 @@ describe('lifecycle', () => { let test = new FirebaseFunctionsTest(); test.init(); - mockConfig({ foo: { bar: 'faz ' } }); + setRuntimeConfig({ foo: { bar: 'faz ' } }); test.cleanup(); expect(process.env.FIREBASE_CONFIG).to.equal('oldFb'); diff --git a/spec/main.spec.ts b/spec/main.spec.ts index 703495c..808ceba 100644 --- a/spec/main.spec.ts +++ b/spec/main.spec.ts @@ -25,7 +25,7 @@ import * as functions from 'firebase-functions/v1'; import { set } from 'lodash'; import { mockConfig, makeChange, wrap } from '../src/main'; -import { _makeResourceName, _extractParams } from '../src/v1'; +import { _makeResourceName, _extractParams, _isConfigRemoved } from '../src/v1'; import { features } from '../src/features'; import { FirebaseFunctionsTest } from '../src/lifecycle'; import { alerts } from 'firebase-functions/v2'; @@ -329,17 +329,32 @@ describe('main', () => { delete process.env.CLOUD_RUNTIME_CONFIG; }); - it('should mock functions.config()', () => { - mockConfig(config); - expect(functions.config()).to.deep.equal(config); - }); + if (_isConfigRemoved()) { + // functions.config() was removed in firebase-functions v7, so + // mockConfig() must fail loudly with migration guidance. + it('should throw explaining that functions.config() was removed', () => { + expect(() => mockConfig(config)).to.throw( + 'mockConfig() is not supported with firebase-functions v7+' + ); + }); + + it('should not set CLOUD_RUNTIME_CONFIG when throwing', () => { + expect(() => mockConfig(config)).to.throw(); + expect(process.env.CLOUD_RUNTIME_CONFIG).to.be.undefined; + }); + } else { + it('should mock functions.config()', () => { + mockConfig(config); + expect(functions.config()).to.deep.equal(config); + }); - it('should purge singleton config object when it is present', () => { - mockConfig(config); - config.foo = { baz: 'qux' }; - mockConfig(config); + it('should purge singleton config object when it is present', () => { + mockConfig(config); + config.foo = { baz: 'qux' }; + mockConfig(config); - expect(functions.config()).to.deep.equal(config); - }); + expect(functions.config()).to.deep.equal(config); + }); + } }); }); diff --git a/src/v1.ts b/src/v1.ts index 6fed427..2580dc3 100644 --- a/src/v1.ts +++ b/src/v1.ts @@ -20,6 +20,9 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. +import * as fs from 'fs'; +import * as path from 'path'; + import { has, merge, random, get } from 'lodash'; import { @@ -369,8 +372,99 @@ export function makeChange(before: T, after: T): Change { return Change.fromObjects(before, after); } +/** + * Detects the major version of the installed firebase-functions package. + * Exported for internal testing purposes only. + * @internal + */ +export function _firebaseFunctionsMajorVersion(): number | undefined { + try { + // `firebase-functions/package.json` is not exposed by the package's + // exports map, so resolve the main entry point and walk up to the + // package root instead. + let dir = path.dirname(require.resolve('firebase-functions')); + while (dir !== path.dirname(dir)) { + const pkgPath = path.join(dir, 'package.json'); + if (fs.existsSync(pkgPath)) { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + if ( + pkg.name === 'firebase-functions' && + typeof pkg.version === 'string' + ) { + const major = parseInt(pkg.version.split('.')[0], 10); + if (!Number.isNaN(major)) { + return major; + } + } + } + dir = path.dirname(dir); + } + } catch (e) { + // Fall through to feature detection. + } + return undefined; +} + +let isConfigRemovedCache: boolean | undefined; + +/** + * Returns true if the installed firebase-functions no longer supports + * `functions.config()` (removed in v7). The result is cached, since the + * installed version cannot change within a process. + * Exported for internal testing purposes only. + * @internal + */ +export function _isConfigRemoved(): boolean { + if (isConfigRemovedCache === undefined) { + isConfigRemovedCache = detectConfigRemoved(); + } + return isConfigRemovedCache; +} + +function detectConfigRemoved(): boolean { + if (typeof config !== 'function') { + return true; + } + const major = _firebaseFunctionsMajorVersion(); + if (major !== undefined) { + return major >= 7; + } + // Fallback: feature-detect on the v1 entry point. In v7+, config() + // throws unconditionally; in v4-v6 it parses CLOUD_RUNTIME_CONFIG. + // K_CONFIGURATION must be cleared for the probe, since v4-v6's config() + // also throws unconditionally when it is set (GCFv2 detection). + const previous = process.env.CLOUD_RUNTIME_CONFIG; + const previousKConfiguration = process.env.K_CONFIGURATION; + process.env.CLOUD_RUNTIME_CONFIG = '{}'; + delete process.env.K_CONFIGURATION; + try { + config(); + return false; + } catch (e) { + return true; + } finally { + if (previous === undefined) { + delete process.env.CLOUD_RUNTIME_CONFIG; + } else { + process.env.CLOUD_RUNTIME_CONFIG = previous; + } + if (previousKConfiguration === undefined) { + delete process.env.K_CONFIGURATION; + } else { + process.env.K_CONFIGURATION = previousKConfiguration; + } + } +} + /** Mock values returned by `functions.config()`. */ export function mockConfig(conf: { [key: string]: { [key: string]: any } }) { + if (_isConfigRemoved()) { + throw new Error( + 'mockConfig() is not supported with firebase-functions v7+ because ' + + 'functions.config() was removed. Migrate to environment parameters ' + + 'using the params module.' + ); + } if (resetCache) { resetCache(); }