gristlabs_grist-core/test/server/testUtils.js
Alex Hall 8a940676e9 (core) Generic tools for recording pycalls, deterministic mode.
Summary:
Replaces https://phab.getgrist.com/D2854

Refactoring of NSandbox:
- Simplify arguments to NSandbox.spawn. Only half the arguments were used depending on the flavour, adding a layer of confusion.
- Ensure the same environment variables are passed to both flavours of sandbox
- Simplify passing down environment variables.

Implement deterministic mode with libfaketime and a seeded random instance.
- Include static prebuilt libfaketime.so.1, may need another solution in future for other platforms.

Recording pycalls:
- Add script recordDocumentPyCalls.js to open a single document outside of tests.
- Refactor out recordPyCalls.ts to support various uses.
- Add afterEach hook to save all pycalls from server tests under $PYCALLS_DIR
- Make docTools usable without mocha.
- Add useLocalDoc and loadLocalDoc for loading non-fixture documents

Test Plan:
Made a document with formulas NOW() and UUID()
Compare two document openings in normal mode:

    diff <(test/recordDocumentPyCalls.js samples/d4W6NrzCMNVSVD6nWgNrGC.grist /dev/stdout) \
         <(test/recordDocumentPyCalls.js samples/d4W6NrzCMNVSVD6nWgNrGC.grist /dev/stdout)

Output:

    <                 1623407499.58132,
    ---
    >                 1623407499.60376,
    1195c1195
    <               "B": "bd2487f6-63c9-4f02-bbbc-5c0d674a2dc6"
    ---
    >               "B": "22e1a4fd-297f-4b86-91a2-bc42cc6da4b2"

`export DETERMINISTIC_MODE=1` and repeat. diff is empty!

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D2857
2021-06-15 20:58:05 +02:00

289 lines
9.9 KiB
JavaScript

