Created
September 6, 2016 14:15
-
-
Save wincent/e6dfd0c19eb4e743b140b68ee6937d99 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Copyright (c) 2013-present, Facebook, Inc. | |
* All rights reserved. | |
* | |
* This source code is licensed under the BSD-style license found in the | |
* LICENSE file in the root directory of this source tree. An additional grant | |
* of patent rights can be found in the PATENTS file in the same directory. | |
*/ | |
'use strict'; | |
const invariant = require('invariant'); | |
const partitionArray = require('partitionArray'); | |
const COLOR = { | |
RESET: '\x1b[0m', | |
BOLD: '\x1b[1m', | |
RED_BG: '\x1b[41m', | |
GREEN_BG: '\x1b[42m', | |
YELLOW_BG: '\x1b[43m', | |
red(string) { | |
return COLOR.BOLD + COLOR.RED_BG + string + COLOR.RESET; | |
}, | |
green(string) { | |
return COLOR.BOLD + COLOR.GREEN_BG + string + COLOR.RESET; | |
}, | |
yellow(string) { | |
return COLOR.BOLD + COLOR.YELLOW_BG + string + COLOR.RESET; | |
}, | |
}; | |
/** | |
* Returns "golden" matchers for use with the specified `testFile`. | |
* | |
* Golden tests read a collection of input files, process them with a | |
* function, and compare the results against a collection of ("golden") output | |
* files. Discrepancies are reported as textual diffs, and the golden files | |
* can be updated by running the tests with an environment variable set. | |
* | |
* A directory containing input and output fixtures can be specified using a | |
* path relative to the directory containing the current test file (passed in | |
* via the `testFile` parameter): | |
* | |
* beforeEach(() => { | |
* jasmine.addMatchers(getGoldenMatchers(__filename)); | |
* }); | |
* | |
* If you don't initialize in this way, either absolute paths, or paths | |
* relative to the current working directory, may be used, but this is not | |
* recommended. | |
* | |
* Additionally, an optional options object can be supplied to fine-tune the | |
* comparison between actual and expected outputs: | |
* | |
* getGoldenMatchers( | |
* __filename, | |
* {trimWhitespace: false} | |
* ); | |
* | |
* Supported options: | |
* | |
* - `trimWhitespace`: If `true` (the default), trailing whitespace is | |
* stripped before comparing actual and expected output. | |
* | |
*/ | |
function getGoldenMatchers(...args) { | |
const [testFile, options] = args; | |
const trimWhitespace = options && options.hasOwnProperty('trimWhitespace') ? | |
options.trimWhitespace : | |
true; | |
return { | |
/** | |
* Implements the "golden" test pattern. Takes a path to a folder of input | |
* and output fixtures, and a function to operate on the contents of each | |
* input file. | |
* | |
* Input files have the form "$BASENAME.input.$EXT" and output files have | |
* the form "$BASENAME.golden.$EXT". Input and output files are matched up | |
* according to their basenames (ie. file extensions are ignored). This | |
* enables viewing and editing input and output files with arbitrary syntax | |
* highlighting in your editor. | |
* | |
* To run the tests: | |
* | |
* expect('fixtures/parser').toMatchGolden(text => parse(text)); | |
* | |
* To update the golden files, re-run the tests with the | |
* `GOLDEN_ACCEPT` environment variable set: | |
* | |
* GOLDEN_ACCEPT=1 jest MyTestModule | |
* | |
* Additionally, note that adding a new input file and running the tests | |
* will cause the corresponding golden file to be created with a default | |
* ".txt" extension. | |
*/ | |
toMatchGolden(util) { | |
const fs = require('fs'); | |
const path = require('path'); | |
const base = testFile ? path.dirname(testFile) : process.cwd(); | |
return { | |
compare(fixtures, operation) { | |
const absoluteFixtures = path.isAbsolute(fixtures) ? | |
fixtures : | |
path.join(base, fixtures); | |
invariant( | |
fs.statSync(absoluteFixtures).isDirectory(), | |
`toMatchGolden: "${fixtures}" is not a directory` | |
); | |
const fixtureInfo = fs.readdirSync(absoluteFixtures).map(file => { | |
const {ext, name: nameWithType} = path.parse(file); | |
const {ext: type, name} = path.parse(nameWithType); | |
const fixture = path.join(absoluteFixtures, file); | |
invariant( | |
ext !== '' && (type === '.input' || type === '.golden'), | |
`toMatchGolden: "${file}" must be named ` + | |
'"*.input.$EXTENSION" or "*.golden.$EXTENSION".' | |
); | |
invariant( | |
fs.statSync(fixture).isFile(), | |
`toMatchGolden: "${file}" must be a regular file.` | |
); | |
return { | |
ext: ext.slice(1), | |
fixture, | |
name, | |
type: type.slice(1), | |
}; | |
}); | |
const inputFilesSet = new Set(); | |
const outputFilesMap = new Map(); | |
const [inputFiles, outputFiles] = partitionArray( | |
fixtureInfo, | |
info => { | |
const {name, type} = info; | |
if (type === 'input') { | |
inputFilesSet.add(name); | |
return true; | |
} else { | |
outputFilesMap.set(name, info); | |
} | |
} | |
); | |
outputFiles.forEach(({ext, name, type}) => { | |
invariant( | |
inputFilesSet.has(name), | |
`toMatchGolden: golden file "${name}.${type}.${ext}" does ` + | |
'not have a corresponding input file.' | |
); | |
}); | |
const failures = []; | |
inputFiles.forEach(({ext, fixture, name, type}) => { | |
const inputFile = `${name}.${type}.${ext}`; | |
const input = fs.readFileSync(fixture).toString(); | |
let output; | |
try { | |
output = operation(input); | |
} catch (e) { | |
throw new Error( | |
'Failure applying function to input from file ' + | |
`"${inputFile}":\n` + | |
`${e.message}\n${e.stack}` | |
); | |
} | |
if (outputFilesMap.has(name)) { | |
const expectedFileInfo = outputFilesMap.get(name); | |
const expectedFile = expectedFileInfo.fixture; | |
const expected = fs.readFileSync(expectedFile).toString(); | |
const trimmedOutput = trimWhitespace ? output.trim() : output; | |
const trimmedExpected = trimWhitespace ? | |
expected.trim() : | |
expected; | |
if (trimmedOutput !== trimmedExpected) { | |
if (process.env.GOLDEN_ACCEPT) { | |
log(COLOR.green(' ACK ') + ' ' + name); | |
fs.writeFileSync(expectedFile, normalize(output)); | |
} else { | |
log(COLOR.red(' FAIL ') + ' ' + name); | |
failures.push({ | |
name, | |
expectedFile, | |
expected, | |
output, | |
}); | |
printDiff(trimmedExpected, trimmedOutput, expectedFileInfo); | |
} | |
} else { | |
log(COLOR.green(' OK ') + ' ' + name); | |
} | |
} else { | |
log(COLOR.yellow(' NEW ') + ' ' + name); | |
const golden = path.join(absoluteFixtures, `${name}.golden.txt`); | |
fs.writeFileSync(golden, normalize(output)); | |
} | |
}); | |
return { | |
pass: failures.length === 0, | |
message: 'actual output did not match expected for files: ' + | |
failures.map(failure => failure.name).join(', ') + | |
' (if these changes are intended, re-run the tests with the ' + | |
'environment variable GOLDEN_ACCEPT=1 to update the ' + | |
'fixtures)', | |
}; | |
}, | |
}; | |
}, | |
}; | |
} | |
/** | |
* Normalize a string by ensuring it ends with exactly one newline character and | |
* no other whitespace. | |
*/ | |
function normalize(string) { | |
return string.replace(/\s*$/, '\n'); | |
} | |
/** | |
* Log some output. | |
*/ | |
function log(...args) { | |
// Before logging, erase "Running 6 test suites..." that Jest may have | |
// inserted at the beginning of the current line. | |
const ERASE_LINE = '\x1b[2K'; | |
const BEGINNING_OF_LINE = '\x1b[50D'; | |
process.stdout.write(`${ERASE_LINE}${BEGINNING_OF_LINE}`); | |
console.log.apply(console, args); // eslint-disable-line no-console-disallow | |
} | |
function printDiff(expectedText, actualText, info) { | |
const child_process = require('child_process'); | |
const fs = require('fs'); | |
const path = require('path'); | |
const {ext, name, type} = info; | |
const temp = getTemporaryDirectory(); | |
function write(suffix, contents) { | |
const file = `${name}.${type}.${ext}.${suffix}`; | |
fs.writeFileSync(path.join(temp, file), normalize(contents)); | |
return file; | |
} | |
try { | |
child_process.execFileSync( | |
'git', | |
[ | |
'diff', | |
'--color=always', | |
write('expected', normalize(expectedText)), | |
write('actual', normalize(actualText)), | |
], | |
// Change into temporary directory to get prettier diff headers. | |
{cwd: temp} | |
); | |
} catch (e) { | |
if (e.status === 1) { | |
// This is normal, because `git diff` exits with an exit code of 1 | |
// whenever there is a diff. | |
log(e.stdout.toString()); | |
} else { | |
throw e; | |
} | |
} | |
} | |
function getTemporaryDirectory() { | |
// Very recent versions of Node have an fs.mkdtempSync call, but for now, just | |
// shell out. | |
const child_process = require('child_process'); | |
return child_process.execFileSync('mktemp', ['-d']).toString().trim(); | |
} | |
module.exports = getGoldenMatchers; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment