├── __snapshots__ ├── spec-a.js.snapshot.js ├── spec-b.js.snapshot.js ├── prune-spec.js.snapshot.js ├── sorted-names-spec.js.snapshot.js ├── unsorted-names-spec.js.snapshot.js ├── restore-spec.js.test ├── snap-shot-core-spec.js.snapshot.js ├── utils-spec.js ├── snap-shot-core-spec.js.test └── multi-line-text-spec.js.snapshot.js ├── test-nested-specs ├── __snapshots__ │ ├── spec.js.snapshot.js │ └── spec2.js.snapshot.js ├── specs │ ├── spec.js │ └── subfolder │ │ └── spec2.js ├── README.md └── package.json ├── .imdone ├── sort.json └── config.json ├── .gitignore ├── .npmrc ├── .vscode └── settings.json ├── test-pruning ├── __snapshots__ │ └── spec.js ├── package.json └── spec.js ├── test-subfolders-specs ├── specs │ ├── spec.js │ └── subfolder │ │ └── spec2.js ├── package.json └── README.md ├── renovate.json ├── .travis.yml ├── imdone-help.md ├── bin ├── resave-snapshots.js └── resave-snapshots-spec.js ├── src ├── multi-line-text-spec.js ├── prune-spec.js ├── browser-system.js ├── index-spec.js ├── cypress-system.js ├── prune.js ├── restore-spec.js ├── utils.js ├── file-system-spec.js ├── nested-spec.js ├── utils-spec.js ├── file-system.js ├── snap-shot-core-spec.js └── index.js ├── package.json └── README.md /__snapshots__/spec-a.js.snapshot.js: -------------------------------------------------------------------------------- 1 | exports['foo'] = 42 2 | -------------------------------------------------------------------------------- /__snapshots__/spec-b.js.snapshot.js: -------------------------------------------------------------------------------- 1 | exports['foo'] = 80 2 | -------------------------------------------------------------------------------- /test-nested-specs/__snapshots__/spec.js.snapshot.js: -------------------------------------------------------------------------------- 1 | exports['a 1'] = 42 2 | -------------------------------------------------------------------------------- /test-nested-specs/__snapshots__/spec2.js.snapshot.js: -------------------------------------------------------------------------------- 1 | exports['b 1'] = 42 2 | -------------------------------------------------------------------------------- /.imdone/sort.json: -------------------------------------------------------------------------------- 1 | {"HELP":[12,8,11,10,14,13],"TODO":[0,1,2,4,3,6,7,5],"FIXME":[9]} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .DS_Store 3 | npm-debug.log 4 | bin/test-resave 5 | temp-*/ 6 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=http://registry.npmjs.org/ 2 | save-exact=true 3 | progress=false 4 | package-lock=true 5 | -------------------------------------------------------------------------------- /__snapshots__/prune-spec.js.snapshot.js: -------------------------------------------------------------------------------- 1 | exports['pruning snapshots end to end is a dummy test 1'] = 42 2 | -------------------------------------------------------------------------------- /__snapshots__/sorted-names-spec.js.snapshot.js: -------------------------------------------------------------------------------- 1 | exports['a'] = 60 2 | 3 | exports['b'] = 80 4 | 5 | exports['x'] = 42 6 | -------------------------------------------------------------------------------- /__snapshots__/unsorted-names-spec.js.snapshot.js: -------------------------------------------------------------------------------- 1 | exports['x'] = 42 2 | 3 | exports['b'] = 80 4 | 5 | exports['a'] = 60 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "standard.enable": true, 4 | "workbench.colorTheme": "Activate SCARLET protocol (beta)" 5 | } 6 | -------------------------------------------------------------------------------- /test-pruning/__snapshots__/spec.js: -------------------------------------------------------------------------------- 1 | exports['a 1'] = ` 2 | foo 3 | ` 4 | 5 | exports['a 2'] = ` 6 | bar 7 | ` 8 | 9 | exports['b 1'] = ` 10 | foo 11 | ` 12 | -------------------------------------------------------------------------------- /__snapshots__/restore-spec.js.test: -------------------------------------------------------------------------------- 1 | exports['counters can be restored to zero 1'] = ` 2 | A 3 | ` 4 | 5 | exports['single counter can be restored to zero 1'] = ` 6 | A 7 | ` 8 | -------------------------------------------------------------------------------- /test-nested-specs/specs/spec.js: -------------------------------------------------------------------------------- 1 | const snapshot = require('../..').core 2 | 3 | /* eslint-env mocha */ 4 | it('a', () => { 5 | snapshot({ 6 | what: 42, 7 | __filename, 8 | specName: 'a' 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /test-nested-specs/specs/subfolder/spec2.js: -------------------------------------------------------------------------------- 1 | const snapshot = require('../../..').core 2 | 3 | /* eslint-env mocha */ 4 | it('b', () => { 5 | snapshot({ 6 | what: 42, 7 | __filename, 8 | specName: 'b' 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /test-subfolders-specs/specs/spec.js: -------------------------------------------------------------------------------- 1 | const snapshot = require('../..').core 2 | 3 | /* eslint-env mocha */ 4 | it('a', () => { 5 | snapshot({ 6 | what: 42, 7 | __filename, 8 | specName: 'a', 9 | opts: { 10 | useRelativePath: true 11 | } 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /test-subfolders-specs/specs/subfolder/spec2.js: -------------------------------------------------------------------------------- 1 | const snapshot = require('../../..').core 2 | 3 | /* eslint-env mocha */ 4 | it('b', () => { 5 | snapshot({ 6 | what: 42, 7 | __filename, 8 | specName: 'b', 9 | opts: { 10 | useRelativePath: true 11 | } 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ], 5 | "automerge": true, 6 | "major": { 7 | "automerge": false 8 | }, 9 | "lockFileMaintenance": true, 10 | "timezone": "America/New_York", 11 | "schedule": [ 12 | "every weekend" 13 | ], 14 | "masterIssue": true 15 | } 16 | -------------------------------------------------------------------------------- /__snapshots__/snap-shot-core-spec.js.snapshot.js: -------------------------------------------------------------------------------- 1 | exports['stores comment 1'] = 42 2 | 3 | exports['default compare 1'] = { 4 | "foo": "bar" 5 | } 6 | 7 | exports['default compare 2'] = { 8 | "foo": "bar" 9 | } 10 | 11 | exports['unicode 1'] = ` 12 | \u2028 ✌️ 13 | ` 14 | 15 | exports['my snapshot name'] = 42 16 | -------------------------------------------------------------------------------- /test-nested-specs/README.md: -------------------------------------------------------------------------------- 1 | # test-nested-specs 2 | 3 | This repo shows how `snap-shot-core` stores all snapshots from nested specs like 4 | 5 | ``` 6 | spec.js 7 | subfolder/ 8 | spec2.js 9 | ``` 10 | 11 | in a single flat folder 12 | 13 | ``` 14 | __snapshots__/ 15 | spec.js.snapshot.js 16 | spec2.js.snapshot.js 17 | ``` 18 | -------------------------------------------------------------------------------- /test-nested-specs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test-nested-specs", 3 | "version": "1.0.0", 4 | "description": "shows how snapshots are saved for nested projects", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "../node_modules/.bin/mocha 'specs/**/*.js'" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC" 12 | } 13 | -------------------------------------------------------------------------------- /test-subfolders-specs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test-nested-specs", 3 | "version": "1.0.0", 4 | "description": "shows how snapshots are saved for nested projects", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "../node_modules/.bin/mocha 'specs/**/*.js'" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC" 12 | } 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | cache: 3 | directories: 4 | - ~/.npm 5 | install: 6 | - npm ci 7 | notifications: 8 | email: true 9 | node_js: 10 | - '10' 11 | before_script: 12 | - npm prune 13 | script: 14 | - npm test 15 | - echo "Running unit tests again" 16 | - npm test 17 | after_success: 18 | - npm run semantic-release 19 | branches: 20 | except: 21 | - /^v\d+\.\d+\.\d+$/ 22 | -------------------------------------------------------------------------------- /test-pruning/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test-pruning", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "../node_modules/.bin/mocha spec.js", 8 | "test2": "NODE_PATH=../.. ../node_modules/.bin/mocha spec.js" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "devDependencies": { 14 | "proxyquire": "2.1.3" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /test-subfolders-specs/README.md: -------------------------------------------------------------------------------- 1 | # test-subfolders-specs 2 | 3 | This repo shows how `snap-shot-core` stores all snapshots from nested specs in subfolders that are parallel to the original specs 4 | 5 | ``` 6 | specs/ 7 | spec.js 8 | subfolder/ 9 | spec2.js 10 | ``` 11 | 12 | result should be 13 | 14 | ``` 15 | __snapshots__/ 16 | specs/ 17 | spec.js.snapshot.js 18 | subfolder/ 19 | spec2.js.snapshot.js 20 | ``` 21 | -------------------------------------------------------------------------------- /__snapshots__/utils-spec.js: -------------------------------------------------------------------------------- 1 | exports['compare has error (snapshot) 1'] = { 2 | "@@type": "folktale:Result", 3 | "@@tag": "Error", 4 | "@@value": { 5 | "value": "\"foo\" !== \"bar\"" 6 | } 7 | } 8 | 9 | exports['compare snapshots error value 1'] = ` 10 | "foo" !== "bar" 11 | ` 12 | 13 | exports['removeExtraNewLines leaves other values unchanged 1'] = { 14 | "foo": "bar", 15 | "age": 42 16 | } 17 | 18 | exports['removeExtraNewLines removes new lines 1'] = { 19 | "foo": "bar", 20 | "age": 42 21 | } 22 | -------------------------------------------------------------------------------- /__snapshots__/snap-shot-core-spec.js.test: -------------------------------------------------------------------------------- 1 | exports['this should not be incremented'] = 43 2 | 3 | exports['has single quote -> \' <- 1'] = 42 4 | 5 | exports['my test 1'] = { 6 | "foo": "bar" 7 | } 8 | 9 | exports['can store derived value 1'] = 80 10 | 11 | exports['typeof example 1'] = ` 12 | function 13 | ` 14 | 15 | exports['customer raiser function 1'] = 42 16 | 17 | exports['counters can be restored to zero 1'] = ` 18 | A 19 | ` 20 | 21 | exports['single counter can be restored to zero 1'] = ` 22 | A 23 | ` 24 | 25 | exports['custom name'] = 42 26 | -------------------------------------------------------------------------------- /__snapshots__/multi-line-text-spec.js.snapshot.js: -------------------------------------------------------------------------------- 1 | exports['disparity diff 1'] = ` 2 | line 1 3 | line 2 4 | line 3 5 | 6 | line 5 without line 4 7 | ` 8 | 9 | exports['multi line text 1'] = ` 10 | line 1 11 | line 2 12 | line 3 13 | 14 | line 5 without line 4 15 | ` 16 | 17 | exports['multi line text 2 1'] = ` 18 | line 1 19 | line 2 20 | line 3 21 | 22 | line 5 without line 4 23 | ` 24 | 25 | exports['no first line'] = ` 26 | line 1 27 | line 2 28 | line 3 29 | 30 | line 5 without line 4 31 | ` 32 | 33 | exports['text with backticks 1'] = ` 34 | line 1 35 | line 2 with \`42\` 36 | line 3 with \`foo\` 37 | 38 | line 5 without line 4 39 | ` 40 | -------------------------------------------------------------------------------- /imdone-help.md: -------------------------------------------------------------------------------- 1 | imdone-help 2 | ==== 3 | #HELP: Try dragging this card to your new list id:12 +imdone-help 4 | #HELP: Ignore files by adding `.imdoneignore` to the root of your project. id:8 +imdone-help 5 | - [imdone.io](https://imdone.io) implements this with the [ignore package](https://www.npmjs.com/package/ignore) 6 | 7 | #HELP: Use markdown in todo comments or in the description id:11 +imdone-help 8 | - **This is a description...** 9 | 10 | #HELP: Add tags to your comments like this `+mvp` id:10 +imdone-help 11 | 12 | #HELP: Add metadata like this... points:5 id:14 +imdone-help 13 | - [imdone.io](https://imdone.io) adds `id:n` to all your todo comments, so take care to leave that one alone 14 | 15 | #HELP: Include subtasks using GFM task lists id:13 +imdone-help 16 | - [ ] A task yet to be done 17 | - [x] This is done 18 | -------------------------------------------------------------------------------- /test-pruning/spec.js: -------------------------------------------------------------------------------- 1 | // load "snap-shot-it" BUT replace its dependency "snap-shot-core" 2 | // with current implementation from "../src" 3 | 4 | // console.log(process.env) 5 | const join = require('path').join 6 | // console.log(module) 7 | module.paths.unshift(join(__dirname, '..', '..')) 8 | // console.log('module.paths') 9 | // console.log(module.paths) 10 | 11 | // const resolve = module.require.resolve 12 | // // console.log('require.resolve', require.resolve) 13 | // module.require.resolve = (request, options) => { 14 | // console.log('resolving', request) 15 | // return resolve(request, options) 16 | // } 17 | 18 | const devSnapShotCore = require('..') 19 | const stubs = { 20 | 'snap-shot-core': { 21 | ...devSnapShotCore, 22 | '@runtimeGlobal': true 23 | } 24 | } 25 | 26 | console.log('stubs', stubs) 27 | 28 | const proxyquire = require('proxyquire') 29 | const snapshot = proxyquire('snap-shot-it', stubs) 30 | 31 | // const snapshot = require('snap-shot-it') 32 | 33 | /* eslint-env mocha */ 34 | it('a', () => { 35 | snapshot('foo') 36 | snapshot('bar') 37 | }) 38 | 39 | it('b', () => { 40 | snapshot('foo') 41 | // snapshot('bar') 42 | }) 43 | -------------------------------------------------------------------------------- /bin/resave-snapshots.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict' 4 | 5 | const debug = require('debug')('snap-shot-core') 6 | const path = require('path') 7 | const pluralize = require('pluralize') 8 | const { loadSnapshotsFrom, maybeSortAndSave } = require('../src/file-system') 9 | const arg = require('arg') 10 | 11 | const help = 'USE: resave-snashots [--sort] ' 12 | 13 | const args = arg({ 14 | '--sort': Boolean, 15 | // aliases 16 | '-s': '--sort' 17 | }) 18 | 19 | debug('resave arguments %o', args) 20 | const invalidFilename = args._.length !== 1 21 | if (invalidFilename) { 22 | console.error(help) 23 | process.exit(1) 24 | } 25 | 26 | const snapshotFilename = path.resolve(args._[0]) 27 | const snapshots = loadSnapshotsFrom(snapshotFilename) 28 | const names = Object.keys(snapshots) 29 | debug('loaded %s', pluralize('snapshot', names.length, true)) 30 | debug(names.join('\n')) 31 | 32 | const sortSnapshots = Boolean(args['--sort']) 33 | if (sortSnapshots) { 34 | console.log('saving sorted snapshots to', snapshotFilename) 35 | } else { 36 | console.log('saving snapshots to', snapshotFilename) 37 | } 38 | maybeSortAndSave(snapshots, snapshotFilename, { sortSnapshots }) 39 | -------------------------------------------------------------------------------- /.imdone/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": [ 3 | "^(node_modules|bower_components|\\.imdone|target|build|dist|logs|flow-typed|imdone-export.json)[\\/\\\\]?|\\.(git|svn|hg|npmignore)|\\~$|\\.(jpg|png|gif|swp|ttf|otf)$" 4 | ], 5 | "watcher": true, 6 | "keepEmptyPriority": true, 7 | "code": { 8 | "include_lists": [ 9 | "TODO", 10 | "DOING", 11 | "DONE", 12 | "PLANNING", 13 | "FIXME", 14 | "ARCHIVE", 15 | "HACK", 16 | "CHANGED", 17 | "XXX", 18 | "IDEA", 19 | "NOTE", 20 | "REVIEW", 21 | "MARK", 22 | "HELP" 23 | ] 24 | }, 25 | "lists": [ 26 | { 27 | "name": "HELP", 28 | "hidden": false 29 | }, 30 | { 31 | "name": "TODO", 32 | "hidden": false 33 | }, 34 | { 35 | "name": "FIXME", 36 | "hidden": false 37 | } 38 | ], 39 | "marked": { 40 | "gfm": true, 41 | "tables": true, 42 | "breaks": false, 43 | "pedantic": false, 44 | "smartLists": true, 45 | "langPrefix": "language-" 46 | }, 47 | "sync": { 48 | "id": "5b1adebb4f7fd004e58ef569", 49 | "name": "bahmutov/snap-shot-core", 50 | "useImdoneioForPriority": true 51 | }, 52 | "noHelp": true 53 | } -------------------------------------------------------------------------------- /src/multi-line-text-spec.js: -------------------------------------------------------------------------------- 1 | const snapShotCore = require('.') 2 | const stripIndent = require('common-tags').stripIndent 3 | const disparity = require('disparity') 4 | const Result = require('folktale/result') 5 | 6 | function compareText (options) { 7 | const expected = options.expected 8 | const value = options.value 9 | 10 | const textDiff = disparity.unified(expected, value) 11 | return textDiff ? Result.Error(textDiff) : Result.Ok() 12 | } 13 | 14 | /* eslint-env mocha */ 15 | describe('multi line text', () => { 16 | const text = stripIndent` 17 | line 1 18 | line 2 19 | line 3 20 | 21 | line 5 without line 4 22 | ` 23 | 24 | it('saves long text', () => { 25 | snapShotCore.core({ 26 | what: text, 27 | __filename, 28 | specName: 'multi line text' 29 | }) 30 | }) 31 | 32 | it('another text test', () => { 33 | snapShotCore.core({ 34 | what: text, 35 | __filename, 36 | specName: 'multi line text 2' 37 | }) 38 | }) 39 | 40 | it('uses good diff', () => { 41 | snapShotCore.core({ 42 | what: text, 43 | __filename, 44 | specName: 'disparity diff', 45 | compare: compareText 46 | }) 47 | }) 48 | 49 | it('does not put text on the first line of the snapshot', () => { 50 | snapShotCore.core({ 51 | what: text, 52 | __filename, 53 | exactSpecName: 'no first line', 54 | compare: compareText 55 | }) 56 | }) 57 | }) 58 | 59 | describe('multi line text with backticks', () => { 60 | const text = stripIndent` 61 | line 1 62 | line 2 with \`42\` 63 | line 3 with \`foo\` 64 | 65 | line 5 without line 4 66 | ` 67 | 68 | it('saves text just fine', () => { 69 | snapShotCore.core({ 70 | what: text, 71 | __filename, 72 | specName: 'text with backticks' 73 | }) 74 | }) 75 | }) 76 | -------------------------------------------------------------------------------- /bin/resave-snapshots-spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | const execaWrap = require('execa-wrap') 3 | const path = require('path') 4 | const fs = require('fs') 5 | const { stripIndent } = require('common-tags') 6 | const mkdirp = require('mkdirp') 7 | const la = require('lazy-ass') 8 | 9 | // include an emoji that should NOT be escaped 10 | // https://github.com/bahmutov/snap-shot-core/issues/235 11 | const snapshot = stripIndent` 12 | exports['x'] = 42 13 | 14 | exports['b'] = '👍' 15 | 16 | exports['a'] = 60 17 | ` 18 | 19 | const escapedSnapshot = stripIndent` 20 | exports['x'] = 42 21 | 22 | exports['b'] = \` 23 | 👍 24 | \` 25 | 26 | exports['a'] = 60 27 | ` 28 | 29 | const escapedSortedSnapshot = stripIndent` 30 | exports['a'] = 60 31 | 32 | exports['b'] = \` 33 | 👍 34 | \` 35 | 36 | exports['x'] = 42 37 | ` 38 | 39 | describe('resave', () => { 40 | // these tests create temp folder where a snapshot will be saved 41 | 42 | const script = path.join(__dirname, 'resave-snapshots.js') 43 | const testFolder = path.join(__dirname, 'test-resave') 44 | const filename = path.join(testFolder, 'snapshot.js') 45 | 46 | beforeEach(() => mkdirp.sync(testFolder)) 47 | beforeEach(() => { 48 | fs.writeFileSync(filename, snapshot, 'utf8') 49 | }) 50 | 51 | it('re-saves snapshots without sorting', () => { 52 | const args = [script, filename] 53 | return execaWrap('node', args).then(() => { 54 | const saved = fs.readFileSync(filename, 'utf8').trim() 55 | la( 56 | saved === escapedSnapshot, 57 | 'difference in saved unsorted snapshot\nexpected:\n' + 58 | escapedSnapshot + 59 | '\n---\nactual:\n' + 60 | saved + 61 | '\n---' 62 | ) 63 | }) 64 | }) 65 | 66 | it('re-saves sorted snapshots', () => { 67 | const args = [script, '--sort', filename] 68 | return execaWrap('node', args).then(() => { 69 | const saved = fs.readFileSync(filename, 'utf8').trim() 70 | la( 71 | saved === escapedSortedSnapshot, 72 | 'difference in saved sorted snapshot\nexpected:\n' + 73 | escapedSortedSnapshot + 74 | '\n---\nactual:\n' + 75 | saved + 76 | '\n---' 77 | ) 78 | }) 79 | }) 80 | }) 81 | -------------------------------------------------------------------------------- /src/prune-spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const la = require('lazy-ass') 4 | const is = require('check-more-types') 5 | const debug = require('debug')('test') 6 | const R = require('ramda') 7 | 8 | /* eslint-env mocha */ 9 | describe('pruning snapshots', () => { 10 | describe('pruning an object', () => { 11 | const pruneSnapshotsInObject = require('./prune')().pruneSnapshotsInObject 12 | 13 | it('prunes an object', () => { 14 | const runtimeSnapshots = [ 15 | { 16 | key: 'a', 17 | specFile: 'foo.js' 18 | } 19 | ] 20 | const snapshots = { 21 | a: 1, 22 | b: 2, 23 | c: 3 24 | } 25 | const pruned = pruneSnapshotsInObject(runtimeSnapshots, snapshots) 26 | debug(pruned) 27 | const expected = { 28 | a: 1 29 | } 30 | la( 31 | R.equals(pruned)(expected), 32 | 'invalid pruned', 33 | pruned, 34 | 'should be', 35 | expected 36 | ) 37 | }) 38 | 39 | it('prunes an object 2', () => { 40 | const runtimeSnapshots = [ 41 | { 42 | key: 'a', 43 | specFile: 'foo.js' 44 | }, 45 | { 46 | key: 'b', 47 | specFile: 'foo.js' 48 | } 49 | ] 50 | const snapshots = { 51 | a: 1, 52 | b: 2, 53 | c: 3 54 | } 55 | const pruned = pruneSnapshotsInObject(runtimeSnapshots, snapshots) 56 | debug(pruned) 57 | const expected = { 58 | a: 1, 59 | b: 2 60 | } 61 | la( 62 | R.equals(pruned)(expected), 63 | 'invalid pruned', 64 | pruned, 65 | 'should be', 66 | expected 67 | ) 68 | }) 69 | }) 70 | 71 | describe('end to end', () => { 72 | const snapshot = require('.') 73 | const prune = snapshot.prune 74 | let dummyTestName 75 | 76 | it('is a function', () => { 77 | la(is.fn(prune)) 78 | }) 79 | 80 | it('is a dummy test', function () { 81 | dummyTestName = this.test.fullTitle().trim() 82 | debug('creating a snapshot for test "%s"', dummyTestName) 83 | debug('from filename "%s"', __filename) 84 | 85 | snapshot.core({ 86 | what: 42, 87 | specName: dummyTestName, 88 | __filename, 89 | // avoid skipping pruning when running on CI server 90 | opts: { 91 | ci: false 92 | } 93 | }) 94 | }) 95 | 96 | it('prunes', () => { 97 | const tests = [ 98 | { 99 | file: __filename, 100 | specName: dummyTestName 101 | } 102 | ] 103 | prune({ tests }) 104 | }) 105 | }) 106 | }) 107 | -------------------------------------------------------------------------------- /src/browser-system.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const la = require('lazy-ass') 4 | const is = require('check-more-types') 5 | 6 | /* global localStorage, fetch */ 7 | la(is.object(localStorage), 'missing localStorage') 8 | la(is.fn(fetch), 'missing fetch') 9 | 10 | function getFilename () { 11 | return 'snap-shot.json' 12 | } 13 | 14 | function loadSnapshots () { 15 | const filename = getFilename() 16 | let snapshots = localStorage.getItem(filename) 17 | if (!snapshots) { 18 | snapshots = {} 19 | } else { 20 | snapshots = JSON.parse(snapshots) 21 | } 22 | return snapshots 23 | } 24 | 25 | function saveSnapshots (snapshots) { 26 | const filename = getFilename() 27 | const s = JSON.stringify(snapshots, null, 2) + '\n' 28 | localStorage.setItem(filename, s) 29 | return snapshots 30 | } 31 | 32 | function init () { 33 | // for now disable 34 | return Promise.resolve() 35 | 36 | // find out the source for all test -> this spec file 37 | // const sites = callsites() 38 | // la(sites.length, 'missing callsite') 39 | // const specFileUrl = sites[1].filename 40 | // la(is.webUrl(specFileUrl), 'missing spec url', specFileUrl) 41 | // console.log('loading spec from', specFileUrl) 42 | 43 | // // specFileUrl is something like 44 | // // http://localhost:49829/__cypress/tests?p=cypress/integration/spec.js-438 45 | // // we will need to get "true" filename which in this case should be 46 | // // cypress/integration/spec.js 47 | // const pIndex = specFileUrl.indexOf('?p=') 48 | // const dotJsIndex = specFileUrl.indexOf('.js-', pIndex) 49 | // const specFile = specFileUrl.substr(pIndex + 3, dotJsIndex - pIndex) 50 | // console.log('specFile is "%s"', specFile) 51 | 52 | // // ignore arguments for now 53 | // api.fromCurrentFolder = () => specFile 54 | 55 | // // cache the fetched source, otherwise every test fetches it 56 | // const shouldFetch = api.readFileSync === dummyReadFileSync 57 | // if (shouldFetch) { 58 | // return fetch(specFileUrl).then(r => r.text()) 59 | // .then(source => { 60 | // // ignores filename for now 61 | // api.readFileSync = () => source 62 | // }) 63 | // } else { 64 | // return Promise.resolve() 65 | // } 66 | } 67 | 68 | function dummyReadFileSync () { 69 | throw new Error(`In the browser, please call snapshot.init() 70 | before calling tests, like this: 71 | const snapshot = require('snap-shot') 72 | beforeEach(snapshot.init) 73 | `) 74 | } 75 | 76 | // TODO replace exposed API with error methods that wait id:1 77 | // Gleb Bahmutov 78 | // gleb.bahmutov@gmail.com 79 | // https://github.com/bahmutov/snap-shot-core/issues/86 80 | // until "init" is called 81 | const api = { 82 | loadSnapshots, 83 | saveSnapshots, 84 | init, 85 | readFileSync: dummyReadFileSync 86 | } 87 | module.exports = api 88 | -------------------------------------------------------------------------------- /src/index-spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const la = require('lazy-ass') 4 | const is = require('check-more-types') 5 | const { stripIndent } = require('common-tags') 6 | const fs = require('./file-system') 7 | const sinon = require('sinon') 8 | 9 | /* eslint-env mocha */ 10 | describe('storeValue', () => { 11 | const { storeValue } = require('./index') 12 | 13 | it('return snapshot key for exact snapshot name', () => { 14 | sinon.stub(fs, 'loadSnapshots').returns({}) 15 | const key = storeValue({ 16 | file: 'foo.js', 17 | exactSpecName: 'bar', 18 | value: 42, 19 | opts: { 20 | dryRun: true 21 | } 22 | }) 23 | fs.loadSnapshots.restore() 24 | la(key === 'bar', 'invalid saved snapshot key', key) 25 | }) 26 | }) 27 | 28 | describe('savedSnapshotName', () => { 29 | const { savedSnapshotName } = require('./index') 30 | 31 | it('prefers exact name', () => { 32 | la( 33 | savedSnapshotName({ 34 | exactSpecName: 'foo', 35 | specName: 'bar', 36 | index: 1 37 | }) === 'foo' 38 | ) 39 | }) 40 | 41 | it('uses spec name and index', () => { 42 | la( 43 | savedSnapshotName({ 44 | specName: 'bar', 45 | index: 1 46 | }) === 'bar 1' 47 | ) 48 | }) 49 | }) 50 | 51 | describe('throwCannotSaveOnCI', () => { 52 | const { throwCannotSaveOnCI } = require('./index') 53 | 54 | it('is a function', () => { 55 | la(is.fn(throwCannotSaveOnCI)) 56 | }) 57 | 58 | it('throws good message for auto formed', () => { 59 | let caught 60 | try { 61 | throwCannotSaveOnCI({ 62 | value: 'foo', 63 | fileParameter: 'spec.js', 64 | exactSpecName: null, 65 | specName: 'my spec', 66 | index: 1 67 | }) 68 | } catch (e) { 69 | caught = true 70 | const expected = stripIndent` 71 | Cannot store new snapshot value 72 | in "spec.js" 73 | for snapshot called "my spec" 74 | test key "my spec 1" 75 | when running on CI (opts.ci = 1) 76 | see https://github.com/bahmutov/snap-shot-core/issues/5 77 | ` 78 | la( 79 | e.message === expected, 80 | 'expected:\n' + expected + '\n\nactual:\n' + e.message 81 | ) 82 | } 83 | la(caught, 'did not catch error!') 84 | }) 85 | 86 | it('throws good message for exact snapshot name', () => { 87 | let caught 88 | try { 89 | throwCannotSaveOnCI({ 90 | value: 'foo', 91 | fileParameter: 'spec.js', 92 | exactSpecName: 'my snapshot name', 93 | specName: null, 94 | index: null 95 | }) 96 | } catch (e) { 97 | caught = true 98 | const expected = stripIndent` 99 | Cannot store new snapshot value 100 | in "spec.js" 101 | for snapshot called "my snapshot name" 102 | test key "my snapshot name" 103 | when running on CI (opts.ci = 1) 104 | see https://github.com/bahmutov/snap-shot-core/issues/5 105 | ` 106 | la( 107 | e.message === expected, 108 | 'expected:\n' + expected + '\n\nactual:\n' + e.message 109 | ) 110 | } 111 | la(caught, 'did not catch error!') 112 | }) 113 | }) 114 | -------------------------------------------------------------------------------- /src/cypress-system.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const la = require('lazy-ass') 4 | const is = require('check-more-types') 5 | 6 | // storage adapter for Cypress E2E testing tool 7 | 8 | /* global cy, expect, fetch */ 9 | la(is.fn(fetch), 'missing fetch') 10 | const filename = 'snap-shot.json' 11 | 12 | let snapshots 13 | 14 | function loadSnapshots () { 15 | return snapshots 16 | } 17 | 18 | function saveSnapshots (snapshots) { 19 | const text = JSON.stringify(snapshots, null, 2) + '\n' 20 | cy.writeFile(filename, text) 21 | } 22 | 23 | function init () { 24 | // for now disable 25 | return Promise.resolve() 26 | // // find out the source for all test -> this spec file 27 | // const sites = callsites() 28 | // la(sites.length, 'missing callsite') 29 | // const specFileUrl = sites[1].filename 30 | // la(is.webUrl(specFileUrl), 'missing spec url', specFileUrl) 31 | // console.log('loading spec from', specFileUrl) 32 | 33 | // // specFileUrl is something like 34 | // // http://localhost:49829/__cypress/tests?p=cypress/integration/spec.js-438 35 | // // we will need to get "true" filename which in this case should be 36 | // // cypress/integration/spec.js 37 | // const pIndex = specFileUrl.indexOf('?p=') 38 | // const dotJsIndex = specFileUrl.indexOf('.js-', pIndex) 39 | // const specFile = specFileUrl.substr(pIndex + 3, dotJsIndex - pIndex) 40 | // console.log('specFile is "%s"', specFile) 41 | 42 | // // ignore arguments for now 43 | // api.fromCurrentFolder = () => specFile 44 | 45 | // // cache the fetched source, otherwise every test fetches it 46 | // const shouldFetch = api.readFileSync === dummyReadFileSync 47 | // if (shouldFetch) { 48 | // return fetch(specFileUrl).then(r => r.text()) 49 | // .then(source => { 50 | // // ignores filename for now 51 | // api.readFileSync = () => source 52 | // }) 53 | // .then(() => { 54 | // return fetch('/__cypress/tests?p=./' + filename) 55 | // .then(r => r.text()) 56 | // .then(function loadedText (text) { 57 | // if (text.includes('BUNDLE_ERROR')) { 58 | // return Promise.reject(new Error('not found')) 59 | // } 60 | // cy.log('loaded snapshots', filename) 61 | // // the JSON is wrapped in webpack wrapper ;) 62 | // const req = eval(text) // eslint-disable-line no-eval 63 | // snapshots = req('1') 64 | // }) 65 | // .catch(err => { 66 | // console.error(err) 67 | // snapshots = {} 68 | // }) 69 | // }) 70 | // } else { 71 | // return Promise.resolve() 72 | // } 73 | } 74 | 75 | function raiseIfDifferent ({ value, expected }) { 76 | cy.then(() => { 77 | expect(value).to.equal(expected) 78 | }) 79 | } 80 | 81 | function dummyReadFileSync () { 82 | throw new Error(`In the browser, please call snapshot.init() 83 | before calling tests, like this: 84 | const snapshot = require('snap-shot') 85 | beforeEach(snapshot.init) 86 | `) 87 | } 88 | 89 | // TODO replace exposed API with error methods that wait id:2 90 | // Gleb Bahmutov 91 | // gleb.bahmutov@gmail.com 92 | // https://github.com/bahmutov/snap-shot-core/issues/87 93 | // until "init" is called 94 | const api = { 95 | loadSnapshots, 96 | saveSnapshots, 97 | init, 98 | readFileSync: dummyReadFileSync, 99 | raiseIfDifferent 100 | } 101 | module.exports = api 102 | -------------------------------------------------------------------------------- /src/prune.js: -------------------------------------------------------------------------------- 1 | const R = require('ramda') 2 | const debug = require('debug')('snap-shot-core') 3 | const pluralize = require('pluralize') 4 | const la = require('lazy-ass') 5 | const is = require('check-more-types') 6 | const utils = require('./utils') 7 | 8 | /** 9 | * Checks if the snapshot to keep has all information 10 | */ 11 | const isRunTimeSnapshot = is.schema({ 12 | specFile: is.unemptyString, 13 | key: is.unemptyString 14 | }) 15 | 16 | const pruneSnapshotsInObject = (runtimeSnapshots, snapshots) => { 17 | la(is.array(runtimeSnapshots), 'invalid runtime snapshots', runtimeSnapshots) 18 | runtimeSnapshots.forEach((r, k) => { 19 | la(isRunTimeSnapshot(r), 'invalid runtime snapshot', r, 'at index', k) 20 | }) 21 | 22 | const keys = R.map(R.prop('key'), runtimeSnapshots) 23 | debug( 24 | 'have runtime %s before pruning', 25 | pluralize('snapshot name', keys.length, true) 26 | ) 27 | if (debug.enabled) { 28 | // make sure NOT to mutate the list of snapshot names 29 | // otherwise we will save the pruned object with keys 30 | // in the sorted order! 31 | debug(keys) 32 | debug('snapshot file keys in the current order') 33 | debug(R.keys(snapshots)) 34 | } 35 | 36 | const isPresent = (val, key) => { 37 | return R.includes(key, keys) 38 | } 39 | const prunedSnapshots = R.pickBy(isPresent, snapshots) 40 | debug( 41 | 'after pruning %s remaining', 42 | pluralize('snapshot name', R.keys(prunedSnapshots).length, true) 43 | ) 44 | if (debug.enabled) { 45 | debug(R.keys(prunedSnapshots)) 46 | } 47 | 48 | return prunedSnapshots 49 | } 50 | 51 | const pruneSnapshotsInFile = ({ fs, byFilename, ext }, opts) => file => { 52 | const snapshots = fs.loadSnapshots(file, ext, { 53 | useRelativePath: opts.useRelativePath || false 54 | }) 55 | if (is.empty(snapshots)) { 56 | debug('empty snapshots to prune in file', file) 57 | return 58 | } 59 | 60 | const runtimeSnapshots = byFilename[file] 61 | debug('run time snapshots by file') 62 | debug(runtimeSnapshots) 63 | 64 | const prunedSnapshots = pruneSnapshotsInObject(runtimeSnapshots, snapshots) 65 | if (R.equals(prunedSnapshots, snapshots)) { 66 | debug('nothing to prune for file', file) 67 | return 68 | } 69 | 70 | debug('saving pruned snapshot file for', file) 71 | 72 | const saveOptions = R.pick(['sortSnapshots', 'useRelativePath'], opts) 73 | debug('save options %o', saveOptions) 74 | fs.saveSnapshots(file, prunedSnapshots, ext, saveOptions) 75 | } 76 | 77 | // TODO switch to async id:3 78 | // Gleb Bahmutov 79 | // gleb.bahmutov@gmail.com 80 | // https://github.com/bahmutov/snap-shot-core/issues/88 81 | /** 82 | * Prunes all unused snapshots for given tests. 83 | */ 84 | const pruneSnapshots = fs => ( 85 | { tests, ext = utils.DEFAULT_EXTENSION }, 86 | opts = { 87 | useRelativePath: false, 88 | sortSnapshots: false 89 | } 90 | ) => { 91 | la(is.array(tests), 'missing tests', tests) 92 | debug('pruning snapshots') 93 | debug('run time tests') 94 | debug(tests) 95 | 96 | const byFilename = R.groupBy(R.prop('specFile'), tests) 97 | debug('run-time tests by file') 98 | debug(byFilename) 99 | 100 | Object.keys(byFilename).forEach( 101 | pruneSnapshotsInFile({ fs, byFilename, ext }, opts) 102 | ) 103 | } 104 | 105 | module.exports = fs => { 106 | return { 107 | pruneSnapshots: pruneSnapshots(fs), 108 | pruneSnapshotsInObject 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/restore-spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const la = require('lazy-ass') 4 | const is = require('check-more-types') 5 | const path = require('path') 6 | const debug = require('debug')('test') 7 | 8 | const snapShotExtension = '.test' 9 | 10 | /* global describe, it */ 11 | describe('restore', () => { 12 | const snapShotCore = require('.') 13 | 14 | it('has restore function', () => { 15 | la(is.fn(snapShotCore.restore), '"restore" should be a function') 16 | }) 17 | 18 | it('counters can be restored to zero', function () { 19 | let called 20 | function raiser () { 21 | called = true 22 | } 23 | const specName = this.test.title 24 | const filename = path.join( 25 | process.cwd(), 26 | '__snapshots__/restore-spec.js.test' 27 | ) 28 | 29 | // first snapshot 30 | snapShotCore.core({ 31 | what: 'A', 32 | __filename, 33 | specName, 34 | ext: snapShotExtension 35 | }) 36 | la(!called, 'custom raiser function was not called') 37 | 38 | // restore snapshot counters 39 | snapShotCore.restore() 40 | 41 | // this would repeat first snapshot (and it should fail) 42 | snapShotCore.core({ 43 | what: 'B', 44 | __filename, 45 | specName, 46 | raiser, 47 | ext: snapShotExtension 48 | }) 49 | 50 | let snapshot = require(filename) 51 | debug('loaded snapshot from %s', filename) 52 | debug(snapshot) 53 | 54 | // TODO expose utility functions that form full snapshot name id:6 55 | // Gleb Bahmutov 56 | // gleb.bahmutov@gmail.com 57 | // https://github.com/bahmutov/snap-shot-core/issues/93 58 | // TODO expose utility functions that wrap values before saving id:7 59 | // Gleb Bahmutov 60 | // gleb.bahmutov@gmail.com 61 | // https://github.com/bahmutov/snap-shot-core/issues/94 62 | const expectedSnapshotName = specName + ' 1' 63 | la( 64 | // string value surrounded by new lines 65 | snapshot[expectedSnapshotName] === '\nA\n', 66 | 'first snapshot should be saved "' + expectedSnapshotName + '"', 67 | 'found snapshots', 68 | snapshot 69 | ) 70 | la( 71 | !snapshot[specName + ' 2'], 72 | 'second snapshot should not be saved "', 73 | specName, 74 | ' 2"' 75 | ) 76 | la(called, 'second snapshot should fail instead') 77 | }) 78 | 79 | it('single counter can be restored to zero', function () { 80 | let called 81 | function raiser () { 82 | called = true 83 | } 84 | const specName = this.test.title 85 | const filename = path.join( 86 | process.cwd(), 87 | '__snapshots__/snap-shot-core-spec.js.test' 88 | ) 89 | 90 | // first snapshot 91 | snapShotCore.core({ 92 | what: 'A', 93 | __filename, 94 | specName, 95 | ext: snapShotExtension 96 | }) 97 | snapShotCore.restore({ 98 | file: __filename, 99 | specName 100 | }) 101 | 102 | // this would repeat first snapshot (and it should fail) 103 | snapShotCore.core({ 104 | what: 'B', 105 | __filename, 106 | specName, 107 | raiser, 108 | ext: snapShotExtension 109 | }) 110 | 111 | let snapshot = require(filename) 112 | la( 113 | snapshot[specName + ' 1'] === '\nA\n', 114 | 'first snapshot should be saved "', 115 | specName, 116 | ' 1"' 117 | ) 118 | la( 119 | !snapshot[specName + ' 2'], 120 | 'second snapshot should not be saved "', 121 | specName, 122 | ' 2"' 123 | ) 124 | la(called, 'second snapshot should fail instead') 125 | }) 126 | }) 127 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const la = require('lazy-ass') 4 | const is = require('check-more-types') 5 | const Result = require('folktale/result') 6 | const jsesc = require('jsesc') 7 | 8 | // TODO: we should also consider the file spec name + test name id:5 9 | // Gleb Bahmutov 10 | // gleb.bahmutov@gmail.com 11 | // https://github.com/bahmutov/snap-shot-core/issues/90 12 | // not just spec name (which is test name here) 13 | function snapshotIndex (options) { 14 | const counters = options.counters 15 | const file = options.file 16 | const specName = options.specName 17 | 18 | la(is.object(counters), 'expected counters', counters) 19 | la(is.unemptyString(specName), 'expected specName', specName) 20 | la(is.unemptyString(file), 'missing filename', file) 21 | 22 | if (!(specName in counters)) { 23 | counters[specName] = 1 24 | } else { 25 | counters[specName] += 1 26 | } 27 | return counters[specName] 28 | } 29 | 30 | // make sure values in the object are "safe" to be serialized 31 | // and compared from loaded value 32 | function strip (o) { 33 | if (is.fn(o)) { 34 | return o 35 | } 36 | return JSON.parse(JSON.stringify(o)) 37 | } 38 | 39 | function compare (options) { 40 | const expected = options.expected 41 | const value = options.value 42 | 43 | const e = JSON.stringify(expected) 44 | const v = JSON.stringify(value) 45 | if (e === v) { 46 | return Result.Ok() 47 | } 48 | return Result.Error(`${e} !== ${v}`) 49 | } 50 | 51 | const sameTypes = (a, b) => typeof expected === typeof value 52 | 53 | const compareTypes = options => { 54 | const expected = options.expected 55 | const value = options.value 56 | return sameTypes(expected, value) ? Result.Ok() : Result.Error('no message') 57 | } 58 | 59 | /** 60 | * Serializes and escapes a string value before saving. 61 | * @param {string} name for the snapshot 62 | * @param {string} value text to be escaped for saving 63 | */ 64 | function exportText (name, value) { 65 | la(is.unemptyString(name), 'expected snapshot name, got:', name) 66 | la(is.string(value), 'expected string value', value) 67 | 68 | // jsesc replace "\n" with "\\n" 69 | // https://github.com/mathiasbynens/jsesc/issues/20 70 | const serialized = value 71 | .split('\n') 72 | .map(line => { 73 | return jsesc(line, { 74 | quotes: 'backtick', 75 | minimal: true 76 | }) 77 | }) 78 | .join('\n') 79 | const withNewLines = '\n' + serialized + '\n' 80 | return `exports['${name}'] = \`${withNewLines}\`\n` 81 | } 82 | 83 | /** 84 | * Escapes properties of an object to be safe for saving 85 | */ 86 | function exportObject (name, value) { 87 | const serialized = jsesc(value, { 88 | json: true, 89 | compact: false, 90 | indent: ' ', 91 | minimal: true 92 | }) 93 | return `exports['${name}'] = ${serialized}\n` 94 | } 95 | 96 | const isSurroundedByNewLines = s => 97 | is.string(s) && s.length > 1 && s[0] === '\n' && s[s.length - 1] === '\n' 98 | 99 | // when we save string snapshots we add extra new lines to 100 | // avoid long first lines 101 | // when loading snapshots we should remove these new lines 102 | // from string properties 103 | function removeExtraNewLines (snapshots) { 104 | Object.keys(snapshots).forEach(key => { 105 | const value = snapshots[key] 106 | if (isSurroundedByNewLines(value)) { 107 | snapshots[key] = value.substr(1, value.length - 2) 108 | } 109 | }) 110 | return snapshots 111 | } 112 | 113 | const DEFAULT_EXTENSION = '.snapshot.js' 114 | 115 | module.exports = { 116 | snapshotIndex, 117 | strip, 118 | compare, 119 | sameTypes, 120 | compareTypes, 121 | exportText, 122 | exportObject, 123 | removeExtraNewLines, 124 | DEFAULT_EXTENSION 125 | } 126 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "snap-shot-core", 3 | "description": "Save / load named snapshots, useful for tests", 4 | "version": "0.0.0-development", 5 | "author": "Gleb Bahmutov ", 6 | "bugs": "https://github.com/bahmutov/snap-shot-core/issues", 7 | "config": { 8 | "pre-git": { 9 | "commit-msg": "simple", 10 | "pre-commit": [ 11 | "npm prune", 12 | "npm run deps", 13 | "npm test", 14 | "echo Running unit tests again to check file load", 15 | "npm test", 16 | "echo Running tests again with CI flag", 17 | "CI=1 npm t", 18 | "npm run ban", 19 | "npm run stop-only -- --warn" 20 | ], 21 | "pre-push": [ 22 | "npm run stop-only", 23 | "npm run license", 24 | "npm run ban -- --all", 25 | "echo checking if package lock has been updated by running npm ci command", 26 | "npm ci", 27 | "npm run size" 28 | ], 29 | "post-commit": [], 30 | "post-merge": [] 31 | }, 32 | "next-update": { 33 | "commands": { 34 | "deps-ok": "npm run deps", 35 | "dependency-check": "npm run deps", 36 | "license-checker": "npm run license", 37 | "git-issues": "npm run issues" 38 | } 39 | } 40 | }, 41 | "engines": { 42 | "node": ">=6" 43 | }, 44 | "bin": { 45 | "resave-snapshots": "./bin/resave-snapshots.js" 46 | }, 47 | "files": [ 48 | "bin/*.js", 49 | "!bin/*-spec.js", 50 | "src/*.js", 51 | "!src/*-spec.js" 52 | ], 53 | "homepage": "https://github.com/bahmutov/snap-shot-core#readme", 54 | "keywords": [ 55 | "snapshot", 56 | "test", 57 | "testing" 58 | ], 59 | "license": "MIT", 60 | "main": "src/", 61 | "publishConfig": { 62 | "registry": "http://registry.npmjs.org/" 63 | }, 64 | "repository": { 65 | "type": "git", 66 | "url": "https://github.com/bahmutov/snap-shot-core.git" 67 | }, 68 | "scripts": { 69 | "ban": "ban", 70 | "deps": "deps-ok && dependency-check .", 71 | "issues": "git-issues", 72 | "license": "license-checker --production --onlyunknown --csv", 73 | "lint": "standard --verbose --fix 'src/*.js' 'bin/*.js'", 74 | "pretest": "npm run lint", 75 | "size": "t=\"$(npm pack .)\"; wc -c \"${t}\"; tar tvf \"${t}\"; rm \"${t}\";", 76 | "test": "npm run unit", 77 | "unit": "mocha 'src/*-spec.js' 'bin/*-spec.js'", 78 | "semantic-release": "semantic-release", 79 | "stop-only": "stop-only --folder src" 80 | }, 81 | "release": { 82 | "analyzeCommits": { 83 | "preset": "angular", 84 | "releaseRules": [ 85 | { 86 | "type": "break", 87 | "release": "major" 88 | } 89 | ] 90 | } 91 | }, 92 | "devDependencies": { 93 | "@octokit/request-error": "1.2.1", 94 | "ban-sensitive-files": "1.9.15", 95 | "chdir-promise": "0.6.2", 96 | "dependency-check": "3.4.1", 97 | "deps-ok": "1.4.1", 98 | "disparity": "2.0.0", 99 | "execa": "1.0.0", 100 | "execa-wrap": "1.4.0", 101 | "git-issues": "1.3.1", 102 | "license-checker": "25.0.1", 103 | "mocha": "6.2.3", 104 | "mocked-env": "1.3.2", 105 | "pre-git": "3.17.1", 106 | "semantic-release": "15.14.0", 107 | "shelljs": "0.8.4", 108 | "simple-commit-message": "4.1.1", 109 | "sinon": "7.5.0", 110 | "snap-shot-it": "6.3.5", 111 | "standard": "12.0.1", 112 | "stop-only": "2.2.5" 113 | }, 114 | "dependencies": { 115 | "arg": "4.1.3", 116 | "check-more-types": "2.24.0", 117 | "common-tags": "1.8.0", 118 | "debug": "4.3.1", 119 | "escape-quotes": "1.0.2", 120 | "folktale": "2.3.2", 121 | "is-ci": "2.0.0", 122 | "jsesc": "2.5.2", 123 | "lazy-ass": "1.6.0", 124 | "mkdirp": "1.0.4", 125 | "pluralize": "8.0.0", 126 | "quote": "0.4.0", 127 | "ramda": "0.27.1" 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/file-system-spec.js: -------------------------------------------------------------------------------- 1 | const fileSystem = require('./file-system') 2 | const utils = require('./utils') 3 | const fs = require('fs') 4 | const la = require('lazy-ass') 5 | const is = require('check-more-types') 6 | const sinon = require('sinon') 7 | const mkdirp = require('mkdirp') 8 | const R = require('ramda') 9 | const chdir = require('chdir-promise') 10 | 11 | /* eslint-env mocha */ 12 | describe('file system', () => { 13 | context('joinSnapshotsFolder', () => { 14 | const joinSnapshotsFolder = fileSystem.joinSnapshotsFolder 15 | const cwd = process.cwd() 16 | 17 | it('returns relative path', () => { 18 | const resolved = joinSnapshotsFolder('foo/bar') 19 | la(resolved.startsWith(cwd), resolved) 20 | la( 21 | resolved.endsWith(fileSystem.snapshotsFolderName + '/foo/bar'), 22 | resolved 23 | ) 24 | }) 25 | }) 26 | 27 | describe('prepareFragments', () => { 28 | const prepareFragments = fileSystem.prepareFragments 29 | 30 | it('returns fragments alphabetically', () => { 31 | const snapshots = { 32 | x: 'value of x', 33 | b: 'value of b', 34 | a: 'value of a' 35 | } 36 | const fragments = prepareFragments(snapshots) 37 | const expected = [ 38 | "exports['a'] = `\nvalue of a\n`\n", 39 | "exports['b'] = `\nvalue of b\n`\n", 40 | "exports['x'] = `\nvalue of x\n`\n" 41 | ] 42 | la( 43 | R.equals(fragments)(expected), 44 | 'expected value', 45 | expected, 46 | 'actual fragments', 47 | fragments 48 | ) 49 | }) 50 | 51 | it('returns fragments unsorted', () => { 52 | const snapshots = { 53 | x: 'value of x', 54 | b: 'value of b', 55 | a: 'value of a' 56 | } 57 | const fragments = prepareFragments(snapshots, { sortSnapshots: false }) 58 | // expect the original order 59 | const expected = [ 60 | "exports['x'] = `\nvalue of x\n`\n", 61 | "exports['b'] = `\nvalue of b\n`\n", 62 | "exports['a'] = `\nvalue of a\n`\n" 63 | ] 64 | la( 65 | R.equals(fragments)(expected), 66 | 'expected value', 67 | expected, 68 | 'actual fragments', 69 | fragments 70 | ) 71 | }) 72 | }) 73 | 74 | describe('saveSnapshots', () => { 75 | const saveSnapshots = fileSystem.saveSnapshots 76 | 77 | it('is a function', () => { 78 | la(is.fn(saveSnapshots)) 79 | }) 80 | 81 | it('puts new lines around text snapshots', () => { 82 | sinon.stub(fs, 'writeFileSync') 83 | sinon.stub(mkdirp, 'sync') 84 | 85 | const snapshots = { 86 | test: 'line 1\nline 2' 87 | } 88 | const text = saveSnapshots('./foo-spec.js', snapshots, '.js') 89 | fs.writeFileSync.restore() 90 | mkdirp.sync.restore() 91 | const expected = "exports['test'] = `\nline 1\nline 2\n`\n" 92 | la( 93 | text === expected, 94 | 'should add newlines around text snapshot\n' + 95 | text + 96 | '\nexpected\n' + 97 | expected 98 | ) 99 | }) 100 | }) 101 | 102 | describe('fileForSpec', () => { 103 | const fileForSpec = fileSystem.fileForSpec 104 | 105 | it('adds different extension', () => { 106 | const result = fileForSpec('foo.coffee', '.js') 107 | la(result.endsWith('foo.coffee.js'), result) 108 | }) 109 | 110 | it('does not add .js twice', () => { 111 | const result = fileForSpec('foo.js', '.js') 112 | la(result.endsWith('foo.js'), result) 113 | }) 114 | 115 | it('returns same filename even if current working directory changes', () => { 116 | const fromCurrent = fileForSpec('foo.js', '.js') 117 | return chdir 118 | .to('..') 119 | .then(() => { 120 | const fromParent = fileForSpec('foo.js', '.js') 121 | la( 122 | fromCurrent === fromParent, 123 | 'from current directory', 124 | fromCurrent, 125 | 'is different from the parent', 126 | fromParent 127 | ) 128 | }) 129 | .finally(chdir.back) 130 | }) 131 | 132 | it('returns relative path when true in options', () => { 133 | const result = fileForSpec('test/file/foo.js', '.js', { 134 | useRelativePath: true 135 | }) 136 | la(result.endsWith('__snapshots__/test/file/foo.js'), result) 137 | }) 138 | }) 139 | 140 | describe('error message', () => { 141 | it('includes snapshot name', () => { 142 | const specName = 'foo-bar 1' 143 | 144 | la( 145 | is.raises( 146 | () => { 147 | fileSystem.raiseIfDifferent({ 148 | value: 42, 149 | expected: 41, 150 | specName, 151 | compare: utils.compare 152 | }) 153 | }, 154 | err => { 155 | return err.message.includes(specName) 156 | } 157 | ) 158 | ) 159 | }) 160 | }) 161 | }) 162 | -------------------------------------------------------------------------------- /src/nested-spec.js: -------------------------------------------------------------------------------- 1 | const shell = require('shelljs') 2 | const { join } = require('path') 3 | const la = require('lazy-ass') 4 | const fs = require('fs') 5 | const R = require('ramda') 6 | const execa = require('execa') 7 | 8 | /* eslint-env mocha */ 9 | /** 10 | * Checks a given folder against expected snapshots. 11 | * Pass a root folder without `__snapshots__` part, 12 | * and an object containing as keys 13 | * just names of the expected snapshot files. For value, 14 | * use object of exports in that snapshot 15 | * 16 | * @example 17 | ``` 18 | checkSnapshots(tempFolder, { 19 | 'spec.js.snapshot.js': { 20 | 'a 1': 42 21 | }, 22 | 'spec2.js.snapshot.js': { 23 | 'b 1': 42 24 | } 25 | }) 26 | ``` 27 | */ 28 | const checkSnapshots = (rootFolder, snapshots) => { 29 | const snapshotsFolder = join(rootFolder, '__snapshots__') 30 | la( 31 | fs.existsSync(snapshotsFolder), 32 | 'cannot find snapshots folder', 33 | snapshotsFolder 34 | ) 35 | // TODO check if the snapshots folder does not have extra files id:15 36 | // - 37 | // Gleb Bahmutov 38 | // gleb.bahmutov@gmail.com 39 | Object.keys(snapshots).forEach(expectedFilename => { 40 | const filename = join(snapshotsFolder, expectedFilename) 41 | la( 42 | fs.existsSync(filename), 43 | 'cannot find expected snapshot', 44 | expectedFilename, 45 | 'as file', 46 | filename 47 | ) 48 | const loaded = require(filename) 49 | const expected = snapshots[expectedFilename] 50 | la( 51 | R.equals(loaded, expected), 52 | 'in snapshot file', 53 | filename, 54 | 'a different value loaded', 55 | loaded, 56 | 'from expected', 57 | expected 58 | ) 59 | }) 60 | } 61 | 62 | /** 63 | * if we are running this test on CI, we cannot save new snapshots 64 | * so for this particular test we need to clear CI=1 value 65 | * we remove any of the env variables used by "ci-info" module to detect 66 | * that it is running on CI 67 | * @example 68 | ```js 69 | execa.shellSync('npm test', { 70 | stdio: 'inherit', 71 | env: limitedEnv, 72 | extendEnv: false 73 | }) 74 | ``` 75 | */ 76 | const limitedEnv = R.omit( 77 | ['CI', 'CONTINUOUS_INTEGRATION', 'BUILD_NUMBER', 'RUN_ID', 'TRAVIS'], 78 | process.env 79 | ) 80 | 81 | /** 82 | * Copies "package.json" file and "specs" folder from source folder 83 | * to newly recreated temp folder. 84 | */ 85 | const copyFolder = (sourceFolder, tempFolder) => { 86 | shell.rm('-rf', tempFolder) 87 | shell.mkdir(tempFolder) 88 | shell.cp(join(sourceFolder, 'package.json'), tempFolder) 89 | shell.cp('-R', join(sourceFolder, 'specs'), tempFolder) 90 | } 91 | 92 | describe('nested specs', () => { 93 | // test how the snapshots are saved in flat structure 94 | 95 | // folder with specs to run 96 | const sourceFolder = join(__dirname, '..', 'test-nested-specs') 97 | // temp folder to copy to before running tests 98 | const tempFolder = join(__dirname, '..', 'temp-test-nested-specs') 99 | 100 | beforeEach(() => { 101 | copyFolder(sourceFolder, tempFolder) 102 | }) 103 | 104 | it('saves snapshots in single folder', function () { 105 | this.timeout(5000) 106 | 107 | execa.shellSync('npm test', { 108 | cwd: tempFolder, 109 | stdio: 'inherit', 110 | // only use the limited environment keys 111 | // without "CI=1" value 112 | env: limitedEnv, 113 | extendEnv: false 114 | }) 115 | 116 | checkSnapshots(tempFolder, { 117 | 'spec.js.snapshot.js': { 118 | 'a 1': 42 119 | }, 120 | 'spec2.js.snapshot.js': { 121 | 'b 1': 42 122 | } 123 | }) 124 | 125 | // run the tests again to check if values are not clashing 126 | // but with CI=1 to avoid writing new files accidentally 127 | execa.shellSync('npm test', { 128 | cwd: tempFolder, 129 | stdio: 'inherit', 130 | env: { CI: '1' } 131 | }) 132 | }) 133 | }) 134 | 135 | describe('subfolders', () => { 136 | // snapshots are saved in subfolders like the spec files 137 | 138 | // folder with specs to run 139 | const sourceFolder = join(__dirname, '..', 'test-subfolders-specs') 140 | // temp folder to copy to before running tests 141 | const tempFolder = join(__dirname, '..', 'temp-test-subfolders-specs') 142 | 143 | beforeEach(() => { 144 | copyFolder(sourceFolder, tempFolder) 145 | }) 146 | 147 | it('saves snapshots in subfolders', function () { 148 | this.timeout(5000) 149 | 150 | execa.shellSync('npm test', { 151 | cwd: tempFolder, 152 | stdio: 'inherit', 153 | // only use the limited environment keys 154 | // without "CI=1" value 155 | env: limitedEnv, 156 | extendEnv: false 157 | }) 158 | 159 | checkSnapshots(tempFolder, { 160 | 'specs/spec.js.snapshot.js': { 161 | 'a 1': 42 162 | }, 163 | 'specs/subfolder/spec2.js.snapshot.js': { 164 | 'b 1': 42 165 | } 166 | }) 167 | 168 | // run the tests again to check if values are not clashing 169 | // but with CI=1 to avoid writing new files accidentally 170 | execa.shellSync('npm test', { 171 | cwd: tempFolder, 172 | stdio: 'inherit', 173 | env: { CI: '1' } 174 | }) 175 | }) 176 | }) 177 | -------------------------------------------------------------------------------- /src/utils-spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const la = require('lazy-ass') 4 | const is = require('check-more-types') 5 | const strip = require('./utils').strip 6 | const Result = require('folktale/result') 7 | const snapshot = require('snap-shot-it') 8 | const { stripIndent } = require('common-tags') 9 | const jsesc = require('jsesc') 10 | 11 | const compareText = (expected, formatted) => { 12 | la( 13 | formatted === expected, 14 | 'expected\n' + expected + '\ngot\n' + formatted + '\nend' 15 | ) 16 | } 17 | 18 | /* global describe, it */ 19 | describe('utils', () => { 20 | it('is a function', () => { 21 | la(is.fn(strip)) 22 | }) 23 | 24 | it('handles objects with methods', () => { 25 | const out = strip({ 26 | foo: 'bar', 27 | fn: () => 'nothing' 28 | }) 29 | la(is.object(out), 'returns an object', out) 30 | la(!out.fn, 'method has been removed', out) 31 | }) 32 | 33 | it('passes a function as is', () => { 34 | const fn = () => 'nothing' 35 | const out = strip(fn) 36 | la(out === fn) 37 | }) 38 | }) 39 | 40 | describe('compare', () => { 41 | const compare = require('./utils').compare 42 | 43 | it('returns Result', () => { 44 | const expected = 'foo' 45 | const value = 'foo' 46 | const r = compare({ expected, value }) 47 | la(Result.hasInstance(r)) 48 | }) 49 | 50 | it('passes identical values', () => { 51 | const expected = 'foo' 52 | const value = 'foo' 53 | const r = compare({ expected, value }) 54 | la(r.value === undefined) 55 | }) 56 | 57 | it('has error text', () => { 58 | const expected = 'foo' 59 | const value = 'bar' 60 | const r = compare({ expected, value }) 61 | la(r.value === '"foo" !== "bar"') 62 | }) 63 | 64 | it('has error (snapshot)', () => { 65 | const expected = 'foo' 66 | const value = 'bar' 67 | snapshot(compare({ expected, value })) 68 | }) 69 | 70 | const raise = () => { 71 | throw new Error('Cannot reach this') 72 | } 73 | it('snapshots error value', () => { 74 | const expected = 'foo' 75 | const value = 'bar' 76 | compare({ expected, value }) 77 | .map(raise) 78 | .orElse(snapshot) 79 | }) 80 | }) 81 | 82 | describe('exportObject', () => { 83 | const exportObject = require('./utils').exportObject 84 | 85 | it('is a function', () => { 86 | la(is.fn(exportObject)) 87 | }) 88 | 89 | it('does not escape emoji values', () => { 90 | const formatted = exportObject('name', { 91 | foo: '😁' 92 | }) 93 | const expected = stripIndent` 94 | exports['name'] = { 95 | "foo": "😁" 96 | } 97 | ` 98 | la( 99 | formatted === expected + '\n', 100 | 'expected\n' + expected + '\ngot\n' + formatted + '\nend' 101 | ) 102 | }) 103 | 104 | it('does not escape emoji keys', () => { 105 | const formatted = exportObject('name', { 106 | '🍕': '😁' 107 | }) 108 | const expected = stripIndent` 109 | exports['name'] = { 110 | "🍕": "😁" 111 | } 112 | ` 113 | la( 114 | formatted === expected + '\n', 115 | 'expected\n' + expected + '\ngot\n' + formatted + '\nend' 116 | ) 117 | }) 118 | }) 119 | 120 | describe('jsesc', () => { 121 | const options = { 122 | quotes: 'backtick', 123 | minimal: true 124 | } 125 | 126 | it('normal text', () => { 127 | const text = 'foo' 128 | const escaped = jsesc(text, options) 129 | const expected = 'foo' 130 | compareText(expected, escaped) 131 | }) 132 | 133 | it('escapes backticks', () => { 134 | const text = 'foo `bar` baz' 135 | const escaped = jsesc(text, options) 136 | const expected = 'foo \\`bar\\` baz' 137 | compareText(expected, escaped) 138 | }) 139 | 140 | it('escapes backticks around number', () => { 141 | const text = 'foo `100` baz' 142 | const escaped = jsesc(text, options) 143 | const expected = 'foo \\`100\\` baz' 144 | compareText(expected, escaped) 145 | }) 146 | 147 | it('escapes backticks around emoji', () => { 148 | const text = 'foo `⭐️` baz' 149 | const escaped = jsesc(text, options) 150 | const expected = 'foo \\`⭐️\\` baz' 151 | compareText(expected, escaped) 152 | }) 153 | }) 154 | 155 | describe('exportText', () => { 156 | const exportText = require('./utils').exportText 157 | 158 | it('is a function', () => { 159 | la(is.fn(exportText)) 160 | }) 161 | 162 | it('works properly with empty strings', () => { 163 | const formatted = exportText('name', '') 164 | const expected = "exports['name'] = `\n\n`\n" 165 | la(formatted === expected, 'expected\n' + expected + '\ngot\n' + formatted) 166 | }) 167 | 168 | it('does escape backtick on the text', () => { 169 | const formatted = exportText('name', '`code`') 170 | const expected = "exports['name'] = `\n\\`code\\`\n`\n" 171 | la(formatted === expected, 'expected\n' + expected + '\ngot\n' + formatted) 172 | }) 173 | 174 | it('does escape template variable on the text', () => { 175 | /* eslint-disable no-template-curly-in-string */ 176 | const formatted = exportText('name', '`${1}`') 177 | const expected = "exports['name'] = `\n\\`\\${1}\\`\n`\n" 178 | la(formatted === expected, 'expected\n' + expected + '\ngot\n' + formatted) 179 | /* eslint-enable no-template-curly-in-string */ 180 | }) 181 | 182 | it('does not replace \\n with \n on the text', () => { 183 | const formatted = exportText('name', 'escaped \\n') 184 | const expected = "exports['name'] = `\nescaped \\\\n\n`\n" 185 | la(formatted === expected, 'expected\n' + expected + '\ngot\n' + formatted) 186 | }) 187 | 188 | it('does not put value on the first line', () => { 189 | const formatted = exportText('name', 'foo') 190 | const expected = "exports['name'] = `\nfoo\n`\n" 191 | la(formatted === expected, 'expected\n' + expected + '\ngot\n' + formatted) 192 | }) 193 | 194 | it('does not escape unicode emoji', () => { 195 | const formatted = exportText('reaction', '😁') 196 | const expected = "exports['reaction'] = `\n😁\n`\n" 197 | la(formatted === expected, 'expected\n' + expected + '\ngot\n' + formatted) 198 | }) 199 | 200 | it('does not escape ascii art', () => { 201 | const text = stripIndent` 202 | ============================= 203 | (Run Finished) 204 | 205 | Spec Tests Passing Failing Pending Skipped 206 | ┌─────────────────────────────────────────────────────────────────────────────────────┐ 207 | │ ✔ simple_passing_spec.coffee XX:XX 1 1 - - - │ 208 | └─────────────────────────────────────────────────────────────────────────────────────┘ 209 | All specs passed! XX:XX 1 1 - - - 210 | ` 211 | const formatted = exportText('ascii art', text) 212 | const expected = "exports['ascii art'] = `\n" + text + '\n`\n' 213 | la( 214 | formatted === expected, 215 | 'expected\n' + expected + '\ngot\n' + formatted + '\nend' 216 | ) 217 | }) 218 | }) 219 | 220 | describe('removeExtraNewLines', () => { 221 | const removeExtraNewLines = require('./utils').removeExtraNewLines 222 | 223 | it('is a function', () => { 224 | la(is.fn(removeExtraNewLines)) 225 | }) 226 | 227 | it('leaves other values unchanged', () => { 228 | const snapshots = { 229 | foo: 'bar', 230 | age: 42 231 | } 232 | const result = removeExtraNewLines(snapshots) 233 | snapshot(result) 234 | }) 235 | 236 | it('removes new lines', () => { 237 | const snapshots = { 238 | foo: '\nbar\n', 239 | age: 42 240 | } 241 | const result = removeExtraNewLines(snapshots) 242 | snapshot(result) 243 | }) 244 | }) 245 | -------------------------------------------------------------------------------- /src/file-system.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fs = require('fs') 4 | const path = require('path') 5 | const debug = require('debug')('snap-shot-core') 6 | const verbose = require('debug')('snap-shot-core:verbose') 7 | const la = require('lazy-ass') 8 | const is = require('check-more-types') 9 | const mkdirp = require('mkdirp') 10 | const vm = require('vm') 11 | const escapeQuotes = require('escape-quotes') 12 | const pluralize = require('pluralize') 13 | 14 | const removeExtraNewLines = require('./utils').removeExtraNewLines 15 | const exportText = require('./utils').exportText 16 | const exportObject = require('./utils').exportObject 17 | 18 | /** 19 | * Saved original process current working directory (absolute path). 20 | * We want to save it right away, because during testing CWD often changes, 21 | * and we don't want the snapshots to randomly "jump" around and be 22 | * saved in an unexpected location. 23 | */ 24 | const cwd = process.cwd() 25 | /** 26 | * Returns a relative path to the original working directory. 27 | */ 28 | const fromCurrentFolder = path.relative.bind(null, cwd) 29 | const snapshotsFolderName = '__snapshots__' 30 | /** 31 | * Given relative path, returns same relative path, but inside 32 | * the snapshots folder. 33 | * @example 34 | * joinSnapshotsFolder('foo/bar') 35 | * // CWD/__snapshots__/foo/bar 36 | */ 37 | const joinSnapshotsFolder = path.join.bind(null, cwd, snapshotsFolderName) 38 | 39 | // TODO: expose the name of the snapshots folder to the outside world id:16 40 | // - 41 | // Gleb Bahmutov 42 | // gleb.bahmutov@gmail.com 43 | const snapshotsFolder = fromCurrentFolder(snapshotsFolderName) 44 | debug('process cwd: %s', cwd) 45 | debug('snapshots folder: %s', snapshotsFolder) 46 | 47 | /** 48 | * Changes from relative path to absolute filename with respect to the 49 | * _original working directory_. Always use this function instead of 50 | * `path.resolve(filename)` because `path.resolve` will be affected 51 | * by the _current_ working directory at the moment of resolution, and 52 | * we want to form snapshot filenames wrt to the original starting 53 | * working directory. 54 | */ 55 | const resolveToCwd = path.resolve.bind(null, cwd) 56 | 57 | const isSaveOptions = is.schema({ 58 | sortSnapshots: is.bool 59 | }) 60 | 61 | const isLoadOptions = is.schema({ 62 | useRelativePath: is.bool 63 | }) 64 | 65 | function getSnapshotsFolder (specFile, opts = { useRelativePath: false }) { 66 | if (!opts.useRelativePath) { 67 | // all snapshots go into the same folder 68 | return snapshotsFolder 69 | } 70 | 71 | const relativeDir = fromCurrentFolder(path.dirname(specFile)) 72 | verbose('relative path to spec file %s is %s', specFile, relativeDir) 73 | 74 | // return path.join(resolveToCwd(relativeDir), '__snapshots__') 75 | const folder = joinSnapshotsFolder(relativeDir) 76 | verbose('snapshot folder %s', folder) 77 | 78 | return folder 79 | } 80 | 81 | function loadSnaps (snapshotPath) { 82 | const full = require.resolve(snapshotPath) 83 | if (!fs.existsSync(snapshotPath)) { 84 | return {} 85 | } 86 | 87 | const sandbox = { 88 | exports: {} 89 | } 90 | const source = fs.readFileSync(full, 'utf8') 91 | try { 92 | vm.runInNewContext(source, sandbox) 93 | return removeExtraNewLines(sandbox.exports) 94 | } catch (e) { 95 | console.error('Could not load file', full) 96 | console.error(source) 97 | console.error(e) 98 | if (e instanceof SyntaxError) { 99 | throw e 100 | } 101 | return {} 102 | } 103 | } 104 | 105 | function fileForSpec (specFile, ext, opts = { useRelativePath: false }) { 106 | la(is.unemptyString(specFile), 'missing spec file', specFile) 107 | la(is.maybe.string(ext), 'invalid extension to find', ext) 108 | la(isLoadOptions(opts), 'expected fileForSpec options', opts) 109 | 110 | const specName = path.basename(specFile) 111 | la( 112 | is.unemptyString(specName), 113 | 'could not get spec name from spec file', 114 | specFile 115 | ) 116 | 117 | const snapshotFolder = getSnapshotsFolder(specFile, opts) 118 | 119 | verbose( 120 | 'spec file "%s" has name "%s" and snapshot folder %s', 121 | specFile, 122 | specName, 123 | snapshotFolder 124 | ) 125 | 126 | let filename = path.join(snapshotFolder, specName) 127 | if (ext) { 128 | if (!filename.endsWith(ext)) { 129 | filename += ext 130 | } 131 | } 132 | verbose('formed filename %s', filename) 133 | const fullName = resolveToCwd(filename) 134 | verbose('full resolved name %s', fullName) 135 | 136 | return fullName 137 | } 138 | 139 | function loadSnapshotsFrom (filename) { 140 | la(is.unemptyString(filename), 'missing snapshots filename', filename) 141 | 142 | debug('loading snapshots from %s', filename) 143 | let snapshots = {} 144 | if (fs.existsSync(filename)) { 145 | snapshots = loadSnaps(filename) 146 | } else { 147 | debug('could not find snapshots file %s', filename) 148 | } 149 | return snapshots 150 | } 151 | 152 | function loadSnapshots (specFile, ext, opts = { useRelativePath: false }) { 153 | la(is.unemptyString(specFile), 'missing specFile name', specFile) 154 | la(isLoadOptions(opts), 'expected loadSnapshots options', opts) 155 | 156 | const filename = fileForSpec(specFile, ext, opts) 157 | verbose('from spec %s got snap filename %s', specFile, filename) 158 | return loadSnapshotsFrom(filename) 159 | } 160 | 161 | function prepareFragments (snapshots, opts = { sortSnapshots: true }) { 162 | la(isSaveOptions(opts), 'expected prepare fragments options', opts) 163 | 164 | const keys = Object.keys(snapshots) 165 | debug( 166 | 'prepare %s, sorted? %d', 167 | pluralize('snapshot', keys.length, true), 168 | opts.sortSnapshots 169 | ) 170 | const names = opts.sortSnapshots ? keys.sort() : keys 171 | 172 | const fragments = names.map(testName => { 173 | debug(`snapshot fragment name "${testName}"`) 174 | const value = snapshots[testName] 175 | const escapedName = escapeQuotes(testName) 176 | return is.string(value) 177 | ? exportText(escapedName, value) 178 | : exportObject(escapedName, value) 179 | }) 180 | 181 | return fragments 182 | } 183 | 184 | function maybeSortAndSave (snapshots, filename, opts = { sortSnapshots: true }) { 185 | const fragments = prepareFragments(snapshots, opts) 186 | debug('have %s', pluralize('fragment', fragments.length, true)) 187 | 188 | const s = fragments.join('\n') 189 | fs.writeFileSync(filename, s, 'utf8') 190 | return s 191 | } 192 | 193 | // returns snapshot text 194 | function saveSnapshots ( 195 | specFile, 196 | snapshots, 197 | ext, 198 | opts = { sortSnapshots: true, useRelativePath: false } 199 | ) { 200 | la( 201 | isSaveOptions(opts) && isLoadOptions(opts), 202 | 'expected save snapshots options', 203 | opts 204 | ) 205 | 206 | const snapshotsFolder = getSnapshotsFolder(specFile, opts) 207 | debug('for spec file %s', specFile) 208 | debug('making folder "%s" for snapshot if does not exist', snapshotsFolder) 209 | 210 | mkdirp.sync(snapshotsFolder) 211 | const filename = fileForSpec(specFile, ext, opts) 212 | const specRelativeName = fromCurrentFolder(specFile) 213 | debug('saving snapshots into %s for %s', filename, specRelativeName) 214 | debug('snapshots are') 215 | debug(snapshots) 216 | debug('saveSnapshots options %o', opts) 217 | 218 | return maybeSortAndSave(snapshots, filename, opts) 219 | } 220 | 221 | const isValidCompareResult = is.schema({ 222 | orElse: is.fn 223 | }) 224 | 225 | /** 226 | * Throws error if two values are different. 227 | * 228 | * value - what the test computed right now 229 | * expected - existing value loaded from snapshot 230 | */ 231 | function raiseIfDifferent (options) { 232 | options = options || {} 233 | 234 | const value = options.value 235 | const expected = options.expected 236 | const specName = options.specName 237 | const compare = options.compare 238 | 239 | la(value, 'missing value to compare', value) 240 | la(expected, 'missing expected value', expected) 241 | la(is.unemptyString(specName), 'missing spec name', specName) 242 | 243 | const result = compare({ expected, value }) 244 | la( 245 | isValidCompareResult(result), 246 | 'invalid compare result', 247 | result, 248 | 'when comparing value\n', 249 | value, 250 | 'with expected\n', 251 | expected 252 | ) 253 | 254 | result.orElse(message => { 255 | debug('Test "%s" snapshot difference', specName) 256 | la(is.unemptyString(message), 'missing err string', message) 257 | 258 | const fullMessage = `Different value of snapshot "${specName}"\n${message}` 259 | 260 | // QUESTION should we print the error message by default? 261 | console.error(fullMessage) 262 | 263 | throw new Error(fullMessage) 264 | }) 265 | } 266 | 267 | module.exports = { 268 | readFileSync: fs.readFileSync, 269 | fromCurrentFolder, 270 | loadSnapshots, 271 | loadSnapshotsFrom, 272 | saveSnapshots, 273 | maybeSortAndSave, 274 | raiseIfDifferent, 275 | fileForSpec, 276 | exportText, 277 | prepareFragments, 278 | joinSnapshotsFolder, 279 | snapshotsFolderName 280 | } 281 | -------------------------------------------------------------------------------- /src/snap-shot-core-spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const la = require('lazy-ass') 4 | const is = require('check-more-types') 5 | const fs = require('fs') 6 | const path = require('path') 7 | const utils = require('./utils') 8 | const { stripIndent } = require('common-tags') 9 | const debug = require('debug')('test') 10 | 11 | const opts = { 12 | show: Boolean(process.env.SHOW), 13 | dryRun: Boolean(process.env.DRY), 14 | update: Boolean(process.env.UPDATE), 15 | ci: Boolean(process.env.CI) 16 | } 17 | 18 | const file = __filename 19 | const snapShotExtension = '.test' 20 | 21 | /* eslint-env mocha */ 22 | describe('snap-shot-core', () => { 23 | const snapShotCore = require('.') 24 | 25 | it('exports a top level object', () => { 26 | la(is.object(snapShotCore)) 27 | }) 28 | 29 | it('is a function', () => { 30 | la(is.fn(snapShotCore.core)) 31 | }) 32 | 33 | it('can save without increment the exact snapshot name', () => { 34 | snapShotCore.core({ 35 | what: 43, 36 | file, 37 | exactSpecName: 'this should not be incremented', 38 | compare: utils.compare, 39 | ext: snapShotExtension, 40 | opts 41 | }) 42 | }) 43 | 44 | it('handles single quote in the name', () => { 45 | snapShotCore.core({ 46 | what: 42, 47 | file, 48 | specName: "has single quote -> ' <-", 49 | compare: utils.compare, 50 | ext: snapShotExtension, 51 | opts 52 | }) 53 | }) 54 | 55 | it('stores comment', () => { 56 | snapShotCore.core({ 57 | what: 42, 58 | file, 59 | specName: 'stores comment', 60 | opts, 61 | comment: 'this is a comment' 62 | }) 63 | }) 64 | 65 | it('saves snapshot object', () => { 66 | const what = { 67 | foo: 'bar' 68 | } 69 | const out = snapShotCore.core({ 70 | what, 71 | file, 72 | specName: 'my test', 73 | compare: utils.compare, 74 | ext: snapShotExtension, 75 | opts 76 | }) 77 | la(out.value, 'returned object should have value', out) 78 | la(out.key, 'returned object should have key', out) 79 | 80 | la(out.value !== what, 'returns new reference') 81 | la(out.value.foo === what.foo, 'different values', out) 82 | la(out.key === 'my test 1', 'wrong key', out) 83 | 84 | const filename = path.join( 85 | process.cwd(), 86 | '__snapshots__/snap-shot-core-spec.js.test' 87 | ) 88 | la(fs.existsSync(filename), 'cannot find saved file', filename) 89 | }) 90 | 91 | it('can store derived value', function () { 92 | const specName = this.test.title 93 | la(is.unemptyString(specName), 'could not get name from', this.test) 94 | const store = x => 2 * x 95 | 96 | const what = 40 97 | const out = snapShotCore.core({ 98 | what, 99 | file, 100 | specName, 101 | store, 102 | compare: utils.compareTypes, 103 | ext: snapShotExtension, 104 | opts 105 | }) 106 | la(out.value === what * 2, 'expected saved value', out) 107 | la(out.key === specName + ' 1', 'wrong key', out) 108 | }) 109 | 110 | it('typeof example', function () { 111 | const specName = this.test.title 112 | const store = x => typeof x 113 | // let us try snapshotting a function 114 | // but we only care about the "type" of the value 115 | const what = () => 'noop' 116 | const out = snapShotCore.core({ 117 | what, 118 | file, 119 | specName, 120 | store, 121 | compare: utils.compareTypes, 122 | ext: snapShotExtension, 123 | opts 124 | }) 125 | la(out.value === 'function', 'expected type', out) 126 | la(out.key === specName + ' 1', 'expected type', out) 127 | }) 128 | 129 | it('exact snapshot name', function () { 130 | const out = snapShotCore.core({ 131 | what: 42, 132 | file, 133 | exactSpecName: 'custom name', 134 | specName: 'foo', 135 | ext: snapShotExtension, 136 | opts 137 | }) 138 | la(out.value === 42, 'unexpected value', out) 139 | la(out.key === 'custom name', 'unexpected key', out) 140 | }) 141 | 142 | it('CI does not allow saving', function () { 143 | const what = { 144 | foo: 'bar' 145 | } 146 | la( 147 | is.raises(function snapshotOnCi () { 148 | snapShotCore.core({ 149 | what, 150 | file, 151 | specName: 'ci test', 152 | compare: utils.compare, 153 | ext: snapShotExtension, 154 | opts: { ci: true } 155 | }) 156 | }) 157 | ) 158 | }) 159 | 160 | it('can use custom raiser function', function () { 161 | let called 162 | function raiser () { 163 | called = true 164 | } 165 | 166 | snapShotCore.core({ 167 | what: 42, 168 | file, 169 | specName: 'customer raiser function', 170 | ext: snapShotExtension, 171 | raiser, 172 | compare: utils.compare 173 | }) 174 | la(called, 'customer raiser function was called') 175 | }) 176 | 177 | it('has default compare function', () => { 178 | snapShotCore.core({ 179 | what: { foo: 'bar' }, 180 | file, 181 | specName: 'default compare' 182 | }) 183 | }) 184 | 185 | it('allows passing __filename', () => { 186 | snapShotCore.core({ 187 | what: { foo: 'bar' }, 188 | __filename, 189 | specName: 'default compare' 190 | }) 191 | }) 192 | 193 | it('has restore function', () => { 194 | la(is.fn(snapShotCore.restore), '"restore" should be a function') 195 | }) 196 | 197 | it('escapes unicode sequences', () => { 198 | snapShotCore.core({ 199 | what: '\u2028 \u270C\uFE0F', 200 | __filename, 201 | specName: 'unicode' 202 | }) 203 | }) 204 | 205 | it('throws an error right away when trying to snapshot undefined value', () => { 206 | la( 207 | is.raises( 208 | () => { 209 | snapShotCore.core({ 210 | what: undefined 211 | }) 212 | }, 213 | err => err.message.includes('Cannot store undefined value') 214 | ) 215 | ) 216 | }) 217 | 218 | it('adds key property to the thrown error', () => { 219 | snapShotCore.core({ 220 | exactSpecName: 'my snapshot name', 221 | what: 42, 222 | __filename 223 | }) 224 | debug('saved snapshot') 225 | 226 | la( 227 | is.raises( 228 | () => { 229 | snapShotCore.core({ 230 | exactSpecName: 'my snapshot name', 231 | what: 'some other value', 232 | __filename 233 | }) 234 | }, 235 | err => { 236 | debug('caught error') 237 | debug(err) 238 | debug('err.key = "%s"', err.key) 239 | return err.key === 'my snapshot name' 240 | } 241 | ) 242 | ) 243 | }) 244 | 245 | describe('multiple specs', () => { 246 | it('can have same exact snapshot name from different files', () => { 247 | snapShotCore.core({ 248 | what: 42, 249 | file: 'spec-a.js', 250 | exactSpecName: 'foo' 251 | }) 252 | 253 | snapShotCore.core({ 254 | what: 80, 255 | file: 'spec-b.js', 256 | exactSpecName: 'foo' 257 | }) 258 | }) 259 | 260 | after(() => { 261 | // confirm named snapshots from different spec files 262 | const snapshotsA = require('../__snapshots__/spec-a.js.snapshot') 263 | la(snapshotsA.foo === 42, snapshotsA) 264 | const snapshotsB = require('../__snapshots__/spec-b.js.snapshot') 265 | la(snapshotsB.foo === 80, snapshotsB) 266 | }) 267 | }) 268 | 269 | describe('unsorted snapshot names', () => { 270 | context('sorted', () => { 271 | const file = 'sorted-names-spec.js' 272 | it('sorts snapshot names by default', () => { 273 | // the snapshots arrive NOT in alphabetical order 274 | snapShotCore.core({ 275 | what: 42, 276 | file, 277 | exactSpecName: 'x' 278 | }) 279 | 280 | snapShotCore.core({ 281 | what: 80, 282 | file, 283 | exactSpecName: 'b' 284 | }) 285 | 286 | snapShotCore.core({ 287 | what: 60, 288 | file, 289 | exactSpecName: 'a' 290 | }) 291 | }) 292 | 293 | after(() => { 294 | // confirm order of saved snapshots 295 | const specFilename = path.join( 296 | __dirname, 297 | '..', 298 | '__snapshots__', 299 | `${file}.snapshot.js` 300 | ) 301 | const snapshots = fs.readFileSync(specFilename, 'utf8').trim() 302 | const expected = stripIndent` 303 | exports['a'] = 60 304 | 305 | exports['b'] = 80 306 | 307 | exports['x'] = 42 308 | ` 309 | la( 310 | snapshots === expected, 311 | 'expected sorted snapshot names like\n' + 312 | expected + 313 | '\n\ngot:\n' + 314 | snapshots 315 | ) 316 | }) 317 | }) 318 | 319 | context('unsorted', () => { 320 | const file = 'unsorted-names-spec.js' 321 | 322 | it('leaves the names unsorted', () => { 323 | // the snapshots arrive NOT in alphabetical order 324 | const opts = { 325 | sortSnapshots: false 326 | } 327 | snapShotCore.core({ 328 | what: 42, 329 | file, 330 | exactSpecName: 'x', 331 | opts 332 | }) 333 | 334 | snapShotCore.core({ 335 | what: 80, 336 | file, 337 | exactSpecName: 'b', 338 | opts 339 | }) 340 | 341 | snapShotCore.core({ 342 | what: 60, 343 | file, 344 | exactSpecName: 'a', 345 | opts 346 | }) 347 | }) 348 | 349 | after(() => { 350 | // confirm order of saved snapshots 351 | const specFilename = path.join( 352 | __dirname, 353 | '..', 354 | '__snapshots__', 355 | `${file}.snapshot.js` 356 | ) 357 | const snapshots = fs.readFileSync(specFilename, 'utf8').trim() 358 | const expected = stripIndent` 359 | exports['x'] = 42 360 | 361 | exports['b'] = 80 362 | 363 | exports['a'] = 60 364 | ` 365 | la( 366 | snapshots === expected, 367 | 'expected sorted snapshot names like\n' + 368 | expected + 369 | '\n\ngot:\n' + 370 | snapshots 371 | ) 372 | }) 373 | }) 374 | }) 375 | }) 376 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const debug = require('debug')('snap-shot-core') 4 | const debugSave = require('debug')('save') 5 | const la = require('lazy-ass') 6 | const is = require('check-more-types') 7 | const utils = require('./utils') 8 | const isCI = require('is-ci') 9 | const quote = require('quote') 10 | const R = require('ramda') 11 | 12 | const snapshotIndex = utils.snapshotIndex 13 | const strip = utils.strip 14 | 15 | const isNode = Boolean(require('fs').existsSync) 16 | const isBrowser = !isNode 17 | const isCypress = isBrowser && typeof cy === 'object' 18 | 19 | if (isNode) { 20 | debug('snap-shot-core v%s', require('../package.json').version) 21 | } 22 | 23 | const identity = x => x 24 | 25 | // TODO do we still need this? Is this working? id:4 26 | // Gleb Bahmutov 27 | // gleb.bahmutov@gmail.com 28 | // https://github.com/bahmutov/snap-shot-core/issues/89 29 | let fs 30 | if (isNode) { 31 | fs = require('./file-system') 32 | } else if (isCypress) { 33 | fs = require('./cypress-system') 34 | } else { 35 | fs = require('./browser-system') 36 | } 37 | 38 | // keeps track how many "snapshot" calls were there per test 39 | var snapshotsPerTest = {} 40 | 41 | /** 42 | * Forms unique long name for a snapshot 43 | * @param {string} specName 44 | * @param {number} oneIndex 45 | */ 46 | const formKey = (specName, oneIndex) => `${specName} ${oneIndex}` 47 | 48 | const haveNameParameters = is.schema({ 49 | exactSpecName: is.maybe.unemptyString, 50 | specName: is.maybe.unemptyString, 51 | index: is.maybe.number 52 | }) 53 | 54 | /** 55 | * Returns the name of the snapshot when it is saved. 56 | * Could be either an exact string or a combination of the spec name and index 57 | */ 58 | const savedSnapshotName = (options = {}) => { 59 | la(haveNameParameters(options), 'cannot compute snapshot key from', options) 60 | const { exactSpecName, specName, index } = options 61 | return exactSpecName || formKey(specName, index) 62 | } 63 | 64 | function restore (options) { 65 | if (!options) { 66 | debug('restoring all counters') 67 | snapshotsPerTest = {} 68 | } else { 69 | const file = options.file 70 | const specName = options.specName 71 | la(is.unemptyString(file), 'missing file', options) 72 | la(is.unemptyString(specName), 'missing specName', options) 73 | debug('restoring counter for file "%s" test "%s"', file, specName) 74 | delete snapshotsPerTest[specName] 75 | } 76 | } 77 | 78 | function findStoredValue (options) { 79 | const file = options.file 80 | const specName = options.specName 81 | const exactSpecName = options.exactSpecName 82 | const ext = options.ext 83 | let index = options.index 84 | let opts = options.opts 85 | 86 | if (index === undefined) { 87 | index = 1 88 | } 89 | if (opts === undefined) { 90 | opts = {} 91 | } 92 | 93 | la(is.unemptyString(file), 'missing file to find spec for', file) 94 | const relativePath = fs.fromCurrentFolder(file) 95 | if (opts.update) { 96 | // let the new value replace the current value 97 | return 98 | } 99 | 100 | debug( 101 | 'loading snapshots for file %s ext %s from path %s (relative to CWD)', 102 | file, 103 | ext, 104 | relativePath 105 | ) 106 | const loadOptions = R.pick(['useRelativePath'], opts) 107 | debug('load options %o', loadOptions) 108 | 109 | const snapshots = fs.loadSnapshots(file, ext, loadOptions) 110 | if (!snapshots) { 111 | debug('could not find any snapshots') 112 | return 113 | } 114 | 115 | const key = savedSnapshotName({ exactSpecName, specName, index }) 116 | debug('key "%s"', key) 117 | if (!(key in snapshots)) { 118 | return 119 | } 120 | 121 | return snapshots[key] 122 | } 123 | 124 | /** 125 | * Stores new snapshot value if possible. 126 | * Returns the key for the value 127 | */ 128 | function storeValue (options) { 129 | const file = options.file 130 | const specName = options.specName 131 | const exactSpecName = options.exactSpecName 132 | const index = options.index 133 | const value = options.value 134 | const ext = options.ext 135 | const comment = options.comment 136 | let opts = options.opts 137 | 138 | if (opts === undefined) { 139 | opts = {} 140 | } 141 | 142 | la(value !== undefined, 'cannot store undefined value') 143 | la(is.unemptyString(file), 'missing filename', file) 144 | 145 | la( 146 | is.unemptyString(specName) || is.unemptyString(exactSpecName), 147 | 'missing spec or exact spec name', 148 | specName, 149 | exactSpecName 150 | ) 151 | 152 | if (!exactSpecName) { 153 | la( 154 | is.maybe.positive(index), 155 | 'missing snapshot index', 156 | file, 157 | specName, 158 | index 159 | ) 160 | } 161 | la(is.maybe.unemptyString(comment), 'invalid comment to store', comment) 162 | 163 | // how to serialize comments? 164 | // as comments above each key? 165 | const snapshots = fs.loadSnapshots( 166 | file, 167 | ext, 168 | R.pick(['useRelativePath'], opts) 169 | ) 170 | const key = savedSnapshotName({ exactSpecName, specName, index }) 171 | snapshots[key] = value 172 | 173 | if (opts.show || opts.dryRun) { 174 | const relativeName = fs.fromCurrentFolder(file) 175 | console.log('saving snapshot "%s" for file %s', key, relativeName) 176 | console.log(value) 177 | } 178 | 179 | if (!opts.dryRun) { 180 | fs.saveSnapshots( 181 | file, 182 | snapshots, 183 | ext, 184 | R.pick(['sortSnapshots', 'useRelativePath'], opts) 185 | ) 186 | debug('saved updated snapshot %d for spec "%s"', index, specName) 187 | 188 | debugSave( 189 | 'Saved for "%s %d" snapshot\n%s', 190 | specName, 191 | index, 192 | JSON.stringify(value, null, 2) 193 | ) 194 | } 195 | 196 | return key 197 | } 198 | 199 | const isPromise = x => is.object(x) && is.fn(x.then) 200 | 201 | function throwCannotSaveOnCI ({ 202 | value, 203 | fileParameter, 204 | exactSpecName, 205 | specName, 206 | index 207 | }) { 208 | const key = savedSnapshotName({ exactSpecName, specName, index }) 209 | throw new Error( 210 | 'Cannot store new snapshot value\n' + 211 | 'in ' + 212 | quote(fileParameter) + 213 | '\n' + 214 | 'for snapshot called ' + 215 | quote(exactSpecName || specName) + 216 | '\n' + 217 | 'test key ' + 218 | quote(key) + 219 | '\n' + 220 | 'when running on CI (opts.ci = 1)\n' + 221 | 'see https://github.com/bahmutov/snap-shot-core/issues/5' 222 | ) 223 | } 224 | 225 | /** 226 | * Returns object with "value" property (stored value) 227 | * and "key" (formed snapshot name). 228 | * 229 | * Note: when throwing an error, 230 | * "key" property is attached to the thrown error instance. 231 | */ 232 | function core (options) { 233 | la(is.object(options), 'missing options argument', options) 234 | options = R.clone(options) // to avoid accidental mutations 235 | 236 | const what = options.what // value to store 237 | la( 238 | what !== undefined, 239 | 'Cannot store undefined value\nSee https://github.com/bahmutov/snap-shot-core/issues/111' 240 | ) 241 | 242 | const file = options.file 243 | const __filename = options.__filename 244 | const specName = options.specName 245 | const exactSpecName = options.exactSpecName 246 | const store = options.store || identity 247 | const compare = options.compare || utils.compare 248 | const raiser = options.raiser || fs.raiseIfDifferent 249 | const ext = options.ext || utils.DEFAULT_EXTENSION 250 | const comment = options.comment 251 | const opts = options.opts || {} 252 | 253 | const fileParameter = file || __filename 254 | la(is.unemptyString(fileParameter), 'missing file', fileParameter) 255 | la(is.maybe.unemptyString(specName), 'invalid specName', specName) 256 | la( 257 | is.maybe.unemptyString(exactSpecName), 258 | 'invalid exactSpecName', 259 | exactSpecName 260 | ) 261 | la(specName || exactSpecName, 'missing either specName or exactSpecName') 262 | 263 | la(is.fn(compare), 'missing compare function', compare) 264 | la(is.fn(store), 'invalid store function', store) 265 | la(is.fn(raiser), 'invalid raiser function', raiser) 266 | la(is.maybe.unemptyString(comment), 'wrong comment type', comment) 267 | 268 | if (!('ci' in opts)) { 269 | debug('set CI flag to %s', isCI) 270 | opts.ci = isCI 271 | } 272 | 273 | if (!('sortSnapshots' in opts)) { 274 | debug('setting sortSnapshots flags to true') 275 | opts.sortSnapshots = false 276 | } 277 | 278 | if (!('useRelativePath' in opts)) { 279 | debug('setting useRelativePath flag to false') 280 | opts.useRelativePath = false 281 | } 282 | 283 | if (ext) { 284 | la(ext[0] === '.', 'extension should start with .', ext) 285 | } 286 | debug(`file "${fileParameter}" spec "${specName}"`) 287 | 288 | const setOrCheckValue = any => { 289 | const index = exactSpecName 290 | ? 0 291 | : snapshotIndex({ 292 | counters: snapshotsPerTest, 293 | file: fileParameter, 294 | specName, 295 | exactSpecName 296 | }) 297 | if (index) { 298 | la( 299 | is.positive(index), 300 | 'invalid snapshot index', 301 | index, 302 | 'for\n', 303 | specName, 304 | '\ncounters', 305 | snapshotsPerTest 306 | ) 307 | debug('spec "%s" snapshot is #%d', specName, index) 308 | } 309 | 310 | const value = strip(any) 311 | const key = savedSnapshotName({ exactSpecName, specName, index }) 312 | la( 313 | is.unemptyString(key), 314 | 'expected snapshot key to be a string', 315 | key, 316 | 'exact spec name', 317 | exactSpecName, 318 | 'spec name', 319 | specName, 320 | 'index', 321 | index 322 | ) 323 | 324 | const expected = findStoredValue({ 325 | file: fileParameter, 326 | specName, 327 | exactSpecName, 328 | index, 329 | ext, 330 | opts 331 | }) 332 | if (expected === undefined) { 333 | if (opts.ci) { 334 | console.log('current directory', process.cwd()) 335 | console.log('new value to save: %j', value) 336 | return throwCannotSaveOnCI({ 337 | value, 338 | fileParameter, 339 | exactSpecName, 340 | specName, 341 | index 342 | }) 343 | } 344 | 345 | const storedValue = store(value) 346 | storeValue({ 347 | file: fileParameter, 348 | specName, 349 | exactSpecName, 350 | index, 351 | value: storedValue, 352 | ext, 353 | comment, 354 | opts 355 | }) 356 | 357 | return { 358 | value: storedValue, 359 | key 360 | } 361 | } 362 | 363 | const usedSpecName = specName || exactSpecName 364 | debug('found snapshot for "%s", value', usedSpecName, expected) 365 | 366 | try { 367 | raiser({ 368 | value, 369 | expected, 370 | specName: usedSpecName, 371 | compare 372 | }) 373 | } catch (e) { 374 | // so the users know the snapshot used to compare 375 | e.key = key 376 | throw e 377 | } 378 | 379 | return { 380 | value: expected, 381 | key 382 | } 383 | } 384 | 385 | if (isPromise(what)) { 386 | return what.then(setOrCheckValue) 387 | } else { 388 | return setOrCheckValue(what) 389 | } 390 | } 391 | 392 | if (isBrowser) { 393 | // there might be async step to load test source code in the browser 394 | la(is.fn(fs.init), 'browser file system is missing init', fs) 395 | core.init = fs.init 396 | } 397 | 398 | const prune = require('./prune')(fs).pruneSnapshots 399 | 400 | module.exports = { 401 | core, 402 | restore, 403 | prune, 404 | throwCannotSaveOnCI, 405 | savedSnapshotName, 406 | storeValue 407 | } 408 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![TODO board](https://imdone.io/api/1.0/projects/5b1adebb4f7fd004e58ef569/badge)](https://imdone.io/app#/board/bahmutov/snap-shot-core) 2 | 3 | # snap-shot-core 4 | 5 | > Save / load named snapshots, useful for tests 6 | 7 | [![NPM][npm-icon] ][npm-url] 8 | 9 | [![Build status][ci-image] ][ci-url] 10 | [![semantic-release][semantic-image] ][semantic-url] 11 | [![js-standard-style][standard-image]][standard-url] 12 | [![renovate-app badge][renovate-badge]][renovate-app] 13 | 14 | This is the snapshot loading and saving utility, used by 15 | [snap-shot-it][snap-shot-it] and [schema-shot][schema-shot] projects. 16 | Can be used to save snapshots from any testing project. 17 | 18 | ```sh 19 | npm install --save-dev snap-shot-core 20 | ``` 21 | 22 | ```js 23 | const snapShot = require('snap-shot-core') 24 | const what // my object 25 | const out = snapShot.core({ 26 | what, 27 | file: __filename, // aliases: file, __filename 28 | specName: 'my test', // or whatever name you want to give, 29 | store, // optional function to preprocess the value before storing 30 | compare: compareFn, // optional function that compares values 31 | raiser: raiseErrorFn, // optional 32 | ext: '.test', // default value is '.snapshot.js' 33 | opts: { 34 | // see below 35 | } 36 | }) 37 | ``` 38 | 39 | The returned value contains both the saved value and the snapshot name 40 | 41 | ```js 42 | let out = snapShot.core({ 43 | what: 42, 44 | exactSpecName: 'my snapshot' 45 | }) 46 | console.log(out) 47 | // {value: 42, key 'my snapshot'} 48 | out = snapShot.core({ 49 | what: 42, 50 | specName: 'my snapshot' 51 | }) 52 | console.log(out) 53 | // auto-increments counter if we are using 54 | // just the spec name 55 | // {value: 42, key 'my snapshot 1'} 56 | ``` 57 | 58 | When throwing an error on different value, the error instance will still have `key` property with the final snapshot name. 59 | 60 | ## Save folders 61 | 62 | All snapshots are saved in a single folder `__snapshots__`, even if original spec files are nested. See [test-nested-specs](test-nested-specs) example folder. 63 | 64 | ## Store function 65 | 66 | Sometimes you want to store not the value itself, but something derived, 67 | like the object's schema (check out [schema-shot][schema-shot]). You can 68 | pass a function `store` that transforms the object before saving. 69 | For example if we are only interested in the type of value, we can do the 70 | following (paired with the right `compare` function). 71 | 72 | ```js 73 | const store = x => typeof x 74 | // expected - previously saved "type of" value 75 | // value - current original value 76 | const compare = ( 77 | { expected, value } // return Result 78 | ) => 79 | snapShot({ 80 | what, 81 | store, 82 | compare, 83 | }) 84 | ``` 85 | 86 | Note: by default multi line text is saves using ES6 template string, while 87 | everything else is saved using normal serialization using 88 | [jsesc](https://github.com/mathiasbynens/jsesc). 89 | 90 | ## Compare function 91 | 92 | A function to compare expected and actual value should return `Result` 93 | instance, preferably [Folktable.Result][result]. A simple one could be 94 | 95 | ```js 96 | const Result = require('folktale/result') 97 | function compare({ expected, value }) { 98 | const e = JSON.stringify(expected) 99 | const v = JSON.stringify(value) 100 | if (e === v) { 101 | return Result.Ok() 102 | } 103 | return Result.Error(`${e} !== ${v}`) 104 | } 105 | ``` 106 | 107 | Another one, that compares values by type could be even simpler 108 | 109 | ```js 110 | const sameTypes = (a, b) => typeof a === typeof b 111 | 112 | const compareTypes = ({ expected, value }) => 113 | sameTypes(expected, value) ? Result.Ok() : Result.Error('types are different') 114 | ``` 115 | 116 | Note input is an object `{expected, value}` and if there is a difference 117 | you should describe it as a string `Result.Error()`. 118 | Why does it return a `Result`? Because it makes [life easier][result post]. 119 | 120 | [result]: http://folktale.origamitower.com/api/v2.0.0/en/folktale.result.html 121 | [result post]: https://glebbahmutov.com/blog/use-a-little-bit-of-fp/#result-either-for-utility-functions 122 | 123 | ## Raise function 124 | 125 | Default function will compare current and loaded values using `compare` 126 | function and if the values are different will throw an error. You can provide 127 | your own function to fail a test differently. Your function will be called 128 | with these parameters 129 | 130 | ```js 131 | raiser({ 132 | value, // current value 133 | expected, // loaded value 134 | specName, // the name of the test 135 | compare, // compare function 136 | }) 137 | ``` 138 | 139 | Default `raiser` function just throws an Error with good message. 140 | 141 | ## Returned value 142 | 143 | The `snapShotCore` function returns the _expected_ value. 144 | If this is the first time, it will be `store(what)` value. 145 | Otherwise it will be the loaded `expected` value. 146 | 147 | ## Options 148 | 149 | You can pass several options to control the behavior. I usually grab them 150 | from the environment variables. 151 | 152 | - `show` - log snapshot value when saving new one 153 | - `dryRun` - only show the new snapshot value, but do not save it 154 | - `update` - override snapshot value with the new one if there is difference 155 | - `ci` - the tests are running on CI, which should disallow _saving snapshots_ 156 | - `sortSnapshots` - enable sorting snapshots by name when saving (default is false) 157 | - `useRelativePath` - use relative paths inside `__snapshots__` folder to recreate folder structure to mimic spec file relative path. Default is false. 158 | 159 | ```js 160 | // for example to use environment variables 161 | const opts = { 162 | show: Boolean(process.env.SHOW), 163 | dryRun: Boolean(process.env.DRY), 164 | update: Boolean(process.env.UPDATE), 165 | ci: Boolean(process.env.CI), 166 | sortSnapshots: false, 167 | useRelativePath: false 168 | } 169 | snapShot.core({ 170 | what, 171 | file: __filename, 172 | specName: 'my test', 173 | compare: compareFn, 174 | ext: '.test', 175 | opts, 176 | }) 177 | ``` 178 | 179 | If `opts.ci` is not set, it will use [is-ci](https://github.com/watson/is-ci) 180 | to determine if running on CI or not. 181 | 182 | ## useRelativePath 183 | 184 | When you pass `useRelativePath: true` option, the folder structure inside the `__snapshots__` folder will recreate the folder paths to the spec. For example if the specs are in subfolders: 185 | 186 | ```text 187 | specs/ 188 | foo/ 189 | spec.js 190 | bar/ 191 | spec.js 192 | ``` 193 | 194 | Then output snapshots will be saved as 195 | 196 | ```text 197 | __snapshots__/ 198 | specs/ 199 | foo/ 200 | spec.js.snapshot.js 201 | bar/ 202 | spec.js.snapshot.js 203 | ``` 204 | 205 | ## Pruning snapshots 206 | 207 | When test names change or tests are updated, new snapshots are saved, but old ones remain 208 | in the snapshot file. To prune the old snapshots, the test runner can pass all current spec 209 | names to prune all other ones. Just call `.prune()` method and pass the following options 210 | 211 | ``` 212 | * tests: list of current tests. Each object should have 213 | file: the full test filename 214 | specName: the full title of the test 215 | * ext: optional snapshot filename extension 216 | ``` 217 | 218 | For example see [src/prune-spec.js](src/prune-spec.js) 219 | 220 | **note** this can still leave old snapshot files, if the spec has no tests running or 221 | has been renamed. 222 | 223 | **note 2** if you run tests with `.only` it will remove all other snapshots in that file. 224 | This is normal, you will recreated all snapshots once you run all the tests again. 225 | 226 | ## Exact snapshot name 227 | 228 | Sometimes you do not want to auto increment the snapshots, or use default test name. 229 | In this case you can pass `exactSpecName` to just save the snapshot with that key. 230 | 231 | ```js 232 | snapShot.core({ 233 | what: 42, 234 | exactSpecName: 'computed value', 235 | file: __filename, 236 | }) 237 | ``` 238 | 239 | The snapshot file will have 240 | 241 | ```js 242 | exports['computed value'] = 42 243 | ``` 244 | 245 | ## Text snapshots 246 | 247 | When saving strings, the snapshot will be surrounded by newlines to avoid 248 | extra lone first line (looking like `exports["name"] = ...`). So when saving snapshot text 249 | 250 | ```text 251 | line 1 252 | line 2 253 | ``` 254 | 255 | the snapshot file will have 256 | 257 | ```js 258 | exports['name'] = ` 259 | line 1 260 | line 2 261 | ` 262 | ``` 263 | 264 | The newlines will be trimmed automatically when loading the snapshot value. 265 | 266 | ## Debugging 267 | 268 | Run the code with `DEBUG=snap-shot-core` option to see more log messages. During testing you can see additional output by adding `DEBUG=test` environment variable (or both `DEBUG=snap-shot-core,test`). 269 | 270 | If you want verbose output, use `DEBUG=snap-shot-core*` 271 | 272 | ## Testing in watch mode 273 | 274 | In case you execute your tests in watch mode and you notice the snapshots are always new-created for the same set of tests, then you need to restore the counters per file. 275 | 276 | tape example: 277 | 278 | ```js 279 | //foo.test.js 280 | const test = require('tape') 281 | const snapShot = require('snap-shot-core') 282 | 283 | test.onFinish(snapShot.restore) 284 | 285 | test('one test', function(t) { 286 | t.plan(1) 287 | snapShot.core({ 288 | what: 1, 289 | file: __filename, 290 | specName: 'one test', 291 | }) 292 | }) 293 | ``` 294 | 295 | You can restore / reset a counter for a particular test 296 | 297 | ```js 298 | const snapShot = require('snap-shot-core') 299 | snapShot.restore({ 300 | file: __filename, 301 | specName: 'this test', 302 | }) 303 | ``` 304 | 305 | ## Escaping values 306 | 307 | Because the snapshots are saved as template literals, back ticks and other "niceties" have to be escaped. This module uses [jsesc](https://github.com/mathiasbynens/jsesc) module to do string escaping. Currently only the [minimal set of characters is escaped](https://github.com/mathiasbynens/jsesc#minimal). 308 | 309 | ## Resaving snaphots 310 | 311 | You can re-save snapshot file (for example to escape it again, or to resort the snapshots by name) using [bin/resave-snapshots.js](bin/resave-snapshots.js) script. After installing this module, run `bin` script 312 | 313 | ```bash 314 | $(npm bin)/resave-snapshots [--sort] snapshot-filename 315 | ``` 316 | 317 | To just re-escape the snapshots omit the `--sort` flag. 318 | 319 | ### Small print 320 | 321 | Author: Gleb Bahmutov <gleb.bahmutov@gmail.com> © 2017 322 | 323 | - [@bahmutov](https://twitter.com/bahmutov) 324 | - [glebbahmutov.com](https://glebbahmutov.com) 325 | - [blog](https://glebbahmutov.com/blog) 326 | 327 | License: MIT - do anything with the code, but don't blame me if it does not work. 328 | 329 | Support: if you find any problems with this module, email / tweet / 330 | [open issue](https://github.com/bahmutov/snap-shot-core/issues) on Github 331 | 332 | ## MIT License 333 | 334 | Copyright (c) 2017 Gleb Bahmutov <gleb.bahmutov@gmail.com> 335 | 336 | Permission is hereby granted, free of charge, to any person 337 | obtaining a copy of this software and associated documentation 338 | files (the "Software"), to deal in the Software without 339 | restriction, including without limitation the rights to use, 340 | copy, modify, merge, publish, distribute, sublicense, and/or sell 341 | copies of the Software, and to permit persons to whom the 342 | Software is furnished to do so, subject to the following 343 | conditions: 344 | 345 | The above copyright notice and this permission notice shall be 346 | included in all copies or substantial portions of the Software. 347 | 348 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 349 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 350 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 351 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 352 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 353 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 354 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 355 | OTHER DEALINGS IN THE SOFTWARE. 356 | 357 | [npm-icon]: https://nodei.co/npm/snap-shot-core.svg?downloads=true 358 | [npm-url]: https://npmjs.org/package/snap-shot-core 359 | [ci-image]: https://travis-ci.org/bahmutov/snap-shot-core.svg?branch=master 360 | [ci-url]: https://travis-ci.org/bahmutov/snap-shot-core 361 | [semantic-image]: https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg 362 | [semantic-url]: https://github.com/semantic-release/semantic-release 363 | [standard-image]: https://img.shields.io/badge/code%20style-standard-brightgreen.svg 364 | [standard-url]: http://standardjs.com/ 365 | [renovate-badge]: https://img.shields.io/badge/renovate-app-blue.svg 366 | [renovate-app]: https://renovateapp.com/ 367 | [snap-shot-it]: https://github.com/bahmutov/snap-shot-it 368 | [schema-shot]: https://github.com/bahmutov/schema-shot 369 | --------------------------------------------------------------------------------