├── .gitattributes ├── .gitignore ├── docs └── images │ └── example.png ├── lib ├── snapshot-state-update.js ├── prune.js ├── adapter.js ├── index.js └── format │ └── markdown.js ├── package.json ├── LICENSE ├── yarn.lock └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | 3 | *.js text eol=lf 4 | *.md text eol=lf 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | .DS_Store 4 | .idea 5 | .vscode 6 | npm-debug.log 7 | -------------------------------------------------------------------------------- /docs/images/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/localvoid/karma-snapshot/HEAD/docs/images/example.png -------------------------------------------------------------------------------- /lib/snapshot-state-update.js: -------------------------------------------------------------------------------- 1 | (function (window) { 2 | "use strict"; 3 | window.__snapshot__.update = true; 4 | })(window); 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "karma-snapshot", 3 | "version": "0.6.0", 4 | "main": "lib/index.js", 5 | "description": "Karma plugin for snapshot testing.", 6 | "license": "MIT", 7 | "author": { 8 | "name": "Boris Kaul", 9 | "email": "localvoid@gmail.com", 10 | "url": "https://github.com/localvoid" 11 | }, 12 | "keywords": [ 13 | "karma-plugin", 14 | "karma-adapter", 15 | "snapshot" 16 | ], 17 | "homepage": "https://github.com/localvoid/karma-snapshot", 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/localvoid/karma-snapshot" 21 | }, 22 | "bugs": { 23 | "url": "https://github.com/localvoid/karma-snapshot/issues" 24 | }, 25 | "github": "https://github.com/localvoid/karma-snapshot", 26 | "files": [ 27 | "lib", 28 | "LICENSE", 29 | "README.md" 30 | ], 31 | "scripts": {}, 32 | "dependencies": { 33 | "mkdirp": "^0.5.1", 34 | "remark-parse": "^4.0.0", 35 | "unified": "^6.1.5" 36 | }, 37 | "devDependencies": {} 38 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Boris Kaul . 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/prune.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const fs = require('fs'); 3 | 4 | function _pruneSnapshots(suite, snapshotPath) { 5 | const children = suite.children; 6 | const snapshots = suite.snapshots; 7 | const result = { 8 | pruned: [], 9 | alive: 0, 10 | suite: { 11 | children: {}, 12 | snapshots: {}, 13 | visited: suite.visited, 14 | dirty: suite.dirty 15 | } 16 | }; 17 | let keys, key, i; 18 | 19 | keys = Object.keys(children); 20 | for (i = 0; i < keys.length; i++) { 21 | key = keys[i]; 22 | const c = children[key]; 23 | const nextPath = snapshotPath.slice(); 24 | nextPath.push(key); 25 | const p = _pruneSnapshots(c, nextPath); 26 | if (p.pruned.length > 0) { 27 | result.suite.dirty = true; 28 | result.pruned = result.pruned.concat(p.pruned); 29 | } 30 | if (p.alive > 0) { 31 | result.alive += p.alive; 32 | result.suite.children[key] = p.suite; 33 | } 34 | } 35 | 36 | keys = Object.keys(snapshots); 37 | for (i = 0; i < keys.length; i++) { 38 | key = keys[i]; 39 | const snapshotList = snapshots[key]; 40 | const newSnapshotList = []; 41 | for (let j = 0; j < snapshotList.length; j++) { 42 | const s = snapshotList[j]; 43 | if (s.visited === true) { 44 | newSnapshotList.push(s); 45 | result.alive++; 46 | } else { 47 | const fullPath = snapshotPath.slice(); 48 | fullPath.push(key + ' ' + j); 49 | result.suite.dirty = true; 50 | result.pruned.push(fullPath); 51 | } 52 | } 53 | if (newSnapshotList.length > 0) { 54 | result.suite.snapshots[key] = newSnapshotList; 55 | } 56 | } 57 | 58 | return result; 59 | } 60 | 61 | function pruneSnapshots(suite) { 62 | const result = _pruneSnapshots(suite, []); 63 | const fileNames = Object.keys(suite.children); 64 | 65 | const prunedFiles = []; 66 | for (let i = 0; i < fileNames.length; i++) { 67 | const fileName = fileNames[i]; 68 | if (!result.suite.children.hasOwnProperty(fileName)) { 69 | prunedFiles.push(fileName); 70 | } 71 | } 72 | 73 | return { 74 | pruned: result.pruned, 75 | prunedFiles: prunedFiles, 76 | alive: result.alive, 77 | suite: result.suite, 78 | }; 79 | } 80 | 81 | function pruneFiles(pathResolver, basePath, files) { 82 | files.forEach((f) => { 83 | fs.unlinkSync(pathResolver(basePath, f)); 84 | }); 85 | } 86 | 87 | module.exports = { 88 | pruneSnapshots: pruneSnapshots, 89 | pruneFiles: pruneFiles 90 | }; 91 | -------------------------------------------------------------------------------- /lib/adapter.js: -------------------------------------------------------------------------------- 1 | (function (window) { 2 | "use strict"; 3 | 4 | var hasOwnProperty = Object.prototype.hasOwnProperty; 5 | 6 | // Object.assign polyfill copied from https://github.com/Microsoft/tslib/ 7 | var assign = Object.assign || function (t) { 8 | for (var s, i = 1, n = arguments.length; i < n; i++) { 9 | s = arguments[i]; 10 | for (var p in s) if (hasOwnProperty.call(s, p)) t[p] = s[p]; 11 | } 12 | return t; 13 | }; 14 | 15 | var prevSuite = { 16 | children: {}, 17 | snapshots: {} 18 | }; 19 | var nextSuite = { 20 | visited: false, 21 | dirty: false, 22 | children: {}, 23 | snapshots: {} 24 | }; 25 | 26 | window.__snapshot__ = { 27 | update: false, 28 | suite: prevSuite 29 | }; 30 | 31 | window.__snapshot__.addSuite = function (name, suite) { 32 | prevSuite.children[name] = suite; 33 | }; 34 | 35 | var normalizeNewlines = function (s) { 36 | return s.replace(/\r\n|\r/g, "\n"); 37 | } 38 | 39 | var setSnapshot = function (path, index, code, lang, dirty) { 40 | if (dirty === void 0) { 41 | dirty = true; 42 | } 43 | var suite = nextSuite; 44 | suite.visited = true; 45 | if (dirty) { 46 | suite.dirty = true; 47 | } 48 | for (var i = 0; i < path.length - 1; i++) { 49 | var key = path[i]; 50 | var s = suite.children[key]; 51 | if (s === undefined) { 52 | suite.children[key] = suite = { 53 | visited: true, 54 | dirty: dirty, 55 | children: {}, 56 | snapshots: {} 57 | }; 58 | } else { 59 | suite = s; 60 | suite.visited = true; 61 | if (dirty) { 62 | suite.dirty = true; 63 | } 64 | } 65 | } 66 | var testName = path[path.length - 1]; 67 | var snapshotList = suite.snapshots[testName]; 68 | if (snapshotList === undefined) { 69 | suite.snapshots[testName] = snapshotList = []; 70 | } 71 | snapshotList[index] = { 72 | visited: true, 73 | dirty: dirty, 74 | lang: lang, 75 | code: normalizeNewlines(code) 76 | }; 77 | }; 78 | 79 | window.__snapshot__.get = function (path, index) { 80 | var key, s, i, testName, snapshotList, snapshot; 81 | var suite = prevSuite; 82 | 83 | for (i = 0; i < path.length - 1; i++) { 84 | key = path[i]; 85 | s = suite.children[key]; 86 | if (s === undefined) { 87 | return undefined; 88 | } else { 89 | suite = s; 90 | } 91 | } 92 | 93 | testName = path[path.length - 1]; 94 | snapshotList = suite.snapshots[testName]; 95 | if (snapshotList !== undefined) { 96 | snapshot = snapshotList[index]; 97 | if (snapshot !== undefined) { 98 | setSnapshot(path, index, snapshot.code, snapshot.lang, false); 99 | } 100 | return snapshot; 101 | } 102 | 103 | return undefined; 104 | }; 105 | 106 | window.__snapshot__.set = setSnapshot; 107 | 108 | window.__snapshot__.match = function (received, expected) { 109 | return received === normalizeNewlines(expected); 110 | }; 111 | 112 | var copyMissingSnapshots = function (prev, next) { 113 | var snapshotList, nextChild, key, i; 114 | 115 | var pChildren = prev.children; 116 | var pSnapshots = prev.snapshots; 117 | 118 | for (key in pChildren) { 119 | if (hasOwnProperty.call(pChildren, key)) { 120 | nextChild = next.children[key]; 121 | if (nextChild === undefined) { 122 | next.children[key] = pChildren[key]; 123 | } else { 124 | copyMissingSnapshots(pChildren[key], nextChild); 125 | } 126 | } 127 | } 128 | 129 | for (key in pSnapshots) { 130 | if (hasOwnProperty.call(pSnapshots, key)) { 131 | nextChild = next.snapshots[key]; 132 | if (nextChild === undefined) { 133 | next.snapshots[key] = pSnapshots[key]; 134 | } else { 135 | snapshotList = pSnapshots[key]; 136 | for (i = nextChild.length; i < snapshotList.length; i++) { 137 | nextChild.push(snapshotList[i]); 138 | } 139 | } 140 | } 141 | } 142 | } 143 | 144 | // Patch `karma.complete()` method and add snapshot data to the end result. 145 | var complete = window.__karma__.complete; 146 | window.__karma__.complete = function (data) { 147 | // We need to copy missing snapshots to detect which one should be pruned. Missing snapshots won't have `visited` 148 | // flag. 149 | copyMissingSnapshots(prevSuite, nextSuite); 150 | 151 | var arg = { snapshot: nextSuite }; 152 | if (data !== undefined) { 153 | arg = assign({}, data, arg); 154 | } 155 | return complete.call(window.__karma__, arg); 156 | }; 157 | })(window); 158 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const fs = require('fs'); 3 | const mkdirp = require('mkdirp'); 4 | const createMarkdownSerializer = require('./format/markdown'); 5 | const prune = require('./prune'); 6 | 7 | /** 8 | * filePattern creates a Karma file pattern object that should be included and non-watched. 9 | * 10 | * @param {string} path File path. 11 | * @returns Karma file pattern object. 12 | */ 13 | function filePattern(path) { 14 | return { 15 | pattern: path, 16 | included: true, 17 | served: true, 18 | watched: false, 19 | }; 20 | } 21 | 22 | /** 23 | * defaultPathResolver is a default path resolver for snapshot files. 24 | * 25 | * @param {string} basePath Base path. 26 | * @param {string} suiteName Name of the suite. 27 | * @returns Full path to snapshot file. 28 | */ 29 | function defaultPathResolver(basePath, suiteName) { 30 | const suiteSourcePath = path.join(basePath, suiteName); 31 | const suiteSourceDir = path.dirname(suiteSourcePath); 32 | const sourceFileName = path.basename(suiteName); 33 | 34 | return path.join(suiteSourceDir, "__snapshots__", sourceFileName + ".md"); 35 | } 36 | 37 | /** 38 | * Renders a list of snapshots up to specified limit of lines 39 | * @param list {array} list of snapshots 40 | * @param limit {number} maximum number of lines rendered under summary 41 | * @returns {string} 42 | */ 43 | function formatSnapshotList(list, limit) { 44 | limit = (typeof limit != 'undefined') ? limit : -1; 45 | 46 | const limitedList = limit > 0 ? list.slice(0, limit) : list; 47 | const hasMore = list.length > limitedList.length; 48 | const buildList = (snapshots) => snapshots.map((s) => s.join(' > ')).join('\n'); 49 | 50 | if (hasMore) { 51 | return buildList(limitedList.slice(0, -1)) + `\n +${list.length - limitedList.length + 1} more`; 52 | } 53 | 54 | return buildList(limitedList); 55 | } 56 | 57 | /** 58 | * Renders the message for unused snapshots warning 59 | * @param list {array} list of snapshots 60 | * @param limit {number} maximum number of lines rendered under summary 61 | * @returns {string} 62 | */ 63 | function formatUnusedSnapshotsWarning(list, limit) { 64 | if (limit == 0) { 65 | return `Found ${list.length} unused snapshots`; 66 | } 67 | 68 | const prunedList = formatSnapshotList(list, limit); 69 | return `Found ${list.length} unused snapshots:\n${prunedList}`; 70 | } 71 | 72 | let snapshotSerializer; 73 | 74 | /** 75 | * snapshotFramework 76 | * 77 | * @param {*} files Karma file patterns. 78 | * @param {*} config Karma config. 79 | * @param {*} emitter Karma emitter. 80 | * @param {*} loggerFactory Karma logger factory. 81 | */ 82 | function snapshotFramework(files, config, emitter, loggerFactory) { 83 | const logger = loggerFactory.create('framework.snapshot'); 84 | 85 | const snapshotConfig = Object.assign({ 86 | update: false, 87 | prune: false, 88 | format: "md", 89 | checkSourceFile: false, 90 | pathResolver: defaultPathResolver, 91 | limitUnusedSnapshotsInWarning: -1 92 | }, config.snapshot); 93 | 94 | if (typeof snapshotConfig.format === "string") { 95 | switch (snapshotConfig.format) { 96 | case "indented-md": 97 | snapshotSerializer = createMarkdownSerializer(true); 98 | break; 99 | case "md": 100 | default: 101 | snapshotSerializer = createMarkdownSerializer(false); 102 | } 103 | } else { 104 | snapshotSerializer = snapshotConfig.format; 105 | } 106 | 107 | // it should be in a files list after `adapter.js` 108 | if (snapshotConfig.update) { 109 | files.unshift(filePattern(path.join(__dirname, 'snapshot-state-update.js'))); 110 | } 111 | 112 | // inject snapshot adapter 113 | files.unshift(filePattern(path.join(__dirname, 'adapter.js'))); 114 | 115 | emitter.on('browser_complete', (clientInfo, data) => { 116 | const lastResult = clientInfo.lastResult; 117 | 118 | if (!lastResult.disconnected) { 119 | if (data && data.snapshot) { 120 | let rootSuite = data.snapshot; 121 | let dirty = rootSuite.dirty; 122 | 123 | // prune dead snapshots 124 | if (!lastResult.error && lastResult.failed === 0 && lastResult.skipped === 0) { 125 | const prunedSnapshots = prune.pruneSnapshots(rootSuite); 126 | if (prunedSnapshots.pruned.length > 0) { 127 | if (snapshotConfig.prune) { 128 | const prunedList = formatSnapshotList(prunedSnapshots.pruned) 129 | logger.warn(`Removed ${prunedSnapshots.pruned.length} unused snapshots:\n${prunedList}`); 130 | rootSuite = prunedSnapshots.suite; 131 | prune.pruneFiles(snapshotConfig.pathResolver, config.basePath, prunedSnapshots.prunedFiles); 132 | dirty = true; 133 | } else { 134 | logger.warn(formatUnusedSnapshotsWarning(prunedSnapshots.pruned, snapshotConfig.limitUnusedSnapshotsInWarning)); 135 | } 136 | } 137 | } 138 | 139 | if (dirty) { 140 | Object.keys(rootSuite.children).forEach((suiteName) => { 141 | const suite = rootSuite.children[suiteName]; 142 | if (suite.dirty) { 143 | if (snapshotConfig.checkSourceFile) { 144 | const suiteSourceFilePath = path.join(config.basePath, suiteName); 145 | if (!fs.existsSync(suiteSourceFilePath)) { 146 | logger.error( 147 | 'Failed to save snapshot file. ' + 148 | 'Source file "' + suiteSourceFilePath + '" does not exist.' 149 | ); 150 | return; 151 | } 152 | } 153 | 154 | const snapshotPath = snapshotConfig.pathResolver(config.basePath, suiteName); 155 | const snapshotDir = path.dirname(snapshotPath); 156 | if (!fs.existsSync(snapshotDir)) { 157 | mkdirp.sync(snapshotDir); 158 | } 159 | fs.writeFileSync( 160 | snapshotPath, 161 | snapshotSerializer.serialize(suiteName, suite) 162 | ); 163 | } 164 | }); 165 | } 166 | } 167 | } else { 168 | logger.warn('Snapshot data is unavailable'); 169 | } 170 | }); 171 | } 172 | 173 | snapshotFramework.$inject = ['config.files', 'config', 'emitter', 'logger']; 174 | 175 | /** 176 | * iifeWrapper wraps javascript into IIFE. 177 | * 178 | * @param {string} content 179 | * @returns Javascript wrapped into IIFE. 180 | */ 181 | function iifeWrapper(content) { 182 | return "(function(window){\"use strict\";" + content + "})(window);" 183 | } 184 | 185 | /** 186 | * Snapshot preprocessor. 187 | * 188 | * @param {string} basePath Base path. 189 | * @param {*} loggerFactory Karma logger factory. 190 | * @returns Karma preprocessor. 191 | */ 192 | function snapshotPreprocessor(basePath, loggerFactory) { 193 | const logger = loggerFactory.create('preprocessor.snapshot'); 194 | 195 | return function (content, file, done) { 196 | const root = snapshotSerializer.deserialize(content); 197 | done(iifeWrapper('window.__snapshot__.addSuite("' + root.name + '",' + JSON.stringify(root.suite) + ');')); 198 | }; 199 | } 200 | 201 | snapshotPreprocessor.$inject = ['config.basePath', 'logger']; 202 | 203 | module.exports = { 204 | 'framework:snapshot': ['factory', snapshotFramework], 205 | 'preprocessor:snapshot': ['factory', snapshotPreprocessor] 206 | } 207 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | bail@^1.0.0: 6 | version "1.0.2" 7 | resolved "https://registry.yarnpkg.com/bail/-/bail-1.0.2.tgz#f7d6c1731630a9f9f0d4d35ed1f962e2074a1764" 8 | 9 | character-entities-legacy@^1.0.0: 10 | version "1.1.1" 11 | resolved "https://registry.yarnpkg.com/character-entities-legacy/-/character-entities-legacy-1.1.1.tgz#f40779df1a101872bb510a3d295e1fccf147202f" 12 | 13 | character-entities@^1.0.0: 14 | version "1.2.1" 15 | resolved "https://registry.yarnpkg.com/character-entities/-/character-entities-1.2.1.tgz#f76871be5ef66ddb7f8f8e3478ecc374c27d6dca" 16 | 17 | character-reference-invalid@^1.0.0: 18 | version "1.1.1" 19 | resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-1.1.1.tgz#942835f750e4ec61a308e60c2ef8cc1011202efc" 20 | 21 | collapse-white-space@^1.0.2: 22 | version "1.0.3" 23 | resolved "https://registry.yarnpkg.com/collapse-white-space/-/collapse-white-space-1.0.3.tgz#4b906f670e5a963a87b76b0e1689643341b6023c" 24 | 25 | extend@^3.0.0: 26 | version "3.0.1" 27 | resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.1.tgz#a755ea7bc1adfcc5a31ce7e762dbaadc5e636444" 28 | 29 | inherits@^2.0.1: 30 | version "2.0.3" 31 | resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" 32 | 33 | is-alphabetical@^1.0.0: 34 | version "1.0.1" 35 | resolved "https://registry.yarnpkg.com/is-alphabetical/-/is-alphabetical-1.0.1.tgz#c77079cc91d4efac775be1034bf2d243f95e6f08" 36 | 37 | is-alphanumerical@^1.0.0: 38 | version "1.0.1" 39 | resolved "https://registry.yarnpkg.com/is-alphanumerical/-/is-alphanumerical-1.0.1.tgz#dfb4aa4d1085e33bdb61c2dee9c80e9c6c19f53b" 40 | dependencies: 41 | is-alphabetical "^1.0.0" 42 | is-decimal "^1.0.0" 43 | 44 | is-buffer@^1.1.4: 45 | version "1.1.6" 46 | resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" 47 | 48 | is-decimal@^1.0.0: 49 | version "1.0.1" 50 | resolved "https://registry.yarnpkg.com/is-decimal/-/is-decimal-1.0.1.tgz#f5fb6a94996ad9e8e3761fbfbd091f1fca8c4e82" 51 | 52 | is-hexadecimal@^1.0.0: 53 | version "1.0.1" 54 | resolved "https://registry.yarnpkg.com/is-hexadecimal/-/is-hexadecimal-1.0.1.tgz#6e084bbc92061fbb0971ec58b6ce6d404e24da69" 55 | 56 | is-plain-obj@^1.1.0: 57 | version "1.1.0" 58 | resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e" 59 | 60 | is-whitespace-character@^1.0.0: 61 | version "1.0.1" 62 | resolved "https://registry.yarnpkg.com/is-whitespace-character/-/is-whitespace-character-1.0.1.tgz#9ae0176f3282b65457a1992cdb084f8a5f833e3b" 63 | 64 | is-word-character@^1.0.0: 65 | version "1.0.1" 66 | resolved "https://registry.yarnpkg.com/is-word-character/-/is-word-character-1.0.1.tgz#5a03fa1ea91ace8a6eb0c7cd770eb86d65c8befb" 67 | 68 | markdown-escapes@^1.0.0: 69 | version "1.0.1" 70 | resolved "https://registry.yarnpkg.com/markdown-escapes/-/markdown-escapes-1.0.1.tgz#1994df2d3af4811de59a6714934c2b2292734518" 71 | 72 | minimist@0.0.8: 73 | version "0.0.8" 74 | resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" 75 | 76 | mkdirp@^0.5.1: 77 | version "0.5.1" 78 | resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" 79 | dependencies: 80 | minimist "0.0.8" 81 | 82 | parse-entities@^1.0.2: 83 | version "1.1.1" 84 | resolved "https://registry.yarnpkg.com/parse-entities/-/parse-entities-1.1.1.tgz#8112d88471319f27abae4d64964b122fe4e1b890" 85 | dependencies: 86 | character-entities "^1.0.0" 87 | character-entities-legacy "^1.0.0" 88 | character-reference-invalid "^1.0.0" 89 | is-alphanumerical "^1.0.0" 90 | is-decimal "^1.0.0" 91 | is-hexadecimal "^1.0.0" 92 | 93 | remark-parse@^4.0.0: 94 | version "4.0.0" 95 | resolved "https://registry.yarnpkg.com/remark-parse/-/remark-parse-4.0.0.tgz#99f1f049afac80382366e2e0d0bd55429dd45d8b" 96 | dependencies: 97 | collapse-white-space "^1.0.2" 98 | is-alphabetical "^1.0.0" 99 | is-decimal "^1.0.0" 100 | is-whitespace-character "^1.0.0" 101 | is-word-character "^1.0.0" 102 | markdown-escapes "^1.0.0" 103 | parse-entities "^1.0.2" 104 | repeat-string "^1.5.4" 105 | state-toggle "^1.0.0" 106 | trim "0.0.1" 107 | trim-trailing-lines "^1.0.0" 108 | unherit "^1.0.4" 109 | unist-util-remove-position "^1.0.0" 110 | vfile-location "^2.0.0" 111 | xtend "^4.0.1" 112 | 113 | repeat-string@^1.5.4: 114 | version "1.6.1" 115 | resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" 116 | 117 | replace-ext@1.0.0: 118 | version "1.0.0" 119 | resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-1.0.0.tgz#de63128373fcbf7c3ccfa4de5a480c45a67958eb" 120 | 121 | state-toggle@^1.0.0: 122 | version "1.0.0" 123 | resolved "https://registry.yarnpkg.com/state-toggle/-/state-toggle-1.0.0.tgz#d20f9a616bb4f0c3b98b91922d25b640aa2bc425" 124 | 125 | trim-trailing-lines@^1.0.0: 126 | version "1.1.0" 127 | resolved "https://registry.yarnpkg.com/trim-trailing-lines/-/trim-trailing-lines-1.1.0.tgz#7aefbb7808df9d669f6da2e438cac8c46ada7684" 128 | 129 | trim@0.0.1: 130 | version "0.0.1" 131 | resolved "https://registry.yarnpkg.com/trim/-/trim-0.0.1.tgz#5858547f6b290757ee95cccc666fb50084c460dd" 132 | 133 | trough@^1.0.0: 134 | version "1.0.1" 135 | resolved "https://registry.yarnpkg.com/trough/-/trough-1.0.1.tgz#a9fd8b0394b0ae8fff82e0633a0a36ccad5b5f86" 136 | 137 | unherit@^1.0.4: 138 | version "1.1.0" 139 | resolved "https://registry.yarnpkg.com/unherit/-/unherit-1.1.0.tgz#6b9aaedfbf73df1756ad9e316dd981885840cd7d" 140 | dependencies: 141 | inherits "^2.0.1" 142 | xtend "^4.0.1" 143 | 144 | unified@^6.1.5: 145 | version "6.1.6" 146 | resolved "https://registry.yarnpkg.com/unified/-/unified-6.1.6.tgz#5ea7f807a0898f1f8acdeefe5f25faa010cc42b1" 147 | dependencies: 148 | bail "^1.0.0" 149 | extend "^3.0.0" 150 | is-plain-obj "^1.1.0" 151 | trough "^1.0.0" 152 | vfile "^2.0.0" 153 | x-is-function "^1.0.4" 154 | x-is-string "^0.1.0" 155 | 156 | unist-util-is@^2.1.1: 157 | version "2.1.1" 158 | resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-2.1.1.tgz#0c312629e3f960c66e931e812d3d80e77010947b" 159 | 160 | unist-util-remove-position@^1.0.0: 161 | version "1.1.1" 162 | resolved "https://registry.yarnpkg.com/unist-util-remove-position/-/unist-util-remove-position-1.1.1.tgz#5a85c1555fc1ba0c101b86707d15e50fa4c871bb" 163 | dependencies: 164 | unist-util-visit "^1.1.0" 165 | 166 | unist-util-stringify-position@^1.0.0, unist-util-stringify-position@^1.1.1: 167 | version "1.1.1" 168 | resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-1.1.1.tgz#3ccbdc53679eed6ecf3777dd7f5e3229c1b6aa3c" 169 | 170 | unist-util-visit@^1.1.0: 171 | version "1.3.0" 172 | resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-1.3.0.tgz#41ca7c82981fd1ce6c762aac397fc24e35711444" 173 | dependencies: 174 | unist-util-is "^2.1.1" 175 | 176 | vfile-location@^2.0.0: 177 | version "2.0.2" 178 | resolved "https://registry.yarnpkg.com/vfile-location/-/vfile-location-2.0.2.tgz#d3675c59c877498e492b4756ff65e4af1a752255" 179 | 180 | vfile-message@^1.0.0: 181 | version "1.0.0" 182 | resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-1.0.0.tgz#a6adb0474ea400fa25d929f1d673abea6a17e359" 183 | dependencies: 184 | unist-util-stringify-position "^1.1.1" 185 | 186 | vfile@^2.0.0: 187 | version "2.3.0" 188 | resolved "https://registry.yarnpkg.com/vfile/-/vfile-2.3.0.tgz#e62d8e72b20e83c324bc6c67278ee272488bf84a" 189 | dependencies: 190 | is-buffer "^1.1.4" 191 | replace-ext "1.0.0" 192 | unist-util-stringify-position "^1.0.0" 193 | vfile-message "^1.0.0" 194 | 195 | x-is-function@^1.0.4: 196 | version "1.0.4" 197 | resolved "https://registry.yarnpkg.com/x-is-function/-/x-is-function-1.0.4.tgz#5d294dc3d268cbdd062580e0c5df77a391d1fa1e" 198 | 199 | x-is-string@^0.1.0: 200 | version "0.1.0" 201 | resolved "https://registry.yarnpkg.com/x-is-string/-/x-is-string-0.1.0.tgz#474b50865af3a49a9c4657f05acd145458f77d82" 202 | 203 | xtend@^4.0.1: 204 | version "4.0.1" 205 | resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af" 206 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Karma Plugin for Snapshot Testing 2 | 3 | `karma-snapshot` provides a communication layer between browser and [Karma](http://karma-runner.github.io/) to store and 4 | retrieve snapshots. 5 | 6 | ![karma-snapshot Example][example] 7 | 8 | ## Supported Assertion Libraries 9 | 10 | - [chai](https://github.com/localvoid/chai-karma-snapshot) 11 | - [iko](https://github.com/localvoid/iko) 12 | 13 | ## Snapshot Format 14 | 15 | Snapshot can be stored in different formats. Right now there are two formats supported: `md` and `indented-md`. 16 | 17 | ### Markdown Format 18 | 19 | This format is preferred when you specify language for code blocks in an assertion plugin. With this format, code 20 | editors will automatically highlight syntax of code blocks. 21 | 22 | ````md 23 | # `src/html.js` 24 | 25 | ## `Sub Suite` 26 | 27 | #### `HTML Snapshot` 28 | 29 | ```html 30 |
31 | 32 |
33 | ``` 34 | ```` 35 | 36 | ### Indented Markdown Format 37 | 38 | ```md 39 | # `src/html.js` 40 | 41 | ## `Sub Suite` 42 | 43 | #### `HTML Snapshot` 44 | 45 |
46 | 47 |
48 | ``` 49 | 50 | ## Snapshot File Path 51 | 52 | Snapshot file path is extracted from the name of the root suit cases and stored alongside with a tested files in a 53 | `__snapshots__` directory. 54 | 55 | Snapshot file path can be changed by providing a custom `pathResolver` in snapshot config. 56 | 57 | ## Usage Example with Mocha and Chai 58 | 59 | ```sh 60 | $ npm install karma karma-webpack karma-sourcemap-loader karma-snapshot karma-mocha \ 61 | karma-mocha-snapshot karma-mocha-reporter karma-chrome-launcher mocha \ 62 | chai chai-karma-snapshot webpack --save-dev 63 | ``` 64 | 65 | Karma configuration: 66 | 67 | ```js 68 | // karma.conf.js 69 | const webpack = require("webpack"); 70 | 71 | module.exports = function (config) { 72 | config.set({ 73 | browsers: ["ChromeHeadless"], 74 | frameworks: ["mocha", "snapshot", "mocha-snapshot"], 75 | reporters: ["mocha"], 76 | preprocessors: { 77 | "**/__snapshots__/**/*.md": ["snapshot"], 78 | "__tests__/index.js": ["webpack", "sourcemap"] 79 | }, 80 | files: [ 81 | "**/__snapshots__/**/*.md", 82 | "__tests__/index.js" 83 | ], 84 | 85 | colors: true, 86 | autoWatch: true, 87 | 88 | webpack: { 89 | devtool: "inline-source-map", 90 | performance: { 91 | hints: false 92 | }, 93 | }, 94 | 95 | webpackMiddleware: { 96 | stats: "errors-only", 97 | noInfo: true 98 | }, 99 | 100 | snapshot: { 101 | update: !!process.env.UPDATE, 102 | prune: !!process.env.PRUNE, 103 | }, 104 | 105 | mochaReporter: { 106 | showDiff: true, 107 | }, 108 | 109 | client: { 110 | mocha: { 111 | reporter: "html", 112 | ui: "bdd", 113 | } 114 | }, 115 | }); 116 | }; 117 | ``` 118 | 119 | Source file: 120 | 121 | ```js 122 | // src/index.js 123 | 124 | export function test() { 125 | return "Snapshot Test"; 126 | } 127 | ``` 128 | 129 | Test file: 130 | 131 | ```js 132 | // __tests__/index.js 133 | import { use, expect, assert } from "chai"; 134 | import { matchSnapshot } from "chai-karma-snapshot"; 135 | import { test } from "../src/index.js"; 136 | use(matchSnapshot); 137 | 138 | describe("src/index.js", () => { 139 | it("check snapshot", () => { 140 | // 'expect' syntax 141 | expect(test()).to.matchSnapshot(); 142 | // 'assert' syntax 143 | assert.matchSnapshot(test()); 144 | }); 145 | }); 146 | ``` 147 | 148 | Run tests: 149 | 150 | ```sh 151 | $ karma start 152 | ``` 153 | 154 | Update snapshots: 155 | 156 | ```sh 157 | $ UPDATE=1 karma start --single-run 158 | ``` 159 | 160 | Prune snapshots: 161 | 162 | ```sh 163 | $ PRUNE=1 karma start --single-run 164 | ``` 165 | 166 | ## Config 167 | 168 | ```js 169 | function resolve(basePath, suiteName) { 170 | return path.join(basePath, "__snapshots__", suiteName + ".md"); 171 | } 172 | 173 | config.set({ 174 | ... 175 | snapshot: { 176 | update: true, // Run snapshot tests in UPDATE mode (default: false) 177 | prune: false, // Prune unused snapshots (default: false) 178 | format: "indented-md", // Snapshot format (default: md) 179 | checkSourceFile: true, // Checks existince of the source file associated with tests (default: false) 180 | pathResolver: resolve, // Custom path resolver, 181 | limitUnusedSnapshotsInWarning: -1 // Limit number of unused snapshots reported in the warning 182 | // -1 means no limit 183 | 184 | } 185 | }); 186 | ``` 187 | 188 | ## Custom Snapshot Format 189 | 190 | Snapshot config option `format` also works with custom serialization formats. Custom snapshot serializer should have 191 | interface: 192 | 193 | ```ts 194 | interface SnapshotSerializer { 195 | serialize: (name: string, suite: SnapshotSuite) => string, 196 | deserialize: (content: string) => { name: string, suite: SnapshotSuite }, 197 | } 198 | ``` 199 | 200 | ## Internals 201 | 202 | ### Snapshot Data 203 | 204 | `karma-snapshot` plugin is communicating with a browser by assigning a global variable `__snapshot__` on a `window` 205 | object. 206 | 207 | Snapshot data has a simple data structure: 208 | 209 | ```ts 210 | declare global { 211 | interface Window { 212 | __snapshot__: SnapshotState; 213 | } 214 | } 215 | 216 | interface SnapshotState { 217 | update?: boolean; 218 | suite: SnapshotSuite; 219 | } 220 | 221 | interface SnapshotSuite { 222 | children: { [key: string]: SnapshotSuite }; 223 | snapshots: { [key: string]: Snapshot[] }; 224 | visited?: boolean; 225 | dirty?: boolean; 226 | } 227 | 228 | interface Snapshot { 229 | lang?: string; 230 | code: string; 231 | visited?: boolean; 232 | dirty?: boolean; 233 | } 234 | ``` 235 | 236 | When `SnapshotState.update` variable is `true`, it indicates that assertion plugin should run in update mode, and 237 | instead of checking snapshots, it should update all values. 238 | 239 | `SnapshotState.suite` is a reference to the root suite. 240 | 241 | `SnapshotSuite` is a tree with snapshots that has a similar structure to test suites. `children` property is used to 242 | store references to children suites, and `snapshots` is used to store snapshot lists for tests in the current snapshot. 243 | Snapshots are stored as a list because each test can have multiple snapshot checks, and they should be automatically 244 | indexed by their position. 245 | 246 | `Snapshot` is an object that stores details about snapshot. `lang` property indicates which language should be used 247 | in a markdown format to improve readability. `code` property stores snapshot value that will be checked by an assertion 248 | plugin. 249 | 250 | `visited` is a flag that should be marked by an assertion plugin when it visits suites and snapshots. Visited flags are 251 | used to automatically prune removed snapshots. 252 | 253 | `dirty` is a flag that should be marked by an assertion plugin when it updates or adds a new snapshot. 254 | 255 | ### Interface for Assertion Libraries 256 | 257 | To make it easier to add support for assertion libraries, `SnapshotState` has two methods that should be used when 258 | creating an API for an assertion library. 259 | 260 | ```ts 261 | interface SnapshotSuite { 262 | get(path: string[], index: number): Snapshot | undefined; 263 | set(path: string[], index: number, code: string, lang?: string): void; 264 | match(received: string, expected: string): boolean; 265 | } 266 | ``` 267 | 268 | `get()` method tries to find a `Snapshot` in a current snapshot state. It also automatically marks all nodes on the 269 | `path` as visited. 270 | 271 | `set()` method adds or updates an existing `Snapshot`. 272 | 273 | `match()` method checks if two snapshots are matching in normalized form. 274 | 275 | Here is an example how it should be used: 276 | 277 | ```ts 278 | function matchSnapshot(path: string[], index: number, received: string) { 279 | if (snapshotState.update) { 280 | snapshotState.set(path, index, received); 281 | } else { 282 | const snapshot = snapshotState.get(path, index); 283 | if (!snapshot) { 284 | snapshotState.set(path, index, received); 285 | } else { 286 | const pass = snapshotState.match(received, snapshot.code); 287 | if (!pass) { 288 | throw new AssertionError(`Received value does not match stored snapshot ${index}`); 289 | } 290 | } 291 | } 292 | } 293 | ``` 294 | 295 | [example]: https://localvoid.github.io/karma-snapshot/images/example.png "karma-snapshot Example" 296 | -------------------------------------------------------------------------------- /lib/format/markdown.js: -------------------------------------------------------------------------------- 1 | const unified = require('unified'); 2 | const remarkParse = require('remark-parse') 3 | 4 | const mdParser = unified().use(remarkParse); 5 | 6 | /** 7 | * createMarkdownSerializer create a snapshot serializer. 8 | * 9 | * @param {boolean} indentCodeBlocks Use indentation for code blocks. 10 | * @returns Snapshot serializer. 11 | */ 12 | function createMarkdownSerializer(indentCodeBlocks) { 13 | return { 14 | serialize: (name, suite) => snapshotToMarkdown(name, suite, indentCodeBlocks), 15 | deserialize: markdownToSnapshot, 16 | }; 17 | } 18 | 19 | /** 20 | * markdownToSnapshot converts snapshot from markdown format into native. 21 | * 22 | * @param {string} content Snapshot in a markdown format. 23 | * @returns Snapshot in a native format. 24 | */ 25 | function markdownToSnapshot(content) { 26 | const tree = mdParser.parse(content); 27 | 28 | const state = { 29 | name: null, 30 | suite: null, 31 | suiteStack: [], 32 | currentSuite: null, 33 | currentSnapshotList: null, 34 | depth: 0 35 | }; 36 | 37 | const children = tree.children; 38 | for (let i = 0; i < children.length; i++) { 39 | const c = children[i]; 40 | switch (c.type) { 41 | case 'heading': 42 | if (c.depth === 1) { 43 | enterRootSuite(state, c); 44 | } else if (c.depth === 2) { 45 | tryExit(state, suiteDepth(c)); 46 | enterSuite(state, c); 47 | } else if (c.depth === 4) { 48 | enterSnapshot(state, c); 49 | } 50 | break; 51 | case 'code': 52 | pushSnapshotCode(state, c); 53 | break; 54 | } 55 | } 56 | 57 | return { name: state.name, suite: state.suite }; 58 | }; 59 | 60 | /** 61 | * tryExit tries to pop state until it has correct depth. 62 | * 63 | * @param {SerializerState} state Current state. 64 | * @param {number} depth Current depth. 65 | */ 66 | function tryExit(state, depth) { 67 | while (state.depth >= depth) { 68 | state.suiteStack.pop(); 69 | state.currentSuite = state.suiteStack[state.suiteStack.length - 1]; 70 | state.currentSnapshotList = null; 71 | state.depth--; 72 | } 73 | } 74 | 75 | /** 76 | * suiteDepth calculates current depth of the suite from the offset position. 77 | * 78 | * @param {*} node Markdown node. 79 | * @returns depth. 80 | */ 81 | function suiteDepth(node) { 82 | const inlineCode = node.children[0]; 83 | return ((inlineCode.position.start.column - 4) >> 1) + 1; 84 | } 85 | 86 | /** 87 | * snapshotDepth calculates current depth of the snapshot from the offset position. 88 | * 89 | * @param {*} node Markdown node. 90 | * @returns depth. 91 | */ 92 | function snapshotDepth(node) { 93 | const inlineCode = node.children[0]; 94 | return ((inlineCode.position.start.column - 6) >> 1) + 1; 95 | } 96 | 97 | /** 98 | * enterRootSuite pushes root suite into the current state. 99 | * 100 | * @param {SerializerState} state Current state. 101 | * @param {*} node Markdown node. 102 | */ 103 | function enterRootSuite(state, node) { 104 | const inlineCode = node.children[0]; 105 | const name = inlineCode.value; 106 | const suite = { 107 | children: {}, 108 | snapshots: {} 109 | } 110 | state.name = name; 111 | state.suite = suite; 112 | state.suiteStack.push(suite); 113 | state.currentSuite = suite; 114 | state.currentSnapshotList = null; 115 | state.depth = 0; 116 | } 117 | 118 | /** 119 | * enterSuite pushes suite into the current state. 120 | * 121 | * @param {SerializerState} state Current state. 122 | * @param {*} node Markdown node. 123 | */ 124 | function enterSuite(state, node) { 125 | const inlineCode = node.children[0]; 126 | const name = inlineCode.value; 127 | const suite = { 128 | children: {}, 129 | snapshots: {} 130 | } 131 | state.currentSuite.children[name] = suite; 132 | state.suiteStack.push(suite); 133 | state.currentSuite = suite; 134 | state.currentSnapshotList = null; 135 | state.depth++; 136 | } 137 | 138 | /** 139 | * enterSnapshot pushes snapshot into the current state. 140 | * 141 | * @param {SerializerState} state Current state. 142 | * @param {*} node Markdown node. 143 | */ 144 | function enterSnapshot(state, node) { 145 | const inlineCode = node.children[0]; 146 | const name = inlineCode.value; 147 | const snapshotList = []; 148 | state.currentSuite.snapshots[name] = snapshotList; 149 | state.currentSnapshotList = snapshotList; 150 | } 151 | 152 | /** 153 | * pushSnapshotCode adds snapshot to the current snapshot. 154 | * 155 | * @param {SerializerState} state Current state. 156 | * @param {*} node Markdown node. 157 | */ 158 | function pushSnapshotCode(state, node) { 159 | state.currentSnapshotList.push({ 160 | lang: node.lang, 161 | code: normalizeNewlines(node.value) 162 | }); 163 | } 164 | 165 | /** 166 | * normalizeNewlines normalizes newlines into a standard "\n" form. 167 | * 168 | * @param {string} string 169 | * @returns Normalized string. 170 | */ 171 | function normalizeNewlines(string) { 172 | return string.replace(/\r\n|\r/g, "\n"); 173 | } 174 | 175 | /** 176 | * snapshotToMarkdown converts snapshot from native into markdown format. 177 | * 178 | * @param {string} name Suite name. 179 | * @param {*} suite Root suite. 180 | * @param {boolean} indentCodeBlocks Use indentation for code blocks. 181 | * @returns Snapshot in a markdown format. 182 | */ 183 | function snapshotToMarkdown(name, suite, indentCodeBlocks) { 184 | return transformSuite(name, suite, -1, indentCodeBlocks); 185 | } 186 | 187 | /** 188 | * transformSuite converts suite from native into markdown format. 189 | * 190 | * @param {string} name Suite name. 191 | * @param {*} suite Suite. 192 | * @param {number} depth Suite depth. 193 | * @param {boolean} indentCodeBlocks Use indentation for code blocks. 194 | * @returns Suite in a markdown format. 195 | */ 196 | function transformSuite(name, suite, depth, indentCodeBlocks) { 197 | const children = suite.children; 198 | const snapshots = suite.snapshots; 199 | const nextDepth = depth + 1; 200 | 201 | let result = suiteHeader(name, depth); 202 | let keys, i; 203 | 204 | keys = Object.keys(snapshots); 205 | for (i = 0; i < keys.length; i++) { 206 | const key = keys[i]; 207 | const snapshotList = snapshots[key]; 208 | result += transformSnapshotList(key, snapshotList, nextDepth, indentCodeBlocks); 209 | } 210 | 211 | keys = Object.keys(children); 212 | for (i = 0; i < keys.length; i++) { 213 | const key = keys[i]; 214 | result += transformSuite(key, children[key], nextDepth, indentCodeBlocks); 215 | } 216 | return result; 217 | } 218 | 219 | /** 220 | * transformSnapshotList converts snapshot list from native into markdown format. 221 | * 222 | * @param {number} name Snapshot name. 223 | * @param {*} snapshotList Snapshot list. 224 | * @param {number} depth Snapshot depth. 225 | * @param {boolean} indentCodeBlocks Use indentation for code blocks. 226 | * @returns Snapshot in a markdown format. 227 | */ 228 | function transformSnapshotList(name, snapshotList, depth, indentCodeBlocks) { 229 | let result = snapshotHeader(name, depth); 230 | 231 | for (let i = 0; i < snapshotList.length; i++) { 232 | if (i > 0 && indentCodeBlocks) { 233 | result += '---\n\n'; 234 | } 235 | const snapshot = snapshotList[i]; 236 | const lang = snapshot.lang; 237 | const code = snapshot.code; 238 | const delimiter = safeDelimiter(code); 239 | 240 | if (indentCodeBlocks) { 241 | const lines = code.split('\n'); 242 | for (let i = 0; i < lines.length; i++) { 243 | result += ' ' + lines[i] + '\n'; 244 | } 245 | } else { 246 | result += delimiter; 247 | if (lang) { 248 | result += lang; 249 | } 250 | result += '\n' + code + '\n' + delimiter + '\n'; 251 | } 252 | 253 | result += '\n'; 254 | } 255 | 256 | return result; 257 | } 258 | 259 | /** 260 | * suiteHeader serializes suite header. 261 | * 262 | * @param {string} name Suite name. 263 | * @param {number} depth Suite depth. 264 | * @returns Serialized suite header. 265 | */ 266 | function suiteHeader(name, depth) { 267 | if (depth === -1) { 268 | return "# " + serializeName(name) + "\n\n"; 269 | } 270 | return "## " + indent(depth) + serializeName(name) + "\n\n"; 271 | } 272 | 273 | /** 274 | * snapshotHeader serializes snapshot header. 275 | * 276 | * @param {string} name Snapshot name. 277 | * @param {number} depth Snapshot depth. 278 | * @returns Serialized snapshot header. 279 | */ 280 | function snapshotHeader(name, depth) { 281 | return "#### " + indent(depth) + serializeName(name) + "\n\n"; 282 | } 283 | 284 | /** 285 | * serializeName serializes suite or snapshot names. 286 | * 287 | * @param {string} name Suite or snapshot name. 288 | * @returns Name wrapped into a safe delimiters. 289 | */ 290 | function serializeName(name) { 291 | const delimiter = safeDelimiter(name, '`'); 292 | return delimiter + name + delimiter; 293 | } 294 | 295 | /** 296 | * indent generates indentation string. 297 | * 298 | * @param {number} depth Current depth. 299 | * @returns Indentation string. 300 | */ 301 | function indent(depth) { 302 | let result = ''; 303 | for (let i = 0; i < depth; i++) { 304 | result += ' '; 305 | } 306 | return result; 307 | } 308 | 309 | /** 310 | * safeDelimiter tries to find a safe delimiter by appending backticks until it finally finds it. 311 | * 312 | * @param {string} s String that should be delimited. 313 | * @param {string} delimiter Initial delimiter. 314 | * @returns Safe delimiter. 315 | */ 316 | function safeDelimiter(s, delimiter) { 317 | if (delimiter === undefined) { 318 | delimiter = '```'; 319 | } 320 | while (s.indexOf(delimiter) !== -1) { 321 | delimiter += '`'; 322 | } 323 | return delimiter; 324 | } 325 | 326 | module.exports = createMarkdownSerializer; --------------------------------------------------------------------------------