extensions/snapshot/snapshot.js

'use strict'

/**
 * @module extensions/snapshot/snapshot
 */

const _ = require('lodash')
const path = require('path')
const diff = require('jest-diff')
const jestDiffConstants = require('jest-diff/build/constants')
const naturalCompare = require('natural-compare')
const chalk = require('chalk')

const fileSystem = require('./fs')

const EXPECTED_COLOR = chalk.green
const RECEIVED_COLOR = chalk.red

exports.scenarioRegex = /^[\s]*Scenario:[\s]*(.*[^\s])[\s]*$/

/**
 * Extract scenarios from a feature file
 * @param {string} file - Feature file path
 * @return {Array<string>} - Scenarios names
 */
exports.extractScenarios = file => {
    if (_.isNil(file)) {
        throw new TypeError(`Invalid feature file ${file}`)
    }

    const content = fileSystem.getFileContent(file)
    const linesContent = _.split(content, '\n')

    let result = []
    _.forEach(linesContent, (lineContent, idx) => {
        const line = idx + 1
        const scenarioInfos = this.scenarioRegex.exec(lineContent)
        if (scenarioInfos) result.push({ line, name: scenarioInfos[1] })
    })

    return result
}

/**
 * Create snapshots prefix that will be used for each snapshot step of a scenario
 * For example if the scenario name is 'Scenario 1', then prefix will be 'Scenario 1 1'
 * If then we have in the same file another scenario named 'Scenario 1', it's prefix will be 'Scenario 1 2' to avoid
 * naming collisions
 *
 * Result will follow the pattern :
 * {
 *   scenario_line: {
 *      name: scenario_name,
 *      line: scenario_line,
 *      prefix: scenario_snapshots_prefix
 *   },
 *   scenario2_line: {
 *      name: scenario2_name,
 *      line: scenario2_line,
 *      prefix: scenario2_snapshots_prefix
 *   }
 *   ...
 * }
 *
 * @param {Array<string>} scenarios - Scenarios names
 * @return {Object} - Read above for result format
 */
exports.prefixSnapshots = scenarios => {
    if (_.isNil(scenarios)) {
        throw new Error(`Scenarios are required to prefix snapshots`)
    }

    const nameCount = {}
    const result = {}

    _.forEach(scenarios, scenario => {
        nameCount[scenario.name] = nameCount[scenario.name] | 0
        nameCount[scenario.name]++

        const prefix = `${scenario.name} ${nameCount[scenario.name]}`

        result[scenario.line] = { name: scenario.name, line: scenario.line, prefix: prefix }
    })

    return result
}

/**
 * Read a snapshot file and parse it.
 * For each feature file, we have one snapshot file
 * @param {string} file - snapshot file path
 * @return {Object} - Return follows the pattern : {snapshot_name: snapshot_content}
 */
exports.readSnapshotFile = file => {
    if (_.isNil(file)) {
        throw new Error(`Missing snapshot file ${file} to read snapshots`)
    }

    const info = fileSystem.getFileInfo(file)
    if (!info) return {}

    const content = fileSystem.getFileContent(file)

    return exports.parseSnapshotFile(content)
}

/**
 * Format and write a snapshot file content
 * @param {string} file - file path
 * @param {Object} content - snapshot file content following the pattern : {snapshot_name: snapshot_content}
 */
exports.writeSnapshotFile = (file, content) => {
    const serializedContent = exports.formatSnapshotFile(content)
    return fileSystem.writeFileContent(file, serializedContent)
}

/**
 * Get snapshot file path base on feature file path
 * @param {string} featureFile - Feature file path
 * @param {Object} opts
 * @param {Object} [opts.snaphotsDirname = '__snapshots__'] - Snapshots dirname
 * @param {Object} [opts.snapshotsFileExtension = 'snap'] - Snapshots files extension
 */
exports.snapshotsPath = (featureFile, opts) => {
    const dirname = opts.snaphotsDirname || '__snapshots__'
    const dir = path.join(path.dirname(featureFile), dirname)
    const filename = `${path.basename(featureFile)}.${opts.snapshotsFileExtension || 'snap'}`

    return path.join(dir, filename)
}

/**
 * Compute diff between two contents.
 * If no diff, it returns null
 * @param {string} snapshot - snapshot content
 * @param {string} expected - expected content
 * @returns {string} Diff message
 */
exports.diff = (snapshot, expected) => {
    let diffMessage = diff(snapshot, expected, {
        expand: false,
        colors: true,
        //contextLines: -1, // Forces to use default from Jest
        aAnnotation: 'Snapshot',
        bAnnotation: 'Received'
    })

    diffMessage =
        diffMessage ||
        `${EXPECTED_COLOR('- ' + (expected || ''))} \n ${RECEIVED_COLOR('+ ' + snapshot)}`
    if (diffMessage === jestDiffConstants.NO_DIFF_MESSAGE) return null
    return `\n${diffMessage}`
}

/**
 * Add backticks to wrap snapshot content and replace backticks
 * @param {string} str - snapshot content
 * @return {string} wrapped content
 */
exports.wrapWithBacktick = str => {
    return '`' + str.replace(/`|\\|\${/g, '\\$&') + '`'
}

/**
 * Normalize new lines to be \n only
 * @param {string} string - Content to normalize
 */
exports.normalizeNewlines = string => {
    return string.replace(/\r\n|\r/g, '\n')
}

/**
 * For a snapshot file by add backticks and format it as js files with keys
 * @param {object} content - snapshots content
 * @return {string} formated snapshot file
 */
exports.formatSnapshotFile = content => {
    const snapshots = Object.keys(content)
        .sort(naturalCompare)
        .map(
            key =>
                'exports[' +
                exports.wrapWithBacktick(key) +
                '] = ' +
                exports.wrapWithBacktick(exports.normalizeNewlines(content[key])) +
                ';'
        )
    return '\n\n' + snapshots.join('\n\n') + '\n'
}

/**
 * Extract keys / values from snapshot file
 * @param {string} content - Snapshot file content
 * @return {Object} - should follow the pattern {snapshot_name: snapshot_content}
 */
exports.parseSnapshotFile = content => {
    const data = {}
    const populate = new Function('exports', content)
    populate(data)

    return data
}