Source: TestParser.js

const _ = require("lodash");
const Assertion = require("./Assertion");
const Configuration = require("../runner/Configuration");
const Expression = require("./Expression");
const fs = require("fs");
const ParserError = require("./ParserError");
const Test = require("./Test");
const TestInteraction = require("./TestInteraction");
const TestSuite = require("./TestSuite");
const Util = require("../util/Util");
const yaml = require("js-yaml-bespoken");

/**
 * Represents the parser used for converting a yaml file into a test suite
 */
class TestParser {
    /**
     *
     * @param {string} fileName - the yaml file that will be parsed
     */
    constructor(fileName) {
        this.fileName = fileName;
        if (this.fileName) {
            this.contents = fs.readFileSync(this.fileName, "utf8");
        }
    }

    /**
     * Set the contents of the YML file
     * @param {object} contents - set the contents of the file
     */
    load(contents) {
        this.contents = contents;
    }

    /**
     * Add quotes to string if not a regex
     * @param {string} origin - original string
     * @return {string} string with quotes
     */
    addQuotesToNonRegexString(origin) {
        if (origin.startsWith && origin.startsWith("/")) {
            return origin;
        }
        return `"${origin}"`;
    }

    /**
     * loads a yaml object into a yaml file
     * @param {object} yamlObject - yaml representation of a yaml file
     */
    loadYamlObject(yamlObject) {
        const configuration = yamlObject.configuration;
        const tests = yamlObject.tests;
        this.contents = "";
        if (configuration) {
            this.contents += "---\nconfiguration:\n";
            this.contents += Object.keys(configuration)
                .map(key => `  ${key}: ${configuration[key]}`)
                .join("\n");
        }

        if (tests && tests.length) {
            for (let i = 0; i < tests.length; i++) {
                let testContent = "";
                if (i === 0 && this.contents === "") {
                    testContent = "---\n";
                } else {
                    testContent = "\n---\n";
                }
                const testSuffix = tests[i].only ? ".only" :
                    tests[i].skip ? ".skip" : "";
                testContent += `- test${testSuffix} : ${tests[i].name}\n`;
                if (tests[i].tags && tests[i].tags.length) {
                    testContent += `- tags : ${tests[i].tags.join(", ")}\n`;
                }
                testContent += tests[i].interactions
                    .map((interaction) => {
                        const expected = interaction.expected;
                        const expressions = interaction.expressions;
                        const hasExpressions =  expressions && expressions.length > 0;
                        if (expected.length === 1
                            && expected[0].action === "prompt"
                            && !Array.isArray(expected[0].value)
                            && !hasExpressions) {
                            return `- ${interaction.input} ${expected[0].operator} ${this.addQuotesToNonRegexString(expected[0].value)}\n`;
                        }
                        let interactionString = `- ${interaction.input} :\n`;
                        interactionString += expected.map((item) => {
                            if (!Array.isArray(item.value)) {
                                return `  - ${item.action} ${item.operator} ${this.addQuotesToNonRegexString(item.value)}\n`;
                            }
                            const value = item.value.map(itemValue => `    - ${this.addQuotesToNonRegexString(itemValue)}`).join("\n");
                            return `  - ${item.action} ${item.operator}\n${value}\n`;
                        }).join("");
                        if (expressions && expressions.length) {
                            interactionString += expressions.map((item) => {
                                if (!Array.isArray(item.value)) {
                                    return `  - ${item.path} : ${Util.isString(item.value) ? this.addQuotesToNonRegexString(item.value) : item.value}\n`;
                                }
                                const value = item.value.map(itemValue =>
                                    `    - ${Util.isString(itemValue) ? this.addQuotesToNonRegexString(itemValue) : itemValue}`).join("\n");
                                return `  - ${item.path} :\n${value}\n`;
                            }).join("");
                        }
                        return interactionString;
                    }).join("");
                this.contents += testContent;
            }
        }

        if (this.contents.includes(" : \"\"\n-")) {
            this.contents = this.contents.replace(/ : ""\n-/gi, "\n-");
        }
        if (this.contents.includes(" : \"\"\n")) {
            this.contents = this.contents.replace(/ : ""\n/gi, "\n");
        }
    }

