@adobe/aio-app-scripts
Version:
Utility tooling scripts to build, deploy and run an Adobe I/O App
1,193 lines (1,053 loc) • 46.1 kB
JavaScript
/*
Copyright 2019 Adobe. All rights reserved.
This file is licensed to you 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 REPRESENTATIONS
OF ANY KIND, either express or implied. See the License for the specific language
governing permissions and limitations under the License.
*/
const { vol } = global.mockFs()
const AppScripts = require('../..')
const cloneDeep = require('lodash.clonedeep')
const path = require('path')
const stream = require('stream')
const mockAIOConfig = require('@adobe/aio-lib-core-config')
const util = require('util')
const sleep = util.promisify(setTimeout)
/* ****************** Mocks & beforeEach ******************* */
let onChangeFunc
jest.mock('chokidar', () => {
return {
watch: (...watchArgs) => {
return {
on: (status, method) => {
onChangeFunc = method
},
close: jest.fn()
}
}
}
})
const execa = require('execa')
jest.mock('execa')
const fetch = require('node-fetch')
jest.mock('node-fetch')
const mockLogger = require('@adobe/aio-lib-core-logging')
const Bundler = require('parcel-bundler')
jest.mock('parcel-bundler')
const mockUIServerAddressInstance = { port: 1111 }
const mockUIServerInstance = {
close: jest.fn(),
address: jest.fn().mockReturnValue(mockUIServerAddressInstance)
}
const BuildActions = require('../../scripts/build.actions')
const DeployActions = require('../../scripts/deploy.actions')
jest.mock('../../scripts/build.actions')
jest.mock('../../scripts/deploy.actions')
jest.mock('http-terminator')
const httpTerminator = require('http-terminator')
const mockTerminatorInstance = {
terminate: jest.fn()
}
let deployActionsSpy
process.exit = jest.fn()
const mockOnProgress = jest.fn()
const actualSetTimeout = setTimeout
const now = Date.now
let time
beforeEach(() => {
global.cleanFs(vol)
delete process.env.REMOTE_ACTIONS
BuildActions.mockClear()
DeployActions.mockClear()
fetch.mockReset()
execa.mockReset()
mockLogger.mockReset()
Bundler.mockReset()
// mock bundler server
Bundler.mockServe.mockResolvedValue(mockUIServerInstance)
mockUIServerInstance.close.mockReset()
mockUIServerInstance.address.mockClear()
mockUIServerAddressInstance.port = 1111
process.exit.mockReset()
process.removeAllListeners('SIGINT')
mockOnProgress.mockReset()
httpTerminator.createHttpTerminator.mockReset()
httpTerminator.createHttpTerminator.mockImplementation(() => mockTerminatorInstance)
mockTerminatorInstance.terminate.mockReset()
// workaround for timers and elapsed time
// to replace when https://github.com/facebook/jest/issues/5165 is closed
Date.now = jest.fn()
global.setTimeout = jest.fn()
time = now()
Date.now.mockImplementation(() => time)
global.setTimeout.mockImplementation((fn, d) => { time = time + d; fn() })
deployActionsSpy = jest.spyOn(DeployActions.prototype, 'run')
deployActionsSpy.mockResolvedValue({})
})
afterAll(() => {
deployActionsSpy.mockRestore()
})
/* ****************** Consts ******************* */
const localOWCredentials = {
...global.fakeConfig.local.runtime
}
const remoteOWCredentials = {
...global.fakeConfig.tvm.runtime,
apihost: global.defaultOwApiHost
}
const expectedLocalOWConfig = expect.objectContaining({
ow: expect.objectContaining({
...localOWCredentials
})
})
const expectedRemoteOWConfig = expect.objectContaining({
ow: expect.objectContaining({
...remoteOWCredentials
})
})
// those must match the ones defined in dev.js
const owJarPath = path.resolve(__dirname, '../../bin/openwhisk-standalone.jar')
const owRuntimesConfig = path.resolve(__dirname, '../../bin/openwhisk-standalone-config/runtimes.json')
const owJarUrl = 'https://dl.bintray.com/adobeio-firefly/aio/openwhisk-standalone.jar'
const waitInitTime = 2000
const waitPeriodTime = 500
const execaLocalOWArgs = ['java', expect.arrayContaining(['-jar', r(owJarPath), '-m', owRuntimesConfig, '--no-ui']), expect.anything()]
/* ****************** Helpers ******************* */
function generateDotenvContent (credentials) {
let content = ''
if (credentials.namespace) content = content + `AIO_RUNTIME_NAMESPACE=${credentials.namespace}`
if (credentials.auth) content = content + `\nAIO_RUNTIME_AUTH=${credentials.auth}`
if (credentials.apihost) content = content + `\nAIO_RUNTIME_APIHOST=${credentials.apihost}`
return content
}
async function loadEnvScripts (project, config, excludeFiles = []) {
// create test app
global.loadFs(vol, project)
excludeFiles.forEach(f => vol.unlinkSync(f))
mockAIOConfig.get.mockReturnValue(config)
const scripts = AppScripts({ listeners: { onProgress: mockOnProgress } })
return scripts
}
function writeFakeOwJar () {
global.addFakeFiles(vol, path.dirname(owJarPath), path.basename(owJarPath))
}
function deleteFakeOwJar () {
const parts = owJarPath.split('/').slice(1) // slice(1) to remove first empty '' because of path starting with /
vol.unlinkSync(owJarPath)
parts.pop()
while (parts.length > 0) {
vol.rmdirSync('/' + parts.join('/'))
parts.pop()
}
}
// helpers for checking good path
function expectDevActionBuildAndDeploy (expectedBuildDeployConfig) {
// build & deploy
expect(BuildActions).toHaveBeenCalledTimes(2)
expect(BuildActions.mock.calls[1][0]).toEqual(expectedBuildDeployConfig)
expect(BuildActions.mock.instances[1].run).toHaveBeenCalledTimes(1)
expect(DeployActions).toHaveBeenCalledTimes(2)
expect(DeployActions.mock.calls[1][0]).toEqual(expectedBuildDeployConfig)
expect(DeployActions.mock.instances[1].run).toHaveBeenCalledTimes(1)
}
function expectUIServer (fakeMiddleware, port) {
expect(Bundler.mockConstructor).toHaveBeenCalledTimes(1)
expect(Bundler.mockConstructor).toHaveBeenCalledWith(r('/web-src/index.html'),
expect.objectContaining({
watch: true,
outDir: r('/dist/web-src-dev')
}))
}
function expectAppFiles (expectedFiles) {
expectedFiles = new Set(expectedFiles)
const files = new Set(vol.readdirSync('/'))
// in run local, the openwhisk standalone jar is created at __dirname,
// but as we store the app in the root of the memfs, we need to ignore the extra created folder
files.delete(owJarPath.split(path.sep)[1])
expect(files).toEqual(expectedFiles)
}
async function testCleanupNoErrors (done, scripts, postCleanupChecks) {
// todo why do we need to remove listeners here, somehow the one in beforeEach isn't sufficient, is jest adding a listener?
process.removeAllListeners('SIGINT')
process.exit.mockImplementation(() => {
postCleanupChecks()
expect(process.exit).toHaveBeenCalledWith(0)
done()
})
await scripts.runDev()
expect(process.exit).toHaveBeenCalledTimes(0)
// make sure we have only one listener = cleanup listener after each test + no pending promises
expect(process.listenerCount('SIGINT')).toEqual(1)
// send cleanup signal
process.emit('SIGINT')
// if test times out => means handler is not calling process.exit
}
async function testCleanupOnError (scripts, postCleanupChecks) {
const error = new Error('fake')
mockOnProgress.mockImplementation(msg => {
// throw error for last progress statement
// todo tests for intermediary progress steps aswell
if (msg.includes('CTRL+C to terminate')) {
throw error
}
})
await expect(scripts.runDev()).rejects.toBe(error)
postCleanupChecks()
}
const getExpectedActionVSCodeDebugConfig = actionName =>
expect.objectContaining({
type: 'node',
request: 'launch',
name: 'Action:' + actionName,
runtimeExecutable: r('/node_modules/.bin/wskdebug'),
runtimeArgs: [
actionName,
expect.stringContaining(actionName.split('/')[1]),
'-v',
'--kind',
'nodejs:12'
],
env: { WSK_CONFIG_FILE: r('/.wskdebug.props.tmp') },
localRoot: r('/'),
remoteRoot: '/code'
})
const getExpectedUIVSCodeDebugConfig = uiPort => expect.objectContaining({
type: 'chrome',
request: 'launch',
name: 'Web',
url: `http://localhost:${uiPort}`,
webRoot: r('/web-src'),
breakOnLoad: true,
sourceMapPathOverrides: {
'*': r('/dist/web-src-dev/*')
}
})
/* ****************** Tests ******************* */
test('cna-scripts.runDev command is exported', async () => {
const scripts = await loadEnvScripts('sample-app', global.fakeConfig.tvm)
expect(scripts.runDev).toBeDefined()
expect(typeof scripts.runDev).toBe('function')
})
describe('config fail if', () => {
const failMissingRuntimeConfig = async (configVarName, remoteActionsValue) => {
process.env.REMOTE_ACTIONS = remoteActionsValue
const config = cloneDeep(global.fakeConfig.tvm) // don't override original
delete config.runtime[configVarName]
const scripts = await loadEnvScripts('sample-app', config)
await expect(scripts.runDev()).rejects.toEqual(expect.objectContaining({ message: expect.stringContaining(`missing Adobe I/O Runtime ${configVarName}`) }))
}
test('missing runtime namespace and REMOTE_ACTIONS=true', () => failMissingRuntimeConfig('namespace', 'true')) // eslint-disable-line jest/expect-expect
test('missing runtime namespace and REMOTE_ACTIONS=yes', () => failMissingRuntimeConfig('namespace', 'yes')) // eslint-disable-line jest/expect-expect
test('missing runtime namespace and REMOTE_ACTIONS=1', () => failMissingRuntimeConfig('namespace', '1')) // eslint-disable-line jest/expect-expect
test('missing runtime auth and REMOTE_ACTIONS=true', () => failMissingRuntimeConfig('auth', 'true')) // eslint-disable-line jest/expect-expect
test('missing runtime auth and REMOTE_ACTIONS=yes', () => failMissingRuntimeConfig('auth', 'yes')) // eslint-disable-line jest/expect-expect
test('missing runtime auth and REMOTE_ACTIONS=1', () => failMissingRuntimeConfig('auth', '1')) // eslint-disable-line jest/expect-expect
})
function runCommonTests (ref) {
test('should save a previous existing .vscode/config.json file to .vscode/config.json.save', async () => {
global.addFakeFiles(vol, '.vscode', { 'launch.json': 'fakecontent' })
await ref.scripts.runDev()
expect(vol.existsSync('/.vscode/launch.json.save')).toEqual(true)
expect(vol.readFileSync('/.vscode/launch.json.save').toString()).toEqual('fakecontent')
})
test('should not save to .vscode/config.json.save if there is no existing .vscode/config.json file', async () => {
await ref.scripts.runDev()
expect(vol.existsSync('/.vscode/launch.json.save')).toEqual(false)
})
test('should not overwrite .vscode/config.json.save', async () => {
// why? because it might be because previous restore failed
global.addFakeFiles(vol, '.vscode', { 'launch.json': 'fakecontent' })
global.addFakeFiles(vol, '.vscode', { 'launch.json.save': 'fakecontentsaved' })
await ref.scripts.runDev()
expect(vol.existsSync('/.vscode/launch.json.save')).toEqual(true)
expect(vol.readFileSync('/.vscode/launch.json.save').toString()).toEqual('fakecontentsaved')
})
// eslint-disable-next-line jest/expect-expect
test('should cleanup generated files on SIGINT', async () => {
return new Promise(resolve => {
testCleanupNoErrors(resolve, ref.scripts, () => { expectAppFiles(ref.appFiles) })
})
})
// eslint-disable-next-line jest/expect-expect
test('should cleanup generated files on error', async () => {
await testCleanupOnError(ref.scripts, () => {
expectAppFiles(ref.appFiles)
})
})
test('should cleanup and restore previous existing .vscode/config.json on SIGINT', async () => {
global.addFakeFiles(vol, '.vscode', { 'launch.json': 'fakecontent' })
return new Promise(resolve => {
testCleanupNoErrors(resolve, ref.scripts, () => {
expectAppFiles([...ref.appFiles, '.vscode'])
expect(vol.existsSync('/.vscode/launch.json.save')).toEqual(false)
expect(vol.existsSync('/.vscode/launch.json')).toEqual(true)
expect(vol.readFileSync('/.vscode/launch.json').toString()).toEqual('fakecontent')
})
})
})
test('should cleanup and restore previous existing .vscode/config.json on error', async () => {
global.addFakeFiles(vol, '.vscode', { 'launch.json': 'fakecontent' })
await testCleanupOnError(ref.scripts, () => {
expectAppFiles([...ref.appFiles, '.vscode'])
expect(vol.existsSync('/.vscode/launch.json.save')).toEqual(false)
expect(vol.existsSync('/.vscode/launch.json')).toEqual(true)
expect(vol.readFileSync('/.vscode/launch.json').toString()).toEqual('fakecontent')
})
})
test('should not remove previously existing ./vscode/launch.json.save on SIGINT', async () => {
global.addFakeFiles(vol, '.vscode', { 'launch.json': 'fakecontent' })
global.addFakeFiles(vol, '.vscode', { 'launch.json.save': 'fakecontentsaved' })
return new Promise(resolve => {
testCleanupNoErrors(resolve, ref.scripts, () => {
expect(vol.existsSync('/.vscode/launch.json.save')).toEqual(true)
expect(vol.readFileSync('/.vscode/launch.json.save').toString()).toEqual('fakecontentsaved')
})
})
})
test('should not remove previously existing ./vscode/launch.json.save on error', async () => {
global.addFakeFiles(vol, '.vscode', { 'launch.json': 'fakecontent' })
global.addFakeFiles(vol, '.vscode', { 'launch.json.save': 'fakecontentsaved' })
await testCleanupOnError(ref.scripts, () => {
expect(vol.existsSync('/.vscode/launch.json.save')).toEqual(true)
expect(vol.readFileSync('/.vscode/launch.json.save').toString()).toEqual('fakecontentsaved')
})
})
test('should not build and deploy actions if skipActions is set', async () => {
await ref.scripts.runDev([], { skipActions: true })
// build & deploy constructor have been called once to init the scripts
// here we make sure run has not been called
expect(BuildActions.mock.instances[0].run).toHaveBeenCalledTimes(0)
expect(DeployActions.mock.instances[0].run).toHaveBeenCalledTimes(0)
expect(BuildActions.mock.instances[1]).toBeUndefined()
expect(DeployActions.mock.instances[1]).toBeUndefined()
})
test('should not set vscode config for actions if skipActions is set', async () => {
await ref.scripts.runDev([], { skipActions: true })
expect(vol.readFileSync('/.vscode/launch.json').toString()).not.toEqual(expect.stringContaining('wskdebug'))
})
}
function runCommonWithBackendTests (ref) {
test('should log actions url or name when actions are deployed', async () => {
deployActionsSpy.mockResolvedValue({
actions: [
{ name: 'pkg/action', url: 'https://fake.com/action' },
{ name: 'pkg/actionNoUrl' }
]
})
await ref.scripts.runDev()
expect(mockOnProgress).toHaveBeenCalledWith(expect.stringContaining('pkg/actionNoUrl'))
expect(mockOnProgress).toHaveBeenCalledWith(expect.stringContaining('https://fake.com/action'))
})
}
function runCommonRemoteTests (ref) {
// eslint-disable-next-line jest/expect-expect
test('should build and deploy actions to remote', async () => {
await ref.scripts.runDev()
expectDevActionBuildAndDeploy(expectedRemoteOWConfig)
BuildActions.mockClear()
DeployActions.mockClear()
jest.useFakeTimers()
DeployActions.prototype.run.mockImplementation(async () => { await sleep(2000); return {} })
// First change
onChangeFunc('changed')
DeployActions.prototype.run.mockImplementation(async () => { throw new Error() })
// Second change
onChangeFunc('changed')
await jest.runAllTimers()
// Second change should not have resulted in build & deploy yet because first deploy would take 2 secs
expect(BuildActions).toHaveBeenCalledTimes(1)
expect(DeployActions).toHaveBeenCalledTimes(1)
await jest.runAllTimers()
await sleep(1)
// The second call to DeployActions will result in an error because of the second mock above
expect(mockOnProgress).toHaveBeenCalledWith(expect.stringContaining('Stopping'))
expect(BuildActions).toHaveBeenCalledTimes(2)
expect(BuildActions.mock.instances[0].run).toHaveBeenCalledTimes(1)
expect(DeployActions).toHaveBeenCalledTimes(2)
expect(DeployActions.mock.instances[0].run).toHaveBeenCalledTimes(1)
})
test('should not start the local openwhisk stack', async () => {
await ref.scripts.runDev()
expect(execa).not.toHaveBeenCalledWith(...execaLocalOWArgs)
})
test('should generate a .wskdebug.props.tmp file with the remote credentials', async () => {
await ref.scripts.runDev()
const debugProps = vol.readFileSync('.wskdebug.props.tmp').toString()
expect(debugProps).toContain(`NAMESPACE=${remoteOWCredentials.namespace}`)
expect(debugProps).toContain(`AUTH=${remoteOWCredentials.auth}`)
expect(debugProps).toContain(`APIHOST=${remoteOWCredentials.apihost}`)
})
}
function runCommonBackendOnlyTests (ref) {
test('should not start a ui server', async () => {
await ref.scripts.runDev()
expect(Bundler.mockConstructor).toHaveBeenCalledTimes(0)
})
test('should generate a vscode config for actions only', async () => {
await ref.scripts.runDev()
expect(JSON.parse(vol.readFileSync('/.vscode/launch.json').toString())).toEqual(expect.objectContaining({
configurations: [
getExpectedActionVSCodeDebugConfig('sample-app-1.0.0/action'),
getExpectedActionVSCodeDebugConfig('sample-app-1.0.0/action-zip')
// fails if ui config
]
}))
})
}
function runCommonWithFrontendTests (ref) {
// eslint-disable-next-line jest/expect-expect
test('should start a ui server', async () => {
const fakeMiddleware = Symbol('fake middleware')
Bundler.mockMiddleware.mockReturnValue(fakeMiddleware)
await ref.scripts.runDev()
expectUIServer(fakeMiddleware, 9080)
})
test('should use https cert/key if passed', async () => {
const options = { parcel: { https: { cert: 'cert.cert', key: 'key.key' } } }
const port = 8888
await ref.scripts.runDev([port], options)
expect(Bundler.mockServe).toHaveBeenCalledWith(port, options.parcel.https)
})
test('should cleanup ui server on SIGINT', async () => {
return new Promise(resolve => {
testCleanupNoErrors(resolve, ref.scripts, () => {
expect(Bundler.mockStop).toHaveBeenCalledTimes(1)
expect(mockUIServerInstance.close).toBeCalledTimes(0) // should not be called directly, b/c terminator does
expect(mockTerminatorInstance.terminate).toBeCalledTimes(1)
expect(httpTerminator.createHttpTerminator).toHaveBeenCalledWith({
server: mockUIServerInstance
})
})
})
})
test('should cleanup ui server on error', async () => {
await testCleanupOnError(ref.scripts, () => {
expect(Bundler.mockStop).toHaveBeenCalledTimes(1)
expect(mockUIServerInstance.close).toBeCalledTimes(0) // should not be called directly, b/c terminator does
expect(mockTerminatorInstance.terminate).toBeCalledTimes(1)
expect(httpTerminator.createHttpTerminator).toHaveBeenCalledWith({
server: mockUIServerInstance
})
})
})
// eslint-disable-next-line jest/no-test-callback
test('should exit with 1 if there is an error in cleanup', async done => {
const theError = new Error('theerror')
Bundler.mockStop.mockRejectedValue(theError)
process.removeAllListeners('SIGINT')
process.exit.mockImplementation(() => {
expect(mockLogger.error).toHaveBeenCalledWith(theError)
expect(process.exit).toHaveBeenCalledWith(1)
done()
})
await ref.scripts.runDev()
expect(process.exit).toHaveBeenCalledTimes(0)
// send cleanup signal
process.emit('SIGINT')
// if test times out => means handler is not calling process.exit
})
test('should return another available port for the UI server if used', async () => {
mockUIServerAddressInstance.port = 9999
const options = { parcel: { https: { cert: 'cert.cert', key: 'key.key' } } }
const resultUrl = await ref.scripts.runDev([8888], options)
expect(Bundler.mockServe).toHaveBeenCalledWith(8888, options.parcel.https)
expect(resultUrl).toBe('https://localhost:9999')
})
test('should return the used ui server port', async () => {
mockUIServerAddressInstance.port = 8888
const options = { parcel: { https: { cert: 'cert.cert', key: 'key.key' } } }
const resultUrl = await ref.scripts.runDev([8888], options)
expect(Bundler.mockServe).toHaveBeenCalledWith(8888, options.parcel.https)
expect(resultUrl).toBe('https://localhost:8888')
})
}
function runCommonLocalTests (ref) {
test('should fail if java is not installed', async () => {
execa.mockImplementation((cmd, args) => {
if (cmd === 'java') {
throw new Error('fake error')
}
return { stdout: jest.fn() }
})
await expect(ref.scripts.runDev()).rejects.toEqual(expect.objectContaining({ message: 'could not find java CLI, please make sure java is installed' }))
})
test('should fail if docker CLI is not installed', async () => {
execa.mockImplementation((cmd, args) => {
if (cmd === 'docker' && args.includes('-v')) {
throw new Error('fake error')
}
return { stdout: jest.fn() }
})
await expect(ref.scripts.runDev()).rejects.toEqual(expect.objectContaining({ message: 'could not find docker CLI, please make sure docker is installed' }))
})
test('should fail if docker is not running', async () => {
execa.mockImplementation((cmd, args) => {
if (cmd === 'docker' && args.includes('info')) {
throw new Error('fake error')
}
return { stdout: jest.fn() }
})
await expect(ref.scripts.runDev()).rejects.toEqual(expect.objectContaining({ message: 'docker is not running, please make sure to start docker' }))
})
test('should download openwhisk-standalone.jar on first usage', async () => {
// there seems to be a bug with memfs streams + mock timeouts
// Error [ERR_UNHANDLED_ERROR]: Unhandled error. (Error: EBADF: bad file descriptor, close)
// so disabling mocks for this test only, with the consequence of taking 2 seconds to run
// !!!! todo fix and use timer mocks to avoid bugs in new tests + performance !!!!
global.setTimeout = actualSetTimeout
Date.now = now
deleteFakeOwJar()
const streamBuffer = ['fake', 'ow', 'jar', null]
const fakeOwJarStream = stream.Readable({
read: function () {
this.push(streamBuffer.shift())
},
emitClose: true
})
fetch.mockResolvedValue({
ok: true,
body: fakeOwJarStream
})
await ref.scripts.runDev()
expect(fetch).toHaveBeenCalledWith(owJarUrl)
expect(vol.existsSync(owJarPath)).toEqual(true)
expect(vol.readFileSync(owJarPath).toString()).toEqual('fakeowjar')
})
test('should fail if downloading openwhisk-standalone.jar creates a stream error', async () => {
// restore timeouts see above
global.setTimeout = actualSetTimeout
Date.now = now
deleteFakeOwJar()
const fakeOwJarStream = stream.Readable({
read: function () {
this.emit('error', new Error('fake stream error'))
},
emitClose: true
})
fetch.mockResolvedValue({
ok: true,
body: fakeOwJarStream
})
await expect(ref.scripts.runDev()).rejects.toThrow('fake stream error')
})
test('should fail when there is a connection error while downloading openwhisk-standalone.jar on first usage', async () => {
deleteFakeOwJar()
fetch.mockRejectedValue(new Error('fake connection error'))
await expect(ref.scripts.runDev()).rejects.toEqual(expect.objectContaining({ message: `connection error while downloading '${owJarUrl}', are you online?` }))
})
test('should fail if fetch fails to download openwhisk-standalone.jar on first usage because of status error', async () => {
deleteFakeOwJar()
fetch.mockResolvedValue({
ok: false,
statusText: 404
})
await expect(ref.scripts.runDev()).rejects.toEqual(expect.objectContaining({ message: `unexpected response while downloading '${owJarUrl}': 404` }))
})
// eslint-disable-next-line jest/expect-expect
test('should build and deploy actions to local ow', async () => {
await ref.scripts.runDev()
expectDevActionBuildAndDeploy(expectedLocalOWConfig)
BuildActions.mockClear()
DeployActions.mockClear()
mockOnProgress.mockClear()
jest.useFakeTimers()
DeployActions.prototype.run.mockImplementation(async () => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({})
}, 2000)
})
})
// First change
onChangeFunc('changed')
// Defensive sleep just to let the onChange handler pass through
await sleep(1)
// Second change
DeployActions.prototype.run.mockImplementation(async () => { throw new Error() })
onChangeFunc('changed')
await jest.runAllTimers()
// Second change should not have resulted in build & deploy yet because first deploy would take 2 secs
expect(BuildActions).toHaveBeenCalledTimes(1)
expect(DeployActions).toHaveBeenCalledTimes(1)
await jest.runAllTimers()
// Defensive sleep just to let the onChange handler pass through
await sleep(1)
// The second call to DeployActions will result in an error because of the second mock above
expect(mockOnProgress).toHaveBeenCalledWith(expect.stringContaining('Stopping'))
expect(BuildActions).toHaveBeenCalledTimes(2)
expect(BuildActions.mock.instances[0].run).toHaveBeenCalledTimes(1)
expect(DeployActions).toHaveBeenCalledTimes(2)
expect(DeployActions.mock.instances[0].run).toHaveBeenCalledTimes(1)
})
test('should create a tmp .env file with local openwhisk credentials if there is no existing .env', async () => {
await ref.scripts.runDev()
expect(vol.existsSync('/.env')).toBe(true)
const dotenvContent = vol.readFileSync('/.env').toString()
expect(dotenvContent).toContain(generateDotenvContent(localOWCredentials))
})
test('should backup an existing .env and create a new .env with local openwhisk credentials', async () => {
vol.writeFileSync('/.env', generateDotenvContent(remoteOWCredentials))
await ref.scripts.runDev()
// 1. make sure the new .env is still written properly
expect(vol.existsSync('/.env')).toBe(true)
const dotenvContent = vol.readFileSync('/.env').toString()
expect(dotenvContent).toContain(generateDotenvContent(localOWCredentials))
// 2. check that saved file has old content
expect(vol.existsSync('/.env.app.save')).toBe(true)
const dotenvSaveContent = vol.readFileSync('/.env.app.save').toString()
expect(dotenvSaveContent).toEqual(generateDotenvContent(remoteOWCredentials))
})
test('should fail backup an existing .env if .env.save already exists', async () => {
vol.writeFileSync('/.env', generateDotenvContent(remoteOWCredentials))
vol.writeFileSync('/.env.app.save', 'fake content')
await expect(ref.scripts.runDev()).rejects.toThrow(`cannot save .env, please make sure to restore and delete ${r('/.env.app.save')}`)
expect(vol.readFileSync('/.env.app.save').toString()).toEqual('fake content')
})
test('should take additional variables from existing .env and plug them into new .env with local openwhisk credentials', async () => {
const dotenvOldContent = generateDotenvContent(remoteOWCredentials) + `
AIO_RUNTIME_MORE=hello
AIO_CNA_TVMURL=yolo
MORE_VAR_1=hello2
`
vol.writeFileSync('/.env', dotenvOldContent)
await ref.scripts.runDev()
// 1. make sure the new .env is still written properly
expect(vol.existsSync('/.env')).toBe(true)
const dotenvContent = vol.readFileSync('/.env').toString()
expect(dotenvContent).toContain(generateDotenvContent(localOWCredentials))
// 2. make sure the new .env include additional variables
expect(dotenvContent).toContain('AIO_RUNTIME_MORE=hello')
expect(dotenvContent).toContain('AIO_CNA_TVMURL=yolo')
expect(dotenvContent).toContain('MORE_VAR_1=hello2')
// 3. check that saved file has old content
expect(vol.existsSync('/.env.app.save')).toBe(true)
const dotenvSaveContent = vol.readFileSync('/.env.app.save').toString()
expect(dotenvSaveContent).toEqual(dotenvOldContent)
})
test('should restore .env file on SIGINT', async () => {
const dotenvOldContent = generateDotenvContent(remoteOWCredentials) + `
AIO_RUNTIME_MORE=hello
AIO_CNA_TVMURL=yolo
MORE_VAR_1=hello2
`
vol.writeFileSync('/.env', dotenvOldContent)
return new Promise(resolve => {
testCleanupNoErrors(resolve, ref.scripts, () => {
expect(vol.existsSync('/.env.app.save')).toBe(false)
expect(vol.existsSync('/.env')).toBe(true)
const dotenvContent = vol.readFileSync('/.env').toString()
expect(dotenvContent).toEqual(dotenvOldContent)
})
})
})
test('should restore .env file on error', async () => {
const dotenvOldContent = generateDotenvContent(remoteOWCredentials) + `
AIO_RUNTIME_MORE=hello
AIO_CNA_TVMURL=yolo
MORE_VAR_1=hello2
`
vol.writeFileSync('/.env', dotenvOldContent)
await testCleanupOnError(ref.scripts, () => {
expect(vol.existsSync('/.env.app.save')).toBe(false)
expect(vol.existsSync('/.env')).toBe(true)
const dotenvContent = vol.readFileSync('/.env').toString()
expect(dotenvContent).toEqual(dotenvOldContent)
})
})
test('should start openwhisk-standalone jar', async () => {
await ref.scripts.runDev()
expect(execa).toHaveBeenCalledWith(...execaLocalOWArgs)
})
test('should kill openwhisk-standalone subprocess on SIGINT', async () => {
const owProcessMockKill = jest.fn()
execa.mockImplementation((cmd, args) => {
if (cmd === 'java' && args.includes('-jar') && args.includes(owJarPath)) {
return {
stdout: jest.fn(),
kill: owProcessMockKill
}
}
return {
stdout: jest.fn(),
kill: jest.fn()
}
})
return new Promise(resolve => {
testCleanupNoErrors(resolve, ref.scripts, () => {
expect(execa).toHaveBeenCalledWith(...execaLocalOWArgs)
expect(owProcessMockKill).toHaveBeenCalledTimes(1)
})
})
})
test('should kill openwhisk-standalone subprocess on error', async () => {
const owProcessMockKill = jest.fn()
execa.mockImplementation((cmd, args) => {
if (cmd === 'java' && args.includes('-jar') && args.includes(owJarPath)) {
return {
stdout: jest.fn(),
kill: owProcessMockKill
}
}
return {
stdout: jest.fn(),
kill: jest.fn()
}
})
await testCleanupOnError(ref.scripts, () => {
expect(execa).toHaveBeenCalledWith(...execaLocalOWArgs)
expect(owProcessMockKill).toHaveBeenCalledTimes(1)
})
})
test('should wait for local openwhisk-standalone jar startup', async () => {
let waitSteps = 4
fetch.mockImplementation(async url => {
if (url === 'http://localhost:3233/api/v1') {
if (waitSteps > 3) {
// fake first call connection error
waitSteps--
throw new Error('connection error')
}
if (waitSteps > 0) {
// fake some calls status error
waitSteps--
return { ok: false }
}
}
return { ok: true }
})
await ref.scripts.runDev()
expect(execa).toHaveBeenCalledWith(...execaLocalOWArgs)
expect(fetch).toHaveBeenCalledWith('http://localhost:3233/api/v1')
expect(fetch).toHaveBeenCalledTimes(5)
expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), waitInitTime) // initial wait
expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), waitPeriodTime) // period wait
expect(setTimeout).toHaveBeenCalledTimes(5)
})
test('should fail if local openwhisk-standalone jar startup takes 61seconds', async () => {
const initialTime = Date.now() // fake Date.now() only increases with setTimeout, see beginning of this file
fetch.mockImplementation(async url => {
if (url === 'http://localhost:3233/api/v1') {
if (Date.now() < initialTime + 61000) return { ok: false }
}
return { ok: true }
})
await expect(ref.scripts.runDev()).rejects.toEqual(expect.objectContaining({ message: 'local openwhisk stack startup timed out: 60000ms' }))
expect(execa).toHaveBeenCalledWith(...execaLocalOWArgs)
expect(fetch).toHaveBeenCalledWith('http://localhost:3233/api/v1')
expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), waitInitTime) // initial wait
expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), waitPeriodTime) // period wait
})
test('should run if local openwhisk-standalone jar startup takes 59seconds', async () => {
const initialTime = Date.now() // fake Date.now() only increases with setTimeout, see beginning of this file
fetch.mockImplementation(async url => {
if (url === 'http://localhost:3233/api/v1') {
if (Date.now() < initialTime + 59000) return { ok: false }
}
return { ok: true }
})
await ref.scripts.runDev()
expect(execa).toHaveBeenCalledWith(...execaLocalOWArgs)
expect(fetch).toHaveBeenCalledWith('http://localhost:3233/api/v1')
expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), waitInitTime) // initial wait
expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), waitPeriodTime) // period wait
})
}
describe('with remote actions and no frontend', () => {
const ref = {}
beforeEach(async () => {
process.env.REMOTE_ACTIONS = 'true'
// remove '/web-src/index.html' file = no ui
ref.scripts = await loadEnvScripts('sample-app', global.fakeConfig.tvm, ['/web-src/index.html'])
ref.appFiles = ['manifest.yml', 'package.json', 'web-src', 'actions'] // still have web-src cause we only delete index.html
})
runCommonTests(ref)
runCommonWithBackendTests(ref)
runCommonRemoteTests(ref)
runCommonBackendOnlyTests(ref)
test('should start a dummy node background process to wait1 on sigint', async () => {
await ref.scripts.runDev()
expect(execa).toHaveBeenCalledWith('node')
})
test('should kill dummy node background process on sigint', async () => {
const mockKill = jest.fn()
execa.mockReturnValue({ kill: mockKill })
await ref.scripts.runDev()
return new Promise(resolve => {
testCleanupNoErrors(resolve, ref.scripts, () => {
expect(mockKill).toHaveBeenCalledTimes(1)
})
})
})
test('should kill dummy node background process on error', async () => {
const mockKill = jest.fn()
execa.mockReturnValue({ kill: mockKill })
await ref.scripts.runDev()
await testCleanupOnError(ref.scripts, () => {
expect(mockKill).toHaveBeenCalledTimes(1)
})
})
})
describe('with remote actions and frontend', () => {
const ref = {}
beforeEach(async () => {
process.env.REMOTE_ACTIONS = 'true'
ref.scripts = await loadEnvScripts('sample-app', global.fakeConfig.tvm)
ref.appFiles = ['manifest.yml', 'package.json', 'web-src', 'actions']
})
runCommonTests(ref)
runCommonRemoteTests(ref)
runCommonWithBackendTests(ref)
runCommonWithFrontendTests(ref)
test('should generate a vscode debug config for actions and web-src', async () => {
mockUIServerAddressInstance.port = 9999
await ref.scripts.runDev()
expect(JSON.parse(vol.readFileSync('/.vscode/launch.json').toString())).toEqual(expect.objectContaining({
configurations: [
getExpectedActionVSCodeDebugConfig('sample-app-1.0.0/action'),
getExpectedActionVSCodeDebugConfig('sample-app-1.0.0/action-zip'),
getExpectedUIVSCodeDebugConfig(9999)
]
}))
})
test('should inject remote action urls into the UI', async () => {
await ref.scripts.runDev()
expect(vol.existsSync('/web-src/src/config.json')).toEqual(true)
const baseUrl = 'https://' + remoteOWCredentials.namespace + '.' + global.defaultOwApiHost.split('https://')[1] + '/api/v1/web/sample-app-1.0.0/'
expect(JSON.parse(vol.readFileSync('/web-src/src/config.json').toString())).toEqual({
action: baseUrl + 'action',
'action-zip': baseUrl + 'action-zip',
'action-sequence': baseUrl + 'action-sequence'
})
})
test('should still inject remote action urls into the UI if skipActions is set', async () => {
await ref.scripts.runDev([], { skipActions: true })
expect(vol.existsSync('/web-src/src/config.json')).toEqual(true)
const baseUrl = 'https://' + remoteOWCredentials.namespace + '.' + global.defaultOwApiHost.split('https://')[1] + '/api/v1/web/sample-app-1.0.0/'
expect(JSON.parse(vol.readFileSync('/web-src/src/config.json').toString())).toEqual({
action: baseUrl + 'action',
'action-zip': baseUrl + 'action-zip',
'action-sequence': baseUrl + 'action-sequence'
})
})
})
describe('with local actions and no frontend', () => {
const ref = {}
beforeEach(async () => {
process.env.REMOTE_ACTIONS = 'false'
ref.scripts = await loadEnvScripts('sample-app', global.fakeConfig.tvm, ['/web-src/index.html'])
ref.appFiles = ['manifest.yml', 'package.json', 'web-src', 'actions'] // still have web-src cause we only delete index.html
// default mocks
// assume ow jar is already downloaded
writeFakeOwJar()
execa.mockReturnValue({
stdout: jest.fn(),
kill: jest.fn()
})
fetch.mockResolvedValue({
ok: true
})
// should expose a new config with local credentials when reloaded in the dev cmd
// we could also not mock aioConfig and expect it to read from .env
mockAIOConfig.get.mockReturnValue(global.fakeConfig.local)
})
runCommonTests(ref)
runCommonWithBackendTests(ref)
runCommonBackendOnlyTests(ref)
runCommonLocalTests(ref)
})
describe('with local actions and frontend', () => {
const ref = {}
beforeEach(async () => {
process.env.REMOTE_ACTIONS = 'false'
ref.scripts = await loadEnvScripts('sample-app', global.fakeConfig.tvm)
ref.appFiles = ['manifest.yml', 'package.json', 'web-src', 'actions']
// default mocks
// assume ow jar is already downloaded
writeFakeOwJar()
execa.mockReturnValue({
stdout: jest.fn(),
kill: jest.fn()
})
fetch.mockResolvedValue({
ok: true
})
// should expose a new config with local credentials when reloaded in the dev cmd
// we could also not mock aioConfig and expect it to read from .env
mockAIOConfig.get.mockReturnValue(global.fakeConfig.local)
})
runCommonTests(ref)
runCommonWithBackendTests(ref)
runCommonWithFrontendTests(ref)
runCommonLocalTests(ref)
test('should generate a vscode debug config for actions and web-src', async () => {
mockUIServerAddressInstance.port = 9999
await ref.scripts.runDev()
expect(JSON.parse(vol.readFileSync('/.vscode/launch.json').toString())).toEqual(expect.objectContaining({
configurations: [
getExpectedActionVSCodeDebugConfig('sample-app-1.0.0/action'),
getExpectedActionVSCodeDebugConfig('sample-app-1.0.0/action-zip'),
getExpectedUIVSCodeDebugConfig(9999)
]
}))
})
test('should inject local action urls into the UI', async () => {
await ref.scripts.runDev()
expect(vol.existsSync('/web-src/src/config.json')).toEqual(true)
const baseUrl = localOWCredentials.apihost + '/api/v1/web/' + localOWCredentials.namespace + '/sample-app-1.0.0/'
expect(JSON.parse(vol.readFileSync('/web-src/src/config.json').toString())).toEqual({
action: baseUrl + 'action',
'action-zip': baseUrl + 'action-zip',
'action-sequence': baseUrl + 'action-sequence'
})
})
test('should inject REMOTE action urls into the UI if skipActions is set', async () => {
await ref.scripts.runDev([], { skipActions: true })
expect(vol.existsSync('/web-src/src/config.json')).toEqual(true)
const baseUrl = 'https://' + remoteOWCredentials.namespace + '.' + global.defaultOwApiHost.split('https://')[1] + '/api/v1/web/sample-app-1.0.0/'
expect(JSON.parse(vol.readFileSync('/web-src/src/config.json').toString())).toEqual({
action: baseUrl + 'action',
'action-zip': baseUrl + 'action-zip',
'action-sequence': baseUrl + 'action-sequence'
})
})
})
describe('with frontend only', () => {
const ref = {}
beforeEach(async () => {
// exclude manifest file = backend only (should we make a fixture app without actions/ as well?)
ref.scripts = await loadEnvScripts('sample-app', global.fakeConfig.tvm, ['./manifest.yml'])
ref.appFiles = ['package.json', 'web-src', 'actions'] // still have actions cause we only delete manifest.yml
})
runCommonTests(ref)
runCommonWithFrontendTests(ref)
test('should set hasBackend=false', async () => {
expect(ref.scripts._config.app.hasBackend).toBe(false)
})
// eslint-disable-next-line jest/expect-expect
test('should start a ui server', async () => {
await ref.scripts.runDev()
expectUIServer(null, 9080)
})
test('should not call build and deploy', async () => {
await ref.scripts.runDev()
// build & deploy constructor have been called once to init the scripts
// here we make sure run has not been calle
expect(BuildActions.mock.instances[0].run).toHaveBeenCalledTimes(0)
expect(DeployActions.mock.instances[0].run).toHaveBeenCalledTimes(0)
expect(BuildActions.mock.instances[1]).toBeUndefined()
expect(DeployActions.mock.instances[1]).toBeUndefined()
})
test('should generate a vscode config for ui only', async () => {
mockUIServerAddressInstance.port = 9999
await ref.scripts.runDev()
expect(JSON.parse(vol.readFileSync('/.vscode/launch.json').toString())).toEqual(expect.objectContaining({
configurations: [
getExpectedUIVSCodeDebugConfig(9999)
]
}))
})
test('should create config.json = {}', async () => {
await ref.scripts.runDev()
expect(vol.existsSync('/web-src/src/config.json')).toEqual(true)
expect(JSON.parse(vol.readFileSync('/web-src/src/config.json').toString())).toEqual({})
})
})
// Note: these tests can be safely deleted once the require-adobe-auth is
// natively supported in Adobe I/O Runtime.
test('vscode wskdebug config with require-adobe-auth annotation && apihost=https://adobeioruntime.net', async () => {
// create test app
global.loadFs(vol, 'sample-app')
vol.unlinkSync('web-src/index.html')
mockAIOConfig.get.mockReturnValue(global.fakeConfig.tvm)
process.env.REMOTE_ACTIONS = 'true'
const scripts = AppScripts({})
// avoid recreating a new fixture
scripts._config.manifest.package.actions.action.annotations = { 'require-adobe-auth': true }
scripts._config.ow.apihost = 'https://adobeioruntime.net'
await scripts.runDev()
expect(JSON.parse(vol.readFileSync('/.vscode/launch.json').toString())).toEqual(expect.objectContaining({
configurations: expect.arrayContaining([
expect.objectContaining({
type: 'node',
request: 'launch',
name: 'Action:' + 'sample-app-1.0.0/action',
runtimeExecutable: r('/node_modules/.bin/wskdebug'),
runtimeArgs: [
'sample-app-1.0.0/__secured_action',
r('actions/action.js'),
'-v',
'--kind',
'nodejs:12'
],
env: { WSK_CONFIG_FILE: r('/.wskdebug.props.tmp') },
localRoot: r('/'),
remoteRoot: '/code'
})
])
}))
})
test('vscode wskdebug config with require-adobe-auth annotation && apihost!=https://adobeioruntime.net', async () => {
// create test app
global.loadFs(vol, 'sample-app')
vol.unlinkSync('web-src/index.html')
mockAIOConfig.get.mockReturnValue(global.fakeConfig.tvm)
process.env.REMOTE_ACTIONS = 'true'
const scripts = AppScripts({})
// avoid recreating a new fixture
scripts._config.manifest.package.actions.action.annotations = { 'require-adobe-auth': true }
scripts._config.ow.apihost = 'https://notadobeioruntime.net'
await scripts.runDev()
expect(JSON.parse(vol.readFileSync('/.vscode/launch.json').toString())).toEqual(expect.objectContaining({
configurations: expect.arrayContaining([
expect.objectContaining({
type: 'node',
request: 'launch',
name: 'Action:' + 'sample-app-1.0.0/action',
runtimeExecutable: r('/node_modules/.bin/wskdebug'),
runtimeArgs: [
'sample-app-1.0.0/action',
r('actions/action.js'),
'-v',
'--kind',
'nodejs:12'
],
env: { WSK_CONFIG_FILE: r('/.wskdebug.props.tmp') },
localRoot: r('/'),
remoteRoot: '/code'
})
])
}))
})
test('vscode wskdebug config without runtime option', async () => {
// create test app
global.loadFs(vol, 'sample-app')
vol.unlinkSync('web-src/index.html')
mockAIOConfig.get.mockReturnValue(global.fakeConfig.tvm)
process.env.REMOTE_ACTIONS = 'true'
const scripts = AppScripts({})
// avoid recreating a new fixture
delete scripts._config.manifest.package.actions.action.runtime
await scripts.runDev()
expect(JSON.parse(vol.readFileSync('/.vscode/launch.json').toString())).toEqual(expect.objectContaining({
configurations: expect.arrayContaining([
expect.objectContaining({
type: 'node',
request: 'launch',
name: 'Action:' + 'sample-app-1.0.0/action',
runtimeExecutable: r('/node_modules/.bin/wskdebug'),
runtimeArgs: [
'sample-app-1.0.0/action',
r('actions/action.js'),
'-v'
// no kind
],
env: { WSK_CONFIG_FILE: r('/.wskdebug.props.tmp') },
localRoot: r('/'),
remoteRoot: '/code'
})
])
}))
})