diff --git a/bin/askx.js b/bin/askx.js index 2cb33e64..39d977f3 100755 --- a/bin/askx.js +++ b/bin/askx.js @@ -14,6 +14,7 @@ require('@src/commands/configure').createCommand(commander); require('@src/commands/deploy').createCommand(commander); require('@src/commands/v2new').createCommand(commander); require('@src/commands/init').createCommand(commander); +require('@src/commands/dialog').createCommand(commander); commander .description('Command Line Interface for Alexa Skill Kit') @@ -22,7 +23,7 @@ commander .version(require('../package.json').version) .parse(process.argv); -const ALLOWED_ASK_ARGV_2 = ['api', 'configure', 'deploy', 'new', 'init', 'util', 'help', '-v', '--version', '-h', '--help']; +const ALLOWED_ASK_ARGV_2 = ['api', 'configure', 'deploy', 'new', 'init', 'dialog', 'util', 'help', '-v', '--version', '-h', '--help']; if (process.argv[2] && ALLOWED_ASK_ARGV_2.indexOf(process.argv[2]) === -1) { console.log('Command not recognized. Please run "askx" to check the user instructions.'); } diff --git a/lib/commands/dialog/helper.js b/lib/commands/dialog/helper.js new file mode 100644 index 00000000..12ff7ea0 --- /dev/null +++ b/lib/commands/dialog/helper.js @@ -0,0 +1,62 @@ +const R = require('ramda'); + +module.exports = { + validateDialogArgs +}; + +/** + * Validates if a skill is enabled for simulation. Calls Skill Management apis (SMAPI) to achieve this. + * @param {*} dialogMode encapsulates configuration required validate skill information + * @param {*} callback + */ +function validateDialogArgs(dialogMode, callback) { + const { smapiClient, skillId, stage, locale } = dialogMode; + + smapiClient.skill.manifest.getManifest(skillId, stage, (err, response) => { + if (err) { + return callback(err); + } + if (response.statusCode !== 200) { + return callback(smapiErrorMsg('get-manifest', response)); + } + const apis = R.view(R.lensPath(['body', 'manifest', 'apis']), response); + if (!apis) { + return callback('Ensure "manifest.apis" object exists in the skill manifest.'); + } + + const apisKeys = Object.keys(apis); + if (!apisKeys || apisKeys.length !== 1) { + return callback('Dialog command only supports custom skill type.'); + } + + if (apisKeys[0] !== 'custom') { + return callback(`Dialog command only supports custom skill type, but current skill is a "${apisKeys[0]}" type.`); + } + + const locales = R.view(R.lensPath(['body', 'manifest', 'publishingInformation', 'locales']), response); + if (!locales) { + return callback('Ensure the "manifest.publishingInformation.locales" exists in the skill manifest before simulating your skill.'); + } + + if (!R.view(R.lensProp(locale), locales)) { + return callback( + `Locale ${locale} was not found for your skill. ` + + 'Ensure the locale you want to simulate exists in your publishingInformation.' + ); + } + + smapiClient.skill.getSkillEnablement(skillId, stage, (enableErr, enableResponse) => { + if (enableErr) { + return callback(enableErr); + } + if (enableResponse.statusCode > 300) { + return callback(smapiErrorMsg('get-skill-enablement', enableResponse)); + } + callback(); + }); + }); +} + +function smapiErrorMsg(operation, res) { + return `SMAPI ${operation} request error: ${res.statusCode} - ${res.body.message}`; +} diff --git a/lib/commands/dialog/index.js b/lib/commands/dialog/index.js new file mode 100644 index 00000000..db666d7a --- /dev/null +++ b/lib/commands/dialog/index.js @@ -0,0 +1,128 @@ +const path = require('path'); + +const SmapiClient = require('@src/clients/smapi-client'); +const { AbstractCommand } = require('@src/commands/abstract-command'); +const optionModel = require('@src/commands/option-model'); +const DialogReplayFile = require('@src/model/dialog-replay-file'); +const ResourcesConfig = require('@src/model/resources-config'); +const CONSTANTS = require('@src/utils/constants'); +const profileHelper = require('@src/utils/profile-helper'); +const Messenger = require('@src/view/messenger'); +const SpinnerView = require('@src/view/spinner-view'); +const stringUtils = require('@src/utils/string-utils'); + +const InteractiveMode = require('./interactive-mode'); +const ReplayMode = require('./replay-mode'); + +const helper = require('./helper'); + +class DialogCommand extends AbstractCommand { + name() { + return 'dialog'; + } + + description() { + return 'simulate your skill via an interactive dialog with Alexa'; + } + + requiredOptions() { + return []; + } + + optionalOptions() { + return ['skill-id', 'locale', 'stage', 'replay', 'profile', 'debug']; + } + + handle(cmd, cb) { + let dialogMode; + try { + dialogMode = this._dialogModeFactory(this._getDialogConfig(cmd)); + } catch (err) { + Messenger.getInstance().error(err); + return cb(err); + } + + const spinner = new SpinnerView(); + spinner.start('Checking if skill is ready to simulate...'); + helper.validateDialogArgs(dialogMode, (dialogArgsValidationError) => { + if (dialogArgsValidationError) { + spinner.terminate(SpinnerView.TERMINATE_STYLE.FAIL, 'Failed to validate command options'); + Messenger.getInstance().error(dialogArgsValidationError); + return cb(dialogArgsValidationError); + } + spinner.terminate(); + dialogMode.start((controllerError) => { + if (controllerError) { + Messenger.getInstance().error(controllerError); + return cb(controllerError); + } + cb(); + }); + }); + } + + /** + * Function processes dialog arguments and returns a consolidated object. + * @param {Object} cmd encapsulates arguments provided to the dialog command. + * @return { skillId, locale, stage, profile, debug, replayFile, smapiClient, userInputs } + */ + _getDialogConfig(cmd) { + const debug = Boolean(cmd.debug); + let { skillId, locale, stage, profile } = cmd; + profile = profileHelper.runtimeProfile(profile); + stage = stage || CONSTANTS.SKILL.STAGE.DEVELOPMENT; + let userInputs; + if (cmd.replay) { + const dialogReplayConfig = new DialogReplayFile(cmd.replay); + skillId = dialogReplayConfig.getSkillId(); + if (!stringUtils.isNonBlankString(skillId)) { + throw 'Replay file must contain skillId'; + } + locale = dialogReplayConfig.getLocale(); + if (!stringUtils.isNonBlankString(locale)) { + throw 'Replay file must contain locale'; + } + userInputs = this._validateUserInputs(dialogReplayConfig.getUserInput()); + } else { + if (!stringUtils.isNonBlankString(skillId)) { + try { + new ResourcesConfig(path.join(process.cwd(), CONSTANTS.FILE_PATH.ASK_RESOURCES_JSON_CONFIG)); + skillId = ResourcesConfig.getInstance().getSkillId(profile); + } catch (err) { + throw 'Failed to read project resource file.'; + } + if (!stringUtils.isNonBlankString(skillId)) { + throw `Failed to obtain skill-id from project resource file ${CONSTANTS.FILE_PATH.ASK_RESOURCES_JSON_CONFIG}`; + } + } + locale = locale || process.env.ASK_DEFAULT_DEVICE_LOCALE; + if (!stringUtils.isNonBlankString(locale)) { + throw 'Locale has not been specified.'; + } + } + const smapiClient = new SmapiClient({ profile, doDebug: debug }); + return { skillId, locale, stage, profile, debug, replay: cmd.replay, smapiClient, userInputs }; + } + + _dialogModeFactory(config) { + if (config.replay) { + return new ReplayMode(config); + } + return new InteractiveMode(config); + } + + _validateUserInputs(userInputs) { + const validatedInputs = []; + userInputs.forEach((input) => { + const trimmedInput = input.trim(); + if (!stringUtils.isNonBlankString(trimmedInput)) { + throw "Replay file's userInput cannot contain empty string."; + } + validatedInputs.push(trimmedInput); + }); + return validatedInputs; + } +} + +module.exports = DialogCommand; +module.exports.createCommand = new DialogCommand(optionModel).createCommand(); diff --git a/lib/commands/dialog/interactive-mode.js b/lib/commands/dialog/interactive-mode.js new file mode 100644 index 00000000..5b29c6f9 --- /dev/null +++ b/lib/commands/dialog/interactive-mode.js @@ -0,0 +1,35 @@ +const chalk = require('chalk'); + +const DialogReplView = require('@src/view/dialog-repl-view'); +const DialogController = require('@src/controllers/dialog-controller'); + +module.exports = class InteractiveMode extends DialogController { + constructor(config) { + super(config); + this.header = config.header || 'Welcome to ASK Dialog\n' + + 'In interactive mode, type your utterance text onto the console and hit enter\n' + + 'Alexa will then evaluate your input and give a response!'; + } + + start(callback) { + let interactiveReplView; + try { + interactiveReplView = new DialogReplView({ + prompt: chalk.yellow.bold('User > '), + header: this.header, + footer: 'Goodbye!', + evalFunc: (input, replCallback) => { + this.evaluateUtterance(input, interactiveReplView, replCallback); + }, + }); + } catch (error) { + return callback(error); + } + + this.setupSpecialCommands(interactiveReplView, (error) => { + if (error) { + return callback(error); + } + }); + } +}; diff --git a/lib/commands/dialog/replay-mode.js b/lib/commands/dialog/replay-mode.js new file mode 100644 index 00000000..f5d50fb2 --- /dev/null +++ b/lib/commands/dialog/replay-mode.js @@ -0,0 +1,62 @@ +const chalk = require('chalk'); +const { Readable } = require('stream'); + +const DialogController = require('@src/controllers/dialog-controller'); +const DialogReplView = require('@src/view/dialog-repl-view'); +const InteractiveMode = require('./interactive-mode'); + +module.exports = class ReplayMode extends DialogController { + constructor(config) { + super(config); + this.config = config; + this.userInputs = config.userInputs; + this.replay = config.replay; + } + + start(callback) { + const replayInputStream = new Readable({ read() {} }); + let replayReplView; + try { + replayReplView = new DialogReplView({ + prompt: chalk.yellow.bold('User > '), + header: this._getHeader(), + footer: 'Goodbye!', + inputStream: replayInputStream, + evalFunc: (replayInput, replayCallback) => { + this._evaluateInput(replayInput, replayReplView, replayInputStream, replayCallback, callback); + } + }); + } catch (error) { + return callback(error); + } + + this.setupSpecialCommands(replayReplView, (error) => { + if (error) { + return callback(error); + } + }); + replayInputStream.push(`${this.userInputs.shift()}\n`, 'utf8'); + } + + _getHeader() { + return 'Welcome to ASK Dialog\n' + + `Replaying a multi turn conversation with Alexa from ${this.replay}\n` + + 'Alexa will then evaluate your input and give a response!'; + } + + _evaluateInput(replayInput, replayReplView, replayInputStream, replayCallback, callback) { + this.evaluateUtterance(replayInput, replayReplView, () => { + if (this.userInputs.length > 0) { + replayCallback(); + replayInputStream.push(`${this.userInputs.shift()}\n`, 'utf8'); + } else { + replayReplView.clearSpecialCommands(); + replayReplView.close(); + this.config.header = 'Switching to interactive dialog.\n' + + 'To automatically quit after replay, append \'.quit\' to the userInput of your replay file.'; + const interactiveReplView = new InteractiveMode(this.config); + interactiveReplView.start(callback); + } + }); + } +}; diff --git a/lib/commands/option-model.json b/lib/commands/option-model.json index a0fe89c8..bee39ad3 100644 --- a/lib/commands/option-model.json +++ b/lib/commands/option-model.json @@ -29,6 +29,16 @@ "alias": "s", "stringInput": "REQUIRED" }, + "replay": { + "name": "replay", + "description": "Specify a replay file to simulate dialog with Alexa", + "alias": "r", + "stringInput": "REQUIRED", + "rule": [{ + "type": "REGEX", + "regex": "^[./]?[\\w\\-. /]+\\.(json)" + }] + }, "isp-id": { "name": "isp-id", "description": "isp-id for the in skill product", @@ -374,7 +384,7 @@ "stringInput": "REQUIRED", "rule": [{ "type": "ENUM", - "values": ["uniqueCustomers", "totalEnablements", "totalUtterances", "successfulUtterances", "failedUtterances", "totalSessions", + "values": ["uniqueCustomers", "totalEnablements", "totalUtterances", "successfulUtterances", "failedUtterances", "totalSessions", "successfulSessions", "incompleteSessions", "userEndedSessions", "skillEndedSessions"] }] }, diff --git a/lib/controllers/dialog-controller/index.js b/lib/controllers/dialog-controller/index.js new file mode 100644 index 00000000..307c0070 --- /dev/null +++ b/lib/controllers/dialog-controller/index.js @@ -0,0 +1,150 @@ +const chalk = require('chalk'); +const fs = require('fs-extra'); +const R = require('ramda'); +const path = require('path'); + +const stringUtils = require('@src/utils/string-utils'); +const Messenger = require('@src/view/messenger'); +const responseParser = require('@src/controllers/dialog-controller/simulation-response-parser'); +const SkillSimulationController = require('@src/controllers/skill-simulation-controller'); + +module.exports = class DialogController extends SkillSimulationController { + /** + * Constructor for DialogModeController. + * @param {Object} configuration | config object includes information such as skillId, locale, profile, stage. + */ + constructor(configuration) { + super(configuration); + this.newSession = true; + this.utteranceCache = []; + } + + /** + * Evaluate individual utterance input by the User/replay_file. + * @param {String} input Utterance by the user sent to Alexa. + * @param {Object} replView Dialog command's repl view. + * @param {Function} replCallback + */ + evaluateUtterance(input, replView, replCallback) { + replView.startProgressSpinner('Sending simulation request to Alexa...'); + + this.startSkillSimulation(input.trim(), (startErr, startResponse) => { + if (startErr) { + replView.terminateProgressSpinner(); + Messenger.getInstance().error(startErr); + replCallback(); + } else if (startResponse.statusCode >= 300) { + replView.terminateProgressSpinner(); + Messenger.getInstance().error(R.view(R.lensPath(['body', 'error', 'message']), startResponse)); + replCallback(); + } else { + replView.updateProgressSpinner('Waiting for the simulation response...'); + const simulationId = R.view(R.lensPath(['body', 'id']), startResponse); + + this.getSkillSimulationResult(simulationId, (getErr, getResponse) => { + replView.terminateProgressSpinner(); + if (getErr) { + Messenger.getInstance().error(getErr); + } else { + if (responseParser.shouldEndSession(getResponse.body)) { + Messenger.getInstance().info('Session ended'); + this.clearSession(); + } + const captions = responseParser.getCaption(getResponse.body); + captions.forEach((caption) => { + Messenger.getInstance().info(chalk.yellow.bold('Alexa > ') + caption); + }); + } + replCallback(); + }); + } + }); + } + + /** + * Registers special commands with the REPL server. + * @param {Object} dialogReplView dialog command's repl view. + * @param {Function} callback + */ + setupSpecialCommands(dialogReplView, callback) { + dialogReplView.registerRecordCommand((filePath) => { + if (!stringUtils.isNonBlankString(filePath)) { + return callback('A file name has not been specified'); + } + const JSON_FILE_EXTENSION = '.json'; + if (path.extname(filePath).toLowerCase() !== JSON_FILE_EXTENSION) { + return callback(`File should be of type '${JSON_FILE_EXTENSION}'`); + } + try { + this.createReplayFile(filePath); + Messenger.getInstance().info(`Created replay file at ${filePath}`); + } catch (err) { + return callback(err); + } + }); + dialogReplView.registerQuitCommand(() => {}); + } + + /** + * Start skill simulation by calling SMAPI POST skill simulation endpoint. + * @param {String} utterance text utterance to simulate against. + * @param {Function} onSuccess callback to execute upon a successful request. + * @param {Function} onError callback to execute upon a failed request. + */ + startSkillSimulation(utterance, callback) { + super.startSkillSimulation( + utterance, + this.newSession, + (err, response) => { + if (response) { + this.utteranceCache.push(utterance); + } + return callback(err, response); + } + ); + } + + /** + * Poll for skill simulation results. + * @param {String} simulationId simulation ID associated to the current simulation. + * @param {Function} onSuccess callback to execute upon a successful request. + * @param {Function} onError callback to execute upon a failed request. + */ + getSkillSimulationResult(simulationId, callback) { + super.getSkillSimulationResult(simulationId, (err, response) => { + if (err) { + return callback(err); + } + const errorMsg = responseParser.getErrorMessage(response.body); + if (errorMsg) { + return callback(errorMsg); + } + this.newSession = false; + return callback(null, response); + }); + } + + /** + * Clears dialog session by resetting to a new session and clearing caches. + */ + clearSession() { + this.newSession = true; + this.utteranceCache = []; + } + + /** + * Function to create replay file. + * @param {String} filename name of file to save replay JSON. + */ + createReplayFile(filename) { + if (stringUtils.isNonBlankString(filename)) { + const content = { + skillId: this.skillId, + locale: this.locale, + type: 'text', + userInput: this.utteranceCache + }; + fs.outputJSONSync(filename, content); + } + } +}; diff --git a/lib/controllers/dialog-controller/simulation-response-parser.js b/lib/controllers/dialog-controller/simulation-response-parser.js new file mode 100644 index 00000000..763ae705 --- /dev/null +++ b/lib/controllers/dialog-controller/simulation-response-parser.js @@ -0,0 +1,84 @@ +const R = require('ramda'); + +module.exports = { + getJsonInputAndOutputs, + shouldEndSession, + getConsideredIntents, + getErrorMessage, + getCaption, + getStatus, + getSimulationId +}; + +function getConsideredIntents(response) { + const consideredIntents = R.view(R.lensPath(['result', 'alexaExecutionInfo', 'consideredIntents']), response); + + if (!consideredIntents) { + return []; + } + return consideredIntents; +} + +function getJsonInputAndOutputs(response) { + const invocations = R.view(R.lensPath(['result', 'skillExecutionInfo', 'invocations']), response); + + if (!invocations) { + return []; + } + + const jsonInputs = invocations.map((invocation) => { + const invocationRequest = R.view(R.lensPath(['invocationRequest', 'body']), invocation); + return invocationRequest || { }; + }); + + const jsonOutputs = invocations.map((invocation) => { + const invocationResponse = R.view(R.lensPath(['invocationResponse']), invocation); + return invocationResponse || { }; + }); + + const result = []; + for (let i = 0; i < jsonInputs.length; i++) { + const io = { + jsonInput: jsonInputs[i], + jsonOutput: i < jsonOutputs.length ? jsonOutputs[i] : { } + }; + result.push(io); + } + + return result; +} + +function shouldEndSession(response) { + const invocations = R.view(R.lensPath(['result', 'skillExecutionInfo', 'invocations']), response); + + if (!invocations) { + return false; + } + + for (const invocation of invocations) { + if (R.view(R.lensPath(['invocationResponse', 'body', 'response', 'shouldEndSession']), invocation)) { + return true; + } + } + return false; +} + +function getErrorMessage(response) { + return R.view(R.lensPath(['result', 'error', 'message']), response); +} + +function getCaption(response) { + const alexaResponses = R.view(R.lensPath(['result', 'alexaExecutionInfo', 'alexaResponses']), response); + if (!alexaResponses) { + return []; + } + return alexaResponses.map(element => R.view(R.lensPath(['content', 'caption']), element)); +} + +function getStatus(response) { + return R.view(R.lensPath(['status']), response); +} + +function getSimulationId(response) { + return R.view(R.lensPath(['id']), response); +} diff --git a/lib/controllers/skill-simulation-controller/index.js b/lib/controllers/skill-simulation-controller/index.js new file mode 100644 index 00000000..7067f389 --- /dev/null +++ b/lib/controllers/skill-simulation-controller/index.js @@ -0,0 +1,82 @@ +const R = require('ramda'); +const SmapiClient = require('@src/clients/smapi-client'); +const jsonView = require('@src/view/json-view'); +const CONSTANTS = require('@src/utils/constants'); +const Retry = require('@src/utils/retry-utility'); + +module.exports = class SkillSimulationController { + /** + * Constructor for SkillSimulationController + * @param {Object} configuration { profile, doDebug } + * @throws {Error} if configuration is invalid for dialog. + */ + constructor(configuration) { + if (configuration === undefined) { + throw 'Cannot have an undefined configuration.'; + } + const { skillId, locale, stage, profile, debug, smapiClient } = configuration; + this.profile = profile; + this.doDebug = debug; + this.smapiClient = smapiClient || new SmapiClient({ profile: this.profile, doDebug: this.doDebug }); + this.skillId = skillId; + this.locale = locale; + this.stage = stage; + } + + /** + * Start skill simulation by calling SMAPI POST skill simulation + * @param {String} utterance text utterance to simulate against. + * @param {Boolean} newSession Boolean to specify to FORCE_NEW_SESSION + * @param {Function} callback callback to execute upon a response. + */ + startSkillSimulation(utterance, newSession, callback) { + this.smapiClient.skill.test.simulateSkill(this.skillId, this.stage, utterance, newSession, this.locale, (err, res) => { + if (err) { + return callback(err); + } + if (res.statusCode >= 300) { + return callback(jsonView.toString(res.body)); + } + callback(err, res); + }); + } + + /** + * Poll for skill simulation results. + * @todo Implement timeout. + * @param {String} simulationId simulation ID associated to the current simulation. + * @param {Function} callback function to execute upon a response. + */ + getSkillSimulationResult(simulationId, callback) { + const retryConfig = { + factor: CONSTANTS.CONFIGURATION.RETRY.GET_SIMULATE_STATUS.FACTOR, + maxRetry: CONSTANTS.CONFIGURATION.RETRY.GET_SIMULATE_STATUS.MAX_RETRY, + base: CONSTANTS.CONFIGURATION.RETRY.GET_SIMULATE_STATUS.MIN_TIME_OUT + }; + const retryCall = (loopCallback) => { + this.smapiClient.skill.test.getSimulation(this.skillId, simulationId, this.stage, (pollErr, pollResponse) => { + if (pollErr) { + return loopCallback(pollErr); + } + if (pollResponse.statusCode >= 300) { + return loopCallback(jsonView.toString(pollResponse.body)); + } + loopCallback(null, pollResponse); + }); + }; + const shouldRetryCondition = (retryResponse) => { + const status = R.view(R.lensPath(['body', 'status']), retryResponse); + return !status || status === CONSTANTS.SKILL.SIMULATION_STATUS.IN_PROGRESS; + }; + Retry.retry(retryConfig, retryCall, shouldRetryCondition, (err, res) => { + if (err) { + return callback(err); + } + if (!res.body.status) { + return callback(`Failed to get status for simulation id: ${simulationId}.` + + 'Please run again using --debug for more details.'); + } + return callback(null, res); + }); + } +}; diff --git a/lib/model/dialog-replay-file.js b/lib/model/dialog-replay-file.js new file mode 100644 index 00000000..5429672b --- /dev/null +++ b/lib/model/dialog-replay-file.js @@ -0,0 +1,131 @@ +const fs = require('fs'); +const path = require('path'); +const R = require('ramda'); + +const CliFileNotFoundError = require('@src/exceptions/cli-file-not-found-error'); +const yaml = require('@src/model/yaml-parser'); +const jsonView = require('@src/view/json-view'); + +module.exports = class DialogReplayFile { + /** + * Constructor for GlobalConfig class + * @param {string} filePath + * @throws {Error} + */ + constructor(filePath) { + this.filePath = filePath; + this.content = this.readFileContent(this.filePath); + } + + // Getters and Setters + + getSkillId() { + return this.getProperty(['skillId']); + } + + setSkillId(skillId) { + return this.setProperty(['skillId'], skillId); + } + + getLocale() { + return this.getProperty(['locale']); + } + + setLocale(locale) { + return this.setProperty(['locale'], locale); + } + + getType() { + return this.getProperty(['type']); + } + + setType(type) { + return this.setProperty(['type'], type); + } + + getUserInput() { + return this.getProperty(['userInput']); + } + + setUserInput(userInput) { + return this.setProperty(['userInput'], userInput); + } + + // TODO: move these operations to a model interface since replay file doesn't support yaml files currently. + + /** + * Reads contents of a given file. Currently supports files of the following types: .json, .yaml and .yml + * Throws error if filePath is invalid, file does not have read permissions or is of unsupported file type. + * @param {String} filePath path to the given file. + */ + readFileContent(filePath) { + let fileType; + try { + fileType = path.extname(filePath).toLowerCase(); + this.doesFileExist(filePath); + fs.accessSync(filePath, fs.constants.R_OK); + if (fileType === '.json') { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } + if (fileType === '.yaml' || fileType === '.yml') { + return yaml.load(filePath); + } + throw new Error('ASK CLI does not support this file type.'); + } catch (error) { + throw `Failed to parse ${fileType} file ${filePath}.\n${error.message}`; + } + } + + /** + * Writes contents to a given file. Currently supports files of the following types: .json, .yaml and .yml + * Throws error if filePath is invalid, file does not have write permissions or is of unsupported file type. + * @param {Object} content data to ve written to the file + * @param {String} filePath path to the given file. + */ + writeContentToFile(content, filePath) { + try { + this.doesFileExist(filePath); + fs.accessSync(filePath, fs.constants.W_OK); + const fileType = path.extname(filePath).toLowerCase(); + if (fileType === '.json') { + fs.writeFileSync(filePath, jsonView.toString(content), 'utf-8'); + } else if (fileType === '.yaml' || fileType === '.yml') { + yaml.dump(filePath, content); + } else { + throw new Error('ASK CLI does not support this file type.'); + } + } catch (error) { + throw `Failed to write to file ${filePath}.\n${error.message}`; + } + } + + /** + * Check if the file exists on the given path. Throws error if it doesn't exist. + * @param {String} filePath path to the given file. + */ + doesFileExist(filePath) { + if (!fs.existsSync(filePath)) { + throw new CliFileNotFoundError(`File ${filePath} not exists.`); + } + } + + // TODO: these two methods can be in jsonView since we are reading/modifying JSON content + /** + * Get property based on the property array. + * Return undefined if not found. + * @param {string} pathArray e.g. ['path', 'to', 'the', '3rd', 'object', 2, 'done'] + */ + getProperty(pathArray) { + return R.view(R.lensPath(pathArray), this.content); + } + + /** + * Set property to the runtime object based on the property array. + * Create field if path does not exist. + * @param {string} pathArray + * @param {string} newValue + */ + setProperty(pathArray, newValue) { + this.content = R.set(R.lensPath(pathArray), newValue, this.content); + } +}; diff --git a/lib/utils/constants.js b/lib/utils/constants.js index 34668e7d..7d4c77a3 100644 --- a/lib/utils/constants.js +++ b/lib/utils/constants.js @@ -31,6 +31,11 @@ module.exports.SKILL = { FAILURE: 'FAILED', IN_PROGRESS: 'IN_PROGRESS' }, + SIMULATION_STATUS: { + SUCCESS: 'SUCCESSFUL', + FAILURE: 'FAILED', + IN_PROGRESS: 'IN_PROGRESS' + }, DOMAIN: { CAN_ENABLE_DOMAIN_LIST: ['custom', 'music'] } diff --git a/lib/view/cli-repl-view.js b/lib/view/cli-repl-view.js new file mode 100644 index 00000000..7fac3bbc --- /dev/null +++ b/lib/view/cli-repl-view.js @@ -0,0 +1,135 @@ +const repl = require('repl'); +const SpinnerView = require('@src/view/spinner-view'); +const Messenger = require('@src/view/messenger'); +const stringUtils = require('@src/utils/string-utils'); + +module.exports = class CliReplView { + /** + * Constructor for CLI REPL View + * @param {String} prompt What string to display as the prompt for the REPL. + * @throws {Error} throws error if an undefined configuration is passed. + * @throws {Error} throws error if prompt is not a non-empty string. + */ + constructor(configuration) { + if (!configuration) { + throw 'Cannot have an undefined configuration.'; + } + const { prompt, evalFunc, header, footer, prettifyHeaderFooter, inputStream } = configuration; + this.prompt = prompt || '> '; + this.eval = evalFunc; + this.header = header; + this.footer = footer; + this.prettifyHeaderFooter = prettifyHeaderFooter || (arg => arg); + this.inputStream = inputStream || process.stdin; + this.progressSpinner = new SpinnerView(); + if (!stringUtils.isNonEmptyString(this.prompt)) { + throw 'Prompt must be a non-empty string.'; + } + this.printHeaderFooter(this.header); + // Initialize custom REPL server. + const replConfig = { + prompt: this.prompt, + eval: (cmd, context, filename, callback) => { + this.eval(cmd, callback); + }, + ignoreUndefined: true, + input: this.inputStream, + output: process.stdout + }; + this.replServer = repl.start(replConfig); + this.replServer.removeAllListeners('SIGINT'); + this.clearSpecialCommands(); + this.registerQuitCommand(() => {}); + } + + /** + * Function to print a header or footer line. + * @param {String} data the string that contains the information the caller whats to embed into header/footer. + */ + printHeaderFooter(data) { + if (stringUtils.isNonEmptyString(data)) { + Messenger.getInstance().info(this.prettifyHeaderFooter(data)); + } + } + + /** + * Register a special command to the repl + * @param {String} name name of new special command. + * @param {String} name name of new special command. + * @param {String} helpMessage description of special command + * @param {Function} func function to execute when special command received + * @param {Boolean} displayPrompt specify whether or not to display the prompt after the special command executes. Default: true + */ + registerSpecialCommand(name, helpMessage, func, displayPrompt) { + const shouldDisplayPrompt = displayPrompt === undefined ? true : displayPrompt; + this.replServer.defineCommand(name, { + help: helpMessage || this.replServer.commands[name].help, + action: (args) => { + func(args); + if (shouldDisplayPrompt) this.replServer.displayPrompt(); + } + }); + } + + /** + * Register a special exit command to the repl + * @param {Function} func function to execute when special command received + */ + registerQuitCommand(func) { + this.replServer.removeAllListeners('close'); + this.registerSpecialCommand( + 'quit', + 'Quit repl session.', + () => { + this.close(); + }, + false + ); + this.replServer.on('close', () => { + func(); + this.progressSpinner.terminate(); + this.printHeaderFooter(this.footer); + }); + } + + /** + * Remove all special commands from REPL server + * Remove close event listeners to remove all quit handlers. + */ + clearSpecialCommands() { + this.replServer.commands = { help: this.replServer.commands.help }; + this.replServer.removeAllListeners('close'); + } + + /** + * Wrapper to close instance. This involves terminating the SpinnerView, disposing the Messenger View, and closing the REPL Server. + */ + close() { + this.replServer.close(); + } + + // SpinnerView wrapper functions + + /** + * Wrapper for starting the SpinnerView with a specified message. + * @param {String} text text to display when the spinner starts. + */ + startProgressSpinner(text) { + this.progressSpinner.start(text); + } + + /** + * Wrapper for updating the text of the SpinnerView + * @param {String} text text to replace current message of spinner. + */ + updateProgressSpinner(text) { + this.progressSpinner.update(text); + } + + /** + * Wrapper for terminating the SpinnerView, clearing it from the console + */ + terminateProgressSpinner() { + this.progressSpinner.terminate(); + } +}; diff --git a/lib/view/dialog-repl-view.js b/lib/view/dialog-repl-view.js new file mode 100644 index 00000000..8c78f409 --- /dev/null +++ b/lib/view/dialog-repl-view.js @@ -0,0 +1,39 @@ +const CliReplView = require('@src/view/cli-repl-view'); + +module.exports = class DialogReplView extends CliReplView { + /** + * Constructor for DialogReplView. + * @param {Object} configuration config object. + */ + constructor(configuration) { + const conf = configuration || {}; + conf.prettifyHeaderFooter = (arg) => { + const lines = arg.split('\n'); + const terminalWidth = process.stdout.columns; + const halfWidth = Math.floor(terminalWidth / 2); + const bar = '='.repeat(terminalWidth); + const formattedLines = lines.map((line) => { + const paddedLine = ` ${line.trim()} `; + const offset = halfWidth - Math.floor(paddedLine.length / 2); + if (offset < 0) { + return `===${paddedLine}===`; + } + return bar.slice(0, offset) + paddedLine + bar.slice(offset + paddedLine.length); + }); + return `\n${formattedLines.join('\n')}\n`; + }; + super(conf); + } + + /** + * Specify the record special command. + * @param {Function} func What function to execute when .record command is inputted. + */ + registerRecordCommand(func) { + this.registerSpecialCommand( + 'record', + 'Record input utterances to a replay file of a specified name.', + func + ); + } +}; diff --git a/lib/view/spinner-view.js b/lib/view/spinner-view.js index a3f727cb..275556a3 100644 --- a/lib/view/spinner-view.js +++ b/lib/view/spinner-view.js @@ -28,7 +28,7 @@ class SpinnerView { } update(text) { - this.oraSpinner.text(text); + this.oraSpinner.text = text; } terminate(style, optionalMessage) { diff --git a/test/unit/commands/dialog/helper-test.js b/test/unit/commands/dialog/helper-test.js new file mode 100644 index 00000000..1bcf340f --- /dev/null +++ b/test/unit/commands/dialog/helper-test.js @@ -0,0 +1,263 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const SmapiClient = require('@src/clients/smapi-client'); +const helper = require('@src/commands/dialog/helper'); +const DialogController = require('@src/controllers/dialog-controller'); + +describe('# Commands Dialog test - helper test', () => { + describe('# test validateDialogArgs', () => { + const TEST_SKILL_ID = 'skillId'; + const TEST_STAGE = 'development'; + const TEST_LOCALE = 'en-US'; + const TEST_MSG = 'test_msg'; + const dialogMode = new DialogController({ + smapiClient: new SmapiClient({ profile: 'default', doDebug: false }), + skillId: TEST_SKILL_ID, + stage: TEST_STAGE, + locale: TEST_LOCALE + }); + + let manifestStub; + let enablementStub; + + beforeEach(() => { + manifestStub = sinon.stub(dialogMode.smapiClient.skill.manifest, 'getManifest'); + enablementStub = sinon.stub(dialogMode.smapiClient.skill, 'getSkillEnablement'); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('| Skill Manifest request error runs error callback', (done) => { + // setup + manifestStub.callsArgWith(2, TEST_MSG, null); + // call + helper.validateDialogArgs(dialogMode, (err, response) => { + // verify + expect(err).equal(TEST_MSG); + expect(response).equal(undefined); + done(); + }); + }); + + it('| Skill Manifest non 200 response runs error callback', (done) => { + // setup + const errorMsg = `SMAPI get-manifest request error: 400 - ${TEST_MSG}`; + manifestStub.callsArgWith(2, null, { + statusCode: 400, + body: { + message: TEST_MSG + } + }); + // call + helper.validateDialogArgs(dialogMode, (err, response) => { + // verify + expect(err).equal(errorMsg); + expect(response).equal(undefined); + done(); + }); + }); + + it('| Skill Manifest response body with no api JSON field runs error callback', (done) => { + // setup + const errorMsg = 'Ensure "manifest.apis" object exists in the skill manifest.'; + manifestStub.callsArgWith(2, null, { + statusCode: 200, + body: {} + }); + // call + helper.validateDialogArgs(dialogMode, (err, response) => { + // verify + expect(err).equal(errorMsg); + expect(response).equal(undefined); + done(); + }); + }); + + it('| Skill Manifest response body with api JSON field but no api keys runs error callback', (done) => { + // setup + const errorMsg = 'Dialog command only supports custom skill type.'; + manifestStub.callsArgWith(2, null, { + statusCode: 200, + body: { + manifest: { + apis: {} + } + } + }); + // call + helper.validateDialogArgs(dialogMode, (err, response) => { + // verify + expect(err).equal(errorMsg); + expect(response).equal(undefined); + done(); + }); + }); + + it('| Skill Manifest response body with api JSON field not custom runs error callback', (done) => { + // setup + const errorMsg = 'Dialog command only supports custom skill type, but current skill is a "smartHome" type.'; + manifestStub.callsArgWith(2, null, { + statusCode: 200, + body: { + manifest: { + apis: { + smartHome: {} + } + } + } + }); + // call + helper.validateDialogArgs(dialogMode, (err, response) => { + // verify + expect(err).equal(errorMsg); + expect(response).equal(undefined); + done(); + }); + }); + + it('| Skill Manifest response body with no locales JSON field runs error callback', (done) => { + // setup + const errorMsg = 'Ensure the "manifest.publishingInformation.locales" exists in the skill manifest before simulating your skill.'; + manifestStub.callsArgWith(2, null, { + statusCode: 200, + body: { + manifest: { + apis: { + custom: {} + } + }, + publishingInformation: {} + } + }); + // call + helper.validateDialogArgs(dialogMode, (err, response) => { + // verify + expect(err).equal(errorMsg); + expect(response).equal(undefined); + done(); + }); + }); + + it('| Skill Manifest response body does not contain locale passed into constructor runs error callback', (done) => { + // setup + const errorMsg = 'Locale en-US was not found for your skill. ' + + 'Ensure the locale you want to simulate exists in your publishingInformation.'; + manifestStub.callsArgWith(2, null, { + statusCode: 200, + body: { + manifest: { + apis: { + custom: {} + }, + publishingInformation: { + locales: { + } + } + } + } + }); + // call + helper.validateDialogArgs(dialogMode, (err, response) => { + // verify + expect(err).equal(errorMsg); + expect(response).equal(undefined); + done(); + }); + }); + + it('| Skill Manifest callback successful, Skill Enablement request error runs error callback', (done) => { + // setup + manifestStub.callsArgWith(2, null, { + statusCode: 200, + body: { + manifest: { + apis: { + custom: {} + }, + publishingInformation: { + locales: { + 'en-US': {} + } + } + } + } + }); + enablementStub.callsArgWith(2, TEST_MSG, null); + // call + helper.validateDialogArgs(dialogMode, (err, response) => { + // verify + expect(err).equal(TEST_MSG); + expect(response).equal(undefined); + done(); + }); + }); + + it('| Skill Manifest callback successful, Skill Enablement response of >= 300 runs error callback', (done) => { + // setup + const errorMsg = `SMAPI get-skill-enablement request error: 400 - ${TEST_MSG}`; + manifestStub.callsArgWith(2, null, { + statusCode: 200, + body: { + manifest: { + apis: { + custom: {} + }, + publishingInformation: { + locales: { + 'en-US': {} + } + } + } + } + }); + enablementStub.callsArgWith(2, null, { + statusCode: 400, + body: { + message: TEST_MSG + } + }); + // call + helper.validateDialogArgs(dialogMode, (err, response) => { + // verify + expect(err).equal(errorMsg); + expect(response).equal(undefined); + done(); + }); + }); + + it('| Skill Manifest callback successful, Skill Enablement successful', (done) => { + // setup + const TEST_RES = { + statusCode: 204, + body: { + message: TEST_MSG + } + }; + manifestStub.callsArgWith(2, null, { + statusCode: 200, + body: { + manifest: { + apis: { + custom: {} + }, + publishingInformation: { + locales: { + 'en-US': {} + } + } + } + } + }); + enablementStub.callsArgWith(2, null, TEST_RES); + // call + helper.validateDialogArgs(dialogMode, (err) => { + // verify + expect(err).equal(undefined); + done(); + }); + }); + }); +}); diff --git a/test/unit/commands/dialog/index-test.js b/test/unit/commands/dialog/index-test.js new file mode 100644 index 00000000..5d6619cf --- /dev/null +++ b/test/unit/commands/dialog/index-test.js @@ -0,0 +1,297 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const path = require('path'); + +const DialogCommand = require('@src/commands/dialog'); +const helper = require('@src/commands/dialog/helper'); +const InteractiveMode = require('@src/commands/dialog/interactive-mode'); +const optionModel = require('@src/commands/option-model'); +const ResourcesConfig = require('@src/model/resources-config'); +const CONSTANTS = require('@src/utils/constants'); +const profileHelper = require('@src/utils/profile-helper'); +const stringUtils = require('@src/utils/string-utils'); +const Messenger = require('@src/view/messenger'); +const SpinnerView = require('@src/view/spinner-view'); + + +describe('Commands Dialog test - command class test', () => { + const TEST_ERROR = 'error'; + const DIALOG_FIXTURE_PATH = path.join(process.cwd(), 'test', 'unit', 'fixture', 'model', 'dialog'); + const RESOURCE_CONFIG_FIXTURE_PATH = path.join(process.cwd(), 'test', 'unit', 'fixture', 'model'); + const DIALOG_REPLAY_FILE_JSON_PATH = path.join(DIALOG_FIXTURE_PATH, 'dialog-replay-file.json'); + const INVALID_DIALOG_REPLAY_FILE_JSON_PATH = path.join(DIALOG_FIXTURE_PATH, 'invalid-dialog-replay-file.json'); + const INVALID_RESOURCES_CONFIG_JSON_PATH = path.join(RESOURCE_CONFIG_FIXTURE_PATH, 'json-config.json'); + const VALID_RESOURCES_CONFIG_JSON_PATH = path.join(RESOURCE_CONFIG_FIXTURE_PATH, 'resources-config.json'); + const TEST_PROFILE = 'default'; + const TEST_CMD = { + profile: TEST_PROFILE + }; + + let errorStub; + beforeEach(() => { + errorStub = sinon.stub(); + sinon.stub(Messenger, 'getInstance').returns({ + error: errorStub + }); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('| validate command information is set correctly', () => { + const instance = new DialogCommand(optionModel); + expect(instance.name()).eq('dialog'); + expect(instance.description()).eq('simulate your skill via an interactive dialog with Alexa'); + expect(instance.requiredOptions()).deep.eq([]); + expect(instance.optionalOptions()).deep.eq(['skill-id', 'locale', 'stage', 'replay', 'profile', 'debug']); + }); + + describe('# validate command handle', () => { + let instance; + let spinnerStartStub; + let spinnerTerminateStub; + + beforeEach(() => { + spinnerStartStub = sinon.stub(SpinnerView.prototype, 'start'); + spinnerTerminateStub = sinon.stub(SpinnerView.prototype, 'terminate'); + instance = new DialogCommand(optionModel); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('| error while creating dialogMode', (done) => { + // setup + sinon.stub(DialogCommand.prototype, '_getDialogConfig').throws(new Error(TEST_ERROR)); + + // call + instance.handle(TEST_CMD, (err) => { + // verify + expect(errorStub.args[0][0].message).eq(TEST_ERROR); + expect(err.message).eq(TEST_ERROR); + done(); + }); + }); + + it('| error while validating dialog arguments', (done) => { + // setup + sinon.stub(DialogCommand.prototype, '_getDialogConfig'); + sinon.stub(DialogCommand.prototype, '_dialogModeFactory'); + sinon.stub(helper, 'validateDialogArgs').callsArgWith(1, TEST_ERROR); + // call + instance.handle(TEST_CMD, (err) => { + // verify + expect(err).eq(TEST_ERROR); + expect(spinnerStartStub.calledOnce).eq(true); + expect(spinnerStartStub.args[0][0]).eq('Checking if skill is ready to simulate...'); + expect(spinnerTerminateStub.calledOnce).eq(true); + expect(spinnerTerminateStub.args[0][0]).eq(SpinnerView.TERMINATE_STYLE.FAIL); + expect(spinnerTerminateStub.args[0][1]).eq('Failed to validate command options'); + expect(errorStub.args[0][0]).eq(TEST_ERROR); + done(); + }); + }); + + it('| dialogMode returns error', (done) => { + // setup + sinon.stub(DialogCommand.prototype, '_getDialogConfig').returns({}); + sinon.stub(InteractiveMode.prototype, 'start').callsArgWith(0, TEST_ERROR); + sinon.stub(helper, 'validateDialogArgs').callsArgWith(1, null); + // call + instance.handle(TEST_CMD, (err) => { + // verify + expect(err).eq(TEST_ERROR); + expect(spinnerStartStub.calledOnce).eq(true); + expect(spinnerStartStub.args[0][0]).eq('Checking if skill is ready to simulate...'); + expect(spinnerTerminateStub.calledOnce).eq(true); + expect(errorStub.args[0][0]).eq(TEST_ERROR); + done(); + }); + }); + + it('| dialog command successfully completes execution', (done) => { + // setup + sinon.stub(DialogCommand.prototype, '_getDialogConfig').returns({}); + sinon.stub(InteractiveMode.prototype, 'start').callsArgWith(0, null); + sinon.stub(helper, 'validateDialogArgs').callsArgWith(1, null); + // call + instance.handle(TEST_CMD, (err) => { + // verify + expect(err).eq(undefined); + expect(spinnerStartStub.calledOnce).eq(true); + expect(spinnerStartStub.args[0][0]).eq('Checking if skill is ready to simulate...'); + expect(spinnerTerminateStub.calledOnce).eq(true); + done(); + }); + }); + }); + + describe('# test _getDialogConfig', () => { + let instance; + + beforeEach(() => { + instance = new DialogCommand(optionModel); + }); + + describe('# test with replay option', () => { + it('| empty skillId throws error', (done) => { + // setup + const TEST_CMD_WITH_VALUES = { + stage: 'development', + replay: INVALID_DIALOG_REPLAY_FILE_JSON_PATH + }; + sinon.stub(profileHelper, 'runtimeProfile').returns(TEST_PROFILE); + // call + instance.handle(TEST_CMD_WITH_VALUES, (err) => { + // verify + expect(err).eq('Replay file must contain skillId'); + done(); + }); + }); + + it('| empty locale throws error', (done) => { + // setup + const TEST_CMD_WITH_VALUES = { + stage: '', + replay: INVALID_DIALOG_REPLAY_FILE_JSON_PATH + }; + sinon.stub(profileHelper, 'runtimeProfile').returns(TEST_PROFILE); + const stringUtilsStub = sinon.stub(stringUtils, 'isNonBlankString'); + stringUtilsStub.onCall(0).returns('skillId'); + // call + instance.handle(TEST_CMD_WITH_VALUES, (err) => { + // verify + expect(err).eq('Replay file must contain locale'); + done(); + }); + }); + + it('| invalid user inputs throws error', (done) => { + // setup + const TEST_CMD_WITH_VALUES = { + stage: '', + replay: INVALID_DIALOG_REPLAY_FILE_JSON_PATH + }; + sinon.stub(profileHelper, 'runtimeProfile').returns(TEST_PROFILE); + const stringUtilsStub = sinon.stub(stringUtils, 'isNonBlankString'); + stringUtilsStub.onCall(0).returns('skillId'); + stringUtilsStub.onCall(1).returns('locale'); + // call + instance.handle(TEST_CMD_WITH_VALUES, (err) => { + // verify + expect(err).eq("Replay file's userInput cannot contain empty string."); + done(); + }); + }); + + it('| returns valid config', () => { + // setup + const TEST_CMD_WITH_VALUES = { + stage: '', + replay: DIALOG_REPLAY_FILE_JSON_PATH + }; + sinon.stub(profileHelper, 'runtimeProfile').returns(TEST_PROFILE); + // call + const config = instance._getDialogConfig(TEST_CMD_WITH_VALUES); + // verify + expect(config.debug).equal(false); + expect(config.locale).equal('en-US'); + expect(config.profile).equal('default'); + expect(config.replay).equal(DIALOG_REPLAY_FILE_JSON_PATH); + expect(config.skillId).equal('amzn1.ask.skill.1234567890'); + expect(config.stage).equal('development'); + expect(config.userInputs).deep.equal(['hello', 'world']); + }); + }); + + describe('# test with default (interactive) option', () => { + afterEach(() => { + sinon.restore(); + }); + it('| empty locale throws error', (done) => { + // setup + const TEST_CMD_WITH_VALUES = { + skillId: 'skillId' + }; + sinon.stub(profileHelper, 'runtimeProfile').returns(TEST_PROFILE); + // call + instance.handle(TEST_CMD_WITH_VALUES, (err) => { + // verify + expect(err).equal('Locale has not been specified.'); + done(); + }); + }); + + it('| no resources config file found', (done) => { + // setup + const TEST_CMD_WITH_VALUES = {}; + sinon.stub(profileHelper, 'runtimeProfile').returns(TEST_PROFILE); + sinon.stub(path, 'join').returns(INVALID_RESOURCES_CONFIG_JSON_PATH); + sinon.stub(ResourcesConfig.prototype, 'getSkillId').throws(new Error()); + // call + instance.handle(TEST_CMD_WITH_VALUES, (err) => { + // verify + expect(err).equal('Failed to read project resource file.'); + done(); + }); + }); + + it('| unable to fetch skillId from resources config file', (done) => { + // setup + const TEST_CMD_WITH_VALUES = {}; + sinon.stub(profileHelper, 'runtimeProfile').returns(TEST_PROFILE); + sinon.stub(path, 'join').returns(INVALID_RESOURCES_CONFIG_JSON_PATH); + // call + instance.handle(TEST_CMD_WITH_VALUES, (err) => { + // verify + expect(err).equal(`Failed to obtain skill-id from project resource file ${CONSTANTS.FILE_PATH.ASK_RESOURCES_JSON_CONFIG}`); + done(); + }); + }); + + it('| check valid values are returned in interactive mode', () => { + // setup + const TEST_CMD_WITH_VALUES = {}; + sinon.stub(profileHelper, 'runtimeProfile').returns(TEST_PROFILE); + sinon.stub(path, 'join').returns(VALID_RESOURCES_CONFIG_JSON_PATH); + process.env.ASK_DEFAULT_DEVICE_LOCALE = 'en-US'; + // call + const config = instance._getDialogConfig(TEST_CMD_WITH_VALUES); + // verify + expect(config.debug).equal(false); + expect(config.locale).equal('en-US'); + expect(config.profile).equal('default'); + expect(config.replay).equal(undefined); + expect(config.skillId).equal('amzn1.ask.skill.5555555-4444-3333-2222-1111111111'); + expect(config.stage).equal('development'); + expect(config.userInputs).equal(undefined); + }); + + after(() => { + delete process.env.ASK_DEFAULT_DEVICE_LOCALE; + }); + }); + }); + + + describe('# test _validateUserInputs', () => { + let instance; + + beforeEach(() => { + instance = new DialogCommand(optionModel); + }); + + it('| all valid inputs', () => { + // setup + const userInputs = [' open hello world ', 'help']; + + // call + const validatedInputs = instance._validateUserInputs(userInputs); + + // verify + expect(validatedInputs).deep.equal(['open hello world', 'help']); + }); + }); +}); diff --git a/test/unit/commands/dialog/interactive-mode-test.js b/test/unit/commands/dialog/interactive-mode-test.js new file mode 100644 index 00000000..eea2274c --- /dev/null +++ b/test/unit/commands/dialog/interactive-mode-test.js @@ -0,0 +1,41 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const InteractiveMode = require('@src/commands/dialog/interactive-mode'); +const DialogController = require('@src/controllers/dialog-controller'); +const DialogReplView = require('@src/view/dialog-repl-view'); + +describe('# Command: Dialog - Interactive Mode test ', () => { + const TEST_ERROR = 'error'; + const dialogReplViewPrototype = Object.getPrototypeOf(DialogReplView); + + afterEach(() => { + Object.setPrototypeOf(DialogReplView, dialogReplViewPrototype); + sinon.restore(); + }); + + it('| test interactive mode start, dialog repl view creation throws error', () => { + // setup + const dialogReplViewStub = sinon.stub().throws(new Error(TEST_ERROR)); + Object.setPrototypeOf(DialogReplView, dialogReplViewStub); + const interactiveMode = new InteractiveMode({}); + // call + interactiveMode.start((error) => { + // verify + expect(error.message).equal(TEST_ERROR); + }); + }); + + it('| test interactive mode start, setupSpecialCommands throws error', () => { + // setup + const dialogReplViewStub = sinon.stub(); + Object.setPrototypeOf(DialogReplView, dialogReplViewStub); + sinon.stub(DialogController.prototype, 'setupSpecialCommands').callsArgWith(1, TEST_ERROR); + const interactiveMode = new InteractiveMode({}); + // call + interactiveMode.start((error) => { + // verify + expect(error).equal(TEST_ERROR); + }); + }); +}); diff --git a/test/unit/commands/dialog/replay-mode-test.js b/test/unit/commands/dialog/replay-mode-test.js new file mode 100644 index 00000000..f7b3555c --- /dev/null +++ b/test/unit/commands/dialog/replay-mode-test.js @@ -0,0 +1,101 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const InteractiveMode = require('@src/commands/dialog/interactive-mode'); +const ReplayMode = require('@src/commands/dialog/replay-mode'); +const DialogController = require('@src/controllers/dialog-controller'); +const DialogReplView = require('@src/view/dialog-repl-view'); + +describe('# Command: Dialog - Replay Mode test ', () => { + const TEST_ERROR = 'error'; + const dialogReplViewPrototype = Object.getPrototypeOf(DialogReplView); + + afterEach(() => { + Object.setPrototypeOf(DialogReplView, dialogReplViewPrototype); + sinon.restore(); + }); + + it('| test replay mode start, dialog repl view creation throws error', () => { + // setup + const dialogReplViewStub = sinon.stub().throws(new Error(TEST_ERROR)); + Object.setPrototypeOf(DialogReplView, dialogReplViewStub); + const replayMode = new ReplayMode({}); + + // call + replayMode.start((error) => { + // verify + expect(error.message).equal(TEST_ERROR); + }); + }); + + it('test replay mode start, setupSpecialCommands throws error', () => { + // setup + const dialogReplViewStub = sinon.stub().callsFake(); + Object.setPrototypeOf(DialogReplView, dialogReplViewStub); + sinon.stub(DialogController.prototype, 'setupSpecialCommands').callsArgWith(1, TEST_ERROR); + const replayMode = new ReplayMode({ + userInputs: ['hello'] + }); + + // call + replayMode.start((error) => { + // verify + expect(error).equal(TEST_ERROR); + }); + }); + + describe('# test _evaluateInput', () => { + let replayCallbackStub; + + beforeEach(() => { + replayCallbackStub = sinon.stub(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('| continue in replay mode', () => { + // setup + const replayMode = new ReplayMode({ + userInputs: ['hello'] + }); + const inputStream = []; + sinon.stub(DialogController.prototype, 'evaluateUtterance').callsArgWith(2, ''); + + // call + replayMode._evaluateInput({}, {}, inputStream, replayCallbackStub, () => {}); + + // verify + expect(inputStream[0]).equal('hello\n'); + expect(replayCallbackStub.calledOnce).equal(true); + }); + + it('| switch to interactive mode', () => { + // setup + const config = { + userInputs: [] + }; + const replayMode = new ReplayMode(config); + const clearSpecialCommandsStub = sinon.stub(); + const replViewCloseStub = sinon.stub(); + const replViewStub = { + clearSpecialCommands: clearSpecialCommandsStub, + close: replViewCloseStub + }; + const inputStream = []; + sinon.stub(DialogController.prototype, 'evaluateUtterance').callsArgWith(2, ''); + const interactiveStartStub = sinon.stub(InteractiveMode.prototype, 'start'); + // call + replayMode._evaluateInput({}, replViewStub, inputStream, replayCallbackStub, () => {}); + + // verify + expect(inputStream.length).equal(0); + expect(clearSpecialCommandsStub.calledOnce).equal(true); + expect(replViewCloseStub.calledOnce).equal(true); + expect(config.header).equal('Switching to interactive dialog.\n' + + 'To automatically quit after replay, append \'.quit\' to the userInput of your replay file.'); + expect(interactiveStartStub.calledOnce).equal(true); + }); + }); +}); diff --git a/test/unit/controller/dialog-controller/index-test.js b/test/unit/controller/dialog-controller/index-test.js new file mode 100644 index 00000000..6969bf75 --- /dev/null +++ b/test/unit/controller/dialog-controller/index-test.js @@ -0,0 +1,421 @@ +const chalk = require('chalk'); +const { expect } = require('chai'); +const fs = require('fs-extra'); +const sinon = require('sinon'); + +const DialogController = require('@src/controllers/dialog-controller'); +const stringUtils = require('@src/utils/string-utils'); +const DialogReplView = require('@src/view/dialog-repl-view'); +const Messenger = require('@src/view/messenger'); + +describe('Controller test - dialog controller test', () => { + const TEST_MSG = 'TEST_MSG'; + + describe('# test constructor', () => { + it('| constructor with config parameter returns DialogController object', () => { + // call + const dialogController = new DialogController({ + profile: 'default', + debug: true, + skillId: 'a1b2c3', + locale: 'en-US', + stage: 'DEVELOPMENT' + }); + // verify + expect(dialogController).to.be.instanceOf(DialogController); + expect(dialogController.smapiClient.profile).equal('default'); + expect(dialogController.utteranceCache.length).equal(0); + }); + + it('| constructor with empty config object returns DialogController object', () => { + // call + const dialogController = new DialogController({}); + // verify + expect(dialogController).to.be.instanceOf(DialogController); + expect(dialogController.smapiClient.profile).equal(undefined); + expect(dialogController.utteranceCache.length).equal(0); + }); + + it('| constructor with no args throws exception', () => { + try { + // setup & call + new DialogController(); + } catch (err) { + // verify + expect(err).to.match(new RegExp('Cannot have an undefined configuration.')); + } + }); + }); + + describe('# test class method - startSkillSimulation', () => { + let simulateSkillStub; + let dialogController; + + beforeEach(() => { + dialogController = new DialogController({}); + simulateSkillStub = sinon.stub(dialogController.smapiClient.skill.test, 'simulateSkill'); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('| test utterance input and response is stored into appropriate caches', (done) => { + // setup + simulateSkillStub.callsArgWith(5, null, { msg: TEST_MSG }); + // call + dialogController.startSkillSimulation(TEST_MSG, (err, response) => { + // verify + expect(err).equal(null); + expect(dialogController.utteranceCache.length).equal(1); + expect(dialogController.utteranceCache[0]).equal(response.msg); + done(); + }); + }); + + it('| test an error in request does not effect caches', (done) => { + // setup + simulateSkillStub.callsArgWith(5, TEST_MSG, null); + // call + dialogController.startSkillSimulation(TEST_MSG, (err, response) => { + // verify + expect(err).equal(TEST_MSG); + expect(response).equal(undefined); + expect(dialogController.utteranceCache.length).equal(0); + done(); + }); + }); + }); + + describe('# test class method - getSkillSimulationResult', () => { + let getSimulationStub; + let dialogController; + + beforeEach(() => { + dialogController = new DialogController({}); + getSimulationStub = sinon.stub(dialogController.smapiClient.skill.test, 'getSimulation'); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('| test valid simulation response', (done) => { + // setup + const output = { + statusCode: 200, + headers: {}, + body: { + status: 'SUCCESSFUL', + msg: TEST_MSG + } + }; + getSimulationStub.callsArgWith(3, null, output); + // call + dialogController.getSkillSimulationResult(TEST_MSG, (err, response) => { + // verify + expect(err).equal(null); + expect(response).to.deep.equal(output); + done(); + }); + }); + + it('| test an error in request', (done) => { + // setup + getSimulationStub.callsArgWith(3, TEST_MSG, null); + // call + dialogController.getSkillSimulationResult(TEST_MSG, (err, response) => { + // verify + expect(err).equal(TEST_MSG); + expect(response).equal(undefined); + done(); + }); + }); + + it('| test an error in response', (done) => { + // setup + const output = { + statusCode: 200, + headers: {}, + body: { + status: 'SUCCESSFUL', + msg: TEST_MSG, + result: { + error: { + message: TEST_MSG + } + } + } + }; + getSimulationStub.callsArgWith(3, null, output); + // call + dialogController.getSkillSimulationResult(TEST_MSG, (err, response) => { + // verify + expect(err).equal(TEST_MSG); + expect(response).equal(undefined); + done(); + }); + }); + }); + + describe('# test class method - clearSession', () => { + it('| values are reset after method call', () => { + // setup + const dialogController = new DialogController({}); + + // call + dialogController.clearSession(); + + // verify + expect(dialogController.newSession).equal(true); + }); + }); + + describe('# test class method - createReplayFile', () => { + afterEach(() => { + sinon.restore(); + }); + + it('| successful replay file creation', () => { + // setup + const dialogController = new DialogController({}); + const fileSystemStub = sinon.stub(fs, 'outputJSONSync'); + + // call + dialogController.createReplayFile('random_file_name'); + + // verify + expect(fileSystemStub.callCount).equal(1); + }); + + it('| file name is empty', () => { + // setup + const dialogController = new DialogController({}); + + // call + dialogController.createReplayFile(''); + + // verify + expect(dialogController.utteranceCache.length).equal(0); + }); + }); + + describe('# test class method - setupSpecialCommands', () => { + const dialogController = new DialogController({}); + const dialogReplView = new DialogReplView({}); + + afterEach(() => { + sinon.restore(); + }); + + it('| file path is empty', (done) => { + // setup + sinon.stub(DialogReplView.prototype, 'registerRecordCommand'); + DialogReplView.prototype.registerRecordCommand.callsArgWith(0, ''); + sinon.stub(stringUtils, 'isNonBlankString').returns(false); + + // call + dialogController.setupSpecialCommands(dialogReplView, (error) => { + expect(error).equal('A file name has not been specified'); + done(); + }); + }); + + it('| file is not of JSON type', (done) => { + // setup + sinon.stub(DialogReplView.prototype, 'registerRecordCommand'); + DialogReplView.prototype.registerRecordCommand.callsArgWith(0, 'file.yml'); + + // call + dialogController.setupSpecialCommands(dialogReplView, (error) => { + expect(error).equal("File should be of type '.json'"); + done(); + }); + }); + + it('| replay file creation throws error', (done) => { + // setup + const infoStub = sinon.stub().throws(new Error(TEST_MSG)); + sinon.stub(Messenger, 'getInstance').returns({ + info: infoStub + }); + const replayStub = sinon.stub(DialogController.prototype, 'createReplayFile'); + sinon.stub(DialogReplView.prototype, 'registerRecordCommand'); + DialogReplView.prototype.registerRecordCommand.callsArgWith(0, 'file.json'); + + // call + dialogController.setupSpecialCommands(dialogReplView, (error) => { + expect(error.message).equal(TEST_MSG); + expect(replayStub.calledOnce).equal(true); + expect(infoStub.calledOnce).equal(true); + done(); + }); + }); + }); + + describe('# test evaluateUtterance -', () => { + const dialogController = new DialogController({}); + const utterance = 'hello'; + const simulationId = 'simulationId'; + const prompt = chalk.yellow.bold('Alexa > '); + let infoStub; + let errorStub; + let terminateSpinnerStub; + let updateSpinnerStub; + let startSpinnerStub; + let replViewStub; + + beforeEach(() => { + infoStub = sinon.stub(); + errorStub = sinon.stub(); + terminateSpinnerStub = sinon.stub(); + updateSpinnerStub = sinon.stub(); + startSpinnerStub = sinon.stub(); + sinon.stub(Messenger, 'getInstance').returns({ + info: infoStub, + error: errorStub + }); + replViewStub = { + startProgressSpinner: startSpinnerStub, + terminateProgressSpinner: terminateSpinnerStub, + updateProgressSpinner: updateSpinnerStub + }; + }); + + afterEach(() => { + sinon.restore(); + }); + + it('| start skill simulation throws error', (done) => { + // setup + sinon.stub(DialogController.prototype, 'startSkillSimulation').callsArgWith(1, TEST_MSG); + // call + dialogController.evaluateUtterance(utterance, replViewStub, () => { + // verify + expect(startSpinnerStub.args[0][0]).equal('Sending simulation request to Alexa...'); + expect(terminateSpinnerStub.calledOnce).equal(true); + expect(errorStub.args[0][0]).equal(TEST_MSG); + done(); + }); + }); + + it('| start skill simulation throws error', (done) => { + // setup + const response = { + statusCode: 400, + body: { + error: { + message: TEST_MSG + } + } + }; + sinon.stub(DialogController.prototype, 'startSkillSimulation').callsArgWith(1, null, response); + // call + dialogController.evaluateUtterance(utterance, replViewStub, () => { + // verify + expect(startSpinnerStub.args[0][0]).equal('Sending simulation request to Alexa...'); + expect(terminateSpinnerStub.calledOnce).equal(true); + expect(errorStub.args[0][0]).equal(TEST_MSG); + done(); + }); + }); + + it('| get skill simulation result throws error', (done) => { + // setup + const response = { + statusCode: 200, + body: { + id: simulationId + } + }; + sinon.stub(DialogController.prototype, 'startSkillSimulation').callsArgWith(1, null, response); + sinon.stub(DialogController.prototype, 'getSkillSimulationResult').callsArgWith(1, TEST_MSG); + // call + dialogController.evaluateUtterance(utterance, replViewStub, () => { + // verify + expect(startSpinnerStub.args[0][0]).equal('Sending simulation request to Alexa...'); + expect(updateSpinnerStub.args[0][0]).equal('Waiting for the simulation response...'); + expect(terminateSpinnerStub.calledOnce).equal(true); + expect(errorStub.args[0][0]).equal(TEST_MSG); + done(); + }); + }); + + it('| valid response is returned with existing session', (done) => { + // setup + const response = { + statusCode: 200, + body: { + id: simulationId, + result: { + alexaExecutionInfo: { + alexaResponses: [{ + content: { + caption: 'hello' + } + }] + }, + skillExecutionInfo: { + invocations: ['hello'] + } + } + } + }; + sinon.stub(DialogController.prototype, 'startSkillSimulation').callsArgWith(1, null, response); + sinon.stub(DialogController.prototype, 'getSkillSimulationResult').callsArgWith(1, null, response); + // call + dialogController.evaluateUtterance(utterance, replViewStub, () => { + // verify + expect(startSpinnerStub.args[0][0]).equal('Sending simulation request to Alexa...'); + expect(updateSpinnerStub.args[0][0]).equal('Waiting for the simulation response...'); + expect(terminateSpinnerStub.calledOnce).equal(true); + expect(infoStub.args[0][0]).equal(`${prompt}hello`); + done(); + }); + }); + + it('| valid response is returned with a new session', (done) => { + // setup + const response = { + statusCode: 200, + body: { + id: simulationId, + result: { + alexaExecutionInfo: { + alexaResponses: [{ + content: { + caption: 'hello' + } + }] + }, + skillExecutionInfo: { + invocations: [{ + invocationResponse: { + body: { + response: { + shouldEndSession: true + } + } + } + }] + } + } + } + }; + sinon.stub(DialogController.prototype, 'startSkillSimulation').callsArgWith(1, null, response); + sinon.stub(DialogController.prototype, 'getSkillSimulationResult').callsArgWith(1, null, response); + const clearSessionStub = sinon.stub(DialogController.prototype, 'clearSession'); + // call + dialogController.evaluateUtterance(utterance, replViewStub, () => { + // verify + expect(startSpinnerStub.args[0][0]).equal('Sending simulation request to Alexa...'); + expect(updateSpinnerStub.args[0][0]).equal('Waiting for the simulation response...'); + expect(terminateSpinnerStub.calledOnce).equal(true); + expect(clearSessionStub.calledOnce).equal(true); + expect(infoStub.args[0][0]).equal('Session ended'); + expect(infoStub.args[1][0]).equal(`${prompt}hello`); + done(); + }); + }); + }); +}); diff --git a/test/unit/controller/dialog-controller/simulation-response-parser-test.js b/test/unit/controller/dialog-controller/simulation-response-parser-test.js new file mode 100644 index 00000000..9df079cd --- /dev/null +++ b/test/unit/controller/dialog-controller/simulation-response-parser-test.js @@ -0,0 +1,319 @@ +const { expect } = require('chai'); +const responseParser = require('@src/controllers/dialog-controller/simulation-response-parser'); + +describe('Controller test - skill simulation response parser tests', () => { + describe('# helper function - getConsideredIntents', () => { + it('| Test for defined result', () => { + // setup + const TEST_RES = { foo: 'bar' }; + const TEST_OBJ = { + result: { + alexaExecutionInfo: { + consideredIntents: TEST_RES + } + } + }; + // call + const res = responseParser.getConsideredIntents(TEST_OBJ); + // verify + expect(res).deep.equal(TEST_RES); + }); + + it('| Test for undefined result', () => { + // setup + const TEST_OBJ = { + result: { + alexaExecutionInfo: {} + } + }; + // call + const res = responseParser.getConsideredIntents(TEST_OBJ); + // verify + expect(res).to.eql([]); + }); + }); + + describe('# helper function - getJsonInputAndOutputs', () => { + const TEST_MSG = 'TEST_MSG'; + + it('| Test for undefined invocations', () => { + // setup + const TEST_OBJ = { + result: { + skillExecutionInfo: {} + } + }; + // call + const res = responseParser.getJsonInputAndOutputs(TEST_OBJ); + // verify + expect(res).to.eql([]); + }); + + it('| Test for equal invocations requests and responses', () => { + // setup + const TEST_OBJ = { + result: { + skillExecutionInfo: { + invocations: [ + { + invocationRequest: { + body: TEST_MSG + }, + invocationResponse: { + body: TEST_MSG + } + }, + { + invocationRequest: { + body: TEST_MSG + }, + invocationResponse: { + body: TEST_MSG + } + } + ] + } + } + }; + // call + const res = responseParser.getJsonInputAndOutputs(TEST_OBJ); + // verify + expect(res.length).equal(2); + expect(res[0].jsonInput).deep.equal(TEST_MSG); + expect(res[0].jsonOutput).deep.equal({ body: TEST_MSG }); + expect(res[1].jsonInput).deep.equal(TEST_MSG); + expect(res[1].jsonOutput).deep.equal({ body: TEST_MSG }); + }); + + it('| Test for unequal invocations requests and responses', () => { + // setup + const TEST_OBJ = { + result: { + skillExecutionInfo: { + invocations: [ + { + invocationRequest: { + body: TEST_MSG + } + }, + { + invocationRequest: { + body: TEST_MSG + }, + invocationResponse: { + body: TEST_MSG + } + } + ] + } + } + }; + // call + const res = responseParser.getJsonInputAndOutputs(TEST_OBJ); + // verify + expect(res.length).equal(2); + expect(res[0].jsonInput).equal(TEST_MSG); + expect(res[0].jsonOutput).to.eql({}); + expect(res[1].jsonInput).equal(TEST_MSG); + expect(res[1].jsonOutput).to.eql({ body: TEST_MSG }); + }); + }); + + describe('# helper function - shouldEndSession', () => { + it('| Test for undefined invocations', () => { + // setup + const TEST_OBJ = { + result: { + skillExecutionInfo: {} + } + }; + // call + const res = responseParser.shouldEndSession(TEST_OBJ); + // verify + expect(res).equal(false); + }); + + it('| Test for invocations with one response with shouldEndSession', () => { + // setup + const TEST_OBJ = { + result: { + skillExecutionInfo: { + invocations: [ + { + invocationResponse: { + body: { + response: { + shouldEndSession: true + } + } + } + } + ] + } + } + }; + // call + const res = responseParser.shouldEndSession(TEST_OBJ); + // verify + expect(res).equal(true); + }); + + it('| Test for invocations with no response with shouldEndSession', () => { + // setup + const TEST_OBJ = { + result: { + skillExecutionInfo: { + invocations: [ + { + invocationResponse: { + body: { + response: { + shouldEndSession: false + } + } + } + } + ] + } + } + }; + // call + const res = responseParser.shouldEndSession(TEST_OBJ); + // verify + expect(res).equal(false); + }); + }); + + describe('# helper function - getErrorMessage', () => { + const TEST_MSG = 'TEST_MSG'; + + it('| Test with undefined error message', () => { + // setup + const TEST_OBJ = { + result: { + error: {} + } + }; + // call + const res = responseParser.getErrorMessage(TEST_OBJ); + // verify + expect(res).equal(undefined); + }); + + it('| Test with defined error message', () => { + // setup + const TEST_OBJ = { + result: { + error: { + message: TEST_MSG + } + } + }; + // call + const res = responseParser.getErrorMessage(TEST_OBJ); + // verify + expect(res).equal(TEST_MSG); + }); + }); + + describe('# helper function - getCaption', () => { + const TEST_MSG = 'TEST_MSG'; + + it('| Test with undefined alexaResponse', () => { + // setup + const TEST_OBJ = { + result: { + alexaExecutionInfo: {} + } + }; + // call + const res = responseParser.getCaption(TEST_OBJ); + // verify + expect(res).to.eql([]); + }); + + it('| Test with undefined caption array', () => { + // setup + const TEST_OBJ = { + result: { + alexaExecutionInfo: { + alexaResponses: [] + } + } + }; + // call + const res = responseParser.getCaption(TEST_OBJ); + // verify + expect(res).to.eql([]); + }); + + it('| Test with defined captions array', () => { + // setup + const TEST_OBJ = { + result: { + alexaExecutionInfo: { + alexaResponses: [ + { + content: { + caption: TEST_MSG + } + } + ] + } + } + }; + // call + const res = responseParser.getCaption(TEST_OBJ); + // verify + expect(res).to.be.instanceOf(Array); + expect(res.length).equal(1); + expect(res[0]).equal(TEST_MSG); + }); + }); + + describe('# helper function - getStatus', () => { + it('| Test with undefined status', () => { + // setup + const TEST_OBJ = {}; + // call + const res = responseParser.getStatus(TEST_OBJ); + // verify + expect(res).equal(undefined); + }); + + it('| Test with defined status', () => { + // setup + const TEST_MSG = 'TEST_MSG'; + const TEST_OBJ = { + status: TEST_MSG + }; + // call + const res = responseParser.getStatus(TEST_OBJ); + // verify + expect(res).equal(TEST_MSG); + }); + }); + + describe('# helper function - getSimulationId', () => { + it('| Test with undefined id', () => { + // setup + const TEST_OBJ = {}; + // call + const res = responseParser.getSimulationId(TEST_OBJ); + // verify + expect(res).equal(undefined); + }); + + it('| Test with defined id', () => { + // setup + const TEST_MSG = 'TEST_MSG'; + const TEST_OBJ = { + id: TEST_MSG + }; + // call + const res = responseParser.getSimulationId(TEST_OBJ); + // verify + expect(res).equal(TEST_MSG); + }); + }); +}); diff --git a/test/unit/controller/skill-simulation-controller-test.js b/test/unit/controller/skill-simulation-controller-test.js new file mode 100644 index 00000000..8a08d8ac --- /dev/null +++ b/test/unit/controller/skill-simulation-controller-test.js @@ -0,0 +1,191 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const jsonView = require('@src/view/json-view'); + +const SkillSimulationController = require('@src/controllers/skill-simulation-controller'); + +describe('Controller test - skill simulation controller test', () => { + describe('# test constructor', () => { + it('| constructor with config parameter returns SkillSimulationCotroller object', () => { + // call + const simulationController = new SkillSimulationController({ + profile: 'default', + debug: true, + skillId: 'a1b2c3', + locale: 'en-US', + stage: 'DEVELOPMENT' + }); + // verify + expect(simulationController).to.be.instanceOf(SkillSimulationController); + }); + + it('| constructor with empty config object returns SkillSimulationController object', () => { + // call + const simulationController = new SkillSimulationController({}); + // verify + expect(simulationController).to.be.instanceOf(SkillSimulationController); + }); + + it('| constructor with no args throws exception', () => { + try { + // call + new SkillSimulationController(); + } catch (err) { + // verify + expect(err).to.match(new RegExp('Cannot have an undefined configuration.')); + } + }); + }); + + describe('# test class method - startSkillSimulation', () => { + let simulateSkillStub; + let simulationController; + const TEST_MSG = 'TEST_MSG'; + + before(() => { + simulationController = new SkillSimulationController({}); + simulateSkillStub = sinon.stub(simulationController.smapiClient.skill.test, 'simulateSkill'); + }); + + after(() => { + sinon.restore(); + }); + + it('| test for SMAPI.test.simulateSkill gives correct 200 response', (done) => { + // setup + const TEST_RES = { + statusCode: 200, + headers: {}, + body: { + id: 'a1b2c3', + status: 'IN_PROGRESS', + result: null + } + }; + simulateSkillStub.callsArgWith(5, null, TEST_RES); + // call + simulationController.startSkillSimulation(TEST_MSG, true, (err, response) => { + // verify + expect(err).equal(null); + expect(response.statusCode).equal(200); + done(); + }); + }); + + it('| test for SMAPI.test.simulateSkill gives correct 300+ error', (done) => { + // setup + const TEST_RES = { + statusCode: 400, + headers: {}, + body: { + message: TEST_MSG + } + }; + simulateSkillStub.callsArgWith(5, null, TEST_RES); + // call + simulationController.startSkillSimulation(TEST_MSG, true, (err, response) => { + // verify + expect(err).to.equal(jsonView.toString(TEST_RES.body)); + expect(response).to.equal(undefined); + done(); + }); + }); + + it('| test for SMAPI.test.simulateSkill gives correct error', (done) => { + // setup + simulateSkillStub.callsArgWith(5, TEST_MSG, null); + // call + simulationController.startSkillSimulation(TEST_MSG, true, (err, response) => { + // verify + expect(err).to.equal(TEST_MSG); + expect(response).to.equal(undefined); + done(); + }); + }); + }); + + describe('# test class method - getSkillSimulationResult', () => { + let simulationController; + let getSimulationStub; + const TEST_MSG = 'TEST_MSG'; + + beforeEach(() => { + simulationController = new SkillSimulationController({}); + getSimulationStub = sinon.stub(simulationController.smapiClient.skill.test, 'getSimulation'); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('| getSimulation polling terminates on SUCCESSFUL status received', (done) => { + // setup + const TEST_RES = { + statusCode: 200, + headers: {}, + body: { + status: 'SUCCESSFUL' + } + }; + getSimulationStub.callsArgWith(3, null, TEST_RES); + // call + simulationController.getSkillSimulationResult('', (err, response) => { + // verify + expect(err).equal(null); + expect(response.body.status).equal('SUCCESSFUL'); + done(); + }); + }); + + it('| getSimulation polling terminates on statusCode greater than 300', (done) => { + // setup + const TEST_RES = { + statusCode: 400, + headers: {}, + body: { + status: 'SUCCESSFUL' + } + }; + getSimulationStub.callsArgWith(3, null, TEST_RES); + // call + simulationController.getSkillSimulationResult('', (err, response) => { + // verify + expect(err).equal(jsonView.toString(TEST_RES.body)); + expect(response).equal(undefined); + done(); + }); + }); + + it('| getSimulation polling terminates on FAILED status received', (done) => { + // setup + const TEST_RES = { + statusCode: 200, + headers: {}, + body: { + status: 'FAILED' + } + }; + getSimulationStub.callsArgWith(3, null, TEST_RES); + // call + simulationController.getSkillSimulationResult('', (err, response) => { + // verify + expect(err).equal(null); + expect(response.body.status).equal('FAILED'); + done(); + }); + }); + + it('| getSimulation polling terminates when max retry is reached from request error', (done) => { + // setup + getSimulationStub.callsArgWith(3, TEST_MSG); + // call + simulationController.getSkillSimulationResult('', (err, response) => { + // verify + expect(err).equal(TEST_MSG); + expect(response).equal(undefined); + done(); + }); + }); + }); +}); diff --git a/test/unit/fixture/model/dialog/dialog-replay-file.json b/test/unit/fixture/model/dialog/dialog-replay-file.json new file mode 100644 index 00000000..51d4065d --- /dev/null +++ b/test/unit/fixture/model/dialog/dialog-replay-file.json @@ -0,0 +1,9 @@ +{ + "skillId": "amzn1.ask.skill.1234567890", + "locale": "en-US", + "type": "text", + "userInput": [ + "hello", + "world" + ] +} \ No newline at end of file diff --git a/test/unit/fixture/model/dialog/invalid-dialog-replay-file.json b/test/unit/fixture/model/dialog/invalid-dialog-replay-file.json new file mode 100644 index 00000000..fd2bff1e --- /dev/null +++ b/test/unit/fixture/model/dialog/invalid-dialog-replay-file.json @@ -0,0 +1,9 @@ +{ + "skillId": "", + "locale": "", + "type": "text", + "userInput": [ + " ", + "world" + ] +} \ No newline at end of file diff --git a/test/unit/fixture/model/yaml-config.yml b/test/unit/fixture/model/yaml-config.yml new file mode 100644 index 00000000..84cb508e --- /dev/null +++ b/test/unit/fixture/model/yaml-config.yml @@ -0,0 +1,23 @@ +Parameters: + DeployEngineArn: + Type: String + Description: Deployment Lambda ARN + SkillId: + Type: String + Description: Skill ID +Mappings: + AlexaAwsRegionMap: + NA: + AwsRegion: us-east-1 + EU: + AwsRegion: eu-west-1 + FE: + AwsRegion: us-west-2 +Resources: + HelloWorldSkillInfrastructure: + Type: Custom::AlexaSkillInfrastructure + Properties: + InfrastructureIAMRole: !GetAtt SkillIAMRole.Arn + SkillId: !Ref SkillId + ServiceToken: !Ref DeployEngineArn +AWSTemplateFormatVersion: 2010-09-09 diff --git a/test/unit/model/dialog-replay-file-test.js b/test/unit/model/dialog-replay-file-test.js new file mode 100644 index 00000000..ce35b560 --- /dev/null +++ b/test/unit/model/dialog-replay-file-test.js @@ -0,0 +1,336 @@ +const { expect } = require('chai'); +const fs = require('fs'); +const path = require('path'); +const sinon = require('sinon'); +const yaml = require('@src/model/yaml-parser'); + +const DialogReplayFile = require('@src/model/dialog-replay-file'); + +describe('Model test - dialog replay file test', () => { + let dialogReplayFile; + const TEST_ERROR = 'error'; + const DIALOG_FIXTURE_PATH = path.join(process.cwd(), 'test', 'unit', 'fixture', 'model', 'dialog'); + const DIALOG_REPLAY_FILE_JSON_PATH = path.join(DIALOG_FIXTURE_PATH, 'dialog-replay-file.json'); + const FIXTURE_PATH = path.join(process.cwd(), 'test', 'unit', 'fixture', 'model'); + const NOT_EXISTING_DIALOG_REPLAY_FILE_PATH = path.join(FIXTURE_PATH, 'out-of-noWhere.json'); + const INVALID_JSON_DIALOG_REPLAY_FILE_PATH = path.join(FIXTURE_PATH, 'invalid-json.json'); + + describe('# test constructor', () => { + it('| constructor with non-existing file expect to catch error', () => { + try { + // setup & call + dialogReplayFile = new DialogReplayFile(NOT_EXISTING_DIALOG_REPLAY_FILE_PATH); + throw new Error('No error caught but supposed to throw an error when new.'); + } catch (err) { + // verify + const expectedError = `File ${NOT_EXISTING_DIALOG_REPLAY_FILE_PATH} not exists.`; + expect(err).to.match(new RegExp(expectedError)); + } + }); + + it('| constructor with existing JSON file reads successfully', () => { + // setup + const TEST_CONTENT = JSON.parse(fs.readFileSync(DIALOG_REPLAY_FILE_JSON_PATH)); + + // call + dialogReplayFile = new DialogReplayFile(DIALOG_REPLAY_FILE_JSON_PATH); + + // verify + expect(dialogReplayFile.content).deep.equal(TEST_CONTENT); + }); + + it('| make sure DialogReplayFile class is singleton', () => { + // setup & call + const dialogConfig1 = new DialogReplayFile(DIALOG_REPLAY_FILE_JSON_PATH); + const dialogConfig2 = new DialogReplayFile(DIALOG_REPLAY_FILE_JSON_PATH); + + // verify + expect(dialogConfig1 === dialogConfig2); + }); + + afterEach(() => { + dialogReplayFile = null; + }); + }); + + describe('# test getter methods', () => { + const TEST_CONTENT = JSON.parse(fs.readFileSync(DIALOG_REPLAY_FILE_JSON_PATH)); + + before(() => { + dialogReplayFile = new DialogReplayFile(DIALOG_REPLAY_FILE_JSON_PATH); + }); + + it('| test getSkillId', () => { + // setup & call + const res = dialogReplayFile.getSkillId(); + + // verify + expect(res).equal(TEST_CONTENT.skillId); + }); + + it('| test getLocale', () => { + // setup & call + const res = dialogReplayFile.getLocale(); + + // verify + expect(res).equal(TEST_CONTENT.locale); + }); + + it('| test getType', () => { + // setup & call + const res = dialogReplayFile.getType(); + + // verify + expect(res).equal(TEST_CONTENT.type); + }); + + it('| test getUserInput', () => { + // setup & call + const res = dialogReplayFile.getUserInput(); + + // verify + expect(res).deep.equal(TEST_CONTENT.userInput); + }); + + after(() => { + dialogReplayFile = null; + }); + }); + + describe('# test setter methods', () => { + before(() => { + dialogReplayFile = new DialogReplayFile(DIALOG_REPLAY_FILE_JSON_PATH); + }); + + after(() => { + dialogReplayFile = null; + }); + + it('| test setSkillId', () => { + // setup + const TEST_SKILLID = 'TEST_SKILLID'; + dialogReplayFile.setSkillId(TEST_SKILLID); + + // call + const res = dialogReplayFile.getSkillId(); + + // verify + expect(res).equal(TEST_SKILLID); + }); + + it('| test setLocale', () => { + // setup + const TEST_LOCALE = 'TEST_LOCALE'; + dialogReplayFile.setLocale(TEST_LOCALE); + + // call + const res = dialogReplayFile.getLocale(); + + // verify + expect(res).equal(TEST_LOCALE); + }); + + it('| test setType', () => { + // setup + const TEST_TYPE = 'TEST_TYPE'; + dialogReplayFile.setType(TEST_TYPE); + + // call + const res = dialogReplayFile.getType(); + + // verify + expect(res).equal(TEST_TYPE); + }); + + it('| test setUserInput', () => { + // setup + const TEST_USER_INPUT = ['TEST', 'USER', 'INPUT']; + dialogReplayFile.setUserInput(TEST_USER_INPUT); + + // call + const res = dialogReplayFile.getUserInput(); + + // verify + expect(res).deep.equal(TEST_USER_INPUT); + }); + + describe('# test read file content', () => { + afterEach(() => { + dialogReplayFile = null; + sinon.restore(); + }); + + it('| throws error if input file path does not exist', () => { + try { + // setup and call + dialogReplayFile = new DialogReplayFile(NOT_EXISTING_DIALOG_REPLAY_FILE_PATH); + } catch (error) { + // verify + expect(error).equal(`Failed to parse .json file ${NOT_EXISTING_DIALOG_REPLAY_FILE_PATH}.` + + `\nFile ${NOT_EXISTING_DIALOG_REPLAY_FILE_PATH} not exists.`); + } + }); + + it('| throws error if READ permission is not granted', () => { + // setup + sinon.stub(fs, 'accessSync').throws(new Error(TEST_ERROR)); + try { + // call + dialogReplayFile = new DialogReplayFile(INVALID_JSON_DIALOG_REPLAY_FILE_PATH); + } catch (error) { + // verify + expect(error).equal(`Failed to parse .json file ${INVALID_JSON_DIALOG_REPLAY_FILE_PATH}.` + + `\n${TEST_ERROR}`); + } + }); + + it('| test JSON file path extension', () => { + // setup + sinon.stub(fs, 'readFileSync').returns('{"skillId":"amzn1.ask.skill.1234567890"}'); + + // call + dialogReplayFile = new DialogReplayFile(DIALOG_REPLAY_FILE_JSON_PATH); + + // verify + expect(dialogReplayFile.content).deep.equal({ + skillId: 'amzn1.ask.skill.1234567890' + }); + }); + + it('| test YAML file path extension', () => { + // setup + const YAML_FILE_PATH = path.join(FIXTURE_PATH, 'yaml-config.yaml'); + const yamlStub = sinon.stub(yaml, 'load'); + + // call + dialogReplayFile = new DialogReplayFile(YAML_FILE_PATH); + + // verify + expect(yamlStub.calledOnce).equal(true); + }); + + it('| test YML file path extension', () => { + // setup + const YML_FILE_PATH = path.join(FIXTURE_PATH, 'yaml-config.yml'); + const ymlStub = sinon.stub(yaml, 'load'); + + // call + dialogReplayFile = new DialogReplayFile(YML_FILE_PATH); + + // verify + expect(ymlStub.calledOnce).equal(true); + }); + + it(' throws error if neither JSON nor YAML/YML file path extension', () => { + // setup + sinon.stub(DialogReplayFile.prototype, 'doesFileExist'); + sinon.stub(fs, 'accessSync'); + const UNSUPPORTED_EXTENSION_FILE_PATH = path.join(FIXTURE_PATH, 'yaml-config.random'); + + try { + // call + dialogReplayFile = new DialogReplayFile(UNSUPPORTED_EXTENSION_FILE_PATH); + } catch (error) { + // verify + expect(error).equal(`Failed to parse .random file ${UNSUPPORTED_EXTENSION_FILE_PATH}.` + + '\nASK CLI does not support this file type.'); + } + }); + }); + + describe('# test write file content', () => { + beforeEach(() => { + sinon.stub(DialogReplayFile.prototype, 'readFileContent').returns({}); + }); + + afterEach(() => { + dialogReplayFile = null; + sinon.restore(); + }); + + it('| throws error if input file path does not exist', () => { + // setup + dialogReplayFile = new DialogReplayFile(NOT_EXISTING_DIALOG_REPLAY_FILE_PATH); + + try { + // call + dialogReplayFile.writeContentToFile('', NOT_EXISTING_DIALOG_REPLAY_FILE_PATH); + } catch (error) { + // verify + expect(error).equal(`Failed to write to file ${NOT_EXISTING_DIALOG_REPLAY_FILE_PATH}.` + + `\nFile ${NOT_EXISTING_DIALOG_REPLAY_FILE_PATH} not exists.`); + } + }); + + it('| throws error if WRITE permission is not granted', () => { + // setup + sinon.stub(fs, 'accessSync').throws(new Error(TEST_ERROR)); + dialogReplayFile = new DialogReplayFile(DIALOG_REPLAY_FILE_JSON_PATH); + + try { + // call + dialogReplayFile.writeContentToFile('', DIALOG_REPLAY_FILE_JSON_PATH); + } catch (error) { + // verify + expect(error).equal(`Failed to write to file ${DIALOG_REPLAY_FILE_JSON_PATH}.` + + `\n${TEST_ERROR}`); + } + }); + + it('| test JSON file path extension', () => { + // setup + const writeFileStub = sinon.stub(fs, 'writeFileSync'); + dialogReplayFile = new DialogReplayFile(DIALOG_REPLAY_FILE_JSON_PATH); + + // call + dialogReplayFile.writeContentToFile('', DIALOG_REPLAY_FILE_JSON_PATH); + + // verify + expect(writeFileStub.calledOnce).equal(true); + }); + + it('| test YAML file path extension', () => { + // setup + const YAML_FILE_PATH = path.join(FIXTURE_PATH, 'yaml-config.yaml'); + const yamlStub = sinon.stub(yaml, 'dump'); + + // call + dialogReplayFile = new DialogReplayFile(YAML_FILE_PATH); + dialogReplayFile.writeContentToFile('', YAML_FILE_PATH); + + // verify + expect(yamlStub.calledOnce).equal(true); + }); + + it('| test YML file path extension', () => { + // setup + const YML_FILE_PATH = path.join(FIXTURE_PATH, 'yaml-config.yml'); + const ymlStub = sinon.stub(yaml, 'dump'); + + // call + dialogReplayFile = new DialogReplayFile(YML_FILE_PATH); + dialogReplayFile.writeContentToFile('', YML_FILE_PATH); + + // verify + expect(ymlStub.calledOnce).equal(true); + }); + + it(' throws error if neither JSON nor YAML/YML file path extension', () => { + // setup + sinon.stub(DialogReplayFile.prototype, 'doesFileExist'); + sinon.stub(fs, 'accessSync'); + const UNSUPPORTED_EXTENSION_FILE_PATH = path.join(FIXTURE_PATH, 'yaml-config.random'); + + try { + // call + dialogReplayFile = new DialogReplayFile(UNSUPPORTED_EXTENSION_FILE_PATH); + dialogReplayFile.writeContentToFile('', UNSUPPORTED_EXTENSION_FILE_PATH); + } catch (error) { + // verify + expect(error).equal(`Failed to write to file ${UNSUPPORTED_EXTENSION_FILE_PATH}.` + + '\nASK CLI does not support this file type.'); + } + }); + }); + }); +}); diff --git a/test/unit/run-test.js b/test/unit/run-test.js index 03f35b85..bbd02c36 100644 --- a/test/unit/run-test.js +++ b/test/unit/run-test.js @@ -30,6 +30,11 @@ require('module-alias/register'); '@test/unit/commands/init/index-test', '@test/unit/commands/init/ui-test', '@test/unit/commands/init/helper-test', + // command - dialog + '@test/unit/commands/dialog/index-test', + '@test/unit/commands/dialog/helper-test', + '@test/unit/commands/dialog/replay-mode-test', + '@test/unit/commands/dialog/interactive-mode-test', // command - deploy '@test/unit/commands/deploy/index-test', '@test/unit/commands/deploy/helper-test', @@ -55,11 +60,15 @@ require('module-alias/register'); '@test/unit/model/resources-config-test', '@test/unit/model/yaml-parser-test', '@test/unit/model/regional-stack-file-test', + '@test/unit/model/dialog-replay-file-test', // controller '@test/unit/controller/authorization-controller/index-test', '@test/unit/controller/authorization-controller/server-test', + '@test/unit/controller/dialog-controller/index-test', + '@test/unit/controller/dialog-controller/simulation-response-parser-test', '@test/unit/controller/skill-metadata-controller-test', '@test/unit/controller/skill-code-controller-test', + '@test/unit/controller/skill-simulation-controller-test', '@test/unit/controller/code-builder-test', '@test/unit/controller/skill-infrastructure-controller-test', '@test/unit/controller/deploy-delegate-test', @@ -68,6 +77,8 @@ require('module-alias/register'); '@test/unit/view/json-view-test', '@test/unit/view/spinner-view-test', '@test/unit/view/multi-tasks-view-test', + '@test/unit/view/cli-repl-view-test', + '@test/unit/view/dialog-repl-view-test', // utils '@test/unit/utils/url-utils-test', '@test/unit/utils/string-utils-test', diff --git a/test/unit/view/cli-repl-view-test.js b/test/unit/view/cli-repl-view-test.js new file mode 100644 index 00000000..d2d6eef5 --- /dev/null +++ b/test/unit/view/cli-repl-view-test.js @@ -0,0 +1,297 @@ +const { expect } = require('chai'); +const proxyquire = require('proxyquire'); +const sinon = require('sinon'); + +const Messenger = require('@src/view/messenger'); +const SpinnerView = require('@src/view/spinner-view'); + +describe('View test - cli repl view test', () => { + const TEST_MESSAGE = 'TEST_MESSAGE'; + let infoStub; + let removeListenersStub; + let defineCommandStub; + let onStub; + let replStub; + let closeStub; + + beforeEach(() => { + infoStub = sinon.stub(); + removeListenersStub = sinon.stub(); + defineCommandStub = sinon.stub(); + onStub = sinon.stub(); + closeStub = sinon.stub(); + replStub = { + start: () => ({ + removeAllListeners: removeListenersStub, + commands: { + help: 'help' + }, + defineCommand: defineCommandStub, + on: onStub, + close: closeStub + }) + }; + sinon.stub(Messenger, 'getInstance').returns({ + info: infoStub + }); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('# inspect correctness of the constructor', () => { + it('| initialize CliReplView with custom prompt', () => { + // setup + const ProxyCliReplView = proxyquire('@src/view/cli-repl-view', { + repl: replStub + }); + + // call + new ProxyCliReplView({ prompt: TEST_MESSAGE }); + + // verify + expect(removeListenersStub.callCount).equal(3); + expect(defineCommandStub.callCount).equal(1); + expect(onStub.callCount).equal(1); + }); + + it('| initialize with custom headers array', () => { + // setup + const header = TEST_MESSAGE; + const ProxyCliReplView = proxyquire('@src/view/cli-repl-view', { + repl: replStub + }); + + // call + const cliReplView = new ProxyCliReplView({ header }); + + // verify + expect(cliReplView.header).equal(header); + }); + + it('| initialize with custom eval function', () => { + // setup + const ProxyCliReplView = proxyquire('@src/view/cli-repl-view', { + repl: replStub + }); + const evalFunc = (cmd, context, filename, callback) => { + callback(null, 'Hello'); + }; + + // call + const cliReplView = new ProxyCliReplView({ evalFunc }); + + // verify + expect(cliReplView.eval).equal(evalFunc); + }); + + it('| initialize with custom footer array', () => { + // setup + const footer = TEST_MESSAGE; + const ProxyCliReplView = proxyquire('@src/view/cli-repl-view', { + repl: replStub + }); + + // call + const cliReplView = new ProxyCliReplView({ footer }); + + // verify + expect(cliReplView.footer).equal(footer); + }); + + it('| throw error if configuration is undefined', () => { + // setup + const ProxyCliReplView = proxyquire('@src/view/cli-repl-view', { + repl: replStub + }); + try { + // call + new ProxyCliReplView(); + } catch (err) { + // verify + expect(err).to.match(new RegExp('Cannot have an undefined configuration.')); + } + }); + + it('| throw error if invalid prompt is passed', () => { + // setup + const ProxyCliReplView = proxyquire('@src/view/cli-repl-view', { + repl: replStub + }); + const prompt = 1234; + try { + // call + new ProxyCliReplView({ prompt }); + } catch (err) { + // verify + expect(err).to.match(new RegExp('Prompt must be a non-empty string.')); + } + }); + }); + + describe('# inspect correctness of methods', () => { + afterEach(() => { + sinon.restore(); + }); + + it('| printHeaderFooter call with default options okay.', () => { + // setup + const ProxyCliReplView = proxyquire('@src/view/cli-repl-view', { + repl: replStub + }); + const cliReplView = new ProxyCliReplView({}); + + // call + cliReplView.printHeaderFooter(cliReplView.header); + + // verify + expect(infoStub.callCount).equal(0); + }); + + it('| printHeaderFooter call with custom header option okay.', () => { + // setup + const ProxyCliReplView = proxyquire('@src/view/cli-repl-view', { + repl: replStub + }); + const cliReplView = new ProxyCliReplView({ header: TEST_MESSAGE }); + + // call + cliReplView.printHeaderFooter(cliReplView.header); + + // verify + expect(infoStub.callCount).equal(2); + }); + + it('| printHeaderFooter call with invalid header does print.', () => { + // setup + const header = () => {}; + const ProxyCliReplView = proxyquire('@src/view/cli-repl-view', { + repl: replStub + }); + const cliReplView = new ProxyCliReplView({ header }); + + // call + cliReplView.printHeaderFooter(cliReplView.header); + + // verify + expect(infoStub.callCount).equal(0); + }); + + it('| registerCommand successful', () => { + // setup + const ProxyCliReplView = proxyquire('@src/view/cli-repl-view', { + repl: replStub + }); + const cliReplView = new ProxyCliReplView({}); + const command = { help: TEST_MESSAGE, action: () => {} }; + + // call + cliReplView.registerSpecialCommand(TEST_MESSAGE, command.help, command.action); + + // verify + expect(defineCommandStub.callCount).equal(2); + expect(defineCommandStub.args[0][0]).equal('quit'); + expect(defineCommandStub.args[0][1].help).equal('Quit repl session.'); + expect(defineCommandStub.args[1][0]).equal(TEST_MESSAGE); + }); + + it('| test close method', () => { + // setup + const ProxyCliReplView = proxyquire('@src/view/cli-repl-view', { + repl: replStub + }); + const cliReplView = new ProxyCliReplView({}); + const spinnerStartStub = sinon.stub(SpinnerView.prototype, 'start'); + + // call + cliReplView.startProgressSpinner(TEST_MESSAGE); + cliReplView.close(); + + // verify + expect(spinnerStartStub.calledOnce).equal(true); + expect(closeStub.calledOnce).equal(true); + }); + + it('| test registerQuitFunction method', () => { + // setup + const ProxyCliReplView = proxyquire('@src/view/cli-repl-view', { + repl: replStub + }); + const spinnerTerminateStub = sinon.stub(SpinnerView.prototype, 'terminate'); + const cliReplView = new ProxyCliReplView({}); + replStub.start().on.callsArgWith(1, ''); + const closeEventListener = sinon.stub(); + + // call + cliReplView.registerQuitCommand(closeEventListener); + + // verify + expect(onStub.callCount).equal(2); + expect(removeListenersStub.callCount).equal(4); + expect(defineCommandStub.callCount).equal(2); + expect(defineCommandStub.args[0][1].help).equal('Quit repl session.'); + expect(defineCommandStub.args[1][0]).equal('quit'); + expect(closeEventListener.callCount).equal(1); + expect(spinnerTerminateStub.callCount).equal(1); + }); + + it('| test clearSpecialCommands method', () => { + // setup + const ProxyCliReplView = proxyquire('@src/view/cli-repl-view', { + repl: replStub + }); + const cliReplView = new ProxyCliReplView({}); + + // call + cliReplView.clearSpecialCommands(); + + // verify + expect(removeListenersStub.callCount).equal(4); + expect(removeListenersStub.args[0][0]).equal('SIGINT'); + expect(removeListenersStub.args[1][0]).equal('close'); + }); + }); + + describe('# inspect correctness of spinner view wrapper methods', () => { + it('| updateProgressSpinner okay.', () => { + // setup + const UPDATE_MESSAGE = 'UPDATE_MESSAGE'; + const ProxyCliReplView = proxyquire('@src/view/cli-repl-view', { + repl: replStub + }); + const cliReplView = new ProxyCliReplView({}); + const spinnerStartStub = sinon.stub(SpinnerView.prototype, 'start'); + const spinnerUpdateStub = sinon.stub(SpinnerView.prototype, 'update'); + + // call + cliReplView.startProgressSpinner(TEST_MESSAGE); + cliReplView.updateProgressSpinner(UPDATE_MESSAGE); + + // verify + expect(spinnerStartStub.calledOnce).equal(true); + expect(spinnerStartStub.args[0][0]).equal(TEST_MESSAGE); + expect(spinnerUpdateStub.calledOnce).equal(true); + expect(spinnerUpdateStub.args[0][0]).equal(UPDATE_MESSAGE); + }); + + it('| terminateProgressSpinner okay.', () => { + // setup + const ProxyCliReplView = proxyquire('@src/view/cli-repl-view', { + repl: replStub + }); + const cliReplView = new ProxyCliReplView({}); + + // call + const spinnerStartStub = sinon.stub(SpinnerView.prototype, 'start'); + const spinnerTerminateStub = sinon.stub(SpinnerView.prototype, 'terminate'); + cliReplView.startProgressSpinner(TEST_MESSAGE); + cliReplView.terminateProgressSpinner(); + + // verify + expect(spinnerStartStub.callCount).equal(1); + expect(spinnerStartStub.args[0][0]).equal(TEST_MESSAGE); + expect(spinnerTerminateStub.callCount).equal(1); + }); + }); +}); diff --git a/test/unit/view/dialog-repl-view-test.js b/test/unit/view/dialog-repl-view-test.js new file mode 100644 index 00000000..d4d5404e --- /dev/null +++ b/test/unit/view/dialog-repl-view-test.js @@ -0,0 +1,82 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const DialogReplView = require('@src/view/dialog-repl-view'); +const Messenger = require('@src/view/messenger'); + +describe('View test - dialog repl view test', () => { + const TEST_MESSAGE = 'TEST_MESSAGE'; + + describe('# inspect correctness of the constructor', () => { + const infoStub = sinon.stub(); + beforeEach(() => { + sinon.stub(Messenger, 'getInstance').returns({ + info: infoStub + }); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('| throw exception for initialization with non-String headers arg', () => { + // setup + const header = 123; + try { + // call + new DialogReplView({ header }); + } catch (err) { + // verify + expect(err).to.match(new RegExp('TypeError: arg.split is not a function')); + } + }); + + it('| throw exception for initialization with empty configuration', () => { + // setup + try { + // call + new DialogReplView(); + } catch (err) { + // verify + expect(err).to.match(new RegExp('TypeError: arg.split is not a function')); + } + }); + + it('| test prettifyHeaderFooter returns correct string', () => { + // setup + const header = TEST_MESSAGE; + const dialogReplView = new DialogReplView({ header }); + process.stdout.columns = 20; + + // call + const prettifiedHeader = dialogReplView.prettifyHeaderFooter(header).trim(); + + // verify + expect(prettifiedHeader.length).equal(20); + }); + + it('| test prettifyHeaderFooter returns correct string when terminal is too small', () => { + // setup + const header = TEST_MESSAGE; + const dialogReplView = new DialogReplView({ header }); + process.stdout.columns = 10; + + // call & verify + expect(dialogReplView.prettifyHeaderFooter(header).trim().length).equal(20); + }); + + it('| test record special command registered without error', () => { + // setup + const dialogReplView = new DialogReplView({}); + + // call + dialogReplView.registerRecordCommand(() => {}); + + // verify + expect(dialogReplView.replServer.commands.record.help).deep.equal( + 'Record input utterances to a replay file of a specified name.' + ); + expect(dialogReplView.replServer.commands.record.action).to.be.a('Function'); + }); + }); +}); diff --git a/test/unit/view/spinner-view-test.js b/test/unit/view/spinner-view-test.js index 45cdd93c..3d6c7655 100644 --- a/test/unit/view/spinner-view-test.js +++ b/test/unit/view/spinner-view-test.js @@ -8,9 +8,6 @@ describe('View test - spinner view test', () => { const TEST_MESSAGE = 'TEST_MESSAGE'; describe('# inspect correctness for constructor', () => { - beforeEach(() => { - }); - afterEach(() => { sinon.restore(); }); @@ -65,10 +62,9 @@ describe('View test - spinner view test', () => { describe('# test class methods', () => { let ProxySpinnerView; - let textStub, startStub, stopStub, succeedStub, failStub, warnStub, infoStub, stopAndPersistStub; + let startStub, stopStub, succeedStub, failStub, warnStub, infoStub, stopAndPersistStub; beforeEach(() => { - textStub = sinon.stub(); startStub = sinon.stub(); stopStub = sinon.stub(); succeedStub = sinon.stub(); @@ -78,7 +74,6 @@ describe('View test - spinner view test', () => { stopAndPersistStub = sinon.stub(); ProxySpinnerView = proxyquire('@src/view/spinner-view', { ora: () => ({ - text: textStub, start: startStub, stop: stopStub, succeed: succeedStub, @@ -94,7 +89,7 @@ describe('View test - spinner view test', () => { sinon.restore(); }); - it('| test SpinnerViwe class method - start spinner', () => { + it('| test SpinnerView class method - start spinner', () => { // call const spinner = new ProxySpinnerView(); spinner.start(); @@ -103,7 +98,7 @@ describe('View test - spinner view test', () => { expect(startStub.args[0][0]).equal(undefined); }); - it('| test SpinnerViwe class method - start with message', () => { + it('| test SpinnerView class method - start with message', () => { // call const spinner = new ProxySpinnerView(); spinner.start(TEST_MESSAGE); @@ -112,16 +107,15 @@ describe('View test - spinner view test', () => { expect(startStub.args[0][0]).equal(TEST_MESSAGE); }); - it('| test SpinnerViwe class method - update with message', () => { + it('| test SpinnerView class method - update with message', () => { // call const spinner = new ProxySpinnerView(); spinner.update(TEST_MESSAGE); // expect - expect(textStub.callCount).equal(1); - expect(textStub.args[0][0]).equal(TEST_MESSAGE); + expect(spinner.oraSpinner.text).equal(TEST_MESSAGE); }); - it('| test SpinnerViwe class method - terminate with succeed style', () => { + it('| test SpinnerView class method - terminate with succeed style', () => { // call const spinner = new ProxySpinnerView(); spinner.terminate(SpinnerView.TERMINATE_STYLE.SUCCEED); @@ -130,7 +124,7 @@ describe('View test - spinner view test', () => { expect(succeedStub.args[0][0]).equal(undefined); }); - it('| test SpinnerViwe class method - terminate with fail style', () => { + it('| test SpinnerView class method - terminate with fail style', () => { // call const spinner = new ProxySpinnerView(); spinner.terminate(SpinnerView.TERMINATE_STYLE.FAIL); @@ -139,7 +133,7 @@ describe('View test - spinner view test', () => { expect(failStub.args[0][0]).equal(undefined); }); - it('| test SpinnerViwe class method - terminate with warn style', () => { + it('| test SpinnerView class method - terminate with warn style', () => { // call const spinner = new ProxySpinnerView(); spinner.terminate(SpinnerView.TERMINATE_STYLE.WARN); @@ -148,7 +142,7 @@ describe('View test - spinner view test', () => { expect(warnStub.args[0][0]).equal(undefined); }); - it('| test SpinnerViwe class method - terminate with info style', () => { + it('| test SpinnerView class method - terminate with info style', () => { // call const spinner = new ProxySpinnerView(); spinner.terminate(SpinnerView.TERMINATE_STYLE.INFO); @@ -157,7 +151,7 @@ describe('View test - spinner view test', () => { expect(infoStub.args[0][0]).equal(undefined); }); - it('| test SpinnerViwe class method - terminate with persist style', () => { + it('| test SpinnerView class method - terminate with persist style', () => { // call const spinner = new ProxySpinnerView(); spinner.terminate(SpinnerView.TERMINATE_STYLE.PERSIST); @@ -166,7 +160,7 @@ describe('View test - spinner view test', () => { expect(stopAndPersistStub.args[0][0]).equal(undefined); }); - it('| test SpinnerViwe class method - terminate with clear style', () => { + it('| test SpinnerView class method - terminate with clear style', () => { // call const spinner = new ProxySpinnerView(); spinner.terminate(SpinnerView.TERMINATE_STYLE.CLEAR); @@ -175,7 +169,7 @@ describe('View test - spinner view test', () => { expect(stopStub.args[0][0]).equal(undefined); }); - it('| test SpinnerViwe class method - terminate with default style', () => { + it('| test SpinnerView class method - terminate with default style', () => { // call const spinner = new ProxySpinnerView(); spinner.terminate();