From bda600b058f6fc1a195474255d3f9696b2bc48d6 Mon Sep 17 00:00:00 2001 From: Nadim Date: Wed, 27 Sep 2017 19:47:20 +0200 Subject: [PATCH] feat(snapshot): Add snapshot extension --- README.md | 136 +++++++- doc/README.tpl.md | 132 +++++++ .../http_api/fixtures/fixtures.feature | 10 - .../__snapshots__/snapshot.feature.snap | 24 ++ examples/features/snapshot/fixtures/file.yaml | 4 + examples/features/snapshot/snapshot.feature | 17 + examples/support/world.js | 4 +- package.json | 3 + src/extensions/http_api/definitions.js | 8 +- src/extensions/snapshot/clean.js | 49 +++ src/extensions/snapshot/cmdOptions.js | 27 ++ src/extensions/snapshot/dedent.js | 88 +++++ src/extensions/snapshot/definitions.js | 28 ++ src/extensions/snapshot/extend_world.js | 13 + src/extensions/snapshot/extension.js | 92 +++++ src/extensions/snapshot/fs.js | 71 ++++ src/extensions/snapshot/hooks.js | 55 +++ src/extensions/snapshot/index.js | 53 +++ src/extensions/snapshot/snapshot.js | 186 ++++++++++ src/index.js | 1 + tests/extensions/http_api/definitions.test.js | 6 +- tests/extensions/snapshot/clean.test.js | 80 +++++ tests/extensions/snapshot/dedent.test.js | 83 +++++ tests/extensions/snapshot/definitions.test.js | 64 ++++ tests/extensions/snapshot/extension.test.js | 176 ++++++++++ tests/extensions/snapshot/fixtures.js | 120 +++++++ tests/extensions/snapshot/fs.test.js | 70 ++++ tests/extensions/snapshot/snapshot.test.js | 327 ++++++++++++++++++ yarn.lock | 83 ++++- 29 files changed, 1975 insertions(+), 35 deletions(-) create mode 100644 examples/features/snapshot/__snapshots__/snapshot.feature.snap create mode 100644 examples/features/snapshot/fixtures/file.yaml create mode 100644 examples/features/snapshot/snapshot.feature create mode 100644 src/extensions/snapshot/clean.js create mode 100644 src/extensions/snapshot/cmdOptions.js create mode 100644 src/extensions/snapshot/dedent.js create mode 100644 src/extensions/snapshot/definitions.js create mode 100644 src/extensions/snapshot/extend_world.js create mode 100644 src/extensions/snapshot/extension.js create mode 100644 src/extensions/snapshot/fs.js create mode 100644 src/extensions/snapshot/hooks.js create mode 100644 src/extensions/snapshot/index.js create mode 100644 src/extensions/snapshot/snapshot.js create mode 100644 tests/extensions/snapshot/clean.test.js create mode 100644 tests/extensions/snapshot/dedent.test.js create mode 100644 tests/extensions/snapshot/definitions.test.js create mode 100644 tests/extensions/snapshot/extension.test.js create mode 100644 tests/extensions/snapshot/fixtures.js create mode 100644 tests/extensions/snapshot/fs.test.js create mode 100644 tests/extensions/snapshot/snapshot.test.js diff --git a/README.md b/README.md index ead94974..6715a5b3 100644 --- a/README.md +++ b/README.md @@ -31,12 +31,17 @@ It's also the perfect companion for testing CLI applications built with commande - [Testing a command error](#testing-a-command-error) - [File System testing](#file-system-testing) - [Testing file content](#testing-file-content) + - [Snapshot testing](#snapshot-testing) + - [API Snapshot testing](api-snapshot-testing) + - [CLI Snapshot testing](cli-snapshot-testing) + - [File Snapshot testing](file-snapshot-testing) - [Extensions](#extensions) - [**state**](#state-extension) [install](#state-installation) | [gherkin expressions](#state-gherkin-expressions) | [low level API](#state-low-level-api) - [**fixtures**](#fixtures-extension) [install](#fixtures-installation) | [low level API](#fixtures-low-level-api) - [**http API**](#http-api-extension) [install](#http-api-installation) | [gherkin expressions](#http-api-gherkin-expressions) | [low level API](#http-api-low-level-api) - [**CLI**](#cli-extension) [install](#cli-installation) | [gherkin expressions](#cli-gherkin-expressions) | [low level API](#cli-low-level-api) - [**fileSystem**](#file-system-extension) [install](#file-system-installation) | [gherkin expressions](#file-system-gherkin-expressions) | [low level API](#file-system-low-level-api) + - [**snapshot**](#snapshot-extension) [install](#snapshot-installation) | [low level API](#snapshot-low-level-api) - [Helpers](#helpers) - [**cast**](#cast-helper) [usage](#cast-usage) | [add a type](#add-a-type) - [Examples](#examples) @@ -417,6 +422,89 @@ Scenario: Testing file content related expectations If the file does not exist, the test will fail. +### Snapshot testing + +Snapshot testing test a response / content against a saved snapshot. +Snapshots are stored in a file with same name as the feature file with the extension `.snap` +in a folder __snapshots__ in the same folder as the feature file. +**:warning: Snapshots files should be versioned to be compared while running tests** +Folder tree should look like : +support/ +features/ + feature_with_snapshot.feature + feature_without_snapshot.feature + __snapshots__/ + feature_with_snapshot.feature.snap +… + +In a snapshot file, snapshot name follow the pattern: +SNAPSHOT_NAME NUMBER_OF_TIME_THIS_NAME_WAS_ENCOUNTERED_IN_CURRENT_FILE.NUMBER_OF_TIME_WE_HAD_A_SNAPSHOT_IN_THIS_SCENARIO. +For example, this would give: Scnenario 1 1.1 + +If a snapshot doesn't exists, it will be created the first time. + +To update snapshot use the cucumber command line option '-u'. If you narrowed the tests with tags, only the snapshots +related to the tagged scenarios will be updated. + +In case you need to remove unused snapshots, you can use the option `--cleanSnapshots`. +:warning: You shouldn't use this option with tags. It may result in used snapshots removed. +:information_source: Snapshot files related to feature files with no snapshots anymore won't get removed. You need to do it manually. + +#### API Snapshot testing + +In order to check an api response against a snapshot, you have the following gherkin expression available: + +``` +/^response body should match snapshot$/ +``` + +This example illustrates it: + +```gherkin +Scenario: Creating a resource using typed json payload + Given I set request json body + | username | plouc((string)) | + | team_id | 1((number)) | + | is_active | true((boolean)) | + | hobbies | drawing,hacking((array)) | + When I POST https://my-api.io/users + Then I should receive a 201 HTTP status code + And response body should match snapshot +``` + +#### CLI Snapshot testing + +In order to check a CLI output against a snapshot, you have the following gherkin expression available: + +``` +/^(stderr|stdout) output should match snapshot$/ +``` + +This example illustrates it: + +```gherkin +Scenario: Getting info about installed yarn version + When I run command yarn --version + Then exit code should be 0 + And stdout output should match snapshot + And stderr output should match snapshot +``` + +#### File Snapshot testing + +In order to check a file content against a snapshot, you have the following gherkin expression available: + +``` +/^file (.+) should match snapshot$/ +``` + +This example illustrates it: + +```gherkin +Scenario: Testing file content related expectations + Then file sample_1.text should match snapshot +``` + ## Extensions This module is composed of several extensions. @@ -593,7 +681,6 @@ Then: - /^response (.+) cookie domain should (not )?be (.+)$/ - /^(?:I )?json response should (fully )?match$/ - /^(?:I )?should receive a collection of ([0-9]+) items?(?: for path )?(.+)?$/ - - /^response should match snapshot (.+)$/ - /^response header (.+) should (not )?(equal|contain|match) (.+)$/ ``` @@ -741,6 +828,53 @@ defineSupportCode(({ Then }) => { }) ``` +### Snapshot extension + +#### Snapshot extension installation + +The snapshot extension add capabilities to [api](#http-api-extension), [cli](#cli--extension) and [file](#file-system-extension) extensions, +so you will need these extensions if you want to use snapshot related gherkin definitions. + +To install the extension, you should add the following snippet +to your `world` file: + +```javascript +// /support/world.js + +const { defineSupportCode } = require('cucumber') +const { state, fixtures, cli, fileSystem, snapshot } = require('@ekino/veggies') + +defineSupportCode(({ setWorldConstructor }) => { + setWorldConstructor(function() { + state.extendWorld(this) + fixtures.extendWorld(this) + cli.extendWorld(this) + fileSystem.extendWorld(this) + snapshot.extendWorld(this) + }) +}) + +state.install(defineSupportCode) +fixtures.install(defineSupportCode) +cli.install(defineSupportCode) +fileSystem.install(defineSupportCode) +snapshot.install(defineSupportCode) +``` + +#### Snapshot low level API + +When installed, you can access it from the global cucumber context in your own step definitions. +For available methods on the snapshot, please refer to its own +[documentation](https://ekino.github.io/veggies/module-extensions_snapshot_extension.html). + +```javascript +defineSupportCode(({ Then }) => { + Then(/^Some content should match snapshot$/, function() { + this.snapshot.expectToMatch('whatever') + }) +}) +``` + ## Helpers ### Cast helper diff --git a/doc/README.tpl.md b/doc/README.tpl.md index 6e09cca0..93822db1 100644 --- a/doc/README.tpl.md +++ b/doc/README.tpl.md @@ -31,12 +31,17 @@ It's also the perfect companion for testing CLI applications built with commande - [Testing a command error](#testing-a-command-error) - [File System testing](#file-system-testing) - [Testing file content](#testing-file-content) + - [Snapshot testing](#snapshot-testing) + - [API Snapshot testing](api-snapshot-testing) + - [CLI Snapshot testing](cli-snapshot-testing) + - [File Snapshot testing](file-snapshot-testing) - [Extensions](#extensions) - [**state**](#state-extension) [install](#state-installation) | [gherkin expressions](#state-gherkin-expressions) | [low level API](#state-low-level-api) - [**fixtures**](#fixtures-extension) [install](#fixtures-installation) | [low level API](#fixtures-low-level-api) - [**http API**](#http-api-extension) [install](#http-api-installation) | [gherkin expressions](#http-api-gherkin-expressions) | [low level API](#http-api-low-level-api) - [**CLI**](#cli-extension) [install](#cli-installation) | [gherkin expressions](#cli-gherkin-expressions) | [low level API](#cli-low-level-api) - [**fileSystem**](#file-system-extension) [install](#file-system-installation) | [gherkin expressions](#file-system-gherkin-expressions) | [low level API](#file-system-low-level-api) + - [**snapshot**](#snapshot-extension) [install](#snapshot-installation) | [gherkin expressions](#snapshot-gherkin-expressions) | [low level API](#snapshot-low-level-api) - [Helpers](#helpers) - [**cast**](#cast-helper) [usage](#cast-usage) | [add a type](#add-a-type) - [Examples](#examples) @@ -421,6 +426,79 @@ Scenario: Testing file content related expectations If the file does not exist, the test will fail. +### Snapshot testing + +Snapshot testing test a response / content against a saved response. +Snapshots are stored in a file with same name as the feature file with the extension ".snap" +in a folder __snapshots__ in the same folder as the feature file. +In a snapshot file, snapshot name follow the pattern : +SNAPSHOT_NAME NUMBER_OF_TIME_THIS_NAME_WAS_ENCOUNTERED_IN_CURRENT_FILE.NUMBER_OF_TIME_WE_HAD_A_SNAPSHOT_IN_THIS_SCENARIO. +For example, this would give : Scnenario 1 1.1 + +If a snapshot doesn't exists, it will be created the first time. + +To update snapshot use the cucumber commande line option '-u'. If you narrowed the tests with tags, only the snapshots +related to the tagged scenario will be updated + +In case you need to remove unused snapshots, you can use the option "--cleanSnapshots". +Warning : You shouldn't use this option with tags. It may result in used snapshots removed. +Info : Snapshot files related to feature files with no snapshots anymore won't get removed. You need to do it manually. + +#### API Snapshot testing + +In order to check an api response against a snapshot, you have the following gherkin expression available: + +``` +/^response body should match snapshot$/ +``` + +This example illustrates it: + +```gherkin +Scenario: Creating a resource using typed json payload + Given I set request json body + | username | plouc((string)) | + | team_id | 1((number)) | + | is_active | true((boolean)) | + | hobbies | drawing,hacking((array)) | + When I POST https://my-api.io/users + Then I should receive a 201 HTTP status code + And response body should match snapshot +``` + +#### CLI Snapshot testing + +In order to check a CLI output against a snapshot, you have the following gherkin expression available: + +``` +/^(stderr|stdout) output should match snapshot$/ +``` + +This example illustrates it: + +```gherkin +Scenario: Getting info about installed yarn version + When I run command yarn --version + Then exit code should be 0 + And stdout output should match snapshot + And stderr output should match snapshot +``` + +#### File Snapshot testing + +In order to check a file content against a snapshot, you have the following gherkin expression available: + +``` +/^file (.+) should match snapshot$/ +``` + +This example illustrates it: + +```gherkin +Scenario: Testing file content related expectations + Then file sample_1.text should match snapshot +``` + ## Extensions This module is composed of several extensions. @@ -683,6 +761,60 @@ defineSupportCode(({ Then }) => { }) ``` +### Snapshot extension + +#### Snapshot extension installation + +The snapshot extension add capabilities to [api](#api-extension), [clie](#cli--extension) and [file](#file-extension) extensions, +so you will need these extensions if you want to use the gherkin patterns. + +To install the extension, you should add the following snippet +to your `world` file: + +```javascript +// /support/world.js + +const { defineSupportCode } = require('cucumber') +const { state, fixtures, cli, fileSystem, snapshot } = require('@ekino/veggies') + +defineSupportCode(({ setWorldConstructor }) => { + setWorldConstructor(function() { + state.extendWorld(this) + fixtures.extendWorld(this) + cli.extendWorld(this) + fileSystem.extendWorld(this) + snapshot.extendWorld(this) + }) +}) + +state.install(defineSupportCode) +fixtures.install(defineSupportCode) +cli.install(defineSupportCode) +fileSystem.install(defineSupportCode) +snapshot.install(defineSupportCode) +``` + +#### Snapshot gherkin expressions + +{{#definitions.snapshot}} +{{> definitions}} +{{/definitions.snapshot}} + + +#### Snapshot low level API + +When installed, you can access it from the global cucumber context in your own step definitions. +For available methods on the snapshot, please refer to its own +[documentation](https://ekino.github.io/veggies/module-extensions_snapshot_extension.html). + +```javascript +defineSupportCode(({ Then }) => { + Then(/^Some content should match snapshot$/, function() { + this.snapshot.expectToMatch('whatever') + }) +}) +``` + ## Helpers ### Cast helper diff --git a/examples/features/http_api/fixtures/fixtures.feature b/examples/features/http_api/fixtures/fixtures.feature index 72e20e47..9aa0b9e9 100644 --- a/examples/features/http_api/fixtures/fixtures.feature +++ b/examples/features/http_api/fixtures/fixtures.feature @@ -7,7 +7,6 @@ Feature: Using fixtures with http API extension And set request json body from yaml_00 When I POST http://fake.io/users/yaml Then response status code should be 200 - And response should match snapshot yaml_00 @yaml Scenario: Setting form body from .yaml fixture file @@ -15,7 +14,6 @@ Feature: Using fixtures with http API extension And set request form body from yaml_00 When I POST http://fake.io/users/yaml Then response status code should be 200 - And response should match url encoded snapshot yaml_00 @yaml Scenario: Setting json body from .yml fixture file @@ -23,7 +21,6 @@ Feature: Using fixtures with http API extension And set request json body from yaml_01 When I POST http://fake.io/users/yml Then response status code should be 200 - And response should match snapshot yaml_01 @yaml Scenario: Setting form body from .yml fixture file @@ -31,7 +28,6 @@ Feature: Using fixtures with http API extension And set request form body from yaml_01 When I POST http://fake.io/users/yml Then response status code should be 200 - And response should match url encoded snapshot yaml_01 @text Scenario: Setting json body from .txt fixture file @@ -39,7 +35,6 @@ Feature: Using fixtures with http API extension And set request json body from text_00 When I POST http://fake.io/users/txt Then response status code should be 200 - And response should match snapshot text_00 @text Scenario: Setting form body from .txt fixture file @@ -47,7 +42,6 @@ Feature: Using fixtures with http API extension And set request form body from text_00 When I POST http://fake.io/users/txt Then response status code should be 200 - And response should match snapshot text_00 @js Scenario: Setting json body from .js fixture file @@ -55,7 +49,6 @@ Feature: Using fixtures with http API extension And set request json body from module_00 When I POST http://fake.io/users/js Then response status code should be 200 - And response should match snapshot module_00 @js Scenario: Setting form body from .js fixture file @@ -63,7 +56,6 @@ Feature: Using fixtures with http API extension And set request form body from module_00 When I POST http://fake.io/users/js Then response status code should be 200 - And response should match url encoded snapshot module_00 @json Scenario: Setting json body from .json fixture file @@ -71,7 +63,6 @@ Feature: Using fixtures with http API extension And set request json body from json_00 When I POST http://fake.io/users/json Then response status code should be 200 - And response should match snapshot json_00 @json Scenario: Setting form body from .json fixture file @@ -79,4 +70,3 @@ Feature: Using fixtures with http API extension And set request form body from json_00 When I POST http://fake.io/users/json Then response status code should be 200 - And response should match url encoded snapshot json_00 diff --git a/examples/features/snapshot/__snapshots__/snapshot.feature.snap b/examples/features/snapshot/__snapshots__/snapshot.feature.snap new file mode 100644 index 00000000..8cca0bda --- /dev/null +++ b/examples/features/snapshot/__snapshots__/snapshot.feature.snap @@ -0,0 +1,24 @@ + + +exports[`Setting json body from .yaml fixture file 1.1`] = `Object { + "first_name": "Raphaël", + "gender": "male", + "id": 1, + "last_name": "Benitte", +}`; + +exports[`Snapshot testing on an api 1.1`] = `Object { + "first_name": "Raphaël", + "gender": "male", + "id": 1, + "last_name": "Benitte", +}`; + +exports[`Snapshot testing on cli 1.1`] = `"test +"`; + +exports[`Snapshot testing on files 1.1`] = `"id: 1 +first_name: Raphaël +last_name: Benitte +gender: male +"`; diff --git a/examples/features/snapshot/fixtures/file.yaml b/examples/features/snapshot/fixtures/file.yaml new file mode 100644 index 00000000..e404d41b --- /dev/null +++ b/examples/features/snapshot/fixtures/file.yaml @@ -0,0 +1,4 @@ +id: 1 +first_name: Raphaël +last_name: Benitte +gender: male diff --git a/examples/features/snapshot/snapshot.feature b/examples/features/snapshot/snapshot.feature new file mode 100644 index 00000000..82218472 --- /dev/null +++ b/examples/features/snapshot/snapshot.feature @@ -0,0 +1,17 @@ +@snapshot +Feature: Using snapshot definitions + + Scenario: Snapshot testing on an api + Given I mock http call to forward request body for path /users/yaml + And set request json body from file + When I POST http://fake.io/users/yaml + Then response body should match snapshot + + Scenario: Snapshot testing on cli + When I run command echo test + Then exit code should be 0 + And stdout output should match snapshot + + Scenario: Snapshot testing on files + Given I set cwd to examples/features/snapshot/fixtures + Then file file.yaml should match snapshot diff --git a/examples/support/world.js b/examples/support/world.js index 9435f7af..f9839533 100644 --- a/examples/support/world.js +++ b/examples/support/world.js @@ -1,7 +1,7 @@ 'use strict' const { defineSupportCode } = require('cucumber') -const { state, fixtures, httpApi, cli, fileSystem } = require('../../src') +const { state, fixtures, httpApi, cli, fileSystem, snapshot } = require('../../src') defineSupportCode(({ setWorldConstructor }) => { setWorldConstructor(function() { @@ -10,6 +10,7 @@ defineSupportCode(({ setWorldConstructor }) => { httpApi.extendWorld(this) cli.extendWorld(this) fileSystem.extendWorld(this) + snapshot.extendWorld(this) }) }) @@ -18,3 +19,4 @@ fixtures.install(defineSupportCode) httpApi.install()(defineSupportCode) cli.install(defineSupportCode) fileSystem.install(defineSupportCode) +snapshot.install(defineSupportCode) diff --git a/package.json b/package.json index f28baf28..cb790052 100644 --- a/package.json +++ b/package.json @@ -31,8 +31,11 @@ "chai": "^4.0.2", "fs-extra": "^4.0.0", "glob": "^7.1.2", + "jest-diff": "^21.0.0", "js-yaml": "^3.8.4", "lodash": "^4.17.4", + "natural-compare": "^1.4.0", + "pretty-format": "^21.0.2", "request": "^2.81.0", "tough-cookie": "^2.3.2" }, diff --git a/src/extensions/http_api/definitions.js b/src/extensions/http_api/definitions.js index cb800cf6..f62bca55 100644 --- a/src/extensions/http_api/definitions.js +++ b/src/extensions/http_api/definitions.js @@ -333,12 +333,12 @@ module.exports = ({ baseUrl = '' } = {}) => ({ Given, When, Then }) => { }) /** - * Verifies that response matches snapshot. - */ - Then(/^response should match snapshot (.+)$/, function(snapshotId) { + * Verifies that response matches a fixture. + **/ + Then(/^response should match fixture (.+)$/, function(fixtureId) { const response = mustGetResponse(this.httpApiClient) - return this.fixtures.load(snapshotId).then(snapshot => { + return this.fixtures.load(fixtureId).then(snapshot => { expect(response.body).to.deep.equal(snapshot) }) }) diff --git a/src/extensions/snapshot/clean.js b/src/extensions/snapshot/clean.js new file mode 100644 index 00000000..33a201d7 --- /dev/null +++ b/src/extensions/snapshot/clean.js @@ -0,0 +1,49 @@ +'use strict' + +/** + * @module extensions/snapshot/cleanup + */ + +const _ = require('lodash') + +const snapshot = require('./snapshot') +const fileSystem = require('./fs') + +exports._snapshots = {} + +/** + * Store a snapshot name for a snapshot file + * This can be used after to clean up unused snapshots + * @param {string} file - File path + * @param {string} snapshotName - Snapshot name + */ +exports.referenceSnapshot = function(file, snapshotName) { + exports._snapshots[file] = exports._snapshots[file] || [] + exports._snapshots[file].push(snapshotName) +} + +/** + * Clean snapshots names and files + * Used after tests to clear entries + */ +exports.resetReferences = function() { + exports._snapshots = {} +} + +/** + * Clean snapshots file from removed snapshots + * If a snapshot file is empty, it's deleted + * Only files that have been referenced will be cleaned + */ +exports.cleanSnapshots = function() { + _.forOwn(exports._snapshots, (snapshotNames, file) => { + if (_.isEmpty(snapshotNames)) { + fileSystem.remove(file) + return true + } + + const content = snapshot.readSnapshotFile(file) + const newContent = _.pick(content, snapshotNames) + snapshot.writeSnapshotFile(file, newContent) + }) +} diff --git a/src/extensions/snapshot/cmdOptions.js b/src/extensions/snapshot/cmdOptions.js new file mode 100644 index 00000000..02a3b562 --- /dev/null +++ b/src/extensions/snapshot/cmdOptions.js @@ -0,0 +1,27 @@ +'use strict' + +const _ = require('lodash') + +/** + * @module extensions/snapshot/cmdOptions + */ + +/** + * Read command line option. If there is --cleanSnapshots, than we should clean snapshots + * @type {boolean} + */ +exports.cleanSnapshots = false + +/** + * Read command line option. If there is --updateSnapshots or -u, than we should update snapshots + * @type {boolean} + */ +exports.updateSnapshots = false + +if (!_.isEmpty(_.intersection(process.argv, ['--updateSnapshots', '-u']))) { + exports.updateSnapshots = true +} + +if (!_.isEmpty(_.intersection(process.argv, ['--cleanSnapshots']))) { + exports.cleanSnapshots = true +} diff --git a/src/extensions/snapshot/dedent.js b/src/extensions/snapshot/dedent.js new file mode 100644 index 00000000..a906cfee --- /dev/null +++ b/src/extensions/snapshot/dedent.js @@ -0,0 +1,88 @@ +'use strict' + +/** + * @module extensions/snapshot/dedent + */ + +/** + * Extract spaces length + * @param {string} text + * @returns {number} length tab and space before first char + */ +const getSpacesLength = text => { + let length = 0 + + while (length < text.length) { + const char = text[length] + if (char !== ' ' && char !== '\t') break + length += 1 + } + + return length +} + +/** + * Used to remove indentation from a text. Usefull with multine string in backticks. + * + * Two way to use it : ` + * My text + * Another line + * Another line again + * ` + * the result text will be : + * "My text + * Another line + * Another line again" + * + * In this case, alignment is done on the length of the first character that is not a space or a tab of all lines + * + * Or + * + * Another way : ` + * """ + * My text + * Another line + * Another line again + * """ + * ` + * the result text will be : + * " My text + * Another line + * Another line again" + * + * In this case, alignment is donne on the spaces or tab before """ + * + * Warning : First line and last line will always be ignored + * + * @param {string} text + * @return {string} + */ +module.exports = text => { + if (typeof text !== 'string') text = text[0] + + let lines = text.split('\n') + if (lines.length < 3) return text + + lines = lines.slice(1, lines.length - 1) + + let skipLength = getSpacesLength(lines[0]) + + if ( + lines[0].substr(skipLength, 3) === '"""' && + lines[lines.length - 1].substr(skipLength, 3) === '"""' + ) { + lines = lines.slice(1, lines.length - 1) + } else { + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + skipLength = Math.min(skipLength, getSpacesLength(line)) + } + } + + const resultLines = [] + for (let i = 0; i < lines.length; i++) { + resultLines.push(lines[i].substring(skipLength)) + } + + return resultLines.join('\n') +} diff --git a/src/extensions/snapshot/definitions.js b/src/extensions/snapshot/definitions.js new file mode 100644 index 00000000..53d978e9 --- /dev/null +++ b/src/extensions/snapshot/definitions.js @@ -0,0 +1,28 @@ +'use strict' + +const _ = require('lodash') + +module.exports = () => ({ Then }) => { + /** + * Checking if an http response body match a snapshot + */ + Then(/^response body should match snapshot$/, function() { + this.snapshot.expectToMatch(this.httpApiClient.getResponse().body) + }) + + /** + * Checking a cli stdout or stderr match snapshot + */ + Then(/^(stderr|stdout) output should match snapshot$/, function(type) { + this.snapshot.expectToMatch(this.cli.getOutput(type)) + }) + + /** + * Checking that a file content matches the snapshot + */ + Then(/^file (.+) should match snapshot$/, function(file) { + return this.fileSystem.getFileContent(this.cli.getCwd(), file).then(content => { + this.snapshot.expectToMatch(content) + }) + }) +} diff --git a/src/extensions/snapshot/extend_world.js b/src/extensions/snapshot/extend_world.js new file mode 100644 index 00000000..8c168dca --- /dev/null +++ b/src/extensions/snapshot/extend_world.js @@ -0,0 +1,13 @@ +'use strict' + +const Helper = require('../../helper') +const snapshot = require('./extension') +const cmdOptions = require('./cmdOptions') +const _ = require('lodash') + +module.exports = (world, options) => { + options = _.assign({}, cmdOptions, options) + + world.snapshot = snapshot(options) + Helper.registerExtension(world, 'snapshot') +} diff --git a/src/extensions/snapshot/extension.js b/src/extensions/snapshot/extension.js new file mode 100644 index 00000000..6b2323cf --- /dev/null +++ b/src/extensions/snapshot/extension.js @@ -0,0 +1,92 @@ +'use strict' + +/** + * @module extensions/snapshot/Snapshot + */ + +const prettyFormat = require('pretty-format') + +const snapshot = require('./snapshot') +const clean = require('./clean') + +/** + * Snapshot extension. + * + * @class + */ +class Snapshot { + /** + * @param {Object} options - Options + * @param {boolean} [options.updateSnapshots=false] - Should we update the snapshots + * @param {boolean} [options.cleanSnapshots=false] - Should we clean the snapshot to remove unused snapshots + */ + constructor(options) { + this.options = options || {} + this.shouldUpdate = this.options.updateSnapshots + this.cleanSnapshots = this.options.cleanSnapshots + + this.featureFile = null + this.scenarioLine = -1 + + this._snapshotsCount = 0 + } + + /** + * Compare a content to it's snapshot. + * If no snapshot yet, it create it. + * + * It uses the context to name the snapshot: feature file, scenario name and nth snapshot of scenario + * Snapshot name will be by default stored in FEATURE_FILE_FOLDER_PATH/__snapshots__/FEATURE_FILE_NAME.snap + * And snapshot name will be "SCENARIO_NAME NUMBER_OF_TIME_SCNEARIO_NAME_APPEARD_IN_FEATURE.NUMBER_OF_TIME_WE_SNAPSHOTED_IN_CURRENT_SCENARIO" + * For the first scenario of a scenario called "Scenario 1" that only appears once in feature file, + * snapshot name will be "Scenario 1 1.1" + * + * If option "-u" or "--updateSnapshots" is used, all snapshots will be updated + * If options "--cleanSnapshots" is used, unused stored snapshots will be removed. + * @param {*} expectedContent - Content to compare to snapshot + * @throws {string} If snapshot and expected content doesn't match, it throws diff between both + */ + expectToMatch(expectedContent) { + expectedContent = prettyFormat(expectedContent) + let snapshotsFile = snapshot.snapshotsPath(this.featureFile, this.options) + + const scenarios = snapshot.extractScenarios(this.featureFile) + const snapshotsPrefix = snapshot.prefixSnapshots(scenarios)[this.scenarioLine] + + if (!snapshotsPrefix) + throw new Error( + `Can not do a snapshot. Scenario not found in file ${this + .featureFile} on line ${this.scenarioLine}` + ) + + this._snapshotsCount += 1 + const snapshotName = `${snapshotsPrefix.prefix}.${this._snapshotsCount}` + if (this.cleanSnapshots) clean.referenceSnapshot(snapshotsFile, snapshotName) // To clean after all unreferenced snapshots + + const snapshotsContents = snapshot.readSnapshotFile(snapshotsFile) + let snapshotContent = snapshotsContents[snapshotName] + + if (!snapshotContent || this.shouldUpdate) { + snapshotsContents[snapshotName] = expectedContent + snapshot.writeSnapshotFile(snapshotsFile, snapshotsContents) + snapshotContent = expectedContent + } + + const diff = snapshot.diff(snapshotContent, expectedContent) + if (diff) throw new Error(diff) + } +} + +/** + * Create a new isolated Snapshot module + * @return {Snapshot} + */ +module.exports = function(...args) { + return new Snapshot(...args) +} + +/** + * Snapshot extension. + * @type {Snapshot} + */ +module.exports.Snapshot = Snapshot diff --git a/src/extensions/snapshot/fs.js b/src/extensions/snapshot/fs.js new file mode 100644 index 00000000..7b870ef7 --- /dev/null +++ b/src/extensions/snapshot/fs.js @@ -0,0 +1,71 @@ +'use strict' + +/** + * The FileSystem helper used by the FileSystem extension. + * + * @module extensions/snapshot/FileSystem + */ + +const path = require('path') +const fs = require('fs-extra') + +/** + * Loads file content. + * + * @param {string} file - File path + * @param {string} [encoding='utf8'] - Content encoding + * @return {string} File content + */ +exports.getFileContent = (file, encoding = 'utf8') => { + const data = fs.readFileSync(file) + return data.toString(encoding) +} + +/** + * + * @param {string} file - File path + * @param {string} content - Content to write in the file + * @param {object} [options] - Options + * @param {boolean} [options.createDir = true] - Create path dir if it doesn't exists + */ +exports.writeFileContent = (file, content, { createDir = true } = {}) => { + if (createDir) exports.createDirectory(path.dirname(file)) + return fs.writeFileSync(file, content) +} + +/** + * Gets info about file/directory. + * + * @param {string} file - File path + * @return {fs.Stat|null} File/directory info or null if file/directory does not exist + */ +exports.getFileInfo = file => { + let result = null + try { + result = fs.statSync(file) + } catch (err) { + if (err.code !== 'ENOENT') throw err + } + + return result +} + +/** + * Creates a directory. + * + * @param {string} dir - directory path + * @return {boolean} + */ +exports.createDirectory = dir => { + return fs.mkdirsSync(dir) +} + +/** + * Removes a file or directory. + * + * @param {string} fileOrDirectory - File or directory path + * @return {boolean} + */ +exports.remove = fileOrDir => { + return fs.removeSync(fileOrDir) +} diff --git a/src/extensions/snapshot/hooks.js b/src/extensions/snapshot/hooks.js new file mode 100644 index 00000000..295f2c4d --- /dev/null +++ b/src/extensions/snapshot/hooks.js @@ -0,0 +1,55 @@ +'use strict' + +const path = require('path') +const _ = require('lodash') + +const clean = require('./clean') +const cmdOptions = require('./cmdOptions') + +/** + * Registers hooks for the fixtures extension. + * + * @module extensions/fixtures/hooks + */ + +module.exports = ({ registerHandler, Before, BeforeAll, AfterAll }) => { + Before(function(scenarioInfos) { + let file = null + let line = null + + if (scenarioInfos.sourceLocation) { + // This works with cucumber 3 but not 2 + file = scenarioInfos.sourceLocation.uri + line = scenarioInfos.sourceLocation.line + } else { + // this works with cucumber 2 but not 3 + const fullPath = scenarioInfos.scenario.feature.uri + const relativePath = path.relative(process.cwd(), fullPath) + file = relativePath + line = scenarioInfos.scenario.line + } + + this.snapshot.featureFile = file + this.snapshot.scenarioLine = line + }) + + if (registerHandler) { + // this works with cucumber 2 but not 3 + registerHandler('BeforeFeatures', function() { + clean.resetReferences() + }) + + registerHandler('AfterFeatures', function() { + if (cmdOptions.cleanSnapshots) clean.cleanSnapshots() + }) + } else { + // This works with cucumber 3 but not 2 + BeforeAll(function() { + clean.resetReferences() + }) + + AfterAll(function() { + if (cmdOptions.cleanSnapshots) clean.cleanSnapshots() + }) + } +} diff --git a/src/extensions/snapshot/index.js b/src/extensions/snapshot/index.js new file mode 100644 index 00000000..a58a7083 --- /dev/null +++ b/src/extensions/snapshot/index.js @@ -0,0 +1,53 @@ +'use strict' + +/** + * @module extensions/snapshot + */ + +const definitions = require('./definitions') +const hooks = require('./hooks') + +/** + * Extends cucumber world object. + * Must be used inside customWorldConstructor. + * + * @example + * // /support/world.js + * + * const { defineSupportCode } = require('cucumber') + * const { snapshot } = require('@ekino/veggies') + * + * defineSupportCode(({ setWorldConstructor }) => { + * setWorldConstructor(function() { + * snapshot.extendWorld(this) + * }) + * }) + * + * @function + * @param {Object} world - The cucumber world object + */ +exports.extendWorld = require('./extend_world') + +/** + * Installs the extension. + * + * @example + * // /support/world.js + * + * const { defineSupportCode } = require('cucumber') + * const { snapshot } = require('@ekino/veggies') + * + * defineSupportCode(({ setWorldConstructor }) => { + * setWorldConstructor(function() { + * snapshot.extendWorld(this) + * }) + * }) + * + * snapshot.install(defineSupportCode) + * + * @param {Function} define - The `defineSupportCode` helper from cucumber + */ +exports.install = define => { + define(hooks) + define(definitions()) +} diff --git a/src/extensions/snapshot/snapshot.js b/src/extensions/snapshot/snapshot.js new file mode 100644 index 00000000..1f6db199 --- /dev/null +++ b/src/extensions/snapshot/snapshot.js @@ -0,0 +1,186 @@ +'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} - 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 + * @param {Array} scenarios - Scenarios names + * @return {Object} - Result will follow the pattern : {scenario_line: {name: scenario_name, line: scenario_line, prefix: scenario_snapshots_prefix} } + */ +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 +} diff --git a/src/index.js b/src/index.js index 5071c230..332ee19a 100644 --- a/src/index.js +++ b/src/index.js @@ -8,6 +8,7 @@ exports.fixtures = require('./extensions/fixtures') exports.httpApi = require('./extensions/http_api') exports.cli = require('./extensions/cli') exports.fileSystem = require('./extensions/file_system') +exports.snapshot = require('./extensions/snapshot') //********************************************************************************************************************** // Helpers diff --git a/tests/extensions/http_api/definitions.test.js b/tests/extensions/http_api/definitions.test.js index 33585dbd..69b55fce 100644 --- a/tests/extensions/http_api/definitions.test.js +++ b/tests/extensions/http_api/definitions.test.js @@ -650,10 +650,10 @@ test('response match snapshot', () => { expect.assertions(5) - const def = context.getDefinitionByMatcher('should match snapshot') + const def = context.getDefinitionByMatcher('should match fixture') def.shouldHaveType('Then') - def.shouldNotMatch('response should match snapshot ') - def.shouldMatch('response should match snapshot snapshot', ['snapshot']) + def.shouldNotMatch('response should match fixture ') + def.shouldMatch('response should match fixture fixture', ['fixture']) const snapshot = { testing: true } const worldMock = { diff --git a/tests/extensions/snapshot/clean.test.js b/tests/extensions/snapshot/clean.test.js new file mode 100644 index 00000000..e73438eb --- /dev/null +++ b/tests/extensions/snapshot/clean.test.js @@ -0,0 +1,80 @@ +'use strict' + +const clean = require('../../../src/extensions/snapshot/clean') +const snapshot = require('../../../src/extensions/snapshot/snapshot') + +beforeEach(() => { + clean.resetReferences() +}) + +test('referenceSnapshot should add snapshot file and name to internal list', () => { + const file = './test.js.snap' + const snapshotName = 'Scenario 1 1.1' + + clean.referenceSnapshot(file, snapshotName) + + expect(clean._snapshots).toEqual({ [file]: [snapshotName] }) +}) + +test('resetReferences should remove all entries', () => { + const file = './test.js.snap' + const snapshotName = 'Scenario 1 1.1' + + clean.referenceSnapshot(file, snapshotName) + clean.resetReferences() + + expect(clean._snapshots).toEqual({}) +}) + +test('cleanSnapshots should remove unreferenced snapshots from file', () => { + const file = './test.js.snap' + const snapshotName = 'Scenario 1 1.1' + const snapshot2Name = 'Scenario 2 1.1' + const snapshotContent = { [snapshotName]: 'some content', [snapshot2Name]: 'another content' } + const expectedContent = { [snapshotName]: 'some content' } + + clean.referenceSnapshot(file, snapshotName) + + snapshot.readSnapshotFile = jest.fn() + snapshot.writeSnapshotFile = jest.fn() + + snapshot.readSnapshotFile.mockReturnValueOnce(snapshotContent) + + clean.cleanSnapshots() + + expect(snapshot.readSnapshotFile.mock.calls.length).toBe(1) + expect(snapshot.readSnapshotFile).toHaveBeenCalledWith(file) + + expect(snapshot.writeSnapshotFile.mock.calls.length).toBe(1) + expect(snapshot.writeSnapshotFile).toHaveBeenCalledWith(file, expectedContent) +}) + +test('cleanSnapshots should remove unreferenced snapshots from multiple files', () => { + const file1 = './test1.js.snap' + const file2 = './test2.js.snap' + const snapshot1Name = 'Scenario 1 1.1' + const snapshot2Name = 'Scenario 2 1.1' + const snapshot1Content = { [snapshot1Name]: 'some content', [snapshot2Name]: 'another content' } + const snapshot2Content = { [snapshot1Name]: 'some content', [snapshot2Name]: 'another content' } + const expectedContent1 = { [snapshot1Name]: 'some content' } + const expectedContent2 = { [snapshot2Name]: 'another content' } + + clean.referenceSnapshot(file1, snapshot1Name) + clean.referenceSnapshot(file2, snapshot2Name) + + snapshot.readSnapshotFile = jest.fn() + snapshot.writeSnapshotFile = jest.fn() + + snapshot.readSnapshotFile.mockReturnValueOnce(snapshot1Content) + snapshot.readSnapshotFile.mockReturnValueOnce(snapshot2Content) + + clean.cleanSnapshots() + + expect(snapshot.readSnapshotFile.mock.calls.length).toBe(2) + expect(snapshot.readSnapshotFile).toHaveBeenCalledWith(file1) + expect(snapshot.readSnapshotFile).toHaveBeenCalledWith(file2) + + expect(snapshot.writeSnapshotFile.mock.calls.length).toBe(2) + expect(snapshot.writeSnapshotFile).toHaveBeenCalledWith(file1, expectedContent1) + expect(snapshot.writeSnapshotFile).toHaveBeenCalledWith(file2, expectedContent2) +}) diff --git a/tests/extensions/snapshot/dedent.test.js b/tests/extensions/snapshot/dedent.test.js new file mode 100644 index 00000000..2a638f2a --- /dev/null +++ b/tests/extensions/snapshot/dedent.test.js @@ -0,0 +1,83 @@ +'use strict' + +const dedent = require('../../../src/extensions/snapshot/dedent') + +test('dedent align to first character', () => { + const test = dedent(` + My text + Another line + Another line again + `) + + const expected = 'My text\n Another line\nAnother line again' + expect(test).toEqual(expected) +}) + +test('dedent align to first """', () => { + const test = dedent(` + """ + My text + Another line + Another line again + """ + `) + + const expected = ' My text\n Another line\n Another line again' + expect(test).toEqual(expected) +}) + +test('dedent align should also work with tabulation', () => { + const test = dedent(` + """ + \tMy text + Another line + Another line again + """ + `) + + const expected = ' \tMy text\n Another line\n Another line again' + expect(test).toEqual(expected) +}) + +test('dedent should not edit content if less than lines', () => { + const test = dedent(` + `) + + const expected = '\n ' + expect(test).toEqual(expected) +}) + +test('dedent align should ignore last and first lines', () => { + const test = dedent(`Some first content + My text + Another line + Another line again + Some last content`) + + const expected = 'My text\n Another line\nAnother line again' + expect(test).toEqual(expected) +}) + +test('dedent align with """ should ignore two last and two first lines', () => { + const test = dedent(`Some content + """Some other content + \tMy text + Another line + Another line again + """ Some end content + Some end other content`) + + const expected = ' \tMy text\n Another line\n Another line again' + expect(test).toEqual(expected) +}) + +test('dedent works without parenthesis', () => { + const test = dedent` + My text + Another line + Another line again + ` + + const expected = 'My text\n Another line\nAnother line again' + expect(test).toEqual(expected) +}) diff --git a/tests/extensions/snapshot/definitions.test.js b/tests/extensions/snapshot/definitions.test.js new file mode 100644 index 00000000..3bee774e --- /dev/null +++ b/tests/extensions/snapshot/definitions.test.js @@ -0,0 +1,64 @@ +'use strict' + +const helper = require('../definitions_helper') +const definitions = require('../../../src/extensions/snapshot/definitions')() + +test('response match snapshot', () => { + const context = helper.define(definitions) + + const def = context.getDefinitionByMatcher('response body should match snapshot') + def.shouldHaveType('Then') + def.shouldMatch('response body should match snapshot') + + const content = 'test' + + const mock = { + httpApiClient: { + getResponse: jest.fn(() => { + return { body: content } + }) + }, + snapshot: { expectToMatch: jest.fn() } + } + + def.exec(mock, {}) + expect(mock.snapshot.expectToMatch).toHaveBeenCalledWith(content) +}) + +test('stdout/stderr match snapshot', () => { + const context = helper.define(definitions) + const def = context.getDefinitionByMatcher('(stderr|stdout) output should match snapshot') + def.shouldHaveType('Then') + def.shouldMatch('stdout output should match snapshot') + def.shouldMatch('stderr output should match snapshot') + + const content = 'test' + + const mock = { + cli: { getOutput: jest.fn(() => content) }, + snapshot: { expectToMatch: jest.fn() } + } + + def.exec(mock, {}) + expect(mock.snapshot.expectToMatch).toHaveBeenCalledWith(content) +}) + +test('file match snapshot', () => { + const context = helper.define(definitions) + const def = context.getDefinitionByMatcher('file (.+) should match snapshot') + def.shouldHaveType('Then') + def.shouldMatch('file somefile.txt should match snapshot') + def.shouldNotMatch('file should match snapshot') + + const content = 'test' + + const mock = { + cli: { getCwd: jest.fn(() => '') }, + fileSystem: { getFileContent: jest.fn(() => Promise.resolve(content)) }, + snapshot: { expectToMatch: jest.fn() } + } + + return def.exec(mock, {}).then(() => { + expect(mock.snapshot.expectToMatch).toHaveBeenCalledWith(content) + }) +}) diff --git a/tests/extensions/snapshot/extension.test.js b/tests/extensions/snapshot/extension.test.js new file mode 100644 index 00000000..b4b467cc --- /dev/null +++ b/tests/extensions/snapshot/extension.test.js @@ -0,0 +1,176 @@ +'use strict' + +const fs = require('fs-extra') + +const Snapshot = require('../../../src/extensions/snapshot/extension') +const clean = require('../../../src/extensions/snapshot/clean') +const fixtures = require('./fixtures') + +beforeAll(() => { + fs.statSync = jest.fn() + fs.readFileSync = jest.fn() + fs.writeFileSync = jest.fn() + fs.mkdirsSync = jest.fn() + + fs.readFileSync.mockImplementation(file => { + if (file === fixtures.featureFile1) return fixtures.featureFileContent1 + if (file === fixtures.featureFile1NotExists) return fixtures.featureFileContent1 + if (file === fixtures.featureFile1And2) return fixtures.featureFileContent1 + if (file === fixtures.featureFile1With2SnapshotsInAScenario) + return fixtures.featureFileContent1 + if (file === fixtures.featureFile1With3SnapshotsInAScenario) + return fixtures.featureFileContent1 + if (file === fixtures.snapshotFile1) return fixtures.snapshotFileContent1 + if (file === fixtures.snapshotFile1And2) return fixtures.snapshotFileContent1And2 + if (file === fixtures.snapshotFile1With2SnapshotsInAScenario) + return fixtures.snapshotFileContent1 + if (file === fixtures.snapshotFile1With3SnapshotsInAScenario) + return fixtures.snapshotFileContent1With3SnapshotsInAScenario + throw new Error(`Unexpected call to readFileSync with file ${file}`) + }) + + fs.writeFileSync.mockImplementation(file => {}) + + fs.statSync.mockImplementation(file => { + if (file === fixtures.snapshotFile1) return {} + if (file === fixtures.snapshotFile1NotExists) return null + if (file === fixtures.snapshotFile1And2) return {} + if (file === fixtures.snapshotFile1With2SnapshotsInAScenario) return {} + if (file === fixtures.snapshotFile1With3SnapshotsInAScenario) return {} + throw new Error(`Unexpected call to statSync with file ${file}`) + }) +}) + +afterEach(() => { + fs.statSync.mockClear() + fs.readFileSync.mockClear() + fs.writeFileSync.mockClear() + fs.mkdirsSync.mockClear() + + clean.resetReferences() +}) + +test("expectToMatch shouldn't throw an error if snapshot matches", () => { + const snapshot = Snapshot() + snapshot.featureFile = fixtures.featureFile1 + snapshot.scenarioLine = 3 + + snapshot.expectToMatch(fixtures.value1) + + expect(fs.readFileSync).toHaveBeenCalledWith(fixtures.featureFile1) + expect(fs.readFileSync).toHaveBeenCalledWith(fixtures.snapshotFile1) + expect(fs.statSync).toHaveBeenCalledWith(fixtures.snapshotFile1) + + expect(fs.readFileSync.mock.calls.length).toBe(2) + expect(fs.writeFileSync.mock.calls.length).toBe(0) + expect(fs.statSync.mock.calls.length).toBe(1) +}) + +test("expectToMatch should throw an error if snapshot doesn't match", () => { + const snapshot = Snapshot() + snapshot.featureFile = fixtures.featureFile1 + snapshot.scenarioLine = 3 + + expect(() => snapshot.expectToMatch(fixtures.value2)).toThrow(fixtures.diffErrorValue1VsValue2) + expect(fs.readFileSync.mock.calls.length).toBe(2) +}) + +test("expectToMatch should write a snapshot if it doesn't exists", () => { + const snapshot = Snapshot() + snapshot.featureFile = fixtures.featureFile1 + snapshot.scenarioLine = 6 + + snapshot.expectToMatch(fixtures.value2) + expect(fs.writeFileSync).toHaveBeenCalledWith( + fixtures.snapshotFile1, + fixtures.snapshotFileContent1And2 + ) + expect(fs.writeFileSync.mock.calls.length).toBe(1) +}) + +test("expectToMatch should write a snapshot file if it doesn't exists", () => { + const snapshot = Snapshot() + snapshot.featureFile = fixtures.featureFile1NotExists + snapshot.scenarioLine = 3 + + snapshot.expectToMatch(fixtures.value1) + expect(fs.writeFileSync).toHaveBeenCalledWith( + fixtures.snapshotFile1NotExists, + fixtures.snapshotFileContent1 + ) + expect(fs.writeFileSync.mock.calls.length).toBe(1) +}) + +test('expectToMatch should work even if two scenarios have the same name', () => { + const snapshot = Snapshot() + + snapshot.featureFile = fixtures.featureFile1And2 + snapshot.scenarioLine = 9 + snapshot.expectToMatch(fixtures.value3) + + expect(fs.writeFileSync).toHaveBeenCalledWith( + fixtures.snapshotFile1And2, + fixtures.snapshotFileContent1And2And3 + ) + expect(fs.writeFileSync.mock.calls.length).toBe(1) +}) + +test('expectToMatch should work even if there is multiple snapshots in a scenario', () => { + const snapshot = Snapshot() + + snapshot.featureFile = fixtures.featureFile1With2SnapshotsInAScenario + snapshot.scenarioLine = 3 + snapshot.expectToMatch(fixtures.value1) + snapshot.expectToMatch(fixtures.value2) + + expect(fs.writeFileSync).toHaveBeenCalledWith( + fixtures.snapshotFile1With2SnapshotsInAScenario, + fixtures.snapshotFileContent1With2SnapshotsInAScenario + ) + expect(fs.writeFileSync.mock.calls.length).toBe(1) +}) + +test('expectToMatch should update snapshot if given update option', () => { + const snapshot = Snapshot({ updateSnapshots: true }) + + snapshot.featureFile = fixtures.featureFile1 + snapshot.scenarioLine = 3 + snapshot.expectToMatch(fixtures.value2) + + expect(fs.writeFileSync).toHaveBeenCalledWith( + fixtures.snapshotFile1, + fixtures.snapshotFileContent1WithValue2 + ) + expect(fs.writeFileSync.mock.calls.length).toBe(1) +}) + +test("expectToMatch should notify played scenarios snapshots so they don't get removed", () => { + const snapshot = Snapshot({ cleanSnapshots: true }) + + snapshot.featureFile = fixtures.featureFile1And2 + snapshot.scenarioLine = 3 + snapshot.expectToMatch(fixtures.value1) + clean.cleanSnapshots() + + expect(fs.writeFileSync).toHaveBeenCalledWith( + fixtures.snapshotFile1And2, + fixtures.snapshotFileContent1 + ) + expect(fs.writeFileSync.mock.calls.length).toBe(1) +}) + +test("expectToMatch should notify all played snapshots in scenarios so they don't get removed", () => { + const snapshot = Snapshot({ cleanSnapshots: true }) + + snapshot.featureFile = fixtures.featureFile1With3SnapshotsInAScenario + snapshot.scenarioLine = 3 + snapshot.expectToMatch(fixtures.value1) + snapshot.expectToMatch(fixtures.value2) + clean.cleanSnapshots() + + expect(fs.writeFileSync).toHaveBeenCalledWith( + fixtures.snapshotFile1With3SnapshotsInAScenario, + fixtures.snapshotFileContent1With2SnapshotsInAScenario + ) + expect(fs.writeFileSync.mock.calls.length).toBe(1) +}) diff --git a/tests/extensions/snapshot/fixtures.js b/tests/extensions/snapshot/fixtures.js new file mode 100644 index 00000000..2a75c019 --- /dev/null +++ b/tests/extensions/snapshot/fixtures.js @@ -0,0 +1,120 @@ +const dedent = require('../../../src/extensions/snapshot/dedent') + +exports.featureFileContent1 = dedent` + """ + Feature: Snapshot test + + Scenario: scenario 1 + When I do something... + + Scenario: scenario 2 + When I do something... + + Scenario: scenario 1 + When I do something... + """ +` + +exports.snapshotContent1 = dedent` + Object { + "key1": "value1", + "key2": "value2", + "key3": "value3", + "key4": "value4", + "key5": "value5", + } +` + +exports.snapshotContent2 = dedent` + Object { + "key1": "value1", + "key2": "value2", + "key3": "value8", + "key4": "value4", + "key5": "value5", + } +` + +exports.snapshotContent3 = dedent` + Object { + "key1": "value1", + "key2": "value2", + "key3": "value9", + "key4": "value4", + "key5": "value5", + } +` + +exports.snapshotFileContent1 = ` + +exports[\`scenario 1 1.1\`] = \`${exports.snapshotContent1}\`; +` + +exports.snapshotFileContent1And2 = ` + +exports[\`scenario 1 1.1\`] = \`${exports.snapshotContent1}\`; + +exports[\`scenario 2 1.1\`] = \`${exports.snapshotContent2}\`; +` + +exports.snapshotFileContent1And2And3 = ` + +exports[\`scenario 1 1.1\`] = \`${exports.snapshotContent1}\`; + +exports[\`scenario 1 2.1\`] = \`${exports.snapshotContent3}\`; + +exports[\`scenario 2 1.1\`] = \`${exports.snapshotContent2}\`; +` + +exports.snapshotFileContent1With2SnapshotsInAScenario = ` + +exports[\`scenario 1 1.1\`] = \`${exports.snapshotContent1}\`; + +exports[\`scenario 1 1.2\`] = \`${exports.snapshotContent2}\`; +` + +exports.snapshotFileContent1With3SnapshotsInAScenario = ` + +exports[\`scenario 1 1.1\`] = \`${exports.snapshotContent1}\`; + +exports[\`scenario 1 1.2\`] = \`${exports.snapshotContent2}\`; + +exports[\`scenario 1 1.3\`] = \`${exports.snapshotContent3}\`; +` + +exports.snapshotFileContent1WithValue2 = ` + +exports[\`scenario 1 1.1\`] = \`${exports.snapshotContent2}\`; +` + +exports.diffErrorValue1VsValue2 = dedent` + \u001b[32m- Snapshot\u001b[39m + \u001b[31m+ Received\u001b[39m + + \u001b[2m Object {\u001b[22m + \u001b[2m "key1": "value1",\u001b[22m + \u001b[2m "key2": "value2",\u001b[22m + \u001b[32m- "key3": "value3",\u001b[39m + \u001b[31m+ "key3": "value8",\u001b[39m + \u001b[2m "key4": "value4",\u001b[22m + \u001b[2m "key5": "value5",\u001b[22m + \u001b[2m }\u001b[22m +` + +exports.value1 = { key1: 'value1', key2: 'value2', key3: 'value3', key4: 'value4', key5: 'value5' } +exports.value2 = { key1: 'value1', key2: 'value2', key3: 'value8', key4: 'value4', key5: 'value5' } +exports.value3 = { key1: 'value1', key2: 'value2', key3: 'value9', key4: 'value4', key5: 'value5' } + +exports.featureFile1 = './snapshot1.feature' +exports.featureFile1And2 = './snapshot1And2.feature' +exports.featureFile1NotExists = './snapshot1NotExists.feature' +exports.featureFile1With2SnapshotsInAScenario = './snapshot1With2SnapshotsInAScenario.feature' +exports.featureFile1With3SnapshotsInAScenario = './snapshot1With3SnapshotsInAScenario.feature' + +exports.snapshotFile1 = '__snapshots__/snapshot1.feature.snap' +exports.snapshotFile1NotExists = '__snapshots__/snapshot1NotExists.feature.snap' +exports.snapshotFile1And2 = '__snapshots__/snapshot1And2.feature.snap' +exports.snapshotFile1With2SnapshotsInAScenario = + '__snapshots__/snapshot1With2SnapshotsInAScenario.feature.snap' +exports.snapshotFile1With3SnapshotsInAScenario = + '__snapshots__/snapshot1With3SnapshotsInAScenario.feature.snap' diff --git a/tests/extensions/snapshot/fs.test.js b/tests/extensions/snapshot/fs.test.js new file mode 100644 index 00000000..9e30800f --- /dev/null +++ b/tests/extensions/snapshot/fs.test.js @@ -0,0 +1,70 @@ +'use strict' + +const fileSystem = require('../../../src/extensions/snapshot/fs') +const fs = require('fs-extra') + +test('getFileContent read and decode a file sync', () => { + const filename = 'test.json' + const content = 'é~se' + + fs.readFileSync = jest.fn() + fs.readFileSync.mockReturnValueOnce(Buffer.from(content, 'utf8')) + + expect(fileSystem.getFileContent(filename)).toBe(content) + expect(fs.readFileSync.mock.calls.length).toBe(1) + expect(fs.readFileSync).toHaveBeenCalledWith(filename) +}) + +test("writeFileContent create directory if it doesn't exists", () => { + const file = 'folder1/folder2/test.json' + const folder = 'folder1/folder2' + const content = 'test' + + fileSystem.createDirectory = jest.fn() + fs.writeFileSync = jest.fn() + + fileSystem.writeFileContent(file, content) + expect(fileSystem.createDirectory.mock.calls.length).toBe(1) + expect(fileSystem.createDirectory).toHaveBeenCalledWith(folder) + + expect(fs.writeFileSync.mock.calls.length).toBe(1) + expect(fs.writeFileSync).toHaveBeenCalledWith(file, content) +}) + +test("writeFileContent don't create directory if explicitly not asked to", () => { + const file = 'folder1/folder2/test.json' + const content = 'test' + + fileSystem.createDirectory = jest.fn() + fs.writeFileSync = jest.fn() + + fileSystem.writeFileContent(file, content, { createDir: false }) + expect(fileSystem.createDirectory.mock.calls.length).toBe(0) + + expect(fs.writeFileSync.mock.calls.length).toBe(1) + expect(fs.writeFileSync).toHaveBeenCalledWith(file, content) +}) + +test("getFileInfo returns null if file doesn't exists", () => { + const file = './dontexist.file' + + const statSync = fs.statSync + fs.statSync = jest.fn() + fs.statSync.mockImplementationOnce(statSync) + + expect(fileSystem.getFileInfo(file)).toBe(null) + expect(fs.statSync.mock.calls.length).toBe(1) + expect(fs.statSync).toHaveBeenCalledWith(file) +}) + +test('getFileInfo returns file infos if it exists', () => { + const file = './exist.file' + const infos = { key1: 'value1' } + + fs.statSync = jest.fn() + fs.statSync.mockReturnValueOnce(infos) + + expect(fileSystem.getFileInfo(file)).toBe(infos) + expect(fs.statSync.mock.calls.length).toBe(1) + expect(fs.statSync).toHaveBeenCalledWith(file) +}) diff --git a/tests/extensions/snapshot/snapshot.test.js b/tests/extensions/snapshot/snapshot.test.js new file mode 100644 index 00000000..fdef276f --- /dev/null +++ b/tests/extensions/snapshot/snapshot.test.js @@ -0,0 +1,327 @@ +'use strict' + +const snapshot = require('../../../src/extensions/snapshot/snapshot') +const fileSystem = require('../../../src/extensions/snapshot/fs') +const dedent = require('../../../src/extensions/snapshot/dedent') + +test('parseSnapshotFile should parse snapshot file content', () => { + const content = dedent` + """ + \n + exports[\`scenario 1 1.1\`] = \`some content\`; + + exports[\`scenario 2 1.1\`] = \`another content\`;\n + """ + ` + + const expected = { ['scenario 1 1.1']: 'some content', ['scenario 2 1.1']: 'another content' } + + expect(snapshot.parseSnapshotFile(content)).toEqual(expected) +}) + +test('formatSnapshotFile should format snapshot file content', () => { + const content = { ['scenario 1 1.1']: 'some content', ['scenario 2 1.1']: 'another content' } + + const expected = dedent` + """ + + + exports[\`scenario 1 1.1\`] = \`some content\`; + + exports[\`scenario 2 1.1\`] = \`another content\`; + + """ + ` + + expect(snapshot.formatSnapshotFile(content)).toEqual(expected) +}) + +test('formatSnapshotFile should format snapshot file content, sort by keys and escape back ticks', () => { + const content = { ['scenario` 1 1.1']: 'some` content`', ['scenario 2 1.1']: 'another content' } + + const expected = dedent` + """ + + + exports[\`scenario 2 1.1\`] = \`another content\`; + + exports[\`scenario\\\` 1 1.1\`] = \`some\\\` content\\\`\`; + + """ + ` + + expect(snapshot.formatSnapshotFile(content)).toEqual(expected) +}) + +test('formatSnapshotFile should normalize new lines', () => { + const content = { + ['scenario 1 1.1']: 'some content\rnewline content\r\n', + ['scenario 2 1.1']: 'another content' + } + + const expected = dedent` + """ + + + exports[\`scenario 1 1.1\`] = \`some content + newline content + \`; + + exports[\`scenario 2 1.1\`] = \`another content\`; + + """ + ` + + expect(snapshot.formatSnapshotFile(content)).toEqual(expected) +}) + +test('diff should show multiples diff', () => { + const snapshotContent = ` + Object { + "key1": "value1", + "key2": "value2", + "key3": "value3", + "key4": "value4", + "key5": "value5", + } + ` + + const expectedContent = ` + Object { + "key1": "value1", + "key2": "value6", + "key3": "value3", + "key4": "value7", + "key5": "value5", + } + ` + + const expectedDiff = dedent` + """ + + \u001b[32m- Snapshot\u001b[39m + \u001b[31m+ Received\u001b[39m + + \u001b[2m \u001b[22m + \u001b[2m Object {\u001b[22m + \u001b[2m "key1": "value1",\u001b[22m + \u001b[32m- "key2": "value2",\u001b[39m + \u001b[31m+ "key2": "value6",\u001b[39m + \u001b[2m "key3": "value3",\u001b[22m + \u001b[32m- "key4": "value4",\u001b[39m + \u001b[31m+ "key4": "value7",\u001b[39m + \u001b[2m "key5": "value5",\u001b[22m + \u001b[2m }\u001b[22m + \u001b[2m \u001b[22m + """ + ` + + expect(snapshot.diff(snapshotContent, expectedContent)).toEqual(expectedDiff) +}) + +test('snapshotsPath returns snapshot path', () => { + const featurePath = 'myfolder/featurefile.feature' + const expectedPath = 'myfolder/__snapshots__/featurefile.feature.snap' + const options = {} + expect(snapshot.snapshotsPath(featurePath, options)).toEqual(expectedPath) +}) + +test('snapshotsPath returns snapshot path with overrided folder and extension', () => { + const featurePath = 'myfolder/featurefile.feature' + const expectedPath = 'myfolder/testsnap/featurefile.feature.sna' + const options = { snaphotsDirname: 'testsnap', snapshotsFileExtension: 'sna' } + expect(snapshot.snapshotsPath(featurePath, options)).toEqual(expectedPath) +}) + +test('writeSnapshotFile should format and write snapshot file', () => { + const file = 'folder1/feature1.feature' + + const scenario1Snapshot = dedent` + """ + Object { + "key1": "value1", + "key2": "value2", + "key3": "value3", + "key4": "value4", + "key5": "value5", + } + """ + ` + const contentToWrite = { 'scenario 1 1.1': scenario1Snapshot } + + const expectedWrite = dedent` + """ + + + exports[\`scenario 1 1.1\`] = \`Object { + "key1": "value1", + "key2": "value2", + "key3": "value3", + "key4": "value4", + "key5": "value5", + }\`; + + """ + ` + + fileSystem.writeFileContent = jest.fn() + + snapshot.writeSnapshotFile(file, contentToWrite) + + expect(fileSystem.writeFileContent.mock.calls.length).toBe(1) + expect(fileSystem.writeFileContent).toHaveBeenCalledWith(file, expectedWrite) +}) + +test('readSnapshotFile should read and parse snapshot file', () => { + const file = 'folder1/feature1.feature' + + const snapshotContent = dedent` + """ + Object { + "key1": "value1", + "key2": "value2", + "key3": "value3", + "key4": "value4", + "key5": "value5", + } + """ + ` + + const fileContent = dedent` + """ + + + exports[\`scenario 1 1.1\`] = \`Object { + "key1": "value1", + "key2": "value2", + "key3": "value3", + "key4": "value4", + "key5": "value5", + }\`; + + """ + ` + + const expectedContent = { 'scenario 1 1.1': snapshotContent } + + fileSystem.getFileInfo = jest.fn() + fileSystem.getFileContent = jest.fn() + + fileSystem.getFileInfo.mockImplementationOnce(() => { + return {} + }) + fileSystem.getFileContent.mockImplementationOnce(() => { + return fileContent + }) + + expect(snapshot.readSnapshotFile(file)).toEqual(expectedContent) + + expect(fileSystem.getFileInfo.mock.calls.length).toBe(1) + expect(fileSystem.getFileInfo).toHaveBeenCalledWith(file) + + expect(fileSystem.getFileContent.mock.calls.length).toBe(1) + expect(fileSystem.getFileContent).toHaveBeenCalledWith(file) +}) + +test("readSnapshotFile should give an empty object if file doesn't exists", () => { + const file = 'folder1/feature1.feature' + + const expectedContent = {} + + fileSystem.getFileInfo = jest.fn() + fileSystem.getFileContent = jest.fn() + + fileSystem.getFileInfo.mockImplementationOnce(() => { + null + }) + + expect(snapshot.readSnapshotFile(file)).toEqual(expectedContent) + + expect(fileSystem.getFileInfo.mock.calls.length).toBe(1) + expect(fileSystem.getFileInfo).toHaveBeenCalledWith(file) + + expect(fileSystem.getFileContent.mock.calls.length).toBe(0) +}) + +test('readSnapshotFile throw an error if no file', () => { + expect(snapshot.readSnapshotFile).toThrowError( + /Missing snapshot file undefined to read snapshots/ + ) +}) + +test('prefixSnapshots give a prefix per scenario name', () => { + const scenarios = [{ name: 'Scenario 1', line: 10 }, { name: 'Scenario 2', line: 20 }] + + const expectedResult = { + 10: { name: 'Scenario 1', line: 10, prefix: 'Scenario 1 1' }, + 20: { name: 'Scenario 2', line: 20, prefix: 'Scenario 2 1' } + } + + expect(snapshot.prefixSnapshots(scenarios)).toEqual(expectedResult) +}) + +test('prefixSnapshots works with duplicate scenarios names', () => { + const scenarios = [ + { name: 'Scenario 1', line: 10 }, + { name: 'Scenario 2', line: 20 }, + { name: 'Scenario 1', line: 30 } + ] + + const expectedResult = { + 10: { name: 'Scenario 1', line: 10, prefix: 'Scenario 1 1' }, + 20: { name: 'Scenario 2', line: 20, prefix: 'Scenario 2 1' }, + 30: { name: 'Scenario 1', line: 30, prefix: 'Scenario 1 2' } + } + + expect(snapshot.prefixSnapshots(scenarios)).toEqual(expectedResult) +}) + +test('prefixSnapshots throw an error if no scenarios object', () => { + expect(snapshot.prefixSnapshots).toThrowError(/Scenarios are required to prefix snapshots/) +}) + +test('extractScenarios read scenarios names from a file', () => { + const file = 'folder1/feature1.feature' + + const fileContent = ` + @cli @offline + Feature: yarn CLI + + Scenario: Running an invalid command + When I run command yarn invalid + Then exit code should be 1 + And stderr should contain Command "invalid" not found. + + Scenario: Getting info about installed yarn version + When I run command yarn --version + Then exit code should be 0 + And stdout should match ^[0-9]{1}.[0-9]{1,3}.[0-9]{1,3} + And stderr should be empty + + Scenario: Running an invalid command + When I run command yarn invalid + Then exit code should be 1 + And stderr should contain Command "invalid" not found. + ` + + const expectedContent = [ + { name: 'Running an invalid command', line: 5 }, + { name: 'Getting info about installed yarn version', line: 10 }, + { name: 'Running an invalid command', line: 16 } + ] + + fileSystem.getFileContent = jest.fn() + fileSystem.getFileContent.mockImplementationOnce(() => { + return fileContent + }) + + expect(snapshot.extractScenarios(file)).toEqual(expectedContent) + + expect(fileSystem.getFileContent.mock.calls.length).toBe(1) + expect(fileSystem.getFileContent).toHaveBeenCalledWith(file) +}) + +test('extractScenarios throw an error if no file', () => { + expect(snapshot.extractScenarios).toThrowError(/Invalid feature file undefined/) + expect(snapshot.extractScenarios).toThrowError(TypeError) +}) diff --git a/yarn.lock b/yarn.lock index a45a31a9..735eefd7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -79,6 +79,12 @@ ansi-styles@^3.0.0, ansi-styles@^3.1.0: dependencies: color-convert "^1.0.0" +ansi-styles@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.0.tgz#c159b8d5be0f9e5a6f346dab94f16ce022161b88" + dependencies: + color-convert "^1.9.0" + any-promise@^1.0.0: version "1.3.0" resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f" @@ -338,6 +344,10 @@ bcrypt-pbkdf@^1.0.0: dependencies: tweetnacl "^0.14.3" +becke-ch--regex--s0-0-v1--base--pl--lib@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/becke-ch--regex--s0-0-v1--base--pl--lib/-/becke-ch--regex--s0-0-v1--base--pl--lib-1.2.0.tgz#2e73e9d21f2c2e6f5a5454045636f0ab93e46130" + bluebird@^3.4.1: version "3.5.0" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.0.tgz#791420d7f551eea2897453a8a77653f96606d67c" @@ -538,7 +548,7 @@ code-point-at@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" -color-convert@^1.0.0: +color-convert@^1.0.0, color-convert@^1.9.0: version "1.9.0" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.0.tgz#1accf97dd739b983bf994d56fec8f95853641b7a" dependencies: @@ -661,17 +671,19 @@ cssom@0.3.x, "cssom@>= 0.3.2 < 0.4.0": dependencies: cssom "0.3.x" -cucumber-expressions@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/cucumber-expressions/-/cucumber-expressions-3.0.0.tgz#4cf424813dae396cc9dab714b8104b459befc32c" +cucumber-expressions@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/cucumber-expressions/-/cucumber-expressions-4.0.3.tgz#fc70d1a94e1959c9fd555a41b7e2200e57ac33a7" + dependencies: + becke-ch--regex--s0-0-v1--base--pl--lib "^1.2.0" cucumber-tag-expressions@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/cucumber-tag-expressions/-/cucumber-tag-expressions-1.0.1.tgz#d6d3c43180a03f5fb4fc957fe1382ddce5cb9ac8" -cucumber@2.x.x: - version "2.3.1" - resolved "https://registry.yarnpkg.com/cucumber/-/cucumber-2.3.1.tgz#3791a51ffd0c61462ad57fdb8ed111d55b51cde3" +cucumber@3.x.x: + version "3.0.3" + resolved "https://registry.yarnpkg.com/cucumber/-/cucumber-3.0.3.tgz#9e73912b5fc98d7505bf169f5c2e5702364b9f8c" dependencies: assertion-error-formatter "^2.0.0" babel-runtime "^6.11.6" @@ -679,9 +691,10 @@ cucumber@2.x.x: cli-table "^0.3.1" colors "^1.1.2" commander "^2.9.0" - cucumber-expressions "^3.0.0" + cucumber-expressions "^4.0.3" cucumber-tag-expressions "^1.0.0" duration "^0.2.0" + escape-string-regexp "^1.0.5" figures "2.0.0" gherkin "^4.1.0" glob "^7.0.0" @@ -695,7 +708,7 @@ cucumber@2.x.x: stack-chain "^1.3.5" stacktrace-js "^2.0.0" string-argv "0.0.2" - upper-case-first "^1.1.2" + title-case "^2.1.1" util-arity "^1.0.2" verror "^1.9.0" @@ -1711,6 +1724,15 @@ jest-diff@^20.0.3: jest-matcher-utils "^20.0.3" pretty-format "^20.0.3" +jest-diff@^21.0.0: + version "21.0.0" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-21.0.0.tgz#b996ba2963a783125e6bc59fd5623bce67df7f17" + dependencies: + chalk "^2.0.1" + diff "^3.2.0" + jest-get-type "^21.0.0" + pretty-format "^21.0.0" + jest-docblock@^20.0.3: version "20.0.3" resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-20.0.3.tgz#17bea984342cc33d83c50fbe1545ea0efaa44712" @@ -1730,6 +1752,10 @@ jest-environment-node@^20.0.3: jest-mock "^20.0.3" jest-util "^20.0.3" +jest-get-type@^21.0.0: + version "21.0.0" + resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-21.0.0.tgz#ed8667533c0a24a4feebbf492661f23abac3620b" + jest-haste-map@^20.0.4: version "20.0.4" resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-20.0.4.tgz#653eb55c889ce3c021f7b94693f20a4159badf03" @@ -2141,6 +2167,10 @@ loose-envify@^1.0.0: dependencies: js-tokens "^3.0.0" +lower-case@^1.1.1: + version "1.1.4" + resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-1.1.4.tgz#9a2cabd1b9e8e0ae993a4bf7d5875c39c42e8eac" + lru-cache@^4.0.1: version "4.1.1" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.1.tgz#622e32e82488b49279114a4f9ecf45e7cd6bba55" @@ -2246,6 +2276,12 @@ natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" +no-case@^2.2.0: + version "2.3.2" + resolved "https://registry.yarnpkg.com/no-case/-/no-case-2.3.2.tgz#60b813396be39b3f1288a4c1ed5d1e7d28b464ac" + dependencies: + lower-case "^1.1.1" + nock@^9.0.13: version "9.0.13" resolved "https://registry.yarnpkg.com/nock/-/nock-9.0.13.tgz#d0bc39ef43d3179981e22b2e8ea069f916c5781a" @@ -2513,6 +2549,20 @@ pretty-format@^20.0.3: ansi-regex "^2.1.1" ansi-styles "^3.0.0" +pretty-format@^21.0.0: + version "21.0.0" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-21.0.0.tgz#bea1522c4c47e49b44db5b6fbf83e7737251f305" + dependencies: + ansi-regex "^3.0.0" + ansi-styles "^3.2.0" + +pretty-format@^21.0.2: + version "21.0.2" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-21.0.2.tgz#76adcebd836c41ccd2e6b626e70f63050d2a3534" + dependencies: + ansi-regex "^3.0.0" + ansi-styles "^3.2.0" + private@^0.1.6: version "0.1.7" resolved "https://registry.yarnpkg.com/private/-/private-0.1.7.tgz#68ce5e8a1ef0a23bb570cc28537b5332aba63ef1" @@ -3052,6 +3102,13 @@ through@^2.3.6: version "2.3.8" resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" +title-case@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/title-case/-/title-case-2.1.1.tgz#3e127216da58d2bc5becf137ab91dae3a7cd8faa" + dependencies: + no-case "^2.2.0" + upper-case "^1.0.3" + tmp@^0.0.31: version "0.0.31" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.31.tgz#8f38ab9438e17315e5dbd8b3657e8bfb277ae4a7" @@ -3155,13 +3212,7 @@ universalify@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.0.tgz#9eb1c4651debcc670cc94f1a75762332bb967778" -upper-case-first@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/upper-case-first/-/upper-case-first-1.1.2.tgz#5d79bedcff14419518fd2edb0a0507c9b6859115" - dependencies: - upper-case "^1.1.1" - -upper-case@^1.1.1: +upper-case@^1.0.3: version "1.1.3" resolved "https://registry.yarnpkg.com/upper-case/-/upper-case-1.1.3.tgz#f6b4501c2ec4cdd26ba78be7222961de77621598"