    /**
     * Parses a test file and returns a test suite
     * @param {string} configurationOverride - configuration override for the test suite
     * @return {TestSuite}
     */
    parse(configurationOverride) {
        try {
            const contents = this.findReplace(this.contents);
            const documents = yaml.loadAll(contents);
            let configuration;
            let tests = documents;
            if (documents.length > 1 && documents[0].configuration) {
                if (!Util.isObject(documents[0].configuration)) {
                    throw ParserError.globalError(this.fileName,
                        "Configuration element is not an object",
                        Util.extractLine(documents[0].configuration));
                }
                configuration = _.assign(documents[0].configuration, configurationOverride);
                tests = documents.slice(1);
            } else {
                configuration = configurationOverride;
            }


            const suite = new TestSuite(this.fileName, configuration);
            suite.tests = this.parseTests(suite, tests);
            suite.rawTestContent = this.contents;
            return suite;
        } catch (e) {
            throw e;
        }
    }

    /**
     * Parses the tests section of the yaml test file
     * @param {TestSuite} suite - the complete test suite
     * @param {object} rawTests - the raw tests from the yaml file
     * @return {Test[]}
     */
    parseTests(suite, rawTests) {
        const tests = [];
        const testNames = {};
        let testCount = 0;
        for (const test of rawTests) {
            testCount++;
            const parsedTest = this.parseTest(suite, test, testCount);
            if (parsedTest) {
                const currentTestDescription = parsedTest.description;
                if (testNames[currentTestDescription]) {
                    testNames[currentTestDescription] = testNames[currentTestDescription] + 1;
                    parsedTest.description = currentTestDescription + " (" + testNames[currentTestDescription] + ")";
                } else {
                    testNames[currentTestDescription] = 1;
                }
                tests.push(parsedTest);
            }
        }

        return tests;
    }

    /**
     * Parses a single test of the yaml test file
     * @param {TestSuite} suite - the complete test suite
     * @param {object} rawTest - a single raw test from the yaml file
     * @param {number} testIndex - the index of which test we are parsing
     * @return {Test}
     */
    parseTest(suite, rawTest, testIndex) {
        //If this is not an array, skip it
        if (!Array.isArray(rawTest)) {
            return undefined;
        }

        // The rawTest element is just an array of interactions
        // Optionally preceded by metadata about the test
        let rawInteractions = rawTest;
        if (rawInteractions.length == 0) {
            return new Test(suite, undefined, []);
        }

        // We need to pull out the first key of the first line of the test
        // If there is any metadata, this is where it is

        let testMeta = "Test " + testIndex;
        let only = false;
        let skipped = false;
        let tags = [];
        const filteredInteractions = [];

        for (let i = 0; i < rawInteractions.length; i++) {
            let isSpecialInteraction = false;

            const currentInteraction = rawInteractions[i];

            const interactionKeys = Object.keys(currentInteraction);

            // If the first or second element of the first interaction is "test", "test.only", or "test.skip", it is metadata
            if (i < 2 && interactionKeys.length > 0 && ["test", "test.only", "test.skip"].includes(interactionKeys[0])) {
                isSpecialInteraction = true;
                testMeta = currentInteraction[interactionKeys[0]];
                if (interactionKeys[0] === "test.only") {
                    only = true;
                } else if (interactionKeys[0] === "test.skip") {
                    skipped = true;
                }
            }

            // If the first or second element of the first interaction is "tag", it is metadata
            if (i < 2 && interactionKeys.length > 0 && ["tags"].includes(interactionKeys[0])) {
                isSpecialInteraction = true;

                const tagsString = currentInteraction[interactionKeys[0]];
                if (Util.isString(tagsString)) {
                    tags = tagsString.split(",").map(tag => tag.trim());
                }
            }

            if (!isSpecialInteraction) {
                filteredInteractions.push(currentInteraction);
            }
        }

        const test = new Test(suite, testMeta, []);
        test.index = testIndex - 1;
        for (let i = 0; i < filteredInteractions.length; i++) {
            let rawInteraction = filteredInteractions[i];
            test.interactions.push(this.parseInteraction(test, rawInteraction, i));
        }

        test.skip = skipped;
        test.only = only;

        if (tags.length) {
            test.tags = tags;
        }

        test.validate();
        return test;
    }

