diff --git a/.kokoro/build.sh b/.kokoro/build.sh index 2740468dde..2d38c5f838 100755 --- a/.kokoro/build.sh +++ b/.kokoro/build.sh @@ -44,6 +44,7 @@ export SENDGRID_API_KEY=$(cat $KOKORO_GFILE_DIR/secrets-sendgrid-api-key.txt) # Configure GCF variables export FUNCTIONS_TOPIC=integration-tests-instance export FUNCTIONS_BUCKET=$GCLOUD_PROJECT +export FUNCTIONS_DELETABLE_BUCKET=$GCLOUD_PROJECT-functions # functions/speech-to-speech export OUTPUT_BUCKET=$FUNCTIONS_BUCKET @@ -59,7 +60,7 @@ export RESULT_TOPIC=$FUNCTIONS_TOPIC export RESULT_BUCKET=$FUNCTIONS_BUCKET # functions/imagemagick -export BLURRED_BUCKET_NAME=$GCLOUD_PROJECT-functions +export BLURRED_BUCKET_NAME=$GCLOUD_PROJECT-imagick # Configure IoT variables export NODEJS_IOT_EC_PUBLIC_KEY=${KOKORO_GFILE_DIR}/ec_public.pem @@ -80,19 +81,6 @@ export GOOGLE_APPLICATION_CREDENTIALS=${KOKORO_GFILE_DIR}/secrets-key.json gcloud auth activate-service-account --key-file "$GOOGLE_APPLICATION_CREDENTIALS" gcloud config set project $GCLOUD_PROJECT -npm install -g @google-cloud/functions-framework - -# Start functions emulator, if appropriate -if [[ $PROJECT == functions/* ]] && grep --quiet functions-emulator package.json; then - export BASE_URL="http://localhost:8010/${GCLOUD_PROJECT}/${GCF_REGION}" - - export FUNCTIONS_LOG_PATH=$(pwd)/logs/cloud-functions-emulator.log - npm install -g @google-cloud/functions-emulator - touch "$FUNCTIONS_LOG_PATH" - functions config set logFile "$FUNCTIONS_LOG_PATH" - functions-emulator start -fi - npm test exit $? diff --git a/.kokoro/functions/common.cfg b/.kokoro/functions/common.cfg index 8ca89d179a..a0ffd6fe31 100644 --- a/.kokoro/functions/common.cfg +++ b/.kokoro/functions/common.cfg @@ -12,5 +12,11 @@ build_file: "nodejs-docs-samples/.kokoro/trampoline.sh" # Configure the docker image for kokoro-trampoline. env_vars: { key: "TRAMPOLINE_IMAGE" - value: "gcr.io/cloud-docs-samples/nodejs" + value: "gcr.io/cloud-devrel-kokoro-resources/node:8-user" +} + +# Configure the base URL of the function hosting platform +env_vars: { + key: "BASE_URL" + value: "http://localhost:8080" } \ No newline at end of file diff --git a/.kokoro/functions/helloworld.cfg b/.kokoro/functions/helloworld.cfg index bb7659b39d..033acbf735 100644 --- a/.kokoro/functions/helloworld.cfg +++ b/.kokoro/functions/helloworld.cfg @@ -10,4 +10,10 @@ env_vars: { env_vars: { key: "TRAMPOLINE_BUILD_FILE" value: "github/nodejs-docs-samples/.kokoro/build.sh" -} +} + +# Configure the base URL of the function hosting platform +env_vars: { + key: "BASE_URL" + value: "http://us-central1-nodejs-docs-samples-tests.cloudfunctions.net" +} \ No newline at end of file diff --git a/functions/helloworld/README.md b/functions/helloworld/README.md index 26aef63c1c..b79c036da6 100644 --- a/functions/helloworld/README.md +++ b/functions/helloworld/README.md @@ -14,6 +14,14 @@ See: See the [Cloud Functions Hello World tutorial][tutorial]. +**Note:** in order for the tests to run properly, you'll have to deploy some of the sample functions: + +``` +gcloud functions deploy helloHttp --runtime nodejs8 --trigger-http +gcloud functions deploy helloPubSub --trigger-topic $FUNCTIONS_TOPIC --runtime nodejs8 +gcloud functions deploy helloGCS --runtime nodejs8 --trigger-resource $FUNCTIONS_DELETABLE_BUCKET --trigger-event providers/cloud.storage/eventTypes/object.change +``` + ## Run the tests 1. Read and follow the [prerequisites](../../../../#prerequisites). @@ -31,7 +39,7 @@ See the [Cloud Functions Hello World tutorial][tutorial]. export GCF_REGION=us-central1 export FUNCTIONS_TOPIC=[YOUR_PUBSUB_TOPIC] - export FUNCTIONS_BUCKET=[YOUR_CLOUD_STORAGE_BUCKET] + export FUNCTIONS_DELETABLE_BUCKET=[YOUR_CLOUD_STORAGE_BUCKET] # will be deleted by tests! 1. Run the tests: diff --git a/functions/helloworld/index.js b/functions/helloworld/index.js index 5d5cfdb6c9..87be5a8371 100644 --- a/functions/helloworld/index.js +++ b/functions/helloworld/index.js @@ -57,8 +57,8 @@ exports.helloHttp = (req, res) => { * @param {object} event The Cloud Functions event. * @param {function} callback The callback function. */ -exports.helloBackground = (event, callback) => { - callback(null, `Hello ${event.data.name || 'World'}!`); +exports.helloBackground = (data, context, callback) => { + callback(null, `Hello ${data.name || 'World'}!`); }; // [END functions_helloworld_background] @@ -68,18 +68,16 @@ exports.helloBackground = (event, callback) => { * This function is exported by index.js, and executed when * the trigger topic receives a message. * - * @param {object} event The Cloud Functions event. - * @param {function} callback The callback function. + * @param {object} data The event payload. + * @param {object} context The event metadata. */ -exports.helloPubSub = (event, callback) => { - const pubsubMessage = event.data; - const name = pubsubMessage.data - ? Buffer.from(pubsubMessage.data, 'base64').toString() +exports.helloPubSub = (data, context) => { + const pubSubMessage = data; + const name = pubSubMessage.data + ? Buffer.from(pubSubMessage.data, 'base64').toString() : 'World'; console.log(`Hello, ${name}!`); - - callback(); }; // [END functions_helloworld_pubsub] @@ -87,23 +85,20 @@ exports.helloPubSub = (event, callback) => { /** * Background Cloud Function to be triggered by Cloud Storage. * - * @param {object} event The Cloud Functions event. - * @param {function} callback The callback function. + * @param {object} data The event payload. + * @param {object} context The event metadata. */ -exports.helloGCS = (event, callback) => { - const file = event.data; - +exports.helloGCS = (data, context) => { + const file = data; if (file.resourceState === 'not_exists') { console.log(`File ${file.name} deleted.`); } else if (file.metageneration === '1') { // metageneration attribute is updated on metadata changes. - // value is 1 if file was newly created or overwritten + // on create value is 1 console.log(`File ${file.name} uploaded.`); } else { console.log(`File ${file.name} metadata updated.`); } - - callback(); }; // [END functions_helloworld_storage] @@ -114,11 +109,11 @@ exports.helloGCS = (event, callback) => { * @param {object} event The Cloud Functions event. * @param {function} callback The callback function. */ -exports.helloGCSGeneric = (event, callback) => { - const file = event.data; +exports.helloGCSGeneric = (data, context, callback) => { + const file = data; - console.log(` Event: ${event.eventId}`); - console.log(` Event Type: ${event.eventType}`); + console.log(` Event: ${context.eventId}`); + console.log(` Event Type: ${context.eventType}`); console.log(` Bucket: ${file.bucket}`); console.log(` File: ${file.name}`); console.log(` Metageneration: ${file.metageneration}`); @@ -136,7 +131,7 @@ exports.helloGCSGeneric = (event, callback) => { * @param {function} callback The callback function. */ -exports.helloError = (event, callback) => { +exports.helloError = (data, context, callback) => { // [START functions_helloworld_error] // These WILL be reported to Stackdriver Error Reporting console.error(new Error('I failed you')); @@ -154,7 +149,7 @@ exports.helloError = (event, callback) => { */ /* eslint-disable no-throw-literal */ -exports.helloError2 = (event, callback) => { +exports.helloError2 = (data, context, callback) => { // [START functions_helloworld_error] // These will NOT be reported to Stackdriver Error Reporting console.info(new Error('I failed you')); // Logging an Error object at the info level @@ -171,7 +166,7 @@ exports.helloError2 = (event, callback) => { * @param {function} callback The callback function. */ /* eslint-disable */ -exports.helloError3 = (event, callback) => { +exports.helloError3 = (data, context, callback) => { // This will NOT be reported to Stackdriver Error Reporting // [START functions_helloworld_error] callback('I failed you'); diff --git a/functions/helloworld/package.json b/functions/helloworld/package.json index 9b8c1edd93..6e6d157c32 100644 --- a/functions/helloworld/package.json +++ b/functions/helloworld/package.json @@ -12,24 +12,31 @@ "node": ">=8.0.0" }, "scripts": { - "e2e-test": "export FUNCTIONS_CMD='gcloud functions' && sh test/updateFunctions.sh && BASE_URL=\"https://$GCP_REGION-$GCLOUD_PROJECT.cloudfunctions.net/\" mocha test/*.test.js --timeout=60000 --exit", - "test": "export FUNCTIONS_CMD='functions-emulator' && sh test/updateFunctions.sh && export BASE_URL=\"http://localhost:8010/$GCLOUD_PROJECT/$GCF_REGION\" && mocha test/*.test.js --timeout=60000 --exit", - "system-test": "export FUNCTIONS_CMD='functions-emulator' && sh test/updateFunctions.sh && export BASE_URL=\"http://localhost:8010/$GCLOUD_PROJECT/$GCF_REGION\" && mocha test/*.test.js --timeout=60000 --exit" + "unit-test": "mocha test/index.test.js test/*unit*test.js test/*integration*test.js --timeout=2000 --exit", + "system-test": "mocha test/*system*test.js --timeout=60000 --exit", + "test": "npm run unit-test && npm run system-test" }, "dependencies": { "@google-cloud/debug-agent": "^4.0.0", "escape-html": "^1.0.3", - "pug": "^2.0.3" + "pug": "^2.0.3", + "supertest": "^4.0.2" }, "devDependencies": { + "@google-cloud/functions-framework": "^1.1.1", "@google-cloud/nodejs-repo-tools": "^3.3.0", - "@google-cloud/pubsub": "^0.28.0", + "@google-cloud/pubsub": "^0.30.0", "@google-cloud/storage": "^3.0.0", - "mocha": "^6.0.0", + "child-process-promise": "^2.2.1", + "delay": "^4.3.0", "express": "^4.16.3", + "mocha": "^6.1.4", + "moment": "^2.24.0", + "promise-retry": "^1.1.1", "proxyquire": "^2.1.0", + "request": "^2.88.0", + "requestretry": "^4.0.0", "sinon": "^7.0.0", - "supertest": "^4.0.0", "uuid": "^3.1.0", "yargs": "^14.0.0" }, diff --git a/functions/helloworld/shim.js b/functions/helloworld/shim.js deleted file mode 100644 index bd510180f0..0000000000 --- a/functions/helloworld/shim.js +++ /dev/null @@ -1,153 +0,0 @@ -/** - * Copyright 2018, Google, Inc. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -const httpShim = PORT => { - // [START functions_testing_shim_http] - // Import dependencies - const gcfCode = require('./index.js'); - const express = require('express'); - - // TODO(developer): specify the port to use - // const PORT = 3000; - - // Start local HTTP server - const app = express(); - const server = require(`http`).createServer(app); - server.on('connection', socket => socket.unref()); - server.listen(PORT); - - // Register HTTP handlers - Object.keys(gcfCode).forEach(gcfFn => { - // Handle a single HTTP request - const handler = (req, res) => { - gcfCode[gcfFn](req, res); - server.close(); - }; - - app.get(`/${gcfFn}`, handler); - app.post(`/${gcfFn}`, handler); - }); - // [END functions_testing_shim_http] -}; - -const pubsubShim = (gcfFn, topicName, subscriptionName) => { - // [START functions_testing_shim_pubsub] - // Import dependencies - const {PubSub} = require('@google-cloud/pubsub'); - const pubsub = new PubSub(); - - // TODO(developer): specify a function to test - // const gcfCode = require('./index.js'); - // const gcfFn = gcfCode.YOUR_FUNCTION; - - // TODO(developer): specify an existing topic and subscription to use - // const topicName = process.env.TOPIC || 'YOUR_TOPIC'; - // const subscriptionName = process.env.SUBSCRIPTION || 'YOUR_SUBSCRIPTION'; - - // Subscribe to Pub/Sub topic - const subscription = pubsub.topic(topicName).subscription(subscriptionName); - - // Handle a single Pub/Sub message - const messageHandler = msg => { - gcfFn({data: msg}, () => { - msg.ack(); - subscription.removeListener(`message`, messageHandler); - }); - }; - subscription.on(`message`, messageHandler); - // [END functions_testing_shim_pubsub] -}; - -const storageShim = (gcfFn, bucketName, topicName, subscriptionName) => { - // [START functions_testing_shim_storage] - // Import dependencies - const {PubSub} = require('@google-cloud/pubsub'); - const {Storage} = require(`@google-cloud/storage`); - const pubsub = new PubSub(); - const storage = new Storage(); - - // TODO(developer): specify a function to test - // const gcfCode = require('./index.js'); - // const gcfFn = gcfCode.YOUR_FUNCTION; - - // TODO(developer): specify a Cloud Storage bucket to monitor - // const bucketName = 'YOUR_GCS_BUCKET' - - // TODO(developer): specify an existing topic and subscription to use - // const topicName = process.env.TOPIC || 'YOUR_TOPIC'; - // const subscriptionName = process.env.SUBSCRIPTION || 'YOUR_SUBSCRIPTION'; - - // Create notification on target bucket - // Further info: https://cloud.google.com/storage/docs/reporting-changes - const bucket = storage.bucket(bucketName); - return bucket - .createNotification(topicName) - .then(data => data[0]) - .then( - notification => - new Promise(resolve => { - // Subscribe to Pub/Sub topic - const subscription = pubsub - .topic(topicName) - .subscription(subscriptionName); - - // Handle a single Pub/Sub message - const messageHandler = msg => { - const data = JSON.parse(Buffer.from(msg.data, 'base64').toString()); - gcfFn({data: data}, () => { - msg.ack(); - subscription.removeListener(`message`, messageHandler); - resolve(notification); - }); - }; - subscription.on(`message`, messageHandler); - }) - ) - .then(notification => notification.delete()); // Delete notification - // [END functions_testing_shim_storage] -}; - -const gcfCodeGlobal = require('./index.js'); -require(`yargs`) // eslint-disable-line - .demandCommand(1) - .command('http ', 'HTTP-triggered-function shim', {}, opts => - httpShim(opts.port) - ) - .command( - 'pubsub ', - 'PubSub-triggered-function shim', - {}, - opts => - pubsubShim( - gcfCodeGlobal[opts.functionName], - opts.topic, - opts.subscription - ) - ) - .command( - 'storage ', - 'Storage-triggered-function shim', - {}, - opts => - storageShim( - gcfCodeGlobal[opts.functionName], - opts.bucket, - opts.topic, - opts.subscription - ) - ) - .wrap(120) - .help() - .strict().argv; diff --git a/functions/helloworld/test/index.test.js b/functions/helloworld/test/index.test.js index 373807728e..3388a2119f 100644 --- a/functions/helloworld/test/index.test.js +++ b/functions/helloworld/test/index.test.js @@ -16,256 +16,234 @@ const path = require('path'); const assert = require('assert'); const tools = require('@google-cloud/nodejs-repo-tools'); -const supertest = require('supertest'); +const requestRetry = require('requestretry'); const uuid = require('uuid'); +const sinon = require('sinon'); +const execPromise = require('child-process-promise').exec; + +const program = require('..'); const {PubSub} = require('@google-cloud/pubsub'); const pubsub = new PubSub(); const {Storage} = require('@google-cloud/storage'); const storage = new Storage(); -const baseCmd = process.env.FUNCTIONS_CMD; const topicName = process.env.FUNCTIONS_TOPIC; const localFileName = 'test.txt'; const fileName = `test-${uuid.v4()}.txt`; -const {BASE_URL} = process.env; - const bucketName = process.env.FUNCTIONS_BUCKET; const bucket = storage.bucket(bucketName); -before('Must specify BASE_URL', () => { - assert.ok(BASE_URL); - tools.checkCredentials(); -}); +const startFF = (target, signature, port) => { + const cwd = path.join(__dirname, '..'); + // exec's 'timeout' param won't kill children of "shim" /bin/sh process + // Workaround: include "& sleep ; kill $!" in executed command + return execPromise( + `functions-framework --target=${target} --signature-type=${signature} --port=${port} & sleep 1; kill $!`, + {shell: true, cwd} + ); +}; + +const httpInvocation = (fnUrl, port, body) => { + const baseUrl = `http://localhost:${port}`; + + if (body) { + // POST request + return requestRetry.post({ + url: `${baseUrl}/${fnUrl}`, + retryDelay: 400, + body: body, + json: true, + }); + } else { + // GET request + return requestRetry.get({ + url: `${baseUrl}/${fnUrl}`, + retryDelay: 400, + }); + } +}; + +describe('index.test.js', () => { + before(tools.checkCredentials); -it('helloGET: should print hello world', async () => { - await supertest(BASE_URL) - .get('/helloGET') - .expect(200) - .expect(response => { - assert.strictEqual(response.text, 'Hello World!'); + describe('helloGET', () => { + const PORT = 8081; + let ffProc; + + before(() => { + ffProc = startFF('helloGET', 'http', PORT); }); -}); -it('helloHttp: should print a name via GET', async () => { - await supertest(BASE_URL) - .get('/helloHttp?name=John') - .expect(200) - .expect(response => { - assert.strictEqual(response.text, 'Hello John!'); + after(async () => { + await ffProc; }); -}); -it('helloHttp: should print a name via POST', async () => { - await supertest(BASE_URL) - .post('/helloHttp') - .send({name: 'John'}) - .expect(200) - .expect(response => { - assert.strictEqual(response.text, 'Hello John!'); + it('helloGET: should print hello world', async () => { + const response = await httpInvocation('helloGET', PORT); + + assert.strictEqual(response.statusCode, 200); + assert.strictEqual(response.body, 'Hello World!'); }); -}); + }); + + describe('helloHttp', () => { + const PORT = 8082; + let ffProc; -it('helloHttp: should print hello world', async () => { - await supertest(BASE_URL) - .get('/helloHttp') - .expect(200) - .expect(response => { - assert.strictEqual(response.text, 'Hello World!'); + before(() => { + ffProc = startFF('helloHttp', 'http', PORT); }); -}); -it('helloHttp: should escape XSS', async () => { - await supertest(BASE_URL) - .post('/helloHttp') - .send({name: ''}) - .expect(200) - .expect(response => { - assert.strictEqual(response.text.includes('', + }); - // Check logs - await tools.tryTest(async assert => { - const logs = await tools.runAsync( - `${baseCmd} logs read helloPubSub --start-time ${startTime}` - ); - assert.strictEqual(logs.includes(`Hello, ${name}!`), true); + assert.strictEqual(response.statusCode, 200); + assert.strictEqual(response.body.includes('