/**
* Functions useful for testing.
*
* It re-exports chai.assert, so that you can import it from here with confidence
* that it has been instrumented to support things like assert.isRejected
* (via chai.use(chaiAsPromised).
*
*/
/* global before, after */
const _ = require('underscore');
const chai = require('chai');
const assert = chai.assert;
const chaiAsPromised = require('chai-as-promised');
const path = require('path');
const util = require('util');
const Promise = require('bluebird');
const fs = Promise.promisifyAll(require('fs'));
const tmp = Promise.promisifyAll(require('tmp'));
const tmpFile = Promise.promisify(tmp.file, {multiArgs: true});
const winston = require('winston');
const {serialize} = require('winston/lib/winston/common');
const docUtils = require('app/server/lib/docUtils');
const log = require('app/server/lib/log');
const {getAppRoot} = require('app/server/lib/places');
chai.use(chaiAsPromised);
Promise.config({longStackTraces: true});
/**
* Creates a temporary file with the given contents.
* @param {String} content. Data to store in the file.
* @param {[Boolean]} optKeep. Optionally pass in true to keep the file from being deleted, which
* is useful to see the content while debugging a test.
* @returns {Promise} A promise for the path of the new file.
*/
function writeTmpFile(content, optKeep) {
// discardDescriptor ensures tmp module closes it. It can lead to horrible bugs to close this
// descriptor yourself, since tmp also closes it on exit, and if it's a different descriptor by
// that time, it can lead to a crash. See https://github.com/raszi/node-tmp/issues/168
return tmpFile({keep: optKeep, discardDescriptor: true})
.spread(function(path) {
return fs.writeFileAsync(path, content)
.thenReturn(path);
});
}
exports.writeTmpFile = writeTmpFile;
/**
* Creates a temporary file with `numLines` of generated data, each line about 30 bytes long.
* This is useful for testing operations with large files.
* @param {Number} numLines. How many lines to store in the file.
* @param {[Boolean]} optKeep. Optionally pass in true to keep the file from being deleted, which
* is useful to see the content while debugging a test.
* @returns {Promise} A promise for the path of the new file.
*/
function generateTmpFile(numLines, optKeep) {
// Generate a bigger data file.
var data = [];
for (var i = 0; i < numLines; i++) {
data.push(i + " abcdefghijklmnopqrstuvwxyz\n");
}
return writeTmpFile(data.join(""), optKeep);
}
exports.generateTmpFile = generateTmpFile;
/**
* Helper class to capture log output when we want to test it.
*/
var CaptureTransport = function(options) {
this._captureFunc = options.captureFunc;
if (options.name) {
this.name = options.name;
}
};
util.inherits(CaptureTransport, winston.Transport);
CaptureTransport.prototype.name = 'CaptureTransport';
CaptureTransport.prototype.log = function(level, msg, meta, callback) {
this._captureFunc(level, msg, meta);
callback(null);
};
/**
* When used inside a test suite (inside describe()), changes the log level to the given one
* before tests, restoring it back afterwards. In addition, if optCaptureFunc is given, it will be
* called as optCaptureFunc(level, msg) with every message logged (including those suppressed).
*
* This should be called at the suite level (i.e. inside describe()).
*/
function setTmpLogLevel(level, optCaptureFunc) {
// If verbose is set in the environment, sabotage all reductions in logging level.
// Handier than modifying the setTmpLogLevel line and then remembering to set it back
// before committing.
if (process.env.VERBOSE === '1') {
level = 'debug';
}
var prevLogLevel = null;
before(function() {
if (this.runnable().parent.root) {
throw new Error("setTmpLogLevel should be called at suite level, not at root level");
}
prevLogLevel = log.transports.file.level;
log.transports.file.level = level;
if (optCaptureFunc) {
log.add(CaptureTransport, { captureFunc: optCaptureFunc });
}
});
after(function() {
if (optCaptureFunc) {
log.remove(CaptureTransport);
}
log.transports.file.level = prevLogLevel;
});
}
exports.setTmpLogLevel = setTmpLogLevel;
/**
* Captures debug log messages produced by callback. Suppresses ALL messages from console, and
* captures those at minLevel and higher. Returns a promise for the array of "level: message"
* strings. These may be tested using testUtils.assertMatchArray(). Callback may return a promise.
*/
function captureLog(minLevel, callback) {
const messages = [];
const prevLogLevel = log.transports.file.level;
const name = _.uniqueId('CaptureLog');
function capture(level, msg, meta) {
if (log.levels[level] <= log.levels[minLevel]) {
messages.push(level + ': ' + msg + (meta ? ' ' + serialize(meta) : ''));
}
}
log.transports.file.level = -1; // Suppress all log output.
log.add(CaptureTransport, { captureFunc: capture, name: name });
return Promise.try(() => callback())
.finally(() => {
log.remove(name);
log.transports.file.level = prevLogLevel;
})
.return(messages);
}
exports.captureLog = captureLog;
/**
* Asserts that each string of stringArray matches the corresponding regex in regexArray.
*/
function assertMatchArray(stringArray, regexArray) {
for (let i = 0; i < Math.min(stringArray.length, regexArray.length); i++) {
assert.match(stringArray[i], regexArray[i]);
}
assert.isAtMost(stringArray.length, regexArray.length,
`Unexpected strings seen: ${stringArray.slice(regexArray.length).join('\n')}`);
assert.isAtLeast(stringArray.length, regexArray.length,
'Not all expected strings were seen');
}
exports.assertMatchArray = assertMatchArray;
/**
* Helper method for handling expected Promise rejections.
*
* @param {Promise} promise = the promise we are checking for errors
* @param {String} errCode - Error code to check against `err.code` from the caller.
* @param {RegExp} errRegexp - Regular expression to check against `err.message` from the caller.
*/
function expectRejection(promise, errCode, errRegexp) {
return promise
.then(function() {
assert(false, "Expected promise to return an error: " + errCode);
})
.catch(function(err) {
if (err.cause) {
err = err.cause;
}
assert.strictEqual(err.code, errCode);
if (errRegexp !== undefined) {
assert(errRegexp.test(err.message), "Description doesn't match regexp: " +
errRegexp + ' !~ ' + err.message);
}
});
}
exports.expectRejection = expectRejection;
/**
* Reads in doc actions from a test script. Used in DocStorage_Script.js and DocData.js.
* This parser inserts line numbers into the step names of the test case bodies. Users of the test
* script should iterate through the steps using processTestScriptSteps, which will strip out the
* line numbers, and include them into any failure messages.
*
* @param {String} file - Input test script
* @returns {Promise:Object} - Parsed test script object
*/
exports.readTestScript = function(file) {
return fs.readFileAsync(file, {encoding: 'utf8'})
.then(function(fullText) {
var allLines = [];
fullText.split("\n").forEach(function(line, i) {
if (line.match(/^\s*\/\//)) {
allLines.push('');
} else {
line = line.replace(/"(APPLY|CHECK_OUTPUT|LOAD_SAMPLE)"\s*,/, '"$1@' + (i + 1) + '",');
allLines.push(line);
}
});
return JSON.parse(allLines.join("\n"));
});
};
/**
* For a test case step, such as ["APPLY", {actions}], checks if the step name has an encoded line
* number, strips it, runs the callback with the step data, and inserts the line number into any
* errors thrown by the callback.
*/
exports.processTestScriptSteps = function(body, stepCallback) {
return Promise.each(body, function(step) {
var stepName = step[0];
var lineNoPos = stepName.indexOf('@');
var lineNum = (lineNoPos === -1) ? null : stepName.slice(lineNoPos + 1);
step[0] = (lineNoPos === -1) ? stepName : stepName.slice(0, lineNoPos);
return Promise.try(() => stepCallback(step))
.catch(function(e) {
e.message = "LINE " + lineNum + ": " + e.message;
throw e;
});
});
};
/**
* Helper that substitutes every instance of `from` value to `to` value. Iterates down the object.
*/
function deepSubstitute(obj, from, to) {
assert.lengthOf(arguments, 3, 'Must specify obj, from, and to params');
from = _.isArray(from) ? from : [from];
if (_.isArray(obj)) {
return obj.map(el => deepSubstitute(el, from, to));
} else if (obj && typeof obj === 'object' && !_.isFunction(obj)) {
return _.mapObject(obj, el => deepSubstitute(el, from, to));
} else {
return from.indexOf(obj) !== -1 ? to : obj;
}
}
exports.deepSubstitute = deepSubstitute;
const fixturesRoot = path.resolve(getAppRoot(), 'test', 'fixtures');
exports.fixturesRoot = fixturesRoot;
exports.appRoot = getAppRoot();
/**
* Copy the given filename from the fixtures directory (test/fixtures)
* to the storage manager root.
* @param {string} alias - Optional alias that lets you rename the document on disk.
*/
function useFixtureDoc(fileName, storageManager, alias = fileName) {
var srcPath = path.resolve(fixturesRoot, "docs", fileName);
return useLocalDoc(srcPath, storageManager, alias)
.tap(docName => log.info("Using fixture %s as %s", fileName, docName + ".grist"))
}
exports.useFixtureDoc = useFixtureDoc;
/**
* Copy the given filename from srcPath to the storage manager root.
* @param {string} alias - Optional alias that lets you rename the document on disk.
*/
function useLocalDoc(srcPath, storageManager, alias = srcPath) {
var docName = path.basename(alias || srcPath, ".grist");
return docUtils.createNumbered(docName, "-",
name => docUtils.createExclusive(storageManager.getPath(name))
)
.tap(docName => docUtils.copyFile(srcPath, storageManager.getPath(docName)))
.tap(docName => storageManager.markAsChanged(docName));
}
exports.useLocalDoc = useLocalDoc;
exports.assert = assert;