Source: Assertion.js

  1. const jsonpath = require("jsonpath-bespoken");
  2. const ParserError = require("./ParserError");
  3. const Util = require("../util/Util");
  4. const OPERATORS = ["==", "=~", "!=", ">", ">=", "<", "<="];
  5. const NUMERIC_OPERATORS = [">", ">=", "<", "<="];
  6. /**
  7. * Represents an assertion to evaluate during an interaction
  8. */
  9. class Assertions {
  10. /**
  11. *
  12. * @param {TestInteraction} interaction - the interaction that is being evaluated
  13. * @param {string} path - the Json path of the property in the response being evaluated
  14. * @param {string} operator - the operator used to evaluate the response property
  15. * @param {string} value - the value that is evaluated against the response property
  16. * @param {string[]} variables - variables inside the utterance that can be replaced
  17. * @param {string} originalOperator - the operator before parsing the interaction
  18. */
  19. constructor(interaction, path, operator, value, variables, originalOperator, lenientMode=false) {
  20. this._interaction = interaction;
  21. this._path = path;
  22. this._operator = operator;
  23. this._value = value;
  24. this._goto = undefined;
  25. this._variables = variables;
  26. this._originalOperator = originalOperator;
  27. this._lenientMode = lenientMode;
  28. this.parse();
  29. }
  30. /**
  31. * Parses the value to check and set the goto property if needed
  32. */
  33. parse() {
  34. if (this.exit) {
  35. return;
  36. }
  37. // Looks for a goto in the value statement
  38. if (Util.isString(this._value)
  39. && this._value.includes(" goto ")) {
  40. const gotoRegex = /(.*) goto (.*)/i;
  41. const matchArray = this._value.match(gotoRegex);
  42. if (matchArray.length === 2) {
  43. throw ParserError.interactionError(this.interaction,
  44. "Invalid goto - does not have label: " + this._value,
  45. this.lineNumber);
  46. } else if (matchArray.length === 3) {
  47. this._value = matchArray[1];
  48. this._goto = matchArray[2];
  49. }
  50. }
  51. }
  52. /**
  53. * Validate if the assertion syntax is correct if not throws a Parser Error
  54. * @throws {ParserError}
  55. */
  56. validate() {
  57. const result = Util.executeFilterSync(this.interaction.test.testSuite.filterArray(), "onValidate", this);
  58. if (typeof result !== "undefined") {
  59. return result;
  60. }
  61. const path = jsonpath.parse(this.path);
  62. if (!path) {
  63. throw ParserError.interactionError(this.interaction,
  64. "Invalid JSON path: " + this.path,
  65. this.lineNumber);
  66. }
  67. if (!OPERATORS.includes(this.operator)) {
  68. throw ParserError.interactionError(this.interaction,
  69. "Invalid operator: " + this.operator,
  70. this.lineNumber);
  71. }
  72. // Check to make sure the expected value is a number if this is a numeric operator
  73. if (NUMERIC_OPERATORS.includes(this.operator)) {
  74. if (isNaN(this.value)) {
  75. throw ParserError.interactionError(this.interaction,
  76. "Invalid expected value - must be numeric: " + this.value,
  77. this.lineNumber);
  78. }
  79. }
  80. }
  81. /**
  82. * Evaluates this assertion against the provided response and returns true if it succeeds
  83. * @param {object} response - the response to which we do the assertion
  84. * @return {boolean}
  85. */
  86. evaluate(response) {
  87. if (this.exit) {
  88. return true;
  89. }
  90. const json = response.json;
  91. let jsonValue = this.valueAtPath(json);
  92. const ignoreCase = response.ignoreCase(this.path);
  93. if (this.operator === "==" || this.operator === "=~") {
  94. if (this.value === undefined) {
  95. return jsonValue === undefined;
  96. }
  97. let match = false;
  98. if (jsonValue !== undefined) {
  99. if (Array.isArray(this.value)) {
  100. for (const localizedValue of this.value) {
  101. match = this.evaluateRegexOrString(this.operator, localizedValue, jsonValue, ignoreCase);
  102. // Once matched, do not need to process further
  103. if (match) {
  104. break;
  105. }
  106. }
  107. } else {
  108. const localizedValue = this.value;
  109. match = this.evaluateRegexOrString(this.operator, localizedValue, jsonValue, ignoreCase);
  110. }
  111. }
  112. return match;
  113. } else if (NUMERIC_OPERATORS.includes(this.operator)) {
  114. if (isNaN(jsonValue)) {
  115. return false;
  116. }
  117. const expectedValue = parseInt(this.value, 10);
  118. const actualValue = parseInt(jsonValue, 10);
  119. if (this.operator === ">") {
  120. return actualValue > expectedValue;
  121. } else if (this.operator === ">=") {
  122. return actualValue >= expectedValue;
  123. } else if (this.operator === "<") {
  124. return actualValue < expectedValue;
  125. } else if (this.operator === "<=") {
  126. return actualValue <= expectedValue;
  127. }
  128. } else if (this.operator === "!=") {
  129. if (this.value === undefined) {
  130. return jsonValue !== undefined;
  131. }
  132. if (jsonValue === undefined) {
  133. return this.value !== undefined;
  134. }
  135. if (ignoreCase) {
  136. jsonValue = jsonValue.toLowerCase();
  137. }
  138. if (Array.isArray(this.value)) {
  139. let resultNotEqual = true;
  140. for (let localizedValue of this.value) {
  141. if (ignoreCase) {
  142. localizedValue = localizedValue && localizedValue.toLowerCase();
  143. }
  144. resultNotEqual = jsonValue.includes(localizedValue);
  145. if (resultNotEqual) {
  146. return false;
  147. }
  148. }
  149. return true;
  150. } else {
  151. const valueToCompare = ignoreCase ? this.value.toLowerCase() : this.value;
  152. return !jsonValue || !jsonValue.includes(valueToCompare);
  153. }
  154. } else {
  155. throw "Operator not implemented yet: " + this.operator;
  156. }
  157. }
  158. /**
  159. * Evaluates if a regex or string complies with the assertion
  160. * @param {string} operator - Operator used to evaluate the expected value against the actual one.
  161. * @param {string} expectedValue - Value defined in the assertion
  162. * @param {string} actualValue - Actual value returning in the response
  163. * @param {boolean} ignoreCase - ignore case when evaluating the strings
  164. * @return {boolean}
  165. */
  166. evaluateRegexOrString(operator, expectedValue, actualValue, ignoreCase) {
  167. let operatorToCompare = operator;
  168. // localized values are loaded dynamically, so we need to double check if is a regex or not
  169. if (this._originalOperator === ":") {
  170. // If the operator is the regex operator, or the value starts with /, we treat it as a regex
  171. if (this.isRegex(expectedValue)) {
  172. operatorToCompare = "=~";
  173. } else {
  174. operatorToCompare = "==";
  175. }
  176. }
  177. if (operatorToCompare === "=~") {
  178. return this.evaluateRegex(expectedValue, actualValue, ignoreCase);
  179. } else {
  180. return this.evaluateString(expectedValue, actualValue, ignoreCase);
  181. }
  182. }
  183. stringForLenientMode(value) {
  184. value = `${value}`;
  185. const toRemoveRegex = /[-\f\n\r\t\v.()"',;:!?*^$]/g;
  186. value = value.replace(toRemoveRegex, " ");
  187. value = value.replace(/ +/g, " ");
  188. value = value.trim();
  189. return value;
  190. }
  191. /**
  192. * Evaluates if the actual value contains the expected value
  193. * @param {string} expectedValue - Value defined in the assertion
  194. * @param {string} actualValue - Actual value returning in the response
  195. * @param {boolean} ignoreCase - ignore case when evaluating the strings
  196. * @return {boolean}
  197. */
  198. evaluateString(expectedValue, actualValue, ignoreCase) {
  199. // If the values are not strings, convert to a string for ease of comparison
  200. if (!Util.isString(expectedValue)) {
  201. expectedValue += "";
  202. }
  203. if (!Util.isString(actualValue)) {
  204. actualValue += "";
  205. }
  206. let regex = expectedValue;
  207. if (this._lenientMode) {
  208. regex = this.stringForLenientMode(regex);
  209. actualValue = this.stringForLenientMode(actualValue);
  210. } else {
  211. // We allow for a wild-card *
  212. regex = expectedValue.trim().split("*").join("(.|\\n)*");
  213. // Escape special values that we do NOT want to treat as a wild-card
  214. regex = regex.split("+").join("\\+");
  215. regex = regex.split("^").join("\\^");
  216. regex = regex.split("$").join("\\$");
  217. regex = regex.split("?").join("\\?");
  218. }
  219. let options = "";
  220. if (ignoreCase) {
  221. options = "i";
  222. }
  223. return new RegExp(regex, options).test(actualValue);
  224. }
  225. /**
  226. * Evaluates if the actual value matches the expected value regex
  227. * @param {string} expectedValue - expected value regex defined in the assertion
  228. * @param {string} actualValue - Actual value returning in the response
  229. * @param {boolean} ignoreCase - ignore case when evaluating the strings
  230. * @return {boolean}
  231. */
  232. evaluateRegex(expectedValue, actualValue, ignoreCase) {
  233. let regexString = expectedValue;
  234. let options = "";
  235. if (regexString.startsWith("/")) {
  236. regexString = regexString.substr(1);
  237. // Now get the last /, and treat the part after as options
  238. const endIndex = regexString.lastIndexOf("/");
  239. if (endIndex + 1 < regexString.length) {
  240. options = regexString.substr(endIndex + 1);
  241. }
  242. regexString = regexString.substr(0, endIndex);
  243. }
  244. if (ignoreCase && options.indexOf("i") == -1) {
  245. options += "i";
  246. }
  247. try {
  248. const regex = new RegExp(regexString, options);
  249. return regex.test(actualValue);
  250. } catch (e) {
  251. return false;
  252. }
  253. }
  254. /**
  255. * Validates if the expected value is a regex
  256. * @param {string} expectedValue - a string expected value
  257. * @return {boolean}
  258. */
  259. isRegex(expectedValue) {
  260. return this.operator === "=~" ||
  261. (Util.isString(expectedValue) && expectedValue.startsWith("/"));
  262. }
  263. /**
  264. * Returns true if this assertion includes an exit
  265. * @return {boolean}
  266. */
  267. get exit() {
  268. return this.path === "exit";
  269. }
  270. /**
  271. * Returns true if this assertion includes a go to
  272. * @return {boolean}
  273. */
  274. get goto() {
  275. return Util.cleanString(this._goto);
  276. }
  277. /**
  278. * Returns the interaction that contains this assertion
  279. * @return {TestInteraction}
  280. */
  281. get interaction() {
  282. return this._interaction;
  283. }
  284. /**
  285. * Returns in which line number this assertion is located
  286. * @return {number}
  287. */
  288. get lineNumber() {
  289. return this._lineNumber;
  290. }
  291. /****
  292. * Set in which line number this assertion is located
  293. * @param {number} number - which line number this assertion is located
  294. */
  295. set lineNumber(number) {
  296. this._lineNumber = number;
  297. }
  298. /**
  299. * Returns what is the Json path that we are evaluating in the response
  300. * @return {string}
  301. */
  302. get path() {
  303. return Util.cleanString(this._path);
  304. }
  305. /**
  306. * Returns what is the operator that we are using to evaluate the response
  307. * @return {string}
  308. */
  309. get operator() {
  310. return Util.cleanString(this._operator);
  311. }
  312. /**
  313. * Returns what is the value against we are evaluating
  314. * @return {string | string[]}
  315. */
  316. get value() {
  317. if (this._localizedValue) {
  318. return Util.cleanValue(this._localizedValue);
  319. }
  320. return Util.cleanValue(this._value);
  321. }
  322. /**
  323. * Returns a list of variables to replace in the assertion
  324. * @return {string[]}
  325. */
  326. get variables() {
  327. return this._variables;
  328. }
  329. /**
  330. * Gets the list of variables to replace in the assertion
  331. * @param {string[]} variables
  332. */
  333. set variables(variables) {
  334. this._variables = variables;
  335. }
  336. /**
  337. * Returns the operator before parsing the interaction
  338. * @return {string}
  339. */
  340. get originalOperator() {
  341. return Util.cleanString(this._originalOperator);
  342. }
  343. /****
  344. * Set the localizedUtterance
  345. * @param {string} localizedUtterance
  346. */
  347. set localizedValue(localizedValue) {
  348. this._localizedValue = localizedValue;
  349. }
  350. /**
  351. * Returns what is in the value in the response for the Json path
  352. * @param {object} json - the response being evaluated
  353. * @return {string}
  354. */
  355. valueAtPath(json) {
  356. if (this.interaction && Util.filterExist(this.interaction.test.testSuite.filterArray(), "onValue")) {
  357. const result = Util.executeFilterSync(this.interaction.test.testSuite.filterArray(), "onValue", this, json);
  358. if (typeof result !== "undefined") {
  359. return result;
  360. }
  361. }
  362. try {
  363. return json ? jsonpath.value(json, this.path) : undefined;
  364. } catch (e) {
  365. console.error(e.stack); // eslint-disable-line
  366. return undefined;
  367. }
  368. }
  369. /**
  370. * Returns the assertion evaluation in a string with the error details if the assertion has failed
  371. * @param {object} json - the response being evaluated
  372. * @param {string} errorOnResponse - error that was generated, if present this is what we would return
  373. * @return {string}
  374. */
  375. toString(json, errorOnResponse) {
  376. if (errorOnResponse) {
  377. return errorOnResponse;
  378. }
  379. const testSuite = this.interaction && this.interaction.test && this.interaction.test.testSuite;
  380. let jsonValue = this.valueAtPath(json);
  381. const localizedValue = (testSuite && testSuite.getLocalizedValue(this.value)) || this.value;
  382. const isLenientMode = this._lenientMode && !this.isRegex(localizedValue);
  383. if (isLenientMode) {
  384. jsonValue = this.stringForLenientMode(jsonValue);
  385. }
  386. let expectedValueString = "\t"
  387. + (isLenientMode ? this.stringForLenientMode(localizedValue) : localizedValue)
  388. + "\n";
  389. let operator = this.operator;
  390. if (Array.isArray(this.value)) {
  391. operator = "be one of:";
  392. expectedValueString = "";
  393. for (const value of this.value) {
  394. expectedValueString += "\t"
  395. + (isLenientMode ? this.stringForLenientMode(value) :value)
  396. + "\n";
  397. }
  398. } else if (NUMERIC_OPERATORS.includes(this.operator)) {
  399. operator = "be " + this.operator;
  400. }
  401. let message = "Expected value at [" + this.path + "] to " + operator + "\n"
  402. + expectedValueString
  403. + "\nReceived:\n"
  404. + "\t" + jsonValue + "\n";
  405. // If we have a line number, show it
  406. if (this.lineNumber) {
  407. message += "at " + this.interaction.test.testSuite.fileName + ":" + this.lineNumber + ":0";
  408. }
  409. return message;
  410. }
  411. /**
  412. * Returns the assertion part of a yaml object
  413. * { action: string, operator: string, value: string|string[]}
  414. * @return {object}
  415. */
  416. toYamlObject() {
  417. return {
  418. action: this.path,
  419. operator: this.originalOperator,
  420. value: this.value,
  421. };
  422. }
  423. }
  424. module.exports = Assertions;