├── .eslintrc.json ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── lib ├── parser.js ├── pconfig.js └── runner.js ├── package-lock.json ├── package.json ├── parse.js ├── robotmate.jpg ├── run.js └── test ├── resources ├── runner │ ├── book_a_ride.rmc │ ├── book_a_ride_failing.rmc │ ├── book_a_ride_template.csv │ └── book_a_ride_template.rmc ├── sample.rmc ├── sample_1.rmc ├── sample_1_model.json ├── sample_2.rmc ├── sample_4.rmc ├── sample_5.rmc └── sample_model.json ├── test.js └── test_runner.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb-base", 3 | "env": { 4 | "mocha": true, 5 | "node": true 6 | }, 7 | "rules": { 8 | "no-console": 0, 9 | "max-len": [2, 130, 2] 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .nyc_output -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "10" 4 | - "12" 5 | 6 | script: 7 | - npm test 8 | - npm run lint 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Flix.TECH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ROBOT MATE 2 | 3 | [![Build Status](https://travis-ci.org/flix-tech/robotmate.svg?branch=master)](https://travis-ci.org/flix-tech/robotmate) 4 | 5 | ## About 6 | 7 | Robot Mate 8 | 9 | This project is based on [Botium](https://botium.atlassian.net/wiki/spaces/BOTIUM/overview) and is used for testing the communication path with a chatbot using **dialogflow**. Here you will find how to setup and run the project, plus conversations simulating a real user in order to test the platform answers to guarantee a better coverage of the bot. 10 | 11 | ## Installation 12 | 13 | ### The easiest way 14 | 15 | 1. Start by downloading and installing [NodeJs](https://nodejs.org/en/download/) 16 | 2. Open your terminal (Mac, Linux, ...) or Prompt (Windows) 17 | 3. Execute the following command in the terminal: `npm install -g "git+https://git@github.com/flix-tech/robotmate.git#v0.13.0"` 18 | 19 | 20 | ### Another way 21 | 22 | 1. Start by downloading and installing [NodeJs](https://nodejs.org/en/download/) 23 | 2. Open your terminal (Mac, Linux, ...) or Prompt (Windows) 24 | 3. Clone the repo by executing the following command in the terminal: `git clone "https://github.com/flix-tech/robotmate.git"` 25 | 4. Type `cd robotmate` and press enter to go inside of the project folder 26 | 5. Type `npm install -g .` to install it globally 27 | 28 | ### Main functionalities: 29 | 30 | There are two main commands that can be executed after the project is installed: 31 | 32 | 1. __rmrun__ Runs one or more *RMC* files or *directory* using the provided Botium configuration. 33 | 2. __rmparse__ Parses an *RMC* file or *directory* and shows errors or the JSON output if it is valid. 34 | 35 | We will explain more about these commands in the following sections. 36 | 37 | ## File extension 38 | 39 | All the conversation files must have the extension **.rmc**. The *.rmc* means ***Robot Mate Conversations***. 40 | Make sure that when creating a conversation file to rename the extension to ***.rmc***, otherwise the *Robot Mate* will **not** recognize them. 41 | 42 | ## Parser 43 | 44 | The parser serves to check if your conversations are correctly implemented. It basically will verify your syntax and the structure of the conversation, e.g: 45 | 46 | ```r 47 | rmparse 48 | ``` 49 | 50 | The results would be like the example below if everything is fine: 51 | 52 | ```r 53 | === Parsing: ../conversations/test1.rmc 54 | = OK 55 | === Parsing: ../conversations/test2.rmc 56 | = OK 57 | ``` 58 | 59 | And if something fails.. 60 | 61 | ```javascript 62 | === Parsing: ../conversations/text3.rmc 63 | /Users/flixbus/Documents/tests/rmcf/lib/parser.js:45 64 | 65 | Error: The actor RM do not have the action exxcludes (Line: 8) 66 | ``` 67 | 68 | ## Runner 69 | 70 | In order to run the project just execute **rmrun** command inside the folder of your project. 71 | To see examples of further commands you can execute just type: `rmrun --help` in your terminal. 72 | The code below shows an example on how to run a command that takes three parameters: 73 | 74 | - **rmrun** **folder of the conversations** --conf **configuration file** --jobs **amount of jobs** 75 | 76 | The command above can be used to run the tests within a folder, using the specified configs with N jobs in parallel. 77 | 78 | ### Params 79 | 80 | 1. **folder of the conversations** *(optional)*: The path for the folder containing the conversations. In case this parameter is not set, it will try to find the files in the *root* folder. 81 | 82 | 2. **--conf** **configuration file** *(optional)*: This where the path of the file to setup the botium capabilities can be set. In case the parameter is not set it will look inside of the *root* folder for a file called *botium.json* 83 | 84 | 3. **--jobs** **amount of jobs** *(optional)*: The name *jobs* is the equivalent to *threads*, so the parameter define the amount of jobs (threads) that will be used to split the load and test things in parallel in case it is necessary. Each thread works independently from each other. 85 | 86 | 4. **--retries** **number of total retries** *(optional)*: Number of retries for the conversations in case you have flaky test. 87 | 88 | ### Different ways of using the parameters 89 | 90 | ***Specifying conversation folder*** 91 | ```s 92 | rmrun conversations/base/ 93 | ``` 94 | ***Specifying botium config file*** 95 | ```s 96 | rmrun --conf botium.json 97 | ``` 98 | ***Specifying amount of jobs*** 99 | ```s 100 | rmrun --jobs 10 101 | ``` 102 | ***Variation 1*** 103 | ```s 104 | rmrun conversations/base/ --jobs 10 105 | ``` 106 | ***Variation 2*** 107 | ```s 108 | rmrun conversations/base/ --conf botium.json 109 | ``` 110 | ***Variation 3*** 111 | ```s 112 | rmrun --conf botium.json --jobs 10 113 | ``` 114 | 115 | ## Creating botium.json 116 | 117 | In order to create your own configuration file, please access the following link: [Configuration](https://botium.atlassian.net/wiki/spaces/BOTIUM/pages/360603/Botium+Configuration+-+Capabilities) 118 | 119 | ## Conversation example 120 | 121 | The follow script shows a small example of a conversation containing all **RM** *(Robot Mate)* and the **HM** *(Human Mate)* utterances: 122 | 123 | ```R 124 | LANG: en_US 125 | 126 | HM says : "I would like to talk" 127 | RM includes any : "What" "about" "?" 128 | 129 | HM says: "About intelligent machines." 130 | RM excludes: "Ops" "I didn't understand" 131 | RM starts with: "Interesting" 132 | 133 | HM says: "Do you know any?" 134 | RM contains: "Yes" 135 | 136 | HM says: "Can you tell me more?" 137 | RM equals: "Yes, I'm an intelligent machine created to rule the world!" 138 | ``` 139 | ### Parameters explanation 140 | 141 | #### LANG 142 | 143 | - The lang param is necessary for the ***RM*** to send to the platform which language it is speaking to guarantee understanding, otherwise sending a text in English and receiving an answer in German would be a problem. 144 | 145 | #### HM 146 | 147 | - The **HM** has only one statement, ***says***: The statement **says** sends a message to the platform simulating a human conversation. 148 | 149 | #### RM 150 | 151 | - Different from the *HM* the **RM** contains multiple statements which are necessary to *assert* the conversation. 152 | 153 | 1. **includes any**: *Includes any* checks if any of the texts stated is in the message. 154 | - e.g: The statement below is checking if the **What** *or* **about** *or* **?** is included in the received message. 155 | 156 | ```r 157 | RM includes any : "What" "about" "?" 158 | ``` 159 | 160 | 2. **excludes**: *Excludes* checks if every following text stated is not in the message. 161 | - e.g: The statement below is checking if the **Ops** *and* **I didn't understand** is not in the received message. 162 | 163 | ```r 164 | RM excludes: "Ops" "I didn't understand" 165 | ``` 166 | 167 | 3. **starts with**: *Starts with* verifies if the message checked starts with the text stated. 168 | - e.g: The statement below is checking if **Interesting** is at the beginning of the message. 169 | 170 | ```r 171 | RM starts with: "Interesting" 172 | ``` 173 | 174 | 4. **contains**: This statement has the same functionality of the method contains used in programming languages. 175 | *Contains* checks if the given text is included in the received message. The difference between this statement and the *includes any* statement is that **contains** only accepts one given text. 176 | - e.g: The statement below is checking if the **Yes** is included in the received message. 177 | 178 | ```r 179 | RM contains: "Yes" 180 | ``` 181 | 182 | 5. **equals**: Use this statement to check if the texts stated is exactly the message. 183 | - e.g: The statement below is checking if the **Yes, I'm an intelligent machine created to rule the world!** is exactly the received message. 184 | 185 | ```r 186 | RM equals: "Yes, I'm an intelligent machine created to rule the world!" 187 | ``` 188 | 189 | ## Here is a real example of a robot mate execution 190 | 191 | Executing the command below will initiate the process of sending message to the bot simulating an user interaction. The target *folder* has two tests which will be executed in sequence. 192 | 193 | ```r 194 | rmrun ../conversations/base/exploratory_test/connection/ --conf ../botium.json 195 | ``` 196 | 197 | ```JAVA 198 | find_a_ride_layover_station_change.rmc ::: The runner says : I would like to go from Munich to Berlin tomorrow 199 | find_a_ride_layover_station_change.rmc ::: Robot says: I recommend this direct FlixBus ride that departs from Munich central bus station to Berlin central bus station on Thursday, 16th of May at 9:10, costs $27.99 and lasts 7 hours 5 minutes. Would you like to select this option? 200 | find_a_ride_layover_station_change.rmc ::: === Lets check the robot message! 201 | find_a_ride_layover_station_change.rmc ::: Assertion: RM includes any : "Is this an option for you?" "Would you like to select this option?" 202 | find_a_ride_layover_station_change.rmc ::: === Good! 203 | find_a_ride_layover_duration.rmc ::: The runner says : I would like to go from Munich to Berlin tomorrow 204 | find_a_ride_layover_duration.rmc ::: Robot says: I recommend this direct FlixBus ride that departs from Munich central bus station to Berlin central bus station on Thursday, 16th of May at 9:10, costs $27.99 and lasts 7 hours 5 minutes. Would you like to select this option? 205 | find_a_ride_layover_duration.rmc ::: === Lets check the robot message! 206 | find_a_ride_layover_duration.rmc ::: Assertion: RM includes any : Is this an option for you?" "Would you like to select this option? 207 | find_a_ride_layover_duration.rmc ::: === Good! 208 | find_a_ride_layover_duration.rmc ::: The runner says : How long is the stop duration? 209 | find_a_ride_layover_duration.rmc ::: Robot says: This ride has no change and goes directly to the destination. Is this an option for you? 210 | find_a_ride_layover_duration.rmc ::: === Lets check the robot message! 211 | find_a_ride_layover_duration.rmc ::: Assertion: RM excludes : "None" "Oops!" 212 | find_a_ride_layover_duration.rmc ::: Assertion: RM includes any : "The duration of the change is" "This ride has no change" 213 | find_a_ride_layover_duration.rmc ::: === Good! 214 | find_a_ride_layover_station_change.rmc ::: The runner says : Do I need to change the station? 215 | find_a_ride_layover_station_change.rmc ::: Robot says: This ride has no change and goes directly to the destination. Is this an option for you? 216 | find_a_ride_layover_station_change.rmc ::: === Lets check the robot message! 217 | find_a_ride_layover_station_change.rmc ::: Assertion: RM excludes : "None" "Oops" 218 | find_a_ride_layover_station_change.rmc ::: Assertion: RM includes any : "You don't need to change stations for this interconnection." "This ride has no change and goes directly to the destination." 219 | find_a_ride_layover_station_change.rmc ::: === Good! 220 | 221 | Conversations: 2 passed, 2 total 222 | Duration: 7.57 seconds 223 | ``` 224 | 225 | In case of some conversation fails it will show the path of the file at the end, e.g: 226 | 227 | ```r 228 | Conversations: 1 failed, 1 passed, 2 total 229 | Duration: 7.13 seconds 230 | 1 failed conversations: 231 | /Users/flix/conversations/base/exploratory_test/connection/find_a_ride_layover_duration.rmc 232 | ``` 233 | 234 | # Conclusion 235 | 236 | For sure there are many things to improve and a lot to learn about bot automation. Although, the framework provides an easy way to automate your tests for bots and it helps to increase your coverage, for now it cannot cover all the possible cases as it focuses more on the text messages. 237 | -------------------------------------------------------------------------------- /lib/parser.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const pconfig = require('./pconfig.js'); // Contains all the actions and actors 3 | 4 | class ConversationItem { 5 | constructor(line, actor, action, parameter) { 6 | this.line = line; 7 | this.actor = actor; 8 | this.action = action; 9 | this.parameter = parameter; 10 | } 11 | } 12 | 13 | class Conversation { 14 | constructor(lang, fileName, conversation) { 15 | this.lang = lang; 16 | this.fileName = fileName; 17 | this.conversation = conversation; 18 | } 19 | } 20 | 21 | /** 22 | * @param {string} line line to be parsed. 23 | * @param {number} lineNumber the number of this line inside of the file. 24 | */ 25 | const parseLine = (line, lineNumber) => { 26 | if (line === '' || line.trim() === '' || line.startsWith('//')) { return null; } 27 | 28 | // Check if the line has a separator 29 | if (!line.includes(':')) { 30 | throw new Error(`This line doesn't have a separator as required: (Line: ${lineNumber})`); 31 | } 32 | 33 | const actor = line.slice(0, 2); 34 | const action = line.slice(3, line.indexOf(':')).trim(); 35 | 36 | // Check if the actor exists 37 | if (!pconfig.hasActor(actor)) { 38 | throw new Error(`The actor ${actor} doesn't exists (Line: ${lineNumber})`); 39 | } 40 | 41 | // Check if the action for the actor exists 42 | if (!pconfig.hasAction(actor, action)) { 43 | throw new Error(`The actor ${actor} doesn't have the action ${action} (Line: ${lineNumber})`); 44 | } 45 | 46 | const parameterRaw = line.slice(line.indexOf(':') + 1).trim(); 47 | 48 | const parameter = (parameterRaw.includes('"')) ? parameterRaw.slice(1, parameterRaw.length - 1) : parseFloat(parameterRaw); 49 | 50 | return new ConversationItem(lineNumber, actor, action, parameter); 51 | }; 52 | 53 | const parseString = (fileContent, filePath) => { 54 | const fileLines = fileContent.split('\n'); 55 | const langLine = fileLines[0]; 56 | // Check if it has a lang parameter 57 | if (!pconfig.hasHeaderLang(langLine)) { 58 | throw new Error('Your test doesn\'t have the language set (Line: 1)'); 59 | } 60 | const lang = langLine.slice(langLine.indexOf(':') + 1).trim(); 61 | 62 | const conversation = fileLines.map((line, index) => [line, index]) 63 | .slice(1) 64 | .map(([line, index]) => parseLine(line, index + 1)).filter(x => x != null); 65 | return new Conversation(lang, filePath, conversation); 66 | }; 67 | 68 | /** 69 | * @param {string} filePath file path of the file to be parsed. 70 | */ 71 | const parseFile = (filePath) => { 72 | const fileContent = fs.readFileSync(filePath, 'utf-8'); 73 | return parseString(fileContent, filePath); 74 | }; 75 | 76 | module.exports = { 77 | parseString, parseLine, parseFile, ConversationItem, Conversation, 78 | }; 79 | -------------------------------------------------------------------------------- /lib/pconfig.js: -------------------------------------------------------------------------------- 1 | 2 | const configurations = { 3 | HM: ['says'], 4 | RM: ['starts with', 'contains', 'equals', 'excludes', 'includes any'], 5 | }; 6 | 7 | // Check header lang 8 | const hasHeaderLang = (line) => { 9 | const langParam = '(LANG)'; // Word 1 10 | const re2 = '.*?'; // Non-greedy match on filler 11 | const sepParam = '(:)'; // Any Single Character 1 12 | const re4 = '.*?'; // Non-greedy match on filler 13 | const valueParam = '((?:[a-z][a-z]+))'; // Word 2 14 | const p = new RegExp(langParam + re2 + sepParam + re4 + valueParam, ['i']); 15 | const m = p.exec(line); 16 | return m !== null; 17 | }; 18 | 19 | // Check if it has the actor 20 | const hasActor = who => who in configurations; 21 | 22 | // Return list of actions 23 | const getActions = who => (hasActor(who) ? configurations[who] : null); 24 | 25 | 26 | // Check if the action is on the list 27 | const hasAction = (who, action) => { 28 | const actions = getActions(who); 29 | return actions !== null && actions.includes(action); 30 | }; 31 | 32 | module.exports = { 33 | hasAction, hasActor, hasHeaderLang, 34 | }; 35 | -------------------------------------------------------------------------------- /lib/runner.js: -------------------------------------------------------------------------------- 1 | 2 | require('colors'); 3 | 4 | const DIALOGFLOW_CONSTANTS = { 5 | containerMode: 'dialogflow', 6 | langCapability: 'DIALOGFLOW_LANGUAGE_CODE', 7 | }; 8 | 9 | const FACEBOOK_RECEIVER_CONSTANTS = { 10 | containerMode: 'fbdirect', 11 | }; 12 | 13 | class Logger { 14 | constructor(fileName) { 15 | this.fileName = ` ${fileName.split('/').pop()}`; 16 | } 17 | 18 | error(msg) { 19 | console.error(` ${this.fileName} ::: ${msg}`); 20 | } 21 | 22 | log(msg) { 23 | console.log(` ${this.fileName} ::: ${msg}`); 24 | } 25 | } 26 | 27 | class ConversationProcessor { 28 | /** 29 | * @param {Object} container external dependency (botium) 30 | * @param {String} filePath File path of the file we are processing. 31 | * @param {ConversationItem[]} conversations Array of conversation objects. 32 | * @param {String} lang language of the conversation. 33 | */ 34 | constructor(container, filePath, conversations, lang) { 35 | this.container = container; 36 | this.logger = new Logger(filePath); 37 | this.conversations = conversations; 38 | this.lang = lang; 39 | } 40 | 41 | assertLocal(result, action, actual, expected, lineNumber) { 42 | if (!result) { 43 | this.logger.error(`${'Assertion FAILED'.bold.red} @ line: ${lineNumber}`); 44 | this.logger.error(`Actual value: ${actual} Expected : ${action} ${expected}`); 45 | 46 | this.logger.log('BAD ROBOT'.red); 47 | throw new Error(`Assertion FAILED @ line: ${lineNumber}`); 48 | } 49 | } 50 | 51 | /** 52 | * Perform assertions for each response provided by the bot. 53 | * 54 | * @param {ConversationItem[]} rms collection of RMs (communication from the Robot to the user). 55 | * @param {String} responseText response text as string provided by the Bot given a question. 56 | */ 57 | doAssertions(rms, responseText) { 58 | this.logger.log(`${'=== Lets check the robot message!'.yellow}`); 59 | rms.forEach((rm) => { 60 | this.logger.log(`${'Assertion: '.blue} ${rm.actor} ${rm.action} : ${rm.parameter}`); 61 | switch (rm.action) { 62 | case 'equals': 63 | this.assertLocal(responseText === rm.parameter, 64 | rm.action, responseText, rm.parameter, rm.line); 65 | break; 66 | case 'starts with': 67 | this.assertLocal(responseText.startsWith(rm.parameter), 68 | rm.action, responseText, rm.parameter, rm.line); 69 | break; 70 | case 'contains': 71 | this.assertLocal(responseText.includes(rm.parameter), 72 | rm.action, responseText, rm.parameter, rm.line); 73 | break; 74 | case 'excludes': 75 | this.assertLocal(rm.parameter.split('" "').every(str => !responseText.includes(str)), 76 | rm.action, responseText, rm.parameter, rm.line); 77 | break; 78 | case 'includes any': 79 | this.assertLocal(rm.parameter.split('" "').some(str => responseText.includes(str)), 80 | rm.action, responseText, rm.parameter, rm.line); 81 | break; 82 | default: 83 | throw Error(`${rm.action} is not supported`); 84 | } 85 | }); 86 | this.logger.log('=== Good!'.green); 87 | } 88 | 89 | /** 90 | * Take the conversation node and iterate to each item from the collection in order 91 | * to extract the message that was sent by the user and all the communication that 92 | * was provided by the Bot. 93 | */ 94 | process() { 95 | const { driver } = this.container; 96 | if (driver.caps.CONTAINERMODE === DIALOGFLOW_CONSTANTS.containerMode 97 | || driver.caps.CONTAINERMODE === FACEBOOK_RECEIVER_CONSTANTS.containerMode) { 98 | driver.setCapability(DIALOGFLOW_CONSTANTS.langCapability, this.lang.replace('_', '-')); 99 | } 100 | const result = []; 101 | const conversationMap = this.buildConversationMap(); 102 | 103 | conversationMap.forEach((assertions, hm) => { 104 | this.container.UserSaysText(hm.parameter); 105 | 106 | this.container.WaitBotSaysText((responseText) => { 107 | if (!responseText) { 108 | // This should not happen, there is a problem with botium 109 | this.logger.error(`${'Robot says: Empty response.'.bold.red}`); 110 | } else { 111 | this.logger.log(`${'The runner'.bold.green} ${hm.action.bold} : ${hm.parameter.italic}`); 112 | this.logger.log(`${'Robot says:'.bold.green} ${responseText.italic}`); 113 | result.push(hm.parameter); 114 | result.push(responseText); 115 | this.doAssertions(assertions, responseText); 116 | } 117 | }); 118 | }); 119 | 120 | return result; 121 | } 122 | 123 | buildConversationMap() { 124 | const result = []; 125 | 126 | this.conversations.forEach((conversation) => { 127 | if (conversation.actor === 'HM') { 128 | result.push([conversation, []]); 129 | } else { 130 | result[result.length - 1][1].push(conversation); 131 | } 132 | }); 133 | 134 | return new Map(result); 135 | } 136 | } 137 | 138 | module.exports = { 139 | ConversationProcessor, DIALOGFLOW_CONSTANTS, 140 | }; 141 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "robot-mate", 3 | "version": "0.13.0", 4 | "description": "A format to test robots", 5 | "main": "lib/parser.js", 6 | "bin": { 7 | "rmrun": "run.js", 8 | "rmparse": "parse.js" 9 | }, 10 | "scripts": { 11 | "test": "tap test", 12 | "lint": "eslint --ignore-path .gitignore ." 13 | }, 14 | "author": "", 15 | "license": "ISC", 16 | "devDependencies": { 17 | "acorn": "^6.4.1", 18 | "eslint": "^5.16.0", 19 | "eslint-config-airbnb-base": "^13.2.0", 20 | "eslint-plugin-import": "^2.20.1" 21 | }, 22 | "dependencies": { 23 | "botium-connector-dialogflow": "^0.0.13", 24 | "botium-connector-echo": "0.0.4", 25 | "botium-core": "^1.8.1", 26 | "colors": "^1.4.0", 27 | "dev-null": "^0.1.1", 28 | "dialogflow": "^1.2.0", 29 | "klaw-sync": "^6.0.0", 30 | "rimraf": "^2.7.1", 31 | "tap": "^13.1.11", 32 | "yargs": "^12.0.5" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /parse.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const fs = require('fs'); 3 | const { argv } = require('yargs') 4 | .usage('Usage: $0 ') 5 | .required(1, 'Path is required'); 6 | 7 | const parser = require('./lib/parser.js'); 8 | 9 | if (argv._[0].endsWith('.rmc')) { 10 | const data = parser.parseFile(argv._[0]); 11 | console.log(JSON.stringify(data, null, 4)); 12 | } else { 13 | fs.readdirSync(argv._[0]) 14 | .filter(file => file.endsWith('.rmc')) 15 | .map(file => `${argv._[0]}/${file}`) 16 | .forEach((file) => { 17 | console.log(`=== Parsing: ${file}`); 18 | parser.parseFile(file); 19 | console.log(' = OK'); 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /robotmate.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flix-tech/robotmate/442433672c59885588a123bef571f856df05716f/robotmate.jpg -------------------------------------------------------------------------------- /run.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const { BotDriver } = require('botium-core'); 4 | const fs = require('fs'); 5 | const tap = require('tap'); 6 | const klawSync = require('klaw-sync'); 7 | const rimraf = require('rimraf'); 8 | const devnull = require('dev-null'); 9 | require('colors'); 10 | const { argv } = require('yargs').strict() 11 | .usage('Usage: $0 [--conf conf] [--jobs num] [--retries num]') 12 | .describe('conf', 'Botium configuration.') 13 | .nargs('conf', 1) 14 | .describe('jobs', 'Number of parallel executions to trigger.') 15 | .nargs('jobs', 1) 16 | .describe('retries', 'Number of retries to perform when a conversation fails.') 17 | .nargs('retries', 1); 18 | 19 | const parser = require('./lib/parser.js'); 20 | const runner = require('./lib/runner.js'); 21 | 22 | 23 | const run = (driver, data, childTest, maxRetries, retries = 0) => { 24 | const container = driver.BuildFluent().Start(); 25 | 26 | new runner.ConversationProcessor(container, data.fileName, data.conversation, data.lang).process(); 27 | 28 | container 29 | .Stop() 30 | .Clean() 31 | .Exec() 32 | .then(() => { 33 | childTest.end(); 34 | }) 35 | .catch((error) => { 36 | if (retries < maxRetries) { 37 | run(driver, data, childTest, maxRetries, retries + 1); 38 | } else { 39 | childTest.fail(error); 40 | childTest.end(); 41 | } 42 | }); 43 | }; 44 | 45 | const clean = () => { 46 | rimraf.sync(new BotDriver().caps.TEMPDIR); 47 | }; 48 | 49 | const report = () => { 50 | const failures = tap.results.failures.length; 51 | const testsFailed = tap.results.failures.length > 0; 52 | 53 | const failedTests = (testsFailed) ? `${failures} failed, ` : ''; 54 | const passedTests = `${tap.results.count - failures} passed, `; 55 | console.info(); 56 | console.info(`Conversations: ${failedTests} ${passedTests} ${tap.results.count} total`); 57 | console.info(`Duration: ${(tap.time / 1000).toFixed(2)} seconds`); 58 | 59 | if (testsFailed) { 60 | console.error(`${failures} failed conversations:`.red); 61 | tap.results.failures.forEach(x => console.error(x.name.red)); 62 | process.exit(1); 63 | } 64 | }; 65 | 66 | 67 | const BOTIUM_CONF_DEFAULT = 'botium.json'; 68 | const main = () => { 69 | const conversationsPath = argv._[0] || '.'; 70 | 71 | if (argv.conf) { 72 | if (fs.existsSync(argv.conf)) { 73 | process.env.BOTIUM_CONFIG = argv.conf; 74 | } else { 75 | console.error(`Configuration file ${argv.conf} does not exist`); 76 | process.exit(1); 77 | } 78 | } else { 79 | console.info(`No configuration provided, using: ${BOTIUM_CONF_DEFAULT}`); 80 | process.env.BOTIUM_CONFIG = BOTIUM_CONF_DEFAULT; 81 | } 82 | let driver = null; 83 | try { 84 | driver = new BotDriver(); 85 | } catch (e) { 86 | console.error('There is a problem with your Botium configuration, please check.'); 87 | throw e; 88 | } 89 | 90 | 91 | const files = conversationsPath.endsWith('.rmc') ? [conversationsPath] 92 | : klawSync(conversationsPath) 93 | .filter(file => file.path.endsWith('.rmc')) 94 | .map(file => file.path); 95 | const maxRetries = argv.retries || 0; 96 | tap.jobs = argv.jobs || 1; 97 | tap.pipe(devnull()); 98 | files.forEach((filePath) => { 99 | const testName = filePath; 100 | const fileContent = fs.readFileSync(filePath, 'utf-8'); 101 | const data = parser.parseString(fileContent, filePath); 102 | tap.test(testName, (childTest) => { 103 | run(driver, data, childTest, maxRetries); 104 | }); 105 | }); 106 | tap.tearDown(clean); 107 | tap.tearDown(report); 108 | }; 109 | 110 | main(); 111 | -------------------------------------------------------------------------------- /test/resources/runner/book_a_ride.rmc: -------------------------------------------------------------------------------- 1 | LANG: en_US 2 | 3 | HM says : "I would like to go to Berlin" 4 | RM equals : "What's your departure city?" 5 | RM contains : "departure" 6 | RM starts with : "What" 7 | 8 | 9 | HM says : "Berlin" 10 | RM equals : "Ok, lets go to Berlin there! When do you want to go?" 11 | RM includes any : "Berlin" "Munich" 12 | 13 | HM says : "Tomorrow" 14 | RM equals : "Have a ticket! Enjoy your ride!" 15 | RM excludes : "No" "tomorrow" 16 | -------------------------------------------------------------------------------- /test/resources/runner/book_a_ride_failing.rmc: -------------------------------------------------------------------------------- 1 | LANG: en_US 2 | 3 | HM says : "I would like to go to Berlin" 4 | RM equals : "What's your departure city?" 5 | RM contains : "departure" 6 | RM starts with : "Whatos" 7 | 8 | 9 | -------------------------------------------------------------------------------- /test/resources/runner/book_a_ride_template.csv: -------------------------------------------------------------------------------- 1 | city_1,city_2 2 | Munich,Berlin 3 | Nuremberg,Paris -------------------------------------------------------------------------------- /test/resources/runner/book_a_ride_template.rmc: -------------------------------------------------------------------------------- 1 | LANG: en_US 2 | 3 | HM says : "I would like to go to {{city_2}}" 4 | RM equals : "What's your departure city?" 5 | RM contains : "departure" 6 | RM starts with : "What" 7 | 8 | 9 | HM says : "{{city_1}}" 10 | RM equals : "On which day do you want to travel?" 11 | 12 | HM says : "Tomorrow" 13 | RM contains : "Would you like to select this option?" 14 | 15 | -------------------------------------------------------------------------------- /test/resources/sample.rmc: -------------------------------------------------------------------------------- 1 | LANG: en_US 2 | 3 | HM says : "Hello Gugu!" 4 | 5 | RM starts with : "Hello Granny" 6 | 7 | 8 | -------------------------------------------------------------------------------- /test/resources/sample_1.rmc: -------------------------------------------------------------------------------- 1 | LANG: en_US 2 | 3 | HM says : "Hello Gugu!" 4 | 5 | RM starts with : "Hello Granny" 6 | 7 | 8 | HM says : "Hello Gugu!" 9 | 10 | RM contains : "Cuac!" 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /test/resources/sample_1_model.json: -------------------------------------------------------------------------------- 1 | { 2 | "lang": "en_US", 3 | "fileName": "test/resources/sample_1.rmc", 4 | "conversation": [ 5 | { 6 | "line": 3, 7 | "actor": "HM", 8 | "action": "says", 9 | "parameter": "Hello Gugu!" 10 | }, 11 | { 12 | "line": 5, 13 | "actor": "RM", 14 | "action": "starts with", 15 | "parameter": "Hello Granny" 16 | }, 17 | { 18 | "line": 8, 19 | "actor": "HM", 20 | "action": "says", 21 | "parameter": "Hello Gugu!" 22 | }, 23 | { 24 | "line": 10, 25 | "actor": "RM", 26 | "action": "contains", 27 | "parameter": "Cuac!" 28 | } 29 | ] 30 | } -------------------------------------------------------------------------------- /test/resources/sample_2.rmc: -------------------------------------------------------------------------------- 1 | 2 | 3 | HM says : "Hello Gugu!" 4 | 5 | RM starts with : "Hello Granny" 6 | 7 | HM says : "Hello Gugu!" 8 | 9 | RM contains : "Cuac!" 10 | -------------------------------------------------------------------------------- /test/resources/sample_4.rmc: -------------------------------------------------------------------------------- 1 | LANG: en_US 2 | 3 | HM says : "Hello Gugu!" 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | RM starts with : "Hello Granny" 17 | 18 | HM says "Hello Gugu!" 19 | 20 | RM contains : "Cuac!" 21 | -------------------------------------------------------------------------------- /test/resources/sample_5.rmc: -------------------------------------------------------------------------------- 1 | LANG: en_US 2 | 3 | HM says : "Hello Gugu!" 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | RM starts with : "Hello Granny" 17 | 18 | HM says : "Hello Gugu!" 19 | 20 | RM 21 | 22 | contains : "Cuac!" 23 | -------------------------------------------------------------------------------- /test/resources/sample_model.json: -------------------------------------------------------------------------------- 1 | { 2 | "lang": "en_US", 3 | "fileName": "test/resources/sample.rmc", 4 | "conversation": [ 5 | { 6 | "line": 3, 7 | "actor": "HM", 8 | "action": "says", 9 | "parameter": "Hello Gugu!" 10 | }, 11 | { 12 | "line": 5, 13 | "actor": "RM", 14 | "action": "starts with", 15 | "parameter": "Hello Granny" 16 | } 17 | ] 18 | } -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | require('tap').mochaGlobals(); 2 | const assert = require('assert'); 3 | const fs = require('fs'); 4 | const parser = require('../lib/parser.js'); 5 | 6 | const assertParseLine = (line, conversationItem) => { 7 | assert.deepEqual(parser.parseLine(line, 0, '2018-07-31'), conversationItem); 8 | }; 9 | 10 | const assertParseLineError = (line, msg) => { 11 | assert.throws(() => parser.parseLine(line, 0, '2018-07-31'), { message: msg }); 12 | }; 13 | 14 | const assertFiles = (filename) => { 15 | assert.deepEqual(parser.parseFile(`test/resources/${filename}.rmc`), 16 | JSON.parse(fs.readFileSync(`test/resources/${filename}_model.json`, 'utf-8'))); 17 | }; 18 | 19 | const assertParseErrors = (filename, msg) => { 20 | assert.throws(() => parser.parseFile(`test/resources/${filename}.rmc`), { message: msg }); 21 | }; 22 | 23 | describe('Parser', () => { 24 | describe('Parse Line', () => { 25 | it('Should parse every action successfully', () => { 26 | assertParseLine('HM says : "Hello Gugu!"', 27 | new parser.ConversationItem(0, 'HM', 'says', 'Hello Gugu!')); 28 | 29 | assertParseLine('RM contains : "Hi!"', 30 | new parser.ConversationItem(0, 'RM', 'contains', 'Hi!')); 31 | 32 | assertParseLine('HM says : "I am fine, how are you?"', 33 | new parser.ConversationItem(0, 'HM', 'says', 'I am fine, how are you?')); 34 | 35 | assertParseLine('RM starts with : "I am"', 36 | new parser.ConversationItem(0, 'RM', 'starts with', 'I am')); 37 | 38 | assertParseLine('HM says : "What are you doing?"', 39 | new parser.ConversationItem(0, 'HM', 'says', 'What are you doing?')); 40 | 41 | assertParseLine('RM equals : "Nothing!"', 42 | new parser.ConversationItem(0, 'RM', 'equals', 'Nothing!')); 43 | 44 | assertParseLine('RM includes any : "Fist word" "Second word"', 45 | new parser.ConversationItem(0, 'RM', 'includes any', 'Fist word" "Second word')); 46 | 47 | assertParseLine('RM includes any : "Fist word"', 48 | new parser.ConversationItem(0, 'RM', 'includes any', 'Fist word')); 49 | 50 | assertParseLine('RM excludes : "Fist word" "Second word"', 51 | new parser.ConversationItem(0, 'RM', 'excludes', 'Fist word" "Second word')); 52 | 53 | assertParseLine('RM excludes : "Fist word"', 54 | new parser.ConversationItem(0, 'RM', 'excludes', 'Fist word')); 55 | }); 56 | }); 57 | describe('Fail on incorrect lines', () => { 58 | it('Wrong actor', () => { 59 | assertParseLineError('M says : "Hello Gugu!"', 'The actor M doesn\'t exists (Line: 0)'); 60 | }); 61 | it('Wrong action', () => { 62 | assertParseLineError('HM sayos : "Hello Gugu!"', 'The actor HM doesn\'t have the action sayos (Line: 0)'); 63 | }); 64 | }); 65 | describe('Parse File', () => { 66 | it('Should parse the list of given files', () => { 67 | assertFiles('sample'); 68 | assertFiles('sample_1'); 69 | }); 70 | }); 71 | describe('Fail on incorrect files', () => { 72 | it('File without lang', () => { 73 | assertParseErrors('sample_2', 'Your test doesn\'t have the language set (Line: 1)'); 74 | }); 75 | it('File without separation of action', () => { 76 | assertParseErrors('sample_4', 'This line doesn\'t have a separator as required: (Line: 18)'); 77 | }); 78 | it('File without action or actor in line', () => { 79 | assertParseErrors('sample_5', 'This line doesn\'t have a separator as required: (Line: 20)'); 80 | }); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /test/test_runner.js: -------------------------------------------------------------------------------- 1 | require('tap').mochaGlobals(); 2 | const assert = require('assert'); 3 | const parser = require('../lib/parser.js'); 4 | const { ConversationProcessor, DIALOGFLOW_CONSTANTS } = require('../lib/runner.js'); 5 | 6 | class MockContainer { 7 | constructor(answers) { 8 | this.answers = new Map(answers); 9 | this.driver = { 10 | capabilitiesLoaded: false, 11 | setCapability(cap, value) { 12 | assert(cap, DIALOGFLOW_CONSTANTS.langCapability); 13 | assert(value, 'en-EN'); 14 | this.capabilitiesLoaded = true; 15 | }, 16 | caps: { CONTAINERMODE: DIALOGFLOW_CONSTANTS.containerMode }, 17 | }; 18 | } 19 | 20 | UserSaysText(text) { this.lastText = text; } 21 | 22 | WaitBotSaysText(callback) { 23 | callback(this.answers.get(this.lastText)); 24 | } 25 | } 26 | 27 | describe('Runner', () => { 28 | it('Should run a MOCK conversation', () => { 29 | const data = parser.parseFile('test/resources/runner/book_a_ride.rmc'); 30 | const answers = [ 31 | ['I would like to go to Berlin', "What's your departure city?"], 32 | ['Berlin', 'Ok, lets go to Berlin there! When do you want to go?'], 33 | ['Tomorrow', 'Have a ticket! Enjoy your ride!'], 34 | ]; 35 | 36 | const mockContainer = new MockContainer(answers); 37 | const result = new ConversationProcessor(mockContainer, data.fileName, data.conversation, data.lang).process(); 38 | assert(mockContainer.driver.capabilitiesLoaded, true); 39 | assert.deepEqual(answers.reduce((acc, val) => acc.concat(val), []), result); 40 | }); 41 | it('Should run a failing conversation', () => { 42 | const data = parser.parseFile('test/resources/runner/book_a_ride_failing.rmc'); 43 | const answers = [ 44 | ['I would like to go to Berlin', "What's your departure city?"], 45 | ['Berlin', 'Ok, lets go to Berlin there! When do you want to go?'], 46 | ['Tomorrow', 'Have a ticket! Enjoy your ride!'], 47 | ]; 48 | 49 | const mockContainer = new MockContainer(answers); 50 | assert.throws(() => new ConversationProcessor(mockContainer, data.fileName, data.conversation, data.lang) 51 | .process(), { message: 'Assertion FAILED @ line: 6' }); 52 | assert(mockContainer.driver.capabilitiesLoaded, true); 53 | }); 54 | }); 55 | --------------------------------------------------------------------------------