diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 4160a1eb3..75cee99b0 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -16,6 +16,7 @@ jobs: - 10.x - 12.x - 14.x + - 16.x steps: - uses: actions/checkout@v1 - uses: actions/setup-node@v1 @@ -31,4 +32,26 @@ jobs: - run: npm install - run: npm run lint - run: npm run format - - run: npm run build && npm run test + - run: npm run test + integration: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: + - 12.x + - 14.x + - 16.x + steps: + - uses: actions/checkout@v1 + - uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + + - name: Cache npm + uses: actions/cache@v1 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ matrix.node-version }}-${{ hashFiles('**/package-lock.json') }} + + - run: npm install + - run: npm run test:bin diff --git a/.mocharc.yaml b/.mocharc.yaml index 932144124..2bb78edfc 100644 --- a/.mocharc.yaml +++ b/.mocharc.yaml @@ -1,10 +1,8 @@ exit: true extension: - ts -file: - - mocha/setup.ts package: ./package.json reporter: spec require: - 'ts-node/register' -spec: spec/**/*.spec.ts + - 'source-map-support/register' diff --git a/package.json b/package.json index 25f8d8d7f..888085883 100644 --- a/package.json +++ b/package.json @@ -149,7 +149,8 @@ "format:fix": "prettier --write '**/*.{json,md,ts,yml,yaml}'", "lint": "tslint --config tslint.json --project tsconfig.json ", "lint:fix": "tslint --config tslint.json --fix --project tsconfig.json", - "test": "mocha" + "test": "mocha --file ./mocha/setup.ts spec/**/*.spec.ts ", + "test:bin": "./scripts/bin-test/run.sh" }, "dependencies": { "@types/cors": "^2.8.5", @@ -180,10 +181,12 @@ "mock-require": "^3.0.3", "mz": "^2.7.0", "nock": "^10.0.6", + "node-fetch": "^2.6.7", + "portfinder": "^1.0.28", "prettier": "^1.18.2", "semver": "^7.3.5", "sinon": "^7.3.2", - "ts-node": "^8.3.0", + "ts-node": "^10.4.0", "tslint": "^5.18.0", "tslint-config-prettier": "^1.18.0", "tslint-no-unused-expression-chai": "^0.1.4", diff --git a/scripts/bin-test/mocha-setup.ts b/scripts/bin-test/mocha-setup.ts new file mode 100644 index 000000000..6dfdc7f9d --- /dev/null +++ b/scripts/bin-test/mocha-setup.ts @@ -0,0 +1,4 @@ +import * as chai from 'chai'; +import * as chaiAsPromised from 'chai-as-promised'; + +chai.use(chaiAsPromised); diff --git a/scripts/bin-test/run.sh b/scripts/bin-test/run.sh new file mode 100755 index 000000000..c3e3da673 --- /dev/null +++ b/scripts/bin-test/run.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -ex # Immediately exit on failure + +# Link the Functions SDK for the testing environment. +npm run build +npm link + +# Link local SDK to all test sources. +for f in scripts/bin-test/sources/*; do + if [ -d "$f" ]; then + (cd "$f" && npm link firebase-functions) + fi +done + +## DEBUG +ls -la scripts/bin-test/sources/commonjs/node_modules + +mocha \ + --file ./scripts/bin-test/mocha-setup.ts \ + ./scripts/bin-test/test.ts diff --git a/scripts/bin-test/sources/commonjs-grouped/g1.js b/scripts/bin-test/sources/commonjs-grouped/g1.js new file mode 100644 index 000000000..57766af02 --- /dev/null +++ b/scripts/bin-test/sources/commonjs-grouped/g1.js @@ -0,0 +1,9 @@ +const functions = require("firebase-functions"); + +exports.groupedhttp = functions.https.onRequest((req, resp) => { + resp.status(200).send("PASS"); +}); + +exports.groupedcallable = functions.https.onCall(() => { + return "PASS"; +}); diff --git a/scripts/bin-test/sources/commonjs-grouped/index.js b/scripts/bin-test/sources/commonjs-grouped/index.js new file mode 100644 index 000000000..b00828de0 --- /dev/null +++ b/scripts/bin-test/sources/commonjs-grouped/index.js @@ -0,0 +1,20 @@ +const functions = require("firebase-functions"); +const functionsv2 = require("firebase-functions/v2"); + +exports.v1http = functions.https.onRequest((req, resp) => { + resp.status(200).send("PASS"); +}); + +exports.v1callable = functions.https.onCall(() => { + return "PASS"; +}); + +exports.v2http = functionsv2.https.onRequest((req, resp) => { + resp.status(200).send("PASS"); +}); + +exports.v2callable = functionsv2.https.onCall(() => { + return "PASS"; +}); + +exports.g1 = require("./g1"); diff --git a/scripts/bin-test/sources/commonjs-grouped/package.json b/scripts/bin-test/sources/commonjs-grouped/package.json new file mode 100644 index 000000000..1ec99f52f --- /dev/null +++ b/scripts/bin-test/sources/commonjs-grouped/package.json @@ -0,0 +1,3 @@ +{ + "name": "commonjs-grouped" +} diff --git a/scripts/bin-test/sources/commonjs-main/functions.js b/scripts/bin-test/sources/commonjs-main/functions.js new file mode 100644 index 000000000..9122f9223 --- /dev/null +++ b/scripts/bin-test/sources/commonjs-main/functions.js @@ -0,0 +1,18 @@ +const functions = require("firebase-functions"); +const functionsv2 = require("firebase-functions/v2"); + +exports.v1http = functions.https.onRequest((req, resp) => { + resp.status(200).send("PASS"); +}); + +exports.v1callable = functions.https.onCall(() => { + return "PASS"; +}); + +exports.v2http = functionsv2.https.onRequest((req, resp) => { + resp.status(200).send("PASS"); +}); + +exports.v2callable = functionsv2.https.onCall(() => { + return "PASS"; +}); diff --git a/scripts/bin-test/sources/commonjs-main/package.json b/scripts/bin-test/sources/commonjs-main/package.json new file mode 100644 index 000000000..a781259f8 --- /dev/null +++ b/scripts/bin-test/sources/commonjs-main/package.json @@ -0,0 +1,4 @@ +{ + "name": "commonjs-main", + "main": "functions.js" +} diff --git a/scripts/bin-test/sources/commonjs/index.js b/scripts/bin-test/sources/commonjs/index.js new file mode 100644 index 000000000..9122f9223 --- /dev/null +++ b/scripts/bin-test/sources/commonjs/index.js @@ -0,0 +1,18 @@ +const functions = require("firebase-functions"); +const functionsv2 = require("firebase-functions/v2"); + +exports.v1http = functions.https.onRequest((req, resp) => { + resp.status(200).send("PASS"); +}); + +exports.v1callable = functions.https.onCall(() => { + return "PASS"; +}); + +exports.v2http = functionsv2.https.onRequest((req, resp) => { + resp.status(200).send("PASS"); +}); + +exports.v2callable = functionsv2.https.onCall(() => { + return "PASS"; +}); diff --git a/scripts/bin-test/sources/commonjs/package.json b/scripts/bin-test/sources/commonjs/package.json new file mode 100644 index 000000000..30e1b1b27 --- /dev/null +++ b/scripts/bin-test/sources/commonjs/package.json @@ -0,0 +1,3 @@ +{ + "name": "commonjs" +} diff --git a/spec/fixtures/sources/esm-ext/index.mjs b/scripts/bin-test/sources/esm-ext/index.mjs similarity index 76% rename from spec/fixtures/sources/esm-ext/index.mjs rename to scripts/bin-test/sources/esm-ext/index.mjs index 28d538e3b..91e974d93 100644 --- a/spec/fixtures/sources/esm-ext/index.mjs +++ b/scripts/bin-test/sources/esm-ext/index.mjs @@ -1,5 +1,5 @@ -import * as functions from '../../../../lib/index.js'; -import * as functionsv2 from "../../../../lib/v2/index.js"; +import * as functions from "firebase-functions"; +import * as functionsv2 from "firebase-functions/v2"; export const v1http = functions.https.onRequest((req, resp) => { resp.status(200).send("PASS"); diff --git a/spec/fixtures/sources/esm-ext/package.json b/scripts/bin-test/sources/esm-ext/package.json similarity index 100% rename from spec/fixtures/sources/esm-ext/package.json rename to scripts/bin-test/sources/esm-ext/package.json diff --git a/spec/fixtures/sources/esm-main/functions.js b/scripts/bin-test/sources/esm-main/functions.js similarity index 76% rename from spec/fixtures/sources/esm-main/functions.js rename to scripts/bin-test/sources/esm-main/functions.js index d1f98b49a..b09186731 100644 --- a/spec/fixtures/sources/esm-main/functions.js +++ b/scripts/bin-test/sources/esm-main/functions.js @@ -1,5 +1,5 @@ -import * as functions from "../../../../lib/index.js"; -import * as functionsv2 from "../../../../lib/v2/index.js"; +import * as functions from "firebase-functions"; +import * as functionsv2 from "firebase-functions/v2"; export const v1http = functions.https.onRequest((req, resp) => { resp.status(200).send("PASS"); diff --git a/spec/fixtures/sources/esm-main/package.json b/scripts/bin-test/sources/esm-main/package.json similarity index 100% rename from spec/fixtures/sources/esm-main/package.json rename to scripts/bin-test/sources/esm-main/package.json diff --git a/spec/fixtures/sources/esm/index.js b/scripts/bin-test/sources/esm/index.js similarity index 76% rename from spec/fixtures/sources/esm/index.js rename to scripts/bin-test/sources/esm/index.js index d1f98b49a..b09186731 100644 --- a/spec/fixtures/sources/esm/index.js +++ b/scripts/bin-test/sources/esm/index.js @@ -1,5 +1,5 @@ -import * as functions from "../../../../lib/index.js"; -import * as functionsv2 from "../../../../lib/v2/index.js"; +import * as functions from "firebase-functions"; +import * as functionsv2 from "firebase-functions/v2"; export const v1http = functions.https.onRequest((req, resp) => { resp.status(200).send("PASS"); diff --git a/spec/fixtures/sources/esm/package.json b/scripts/bin-test/sources/esm/package.json similarity index 100% rename from spec/fixtures/sources/esm/package.json rename to scripts/bin-test/sources/esm/package.json diff --git a/scripts/bin-test/test.ts b/scripts/bin-test/test.ts new file mode 100644 index 000000000..1c6f1a24e --- /dev/null +++ b/scripts/bin-test/test.ts @@ -0,0 +1,233 @@ +import * as path from 'path'; +import * as subprocess from 'child_process'; +import { promisify } from 'util'; + +import fetch from 'node-fetch'; +import * as portfinder from 'portfinder'; +import * as yaml from 'js-yaml'; +import * as semver from 'semver'; +import { expect } from 'chai'; + +const TIMEOUT_XL = 20_000; +const TIMEOUT_L = 10_000; +const TIMEOUT_M = 5_000; +const TIMEOUT_S = 1_000; + +const BASE_STACK = { + endpoints: { + v1http: { + platform: 'gcfv1', + entryPoint: 'v1http', + httpsTrigger: {}, + }, + v1callable: { + platform: 'gcfv1', + entryPoint: 'v1callable', + labels: {}, + callableTrigger: {}, + }, + v2http: { + platform: 'gcfv2', + entryPoint: 'v2http', + labels: {}, + httpsTrigger: {}, + }, + v2callable: { + platform: 'gcfv2', + entryPoint: 'v2callable', + labels: {}, + callableTrigger: {}, + }, + }, + requiredAPIs: [], + specVersion: 'v1alpha1', +}; + +type Testcase = { + name: string; + modulePath: string; + expected: Record; +}; + +async function retryUntil( + fn: () => Promise, + timeoutMs: number, + sleepMs: number = TIMEOUT_S +) { + const sleep = () => { + return new Promise((resolve) => { + setTimeout(() => resolve(), sleepMs); + }); + }; + const timedOut = new Promise((resolve, reject) => { + setTimeout(() => { + reject(new Error('retry timeout')); + }, timeoutMs); + }); + const retry = (async () => { + while (true) { + if (await fn()) break; + await sleep(); + } + })(); + await Promise.race([retry, timedOut]); +} + +async function startBin( + tc: Testcase, + debug?: boolean +): Promise<{ port: number; cleanup: () => Promise }> { + const getPort = promisify(portfinder.getPort) as () => Promise; + const port = await getPort(); + + const proc = subprocess.spawn('./node_modules/.bin/firebase-functions', [], { + cwd: path.resolve(tc.modulePath), + env: { + PATH: process.env.PATH, + GLCOUD_PROJECT: 'test-project', + STACK_CONTROL_API_PORT: port, + }, + }); + + if (!proc) { + throw new Error('Failed to start firebase functions'); + } + + await retryUntil(async () => { + try { + await fetch(`http://localhost:${port}/__/stack.yaml`); + } catch (e) { + if (e?.code === 'ECONNREFUSED') { + return false; + } + throw e; + } + return true; + }, TIMEOUT_M); + + if (debug) { + proc.stdout?.on('data', (data: unknown) => { + console.log(`[${tc.name} stdout] ` + data); + }); + + proc.stderr?.on('data', (data: unknown) => { + console.log(`[${tc.name} stderr] ` + data); + }); + } + + return { + port, + cleanup: async () => { + process.kill(proc.pid); + await retryUntil(async () => { + try { + process.kill(proc.pid, 0); + } catch { + // process.kill w/ signal 0 will throw an error if the pid no longer exists. + return true; + } + return false; + }, TIMEOUT_M); + }, + }; +} + +describe('stack.yaml', () => { + async function runTests(tc: Testcase) { + let port: number; + let cleanup: () => Promise; + + before(async () => { + const r = await startBin(tc); + port = r.port; + cleanup = r.cleanup; + }); + + after(async () => { + await cleanup?.(); + }); + + it('stack.yaml returns expected Manifest', async () => { + const res = await fetch(`http://localhost:${port}/__/stack.yaml`); + const text = await res.text(); + let parsed: any; + try { + parsed = yaml.load(text); + } catch (err) { + throw new Error('Failed to parse stack.yaml ' + err); + } + expect(parsed).to.be.deep.equal(tc.expected); + }); + } + + describe('commonjs', () => { + const testcases: Testcase[] = [ + { + name: 'basic', + modulePath: './scripts/bin-test/sources/commonjs', + expected: BASE_STACK, + }, + { + name: 'has main', + modulePath: './scripts/bin-test/sources/commonjs-main', + expected: BASE_STACK, + }, + { + name: 'grouped', + modulePath: './scripts/bin-test/sources/commonjs-grouped', + expected: { + ...BASE_STACK, + endpoints: { + ...BASE_STACK.endpoints, + 'g1-groupedhttp': { + platform: 'gcfv1', + entryPoint: 'g1.groupedhttp', + httpsTrigger: {}, + }, + 'g1-groupedcallable': { + platform: 'gcfv1', + entryPoint: 'g1.groupedcallable', + labels: {}, + callableTrigger: {}, + }, + }, + }, + }, + ]; + + for (const tc of testcases) { + describe(tc.name, async () => { + await runTests(tc); + }); + } + }).timeout(TIMEOUT_L); + + if (semver.gt(process.versions.node, '13.2.0')) { + describe('esm', () => { + const testcases: Testcase[] = [ + { + name: 'basic', + modulePath: './scripts/bin-test/sources/esm', + expected: BASE_STACK, + }, + { + name: 'with main', + + modulePath: './scripts/bin-test/sources/esm-main', + expected: BASE_STACK, + }, + { + name: 'with .m extension', + modulePath: './scripts/bin-test/sources/esm-ext', + expected: BASE_STACK, + }, + ]; + + for (const tc of testcases) { + describe(tc.name, async () => { + await runTests(tc); + }); + } + }).timeout(TIMEOUT_L); + } +}).timeout(TIMEOUT_XL); diff --git a/scripts/publish.sh b/scripts/publish.sh index ea5acde68..a7574a15a 100644 --- a/scripts/publish.sh +++ b/scripts/publish.sh @@ -80,6 +80,7 @@ echo "Ran npm ci." echo "Running tests..." npm test +npm run test:bin echo "Ran tests." echo "Running publish build..." diff --git a/spec/runtime/loader.spec.ts b/spec/runtime/loader.spec.ts index 1dab77c43..436d3ea62 100644 --- a/spec/runtime/loader.spec.ts +++ b/spec/runtime/loader.spec.ts @@ -1,5 +1,4 @@ import * as path from 'path'; -import * as semver from 'semver'; import { expect } from 'chai'; import * as loader from '../../src/runtime/loader'; @@ -251,13 +250,13 @@ describe('loadStack', () => { expected: ManifestStack; }; function runTests(tc: Testcase) { - it('loads backend given relative path', async () => { + it('loads stack given relative path', async () => { await expect(loader.loadStack(tc.modulePath)).to.eventually.deep.equal( tc.expected ); }); - it('loads backend given absolute path', async () => { + it('loads stack given absolute path', async () => { await expect( loader.loadStack(path.join(process.cwd(), tc.modulePath)) ).to.eventually.deep.equal(tc.expected); @@ -317,32 +316,4 @@ describe('loadStack', () => { }); } }); - - if (semver.gt(process.versions.node, '13.2.0')) { - describe('esm', () => { - const testcases: Testcase[] = [ - { - name: 'basic', - modulePath: './spec/fixtures/sources/esm', - expected, - }, - { - name: 'with main', - modulePath: './spec/fixtures/sources/esm-main', - expected, - }, - { - name: 'with .m extension', - modulePath: './spec/fixtures/sources/esm-ext', - expected, - }, - ]; - - for (const tc of testcases) { - describe(tc.name, () => { - runTests(tc); - }); - } - }); - } });