'use strict' const WebSocket = require('ws') const { expect } = require('chai') const awsRequest = require('@serverless/test/aws-request') const CloudFormationService = require('aws-sdk').CloudFormation const log = require('log').get('serverless:test') const wait = require('timers-ext/promise/sleep') const fixtures = require('../../fixtures/programmatic') const { confirmCloudWatchLogs } = require('../../utils/misc') const { deployService, removeService } = require('../../utils/integration') const { createApi, deleteApi, getRoutes, createStage, deleteStage, } = require('../../utils/websocket') describe('AWS - API Gateway Websocket Integration Test', function () { this.timeout(1000 * 60 * 10) // Involves time-taking deploys let stackName let serviceName let serviceDir let updateConfig const stage = 'dev' before(async () => { const serviceData = await fixtures.setup('websocket') ;({ servicePath: serviceDir, updateConfig } = serviceData) serviceName = serviceData.serviceConfig.service stackName = `${serviceName}-${stage}` return deployService(serviceDir) }) after(() => removeService(serviceDir)) async function getWebSocketServerUrl() { const result = await awsRequest(CloudFormationService, 'describeStacks', { StackName: stackName, }) const webSocketServerUrl = result.Stacks[0].Outputs.find( (output) => output.OutputKey === 'ServiceEndpointWebsocket', ).OutputValue return webSocketServerUrl } describe('Two-Way Setup', () => { let timeoutId after(() => clearTimeout(timeoutId)) it('should expose a websocket route that can reply to a message', async () => { const webSocketServerUrl = await getWebSocketServerUrl() return new Promise((resolve, reject) => { const ws = new WebSocket(webSocketServerUrl) reject = ((promiseReject) => (error) => { promiseReject(error) try { ws.close() } catch (closeError) { // safe to ignore } })(reject) const sendMessage = () => { log.debug("Sending message to 'hello' route") ws.send(JSON.stringify({ action: 'hello', name: 'serverless' })) timeoutId = setTimeout(sendMessage, 1000) } ws.on('error', reject) ws.on('open', sendMessage) ws.on('close', resolve) ws.on('message', (event) => { clearTimeout(timeoutId) try { log.debug(`Received WebSocket message: ${event}`) expect(event).to.equal('Hello, serverless') } finally { ws.close() } }) }).finally(() => clearTimeout(timeoutId)) }) }) describe('Minimal Setup', () => { it('should expose an accessible websocket endpoint', async () => { const webSocketServerUrl = await getWebSocketServerUrl() log.debug(`WebSocket Server URL ${webSocketServerUrl}`) expect(webSocketServerUrl).to.match( /wss:\/\/.+\.execute-api\..+\.amazonaws\.com.+/, ) return new Promise((resolve, reject) => { const ws = new WebSocket(webSocketServerUrl) let isRejected = false reject = ((promiseReject) => (error) => { isRejected = true promiseReject(error) try { ws.close() } catch (closeError) { // safe to ignore } })(reject) ws.on('error', reject) ws.on('open', () => { confirmCloudWatchLogs(`/aws/websocket/${stackName}`, () => { if (isRejected) throw new Error('Stop propagation') ws.send('test message') return wait(500) }).then((events) => { expect(events.length > 0).to.equal(true) ws.close() }, reject) }) ws.on('close', resolve) ws.on('message', (event) => { log.debug('Unexpected WebSocket message', event) reject(new Error('Unexpected message')) }) }) }) // NOTE: this test should be at the very end because we're using an external REST API here describe('when using an existing websocket API', () => { let websocketApiId before(async () => { // create an external websocket API const externalWebsocketApiName = `${stage}-${serviceName}-ext-api` const wsApiMeta = await createApi(externalWebsocketApiName) websocketApiId = wsApiMeta.ApiId await createStage(websocketApiId, 'dev') await updateConfig({ provider: { apiGateway: { websocketApiId }, }, }) return deployService(serviceDir) }) after(async () => { // NOTE: deleting the references to the old, external websocket API await updateConfig({ provider: { apiGateway: { websocketApiId: null }, }, }) // NOTE: we need to delete the stage before deleting the stack // otherwise CF will refuse to delete the deployment because a stage refers to that await deleteStage(websocketApiId, 'dev') // NOTE: deploying once again to get the stack into the original state await deployService(serviceDir) log.debug('Deleting external websocket API...') await deleteApi(websocketApiId) }) it('should add the routes to the referenced API', async () => { const routes = await getRoutes(websocketApiId) expect(routes.length).to.equal(4) }) }) }) })