UNPKG

botium-core

Version:
918 lines (842 loc) 40.5 kB
const util = require('util') const _ = require('lodash') const debug = require('debug')('botium-core-Convo') const BotiumMockMessage = require('../mocks/BotiumMockMessage') const Capabilities = require('../Capabilities') const Events = require('../Events') const ScriptingMemory = require('./ScriptingMemory') const { BotiumError, botiumErrorFromErr, botiumErrorFromList } = require('./BotiumError') const { normalizeText, toString, removeBuffers, splitStringInNonEmptyLines } = require('./helper') const { LOGIC_HOOK_INCLUDE } = require('./logichook/LogicHookConsts') class ConvoHeader { constructor (fromJson = {}) { this.name = fromJson.name this.projectname = fromJson.projectname this.testsessionname = fromJson.testsessionname this.sort = fromJson.sort this.order = fromJson.order this.description = fromJson.description Object.assign(this, fromJson) } toString () { return this.order + ' ' + this.name + (this.description ? ` (${this.description})` : '') } } class ConvoStepAssert { constructor (fromJson = {}) { this.name = fromJson.name this.args = fromJson.args this.not = fromJson.not this.optional = fromJson.optional } toString () { return (this.optional ? '?' : '') + (this.not ? '!' : '') + this.name + '(' + (this.args ? this.args.map(a => _.truncate(a, { length: 200 })).join(',') : 'no args') + ')' } } class ConvoStepLogicHook { constructor (fromJson = {}) { this.name = fromJson.name this.args = fromJson.args } toString () { return this.name + '(' + (this.args ? this.args.map(a => _.truncate(a, { length: 200 })).join(',') : 'no args') + ')' } } class ConvoStepUserInput { constructor (fromJson = {}) { this.name = fromJson.name this.args = fromJson.args } toString () { return this.name + '(' + (this.args ? this.args.map(a => _.truncate(a, { length: 200 })).join(',') : 'no args') + ')' } } class ConvoStep { constructor (fromJson = {}) { this.sender = fromJson.sender this.channel = fromJson.channel this.messageText = fromJson.messageText this.sourceData = fromJson.sourceData this.stepTag = fromJson.stepTag this.not = fromJson.not this.optional = fromJson.optional this.asserters = _.map(fromJson.asserters, (asserter) => new ConvoStepAssert(asserter)) this.logicHooks = _.map(fromJson.logicHooks, (logicHook) => new ConvoStepLogicHook(logicHook)) this.userInputs = _.map(fromJson.userInputs, (userInput) => new ConvoStepUserInput(userInput)) } hasInteraction () { return (this.messageText && this.messageText.length > 0) || this.sourceData || (this.asserters && this.asserters.length > 0) || (this.logicHooks && this.logicHooks.findIndex(l => l.name !== LOGIC_HOOK_INCLUDE) >= 0) || (this.userInputs && this.userInputs.length > 0) } toString () { return (this.stepTag ? this.stepTag + ': ' : '') + '#' + this.sender + ' - ' + (this.optional ? '?' : '') + (this.not ? '!' : '') + (this.messageText || '') + (this.asserters && this.asserters.length > 0 ? ' ' + this.asserters.map(a => a.toString()).join(' ') : '') + (this.logicHooks && this.logicHooks.length > 0 ? ' ' + this.logicHooks.map(l => l.toString()).join(' ') : '') + (this.userInputs && this.userInputs.length > 0 ? ' ' + this.userInputs.map(u => u.toString()).join(' ') : '') } } class Transcript { constructor ({ steps, attachments, scriptingMemory, convoBegin, convoEnd, err }) { this.steps = steps this.attachments = attachments this.scriptingMemory = scriptingMemory this.convoBegin = convoBegin this.convoEnd = convoEnd this.err = err } prettifyActual () { const prettifiedSteps = this.steps.map(step => { if (step.actual && step.actual.prettify) { return step.actual.prettify() } else { return '<empty conversation step>' } }) return prettifiedSteps.join('\n') } } class TranscriptAttachment { constructor (fromJson = {}) { this.name = fromJson.name this.mimeType = fromJson.mimeType this.base64 = fromJson.base64 this.href = fromJson.href } } class TranscriptStep { constructor ({ expected, not, optional, actual, stepBegin, stepEnd, botBegin, botEnd, err }) { this.expected = expected this.not = not this.optional = optional this.actual = actual this.stepBegin = stepBegin this.stepEnd = stepEnd this.botBegin = botBegin this.botEnd = botEnd this.err = err } } class TranscriptError extends Error { constructor (err, transcript) { super(err.message) this.name = this.constructor.name this.transcript = transcript this.cause = err Error.captureStackTrace(this, this.constructor) } } class Convo { constructor (context, fromJson = {}) { if (fromJson instanceof Convo) { debug('Illegal state!!! Parameter should be a JSON, but it is a Convo') } else if (fromJson.beginAsserter) { // beginAsserter is one of the fields which are lost debug('Illegal state!!! Parameter should be a native JSON, but looks as a Convo converted to JSON') } this.scriptingEvents = context.scriptingEvents this.context = context this.header = new ConvoHeader(fromJson.header) if (fromJson.conversation && _.isArray(fromJson.conversation)) { this.conversation = _.map(fromJson.conversation, (step) => new ConvoStep(step)) } else { this.conversation = [] } this.sourceTag = fromJson.sourceTag const { beginAsserter, endAsserter } = this.setConvoBeginAndEndAsserter(fromJson) this.beginAsserter = beginAsserter this.endAsserter = endAsserter const { beginLogicHook, endLogicHook } = this.setConvoBeginAndEndLogicHook(fromJson) this.beginLogicHook = beginLogicHook this.endLogicHook = endLogicHook this.effectiveConversation = null } setConvoBeginAndEndAsserter (fromJson) { const beginAsserter = fromJson.conversation .filter(s => s.sender === 'begin' && s.asserters && s.asserters.length > 0) .map(s => s.asserters) .reduce((acc, val) => acc.concat(val), []) const endAsserter = fromJson.conversation .filter(s => s.sender === 'end' && s.asserters && s.asserters.length > 0) .map(s => s.asserters) .reduce((acc, val) => acc.concat(val), []) return { beginAsserter, endAsserter } } setConvoBeginAndEndLogicHook (fromJson) { const beginLogicHook = fromJson.conversation .filter(s => s.sender === 'begin' && s.logicHooks && s.logicHooks.length > 0) .map(s => s.logicHooks) .reduce((acc, val) => acc.concat(val), []) const endLogicHook = fromJson.conversation .filter(s => s.sender === 'end' && s.logicHooks && s.logicHooks.length > 0) .map(s => s.logicHooks) .reduce((acc, val) => acc.concat(val), []) return { beginLogicHook, endLogicHook } } toString () { return this.header.toString() + (this.sourceTag ? ` (${util.inspect(this.sourceTag)})` : '') + ': ' + this.conversation.map((c) => c.toString()).join(' | ') } async Run (container) { const transcript = new Transcript({ steps: [], attachments: [], convoBegin: new Date(), convoEnd: null, err: null }) const scriptingMemory = { } container.caps[Capabilities.TESTCASENAME] = this.header.name try { try { // onConvoBegin first or assertConvoBegin? If onConvoBegin, then it is possible to assert it too await this.scriptingEvents.onConvoBegin({ convo: this, convoStep: { stepTag: '#begin' }, container, transcript, scriptingMemory }) } catch (err) { throw new TranscriptError(botiumErrorFromErr(`${this.header.name}: ${err.message}`, err), transcript) } try { await this.scriptingEvents.assertConvoBegin({ convo: this, convoStep: { stepTag: '#begin' }, container, scriptingMemory }) } catch (err) { throw new TranscriptError(botiumErrorFromErr(`${this.header.name}: ${err.message}`, err), transcript) } await this.runConversation(container, scriptingMemory, transcript) await this._checkBotRepliesConsumed(container) try { await this.scriptingEvents.onConvoEnd({ convo: this, convoStep: { stepTag: '#end' }, container, transcript, scriptingMemory }) } catch (err) { throw new TranscriptError(botiumErrorFromErr(`${this.header.name}: ${err.message}`, err), transcript) } if (transcript.err && container.caps[Capabilities.SCRIPTING_ENABLE_MULTIPLE_ASSERT_ERRORS]) { let assertConvoEndErr = null try { await this.scriptingEvents.assertConvoEnd({ convo: this, convoStep: { stepTag: '#end' }, container, transcript, scriptingMemory }) } catch (err) { assertConvoEndErr = botiumErrorFromErr(`${this.header.name}: ${err.message}`, err) } if (assertConvoEndErr) { const err = transcript.err transcript.err = botiumErrorFromList([transcript.err, assertConvoEndErr], {}) transcript.err.context.input = err.context.input transcript.err.context.transcript = err.context.transcript } throw new TranscriptError(transcript.err, transcript) } else if (transcript.err) { throw new TranscriptError(transcript.err, transcript) } try { await this.scriptingEvents.assertConvoEnd({ convo: this, convoStep: { stepTag: '#end' }, container, transcript, scriptingMemory }) } catch (err) { transcript.err = botiumErrorFromErr(`${this.header.name}: ${err.message}`, err) throw new TranscriptError(transcript.err, transcript) } return transcript } finally { container.eventEmitter.emit(Events.MESSAGE_TRANSCRIPT, container, transcript) } } async runConversation (container, scriptingMemory, transcript) { const transcriptSteps = [] transcript.steps = transcriptSteps try { let lastMeConvoStep = null let botMsg = null let waitForBotSays = true let skipTranscriptStep = false let conditionalGroupId = null let conditionMetInGroup = false let skipOptionalStep = false // If there are optional step(s) in the conversation, and the message from the bot fails on each optional bot step(s) and/or mandatory bot step, then we have an unexpected message. // So in this case an unexpected error should be shown instead of the latest assertion error. let optionalStepAssertionError = false let globalConvoStepParameters = container.caps[Capabilities.SCRIPTING_CONVO_STEP_PARAMETERS] || {} let retryBotMessageTimeoutEnd = null let retryBotMessageConvoId = null let retryBotMessageDropBotResponse = false for (let i = 0; i < this.conversation.length; i = (retryBotMessageDropBotResponse ? i : i + 1)) { retryBotMessageDropBotResponse = false const convoStep = this.conversation[i] if (!convoStep.optional) { skipOptionalStep = false } if (convoStep.optional && skipOptionalStep) { // If there are multiple optional steps, and the previous optional step was timeout, then the next optional step should be skipped to prevent too long convo run with multiple timeout. continue } const rawConvoStepParameters = convoStep.logicHooks.find(lh => lh.name === 'CONVO_STEP_PARAMETERS')?.args let convoStepParameters = {} if (rawConvoStepParameters && rawConvoStepParameters.length) { let params if (rawConvoStepParameters[0].trim().startsWith('{')) { try { params = JSON.parse(rawConvoStepParameters[0]) } catch (e) { debug(`${this.header.name}/${convoStep.stepTag}: Failed to parse convo step parameters from JSON ${rawConvoStepParameters[0]}`) } } if (!params || !Object.keys(params).length) { params = {} for (const param of rawConvoStepParameters) { const semicolon = param.indexOf(':') if (semicolon) { try { const name = param.substring(0, semicolon) const value = param.substring(semicolon + 1) params[name] = value } catch (e) { debug(`${this.header.name}/${convoStep.stepTag}: Failed to parse convo step parameter from arg ${param}`) } } } } if (convoStep.sender === 'begin') { globalConvoStepParameters = Object.assign({}, globalConvoStepParameters || {}, params) } else { convoStepParameters = Object.assign({}, globalConvoStepParameters || {}, params) } } else { if (convoStep.sender !== 'begin') { convoStepParameters = globalConvoStepParameters } } if (Object.keys(convoStepParameters).length) { debug(`${this.header.name}: using convo step parameters ${JSON.stringify(convoStepParameters)}`) } const currentStepIndex = i container.eventEmitter.emit(Events.CONVO_STEP_NEXT, container, convoStep, i) skipTranscriptStep = false const transcriptStep = new TranscriptStep({ expected: new BotiumMockMessage(convoStep), not: convoStep.not, optional: convoStep.optional, actual: null, stepBegin: new Date(), stepEnd: null, botBegin: null, botEnd: null, err: null }) try { if (convoStep.sender === 'begin' || convoStep.sender === 'end') { continue } else if (convoStep.sender === 'me') { const meMsg = new BotiumMockMessage(convoStep) meMsg.messageText = ScriptingMemory.apply(container, scriptingMemory, meMsg.messageText, meMsg) // buggy command is removed, but because sideeffects are possible, it can be reactivated. // If there are no sideeffects coming up, then row can be deleted permanently. if (process.env.WORKAROUND_OVERWRITE_JSON_MESSAGE_TEXT) { // if this line is active, then Random() in me section does not work in performance test // (first run overwrites the function with the value, and the next run has the value, not the function) convoStep.messageText = meMsg.messageText } transcriptStep.actual = meMsg try { await this.scriptingEvents.setUserInput({ convo: this, convoStep, container, scriptingMemory, meMsg, transcript, transcriptStep, transcriptSteps }) await this.scriptingEvents.onMeStart({ convo: this, convoStep, container, scriptingMemory, meMsg, transcript, transcriptStep, transcriptSteps }) await this.scriptingEvents.onMePrepare({ convo: this, convoStep, container, scriptingMemory, meMsg, transcript, transcriptStep, transcriptSteps }) await this._checkBotRepliesConsumed(container) const coreMsg = _.omit(removeBuffers(meMsg), ['sourceData']) debug(`${this.header.name}/${convoStep.stepTag}: user says (cleaned by binary and base64 data and sourceData) ${JSON.stringify(coreMsg, null, 2)}`) await new Promise(resolve => { if (container.caps[Capabilities.SIMULATE_WRITING_SPEED] && meMsg.messageText && meMsg.messageText.length) { setTimeout(() => resolve(), container.caps[Capabilities.SIMULATE_WRITING_SPEED] * meMsg.messageText.length) } else { resolve() } }) lastMeConvoStep = convoStep transcriptStep.botBegin = new Date() if (!_.isNull(meMsg.messageText) || meMsg.sourceData || (meMsg.userInputs && meMsg.userInputs.length) || (meMsg.logicHooks && meMsg.logicHooks.length)) { try { Object.assign(meMsg, { header: this.header, conversation: this.conversation, currentStepIndex, scriptingMemory }) await container.UserSays(meMsg) } finally { delete meMsg.header delete meMsg.conversation delete meMsg.scriptingMemory } transcriptStep.botEnd = new Date() await this.scriptingEvents.onMeEnd({ convo: this, convoStep, container, scriptingMemory, meMsg, transcript, transcriptStep }) continue } else { debug(`${this.header.name}/${convoStep.stepTag}: message not found in #me section, message not sent to container ${util.inspect(convoStep)}`) transcriptStep.botEnd = new Date() await this.scriptingEvents.onMeEnd({ convo: this, convoStep, container, scriptingMemory, meMsg, transcript, transcriptStep }) continue } } catch (err) { transcriptStep.botEnd = new Date() const failErr = botiumErrorFromErr(`${this.header.name}/${convoStep.stepTag}: error sending to bot - ${err.message || err}`, err) debug(failErr) try { this.scriptingEvents.fail && this.scriptingEvents.fail(failErr) } catch (failErr) { } throw failErr } } else if (convoStep.sender === 'bot') { if (this.scriptingEvents.executeBotStep) { const executeBotStepResult = await this.scriptingEvents.executeBotStep({ convo: this, convoStep, container, scriptingMemory, transcript, transcriptStep, transcriptSteps }) if (executeBotStepResult) { skipTranscriptStep = true continue } } if (waitForBotSays) { botMsg = null } else { waitForBotSays = true } try { debug(`${this.header.name} wait for bot ${convoStep.channel || ''}`) await this.scriptingEvents.onBotStart({ convo: this, convoStep, container, scriptingMemory, transcript, transcriptStep }) transcriptStep.botBegin = new Date() if (!botMsg) { botMsg = await container.WaitBotSays(convoStep.channel, convoStepParameters?.stepTimeout) } transcriptStep.botEnd = new Date() transcriptStep.actual = new BotiumMockMessage(botMsg) const coreMsg = _.omit(removeBuffers(botMsg), ['sourceData']) debug(`${this.header.name}: bot says (cleaned by binary and base64 data and sourceData) ${JSON.stringify(coreMsg, null, 2)}`) } catch (err) { transcriptStep.botEnd = new Date() if (!(err.message.indexOf('Bot did not respond within') < 0) && convoStep.optional) { skipOptionalStep = true continue } const failErr = botiumErrorFromErr(`${this.header.name}/${convoStep.stepTag}: error waiting for bot - ${err.message}`, err) debug(failErr) try { this.scriptingEvents.fail && this.scriptingEvents.fail(failErr, lastMeConvoStep) } catch (failErr) { } throw failErr } try { const prepared = await this.scriptingEvents.onBotPrepare({ convo: this, convoStep, container, scriptingMemory, botMsg, transcript, transcriptStep }) if (prepared) { transcriptStep.actual = new BotiumMockMessage(botMsg) const coreMsg = _.omit(removeBuffers(botMsg), ['sourceData']) debug(`${this.header.name}: onBotPrepare (cleaned by binary and base64 data and sourceData) ${JSON.stringify(coreMsg, null, 2)}`) } } catch (err) { const failErr = botiumErrorFromErr(`${this.header.name}/${convoStep.stepTag}: onBotPrepare error - ${err.message || err}`, err) debug(failErr) try { this.scriptingEvents.fail && this.scriptingEvents.fail(failErr, lastMeConvoStep) } catch (failErr) { } throw failErr } if (convoStep.conditional) { waitForBotSays = false let endOfConditionalGroup = false conditionalGroupId = convoStep.logicHooks.find(lh => lh.name.startsWith('CONDITIONAL_STEP')).args[1] const nextConvoStep = this.conversation[i + 1] if (!nextConvoStep || nextConvoStep.sender !== 'bot' || !nextConvoStep.logicHooks || !nextConvoStep.logicHooks.some(lh => lh.name.toUpperCase().startsWith('CONDITIONAL_STEP'))) { endOfConditionalGroup = true } else { const nextConditionalLogicHook = nextConvoStep.logicHooks.find(lh => lh.name.startsWith('CONDITIONAL_STEP')) endOfConditionalGroup = conditionalGroupId !== nextConditionalLogicHook.args[1] } if (convoStep.conditional.skip || conditionMetInGroup) { skipTranscriptStep = true if (endOfConditionalGroup && !conditionMetInGroup && !convoStep.optional) { const failErr = new BotiumError(`${this.header.name}/${convoStep.stepTag}: Non of the conditions are met in ${conditionalGroupId ? `'${conditionalGroupId}' ` : ''}condition group`) debug(failErr) throw failErr } if (endOfConditionalGroup) { waitForBotSays = !convoStep.optional conditionalGroupId = undefined conditionMetInGroup = false } continue } else { conditionMetInGroup = true if (endOfConditionalGroup) { waitForBotSays = !convoStep.optional conditionalGroupId = undefined conditionMetInGroup = false } } } if (!botMsg || (!botMsg.messageText && !botMsg.media && !botMsg.buttons && !botMsg.cards && !botMsg.sourceData && !botMsg.nlp)) { const failErr = new BotiumError(`${this.header.name}/${convoStep.stepTag}: bot says nothing`) debug(failErr) try { this.scriptingEvents.fail && this.scriptingEvents.fail(failErr, lastMeConvoStep) } catch (failErr) { } throw failErr } const isErrorHandledWithOptionConvoStep = (err) => { const nextConvoStep = this.conversation[i + 1] const retryConfig = convoStepParameters?.ignoreNotMatchedBotResponses const retryOn = convoStep.sender === 'bot' && retryConfig && retryConfig.timeout && retryConfig.mainAsserter if (convoStep.optional && nextConvoStep && nextConvoStep.sender === 'bot') { if (retryOn) { debug(`${this.header.name}/${convoStep.stepTag}: Retry failed asserter is ignored on optional convo`) } waitForBotSays = false skipTranscriptStep = true optionalStepAssertionError = true return true } else if (retryOn) { if (!retryBotMessageTimeoutEnd || retryBotMessageConvoId !== convoStep.stepTag) { retryBotMessageTimeoutEnd = transcriptStep.stepBegin.getTime() + +retryConfig.timeout retryBotMessageConvoId = convoStep.stepTag } const now = new Date().getTime() const timeoutRemaining = retryBotMessageTimeoutEnd - now if (timeoutRemaining > 0) { debug(`${this.header.name}/${convoStep.stepTag}: Convo step retry on, timeout remaining: ${timeoutRemaining}, error: "${err.message}"`) retryBotMessageDropBotResponse = true return false } else { debug(`${this.header.name}/${convoStep.stepTag}: Convo step retry on, but timeout is over. error: "${err.message}"`) } } if (container.caps[Capabilities.SCRIPTING_ENABLE_MULTIPLE_ASSERT_ERRORS]) { if (optionalStepAssertionError) { optionalStepAssertionError = false assertErrors.push(new BotiumError(`${this.header.name}: Unexpected message.`)) } else { assertErrors.push(err) } return false } else { if (optionalStepAssertionError) { optionalStepAssertionError = false throw new BotiumError(`${this.header.name}: Unexpected message.`) } throw err } } const assertErrors = [] const scriptingMemoryUpdate = {} if (convoStep.messageText) { const response = this._checkNormalizeText(container, botMsg.messageText) const messageText = this._checkNormalizeText(container, convoStep.messageText) ScriptingMemory.fill(container, scriptingMemoryUpdate, response, messageText, this.scriptingEvents) const tomatch = this._resolveUtterancesToMatch(container, Object.assign({}, scriptingMemoryUpdate, scriptingMemory), messageText, botMsg) if (convoStep.not) { try { this.scriptingEvents.assertBotNotResponse(response, tomatch, `${this.header.name}/${convoStep.stepTag}`, lastMeConvoStep, convoStepParameters) optionalStepAssertionError = false } catch (err) { if (isErrorHandledWithOptionConvoStep(err)) { continue } } } else { try { this.scriptingEvents.assertBotResponse(response, tomatch, `${this.header.name}/${convoStep.stepTag}`, lastMeConvoStep, convoStepParameters) optionalStepAssertionError = false } catch (err) { if (isErrorHandledWithOptionConvoStep(err)) { continue } } } } else if (convoStep.sourceData) { try { this._compareObject(container, scriptingMemory, convoStep, botMsg.sourceData, convoStep.sourceData, botMsg, convoStepParameters) optionalStepAssertionError = false } catch (err) { if (isErrorHandledWithOptionConvoStep(err)) { continue } } } Object.assign(scriptingMemory, scriptingMemoryUpdate) try { await this.scriptingEvents.assertConvoStep({ convo: this, convoStep, container, scriptingMemory, botMsg, transcript, transcriptStep, transcriptSteps }) await this.scriptingEvents.onBotEnd({ convo: this, convoStep, container, scriptingMemory, botMsg, transcript, transcriptStep }) optionalStepAssertionError = false } catch (err) { const nextConvoStep = this.conversation[i + 1] if (convoStep.optional && nextConvoStep && nextConvoStep.sender === 'bot') { waitForBotSays = false skipTranscriptStep = true optionalStepAssertionError = true continue } const errors = err.toArray ? err.toArray() : [] const retryConfig = convoStepParameters?.ignoreNotMatchedBotResponses const retryOn = convoStep.sender === 'bot' && retryConfig && retryConfig.timeout && errors.length && errors.filter(({ type, source, asserter }) => type === 'asserter' && (retryConfig.allAsserters || (retryConfig.asserters && retryConfig.asserters.includes(asserter)))).length if (retryOn && (!retryBotMessageTimeoutEnd || retryBotMessageConvoId !== convoStep.stepTag)) { retryBotMessageTimeoutEnd = transcriptStep.stepBegin.getTime() + +retryConfig.timeout retryBotMessageConvoId = convoStep.stepTag } const now = new Date().getTime() const timeoutRemaining = retryOn && (retryBotMessageTimeoutEnd - now) if (retryOn && timeoutRemaining > 0) { debug(`${this.header.name}/${convoStep.stepTag}: Convo step retry on, timeout remaining: ${timeoutRemaining}, error: "${err.message}"`) retryBotMessageDropBotResponse = true } else { if (retryOn && timeoutRemaining <= 0) { debug(`${this.header.name}/${convoStep.stepTag}: Convo step retry on, but timeout is over. error: "${err.message}"`) } const failErr = botiumErrorFromErr(`${this.header.name}/${convoStep.stepTag}: assertion error - ${err.message || err}`, err) debug(failErr) try { this.scriptingEvents.fail && this.scriptingEvents.fail(failErr, lastMeConvoStep) } catch (failErr) { } if (container.caps[Capabilities.SCRIPTING_ENABLE_MULTIPLE_ASSERT_ERRORS] && err instanceof BotiumError) { if (optionalStepAssertionError) { optionalStepAssertionError = false assertErrors.push(new BotiumError(`${this.header.name}: Unexpected message.`)) } else { assertErrors.push(err) } } else { if (optionalStepAssertionError) { optionalStepAssertionError = false throw new BotiumError(`${this.header.name}: Unexpected message.`) } throw failErr } } } if (container.caps[Capabilities.SCRIPTING_ENABLE_MULTIPLE_ASSERT_ERRORS]) { if (assertErrors.length > 0) { // this has no effect, but logically it has to be false retryBotMessageDropBotResponse = false throw botiumErrorFromList(assertErrors, {}) } } else { if (!transcriptStep.stepEnd) { continue } } } else { const failErr = new BotiumError(`${this.header.name}/${convoStep.stepTag}: invalid sender - ${util.inspect(convoStep.sender)}`) debug(failErr) try { this.scriptingEvents.fail && this.scriptingEvents.fail(failErr) } catch (failErr) { } throw failErr } } catch (err) { if (lastMeConvoStep) { if (err instanceof BotiumError && err.context) { err.context.input = new ConvoStep(lastMeConvoStep) err.context.transcript = [...transcriptSteps, { ...transcriptStep }] } else { err.input = new ConvoStep(lastMeConvoStep) err.transcript = [...transcriptSteps, { ...transcriptStep }] } } transcriptStep.err = err if (err instanceof BotiumError && container.caps[Capabilities.SCRIPTING_ENABLE_SKIP_ASSERT_ERRORS]) { if (!err.isAsserterError()) { throw err } } else { throw err } } finally { if (convoStep.sender !== 'begin' && convoStep.sender !== 'end' && !skipTranscriptStep) { transcriptStep.scriptingMemory = Object.assign({}, scriptingMemory) transcriptStep.stepEnd = new Date() transcriptSteps.push(transcriptStep) } } } } catch (err) { transcript.err = err } finally { transcript.steps = transcriptSteps.filter(s => s) transcript.scriptingMemory = scriptingMemory transcript.convoEnd = new Date() if (container.caps[Capabilities.SCRIPTING_ENABLE_SKIP_ASSERT_ERRORS]) { const transcriptStepErrs = transcript.steps.filter(s => s.err).map(s => s.err) if (transcriptStepErrs && transcriptStepErrs.length > 0) { transcript.err = botiumErrorFromList([...transcriptStepErrs.filter(err => err !== transcript.err), transcript.err].filter(e => e), {}) } } } } _compareObject (container, scriptingMemory, convoStep, result, expected, botMsg, convoStepParameters) { if (expected === null || expected === undefined) return if (_.isArray(expected)) { if (!_.isArray(result)) { throw new BotiumError(`${this.header.name}/${convoStep.stepTag}: bot response expected array, got "${result}"`) } if (expected.length !== result.length) { throw new BotiumError(`${this.header.name}/${convoStep.stepTag}: bot response expected array length ${expected.length}, got ${result.length}`) } for (let i = 0; i < expected.length; i++) { this._compareObject(container, scriptingMemory, convoStep, result[i], expected[i], null, convoStepParameters) } } else if (_.isObject(expected)) { _.forOwn(expected, (value, key) => { if (Object.prototype.hasOwnProperty.call(result, key)) { this._compareObject(container, scriptingMemory, convoStep, result[key], expected[key], null, convoStepParameters) } else { throw new BotiumError(`${this.header.name}/${convoStep.stepTag}: bot response "${result}" missing expected property: ${key}`) } }) } else { ScriptingMemory.fill(container, scriptingMemory, result, expected, this.scriptingEvents) const response = this._checkNormalizeText(container, result) const tomatch = this._resolveUtterancesToMatch(container, scriptingMemory, expected, botMsg) this.scriptingEvents.assertBotResponse(response, tomatch, `${this.header.name}/${convoStep.stepTag}`, null, convoStepParameters) } } GetScriptingMemoryAllVariables (container) { const partialConvos = this.context.GetPartialConvos() const variableNames = this.conversation.reduce((acc, convoStep) => { if (convoStep.sender === 'include') { if (convoStep.channel) { const partialConvo = partialConvos[convoStep.channel] if (partialConvo) { acc = [...acc, ...partialConvo.GetScriptingMemoryAllVariables(container)] } } if (convoStep.messageText) { for (const partialConvoName of splitStringInNonEmptyLines(convoStep.messageText)) { const partialConvo = partialConvos[partialConvoName] if (partialConvo) { acc = [...acc, ...partialConvo.GetScriptingMemoryAllVariables(container)] } } } } else { acc = [...acc, ...this.GetScriptingMemoryVariables(container, convoStep.messageText)] const __extractFromArgs = (convoStepItems) => { let resultInner = [] for (const item of (convoStepItems || [])) { for (const arg of (item.args || [])) { resultInner = resultInner.concat(this.GetScriptingMemoryVariables(container, arg)) } } return resultInner } acc = [...acc, ...__extractFromArgs(convoStep.asserters)] acc = [...acc, ...__extractFromArgs(convoStep.logicHooks)] acc = [...acc, ...__extractFromArgs(convoStep.userInputs)] convoStep.logicHooks.forEach((logicHook) => { if (logicHook.name === LOGIC_HOOK_INCLUDE) { const partialConvo = partialConvos[logicHook.args[0]] if (partialConvo) { acc = [...acc, ...partialConvo.GetScriptingMemoryAllVariables(container)] } } }) } return acc }, []) return _.uniq(variableNames) } GetScriptingMemoryVariables (container, utterance) { if (!utterance || !container.caps[Capabilities.SCRIPTING_ENABLE_MEMORY]) { return [] } const utterances = this.scriptingEvents.resolveUtterance({ utterance }) return utterances.reduce((acc, expected) => { if (_.isUndefined(expected)) return acc else return acc.concat(ScriptingMemory.extractVarNames(toString(expected)) || []) }, []) } _checkBotRepliesConsumed (container) { if (container.caps[Capabilities.SCRIPTING_FORCE_BOT_CONSUMED]) { const queueLength = container._QueueLength() if (queueLength === 1) { throw new Error('There is an unread bot reply in queue') } else if (queueLength > 1) { throw new Error(`There are still ${queueLength} unread bot replies in queue`) } } } _resolveUtterancesToMatch (container, scriptingMemory, utterance, botMsg) { const utterances = this.scriptingEvents.resolveUtterance({ utterance }) const normalizedUtterances = utterances.map(str => this._checkNormalizeText(container, str)) const tomatch = normalizedUtterances.map(str => ScriptingMemory.apply(container, scriptingMemory, str, botMsg)) return tomatch } _checkNormalizeText (container, str) { return normalizeText(str, container.caps) } expandPartialConvos () { const _getIncludeLogicHookNames = (convoStep) => { if (!convoStep.logicHooks) { return [] } const result = [] convoStep.logicHooks.forEach((logicHook) => { if (logicHook.name === LOGIC_HOOK_INCLUDE) { if (logicHook.args.length !== 1) { throw Error('Wrong argument for include logic hook!') } result.push(logicHook) } }) return result.map((hook) => hook.args[0]) } const partialConvos = this.context.GetPartialConvos() const _getEffectiveConversationRecursive = (conversation, parentPConvos = [], result = [], ignoreBeginEnd = true) => { conversation.forEach((convoStep) => { let includeLogicHooks = [] if (convoStep.sender === 'include') { if (convoStep.channel) { includeLogicHooks.push(convoStep.channel) } if (convoStep.messageText) { includeLogicHooks = includeLogicHooks.concat(splitStringInNonEmptyLines(convoStep.messageText)) } } else { includeLogicHooks = _getIncludeLogicHookNames(convoStep) if (includeLogicHooks.length === 0 || convoStep.hasInteraction()) { if (!ignoreBeginEnd || (convoStep.sender !== 'begin' && convoStep.sender !== 'end')) { // dont put convo name for ConvoSteps on the root. const steptagPath = parentPConvos.length === 0 ? '' : parentPConvos.join('/') + '/' result.push(Object.assign(new ConvoStep(), convoStep, { stepTag: `${steptagPath}${convoStep.stepTag}` })) } } } includeLogicHooks.forEach((includeLogicHook) => { const alreadyThereAt = parentPConvos.indexOf(includeLogicHook) if (alreadyThereAt >= 0) { throw new BotiumError(`Partial convos are included circular. "${includeLogicHook}" is referenced by "/${parentPConvos.slice(0, alreadyThereAt).join('/')}" and by "/${parentPConvos.join('/')}" `) } if (!partialConvos || Object.keys(partialConvos).length === 0) { throw new BotiumError(`Cant find partial convo with name ${includeLogicHook} (There are no partial convos)`) } const partialConvo = partialConvos[includeLogicHook] if (!partialConvo) { throw new BotiumError(`Cant find partial convo with name ${includeLogicHook} (available partial convos: ${Object.keys(partialConvos).join(',')})`) } _getEffectiveConversationRecursive(partialConvo.conversation, [...parentPConvos, includeLogicHook], result, true) debug(`Partial convo ${includeLogicHook} included`) }) }) return result } this.conversation = _getEffectiveConversationRecursive(this.conversation, [], [], false) } } module.exports = { Convo, ConvoHeader, ConvoStep, ConvoStepAssert, ConvoStepLogicHook, ConvoStepUserInput, Transcript, TranscriptAttachment, TranscriptStep, TranscriptError }