    /**
     * Parse a single interaction
     * @param {Test} test - the Test the contains the interaction being parsed
     * @param {object|string} interactionJSON - the interaction being parsed
     * @param {number} index - the index of the interaction in the test
     * @return {TestInteraction}
     */
    parseInteraction(test, interactionJSON, index) {
        const interaction = new TestInteraction(test);

        // If we have an object, we process it - alternatively the interaction could just be a string
        //  Such as "- yes" without any assertions        
        if (Util.isObject(interactionJSON)) {
            interaction.utterance = Object.keys(interactionJSON)[0];
            const elements = interactionJSON[interaction.utterance];
            interaction.lineNumber = Util.extractLine(elements);
            // Just because we have an object, we might have a "bad" object
            // Like so: - yes: "okay" - our old-style syntax
            // We just ignore these cases for now
            if (Array.isArray(elements)) {
                for (const element of elements) {
                    if (Util.isValueType(element)) {
                        const { assertion } = this.parseStringAssertion(interaction, element);
                        if (assertion) {
                            interaction.assertions.push(assertion);
                        }
                    } else {
                        if (element.intent) {
                            interaction.intent = element.intent.valueOf();

                            // Treat any other keys as slots
                            const slots = Util.cleanObject(element);
                            delete slots.intent;
                            interaction.slots = slots;
                        } else if (element.slots) {
                            interaction.slots = Util.cleanObject(element.slots);
                        } else if (element.label) {
                            interaction.label = Util.cleanObject(element.label);
                        } else if (Expression.isExpression(element)) {
                            interaction.expressions.push(new Expression(element));
                        } else {
                            // Must be an assertion otherwise
                            interaction.assertions.push(this.parseObjectAssertion(interaction, element));
                        }
                    }
                }
            } else if (elements) {
                let operator = "==";
                if (Util.isString(elements) && elements.startsWith("/")) {
                    operator = "=~";
                }
                interaction.assertions.push(
                    new Assertion(interaction, "prompt", operator, elements, this.getDefinedVariables(elements), ":"));
            }
        } else {
            const { utterance, assertion } = this.parseStringAssertion(interaction, interactionJSON, true);
            interaction.utterance = utterance;
            interaction.lineNumber = Util.extractLine(interactionJSON);
            if (assertion) {
                interaction.assertions.push(assertion);
            }
        }
        interaction.relativeIndex = index;
        return interaction;
    }

    /**
     * Parses a single Assertion inside an interaction
     * @param {TestInteraction} interaction - the interaction where this assertion is used
     * @param {object} element - the object representation of the YAML element
     * @return {Assertions}
     */
    parseObjectAssertion(interaction, element) {
        // Should have just one key
        const path = Object.keys(element)[0];
        const value = element[path];
        let operator = "==";
        // If the value starts with /, then it must be regular expression
        if (Util.isString(value) && value.trim().startsWith("/")) {
            operator = "=~";
        } else if (Array.isArray(value)) {
            if (value.some(x => Util.isString(x) && (x.trim().startsWith("/")))) {
                operator = "=~";
            }
        }

        let variablesMerged = [];

        const values = [];
        if (Array.isArray(value)) {
            for (const element of value) {
                values.push(element);
            }
            variablesMerged = values.reduce((variables, singleValue) => {
                const extractedVariables = this.getDefinedVariables(singleValue);
                return variables.concat(extractedVariables);
            }, []);

        } else {
            variablesMerged = this.getDefinedVariables(value + "");
        }

        const assertion = new Assertion(interaction, path, operator, value, _.uniq(variablesMerged), ":");
        assertion.validate();
        assertion.lineNumber = Util.extractLine(value);
        return assertion;
    }

    /**
     * Parse a string into assertion object.
     * Our short syntax is not valid in YAML. We look for an operator inside the string
     * Examples:
     * Hi
     * exit
     * LaunchRequest != hi
     * cardTitle != hi
     * cardTitle != - Value1 - Value2 - Value2
     *
     * @param {TestInteraction} interaction - the interaction that includes this assertion
     * @param {string} assertionString - the assertion as a string
     * @param {boolean} isUtteranceIncluded - if isUtteranceIncluded is set to true, the first part of the string will
     * will be considered as an utterance
     * @return {Assertion}
     */
    parseStringAssertion(interaction, assertionString, isUtteranceIncluded = false) {
        let path;
        let operator;
        let value;
        let utterance = undefined;
        let assertion = undefined;

        if (typeof assertionString === "number") {
            assertionString = assertionString + "";
        }
        // Special handling for exit
        if (assertionString.trim() === "exit") {
            return { assertion: new Assertion(interaction, "exit") };
        }

        // if utterance is not included, string with empty spaces is invalid 
        if (!isUtteranceIncluded && assertionString.indexOf(" ") === -1) {
            throw ParserError.interactionError(interaction,
                "Invalid assertion: " + assertionString,
                Util.extractLine(assertionString));
        }

        operator = this.getOperator(assertionString, isUtteranceIncluded);

        if (operator) {
            const parts = assertionString.split(operator);
            path = isUtteranceIncluded ? "prompt" : parts[0].trim(); // if utterance include, path set to prompt
            utterance = isUtteranceIncluded ? parts[0].trim() : undefined;
            value = parts[1].trim();
            operator = operator.trim();

            // YAML collections get parsed as string when using our operators
            // Ex: - Value1 - Value2 - Value2
            // checking if is a collection 
            const missingParsedCollection = value.split("-").map(x => x.trim()).filter(x => x);
            if (missingParsedCollection.length > 1) {
                value = missingParsedCollection;
            }
        } else {
            utterance = assertionString;
        }

        if (operator) {
            // We only look for variables in the values (not the path)
            const variables = this.getDefinedVariables(value + "");
            assertion = new Assertion(interaction, path, operator, value, variables, operator);
            assertion.lineNumber = Util.extractLine(assertionString);
            assertion.validate();
        }

        return { assertion, utterance };
    }

    /**
     * returns the operator used inside the assertion
     * @param {string} assertionString - the assertion as a string
     * @param {boolean} isUtteranceIncluded - if isUtteranceIncluded is set to true, the first part of the string will
     * be considered as an utterance
     * @return {string}
     */
    getOperator(assertionString, isUtteranceIncluded) {
        const operators = [" == ", " =~ ", " != ", " >= ", " <= ", " > ", " < "];
        let operatorPosition = -1;
        let operator = undefined;
        for (let i = 0; i < operators.length; i++) {
            operatorPosition = assertionString.indexOf(operators[i]);
            if (operatorPosition > -1) {
                operator = operators[i];
                break;
            }
        }

        // if we don't find our operators, we will search for invalid ones
        // assertion object will validate the invalid ones
        // only applies when the utterance is not included
        if (!operator && !isUtteranceIncluded) {
            const parts = assertionString.split(/ +/);
            operator = parts[1];
        }

        return operator;
    }

    /**
     * Replaces constants set in the configuration which the actual values
     * @param {string} script - the yaml file as a string
     * @return {string}
     */
    findReplace(script) {
        const findReplaceMap = Configuration.instance() && Configuration.instance().findReplaceMap();
        if (!findReplaceMap) return script;
        for (const find of Object.keys(findReplaceMap)) {
            const value = findReplaceMap[find];
            script = script.split(find).join(value);
        }
        return script;
    }

    /**
     * Gets the different variables that needs to be replaced
     * @param {string} assertion - the yaml file as a string
     * @return {string[]} the defined variables
     */
    getDefinedVariables(originalAssertion) {
        const variables = [];
        // cloning the assertion to ensure we are not mutating it inside
        let assertion = originalAssertion + "";
        let startIndex = -1;
        do {
            startIndex = assertion.indexOf("{");
            if (startIndex !== -1) {
                const endIndex = assertion.indexOf("}", startIndex);
                variables.push(assertion.substring(startIndex + 1, endIndex));
                assertion = assertion.substring(endIndex + 1).trim();
            }
        } while (startIndex !== -1);

        if (variables.length) {
            return _.uniq(variables);
        }

        return [];

    }

    /**
     * validate if ivr test are valid,
     * this method should be called after global configuration is load
     * @param {testSuite} suite - test suite will all the tests
     */
    validateIvrTests(suite) {
        const platform = suite.platform;
        if (platform === "twilio" || platform === "phone") {
            const tests = suite.tests;
            for (let i = 0; i < tests.length; i++) {
                const test = tests[i];
                for (let j = 0; j < test.interactions.length; j++) {
                    const interaction = test.interactions[j];
                    const hasFinishOnPhrase = interaction.expressions.some(e => e.path.includes("finishOnPhrase"));
                    const hasListeningTimeout = interaction.expressions.some(e => e.path.includes("listeningTimeout"));
                    if (j < test.interactions.length - 1 && !hasFinishOnPhrase && !hasListeningTimeout) {
                        throw ParserError.error(suite.fileName,
                            "missing required parameter 'finishOnPhrase' or 'listeningTimeout'",
                            interaction.lineNumber);
                    }
                }
            }
        }
    }
}

module.exports = TestParser;