mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
b57a211741
Summary: - Also converted sandboxUtil to typescript. - The issue with %s manifested when a Python traceback contained "%s" in the string; in that case the object with log metadata (e.g. docId) would confusingly replace %s as if it were part of the message from Python. Test Plan: Added a test case for the fix. Reviewers: alexmojaki Reviewed By: alexmojaki Differential Revision: https://phab.getgrist.com/D3486
318 lines
11 KiB
TypeScript
318 lines
11 KiB
TypeScript
/**
|
|
* 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 */
|
|
|
|
import * as _ from 'underscore';
|
|
import * as chai from 'chai';
|
|
import { assert } from 'chai';
|
|
import * as chaiAsPromised from 'chai-as-promised';
|
|
import * as path from 'path';
|
|
import * as fse from 'fs-extra';
|
|
import clone = require('lodash/clone');
|
|
import * as tmp from 'tmp-promise';
|
|
import * as winston from 'winston';
|
|
import { serialize } from 'winston/lib/winston/common';
|
|
|
|
import * as docUtils from 'app/server/lib/docUtils';
|
|
import * as log from 'app/server/lib/log';
|
|
import { getAppRoot } from 'app/server/lib/places';
|
|
|
|
chai.use(chaiAsPromised);
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
export async function writeTmpFile(content: any, optKeep?: boolean) {
|
|
// 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
|
|
const obj = await tmp.file({keep: optKeep, discardDescriptor: true});
|
|
await fse.writeFile(obj.path, content);
|
|
return obj.path;
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
export async function generateTmpFile(numLines: number, optKeep?: boolean) {
|
|
// Generate a bigger data file.
|
|
const data = [];
|
|
for (let i = 0; i < numLines; i++) {
|
|
data.push(i + " abcdefghijklmnopqrstuvwxyz\n");
|
|
}
|
|
return writeTmpFile(data.join(""), optKeep);
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* Helper class to capture log output when we want to test it.
|
|
*/
|
|
class CaptureTransport extends winston.Transport {
|
|
private _captureFunc: (level: string, msg: string, meta: any) => void;
|
|
|
|
public constructor(options: any) {
|
|
super();
|
|
this._captureFunc = options.captureFunc;
|
|
if (options.name) {
|
|
this.name = options.name;
|
|
}
|
|
}
|
|
|
|
public log(level: string, msg: string, meta: any, callback: () => void) {
|
|
this._captureFunc(level, msg, meta);
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* 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()).
|
|
*/
|
|
export function setTmpLogLevel(level: string, optCaptureFunc?: (level: string, msg: string, meta: any) => void) {
|
|
// 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';
|
|
}
|
|
|
|
let prevLogLevel: string|undefined = undefined;
|
|
const name = _.uniqueId('CaptureLog');
|
|
|
|
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 as any, { captureFunc: optCaptureFunc, name }); // typing is off.
|
|
}
|
|
});
|
|
|
|
after(function() {
|
|
if (optCaptureFunc) {
|
|
log.remove(name);
|
|
}
|
|
log.transports.file.level = prevLogLevel;
|
|
});
|
|
}
|
|
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
export async function captureLog(minLevel: string, callback: () => void|Promise<void>): Promise<string[]> {
|
|
const messages: string[] = [];
|
|
const prevLogLevel = log.transports.file.level;
|
|
const name = _.uniqueId('CaptureLog');
|
|
|
|
function capture(level: string, msg: string, meta: any) {
|
|
if ((log as any).levels[level] <= (log as any).levels[minLevel]) { // winston types are off?
|
|
messages.push(level + ': ' + msg + (meta ? ' ' + serialize(meta) : ''));
|
|
}
|
|
}
|
|
|
|
log.transports.file.level = -1 as any; // Suppress all log output.
|
|
log.add(CaptureTransport as any, { captureFunc: capture, name }); // types are off.
|
|
try {
|
|
await callback();
|
|
} finally {
|
|
log.remove(name);
|
|
log.transports.file.level = prevLogLevel;
|
|
}
|
|
return messages;
|
|
}
|
|
|
|
|
|
/**
|
|
* Asserts that each string of stringArray matches the corresponding regex in regexArray.
|
|
*/
|
|
export function assertMatchArray(stringArray: string[], regexArray: RegExp[]) {
|
|
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');
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
export function expectRejection(promise: Promise<any>, errCode: number|string, errRegexp: RegExp) {
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
export async function readTestScript(file: string) {
|
|
const fullText = await fse.readFile(file, {encoding: 'utf8'});
|
|
const allLines: string[] = [];
|
|
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.
|
|
*/
|
|
export async function processTestScriptSteps<T>(body: Promise<[string, T]>[],
|
|
stepCallback: (step: [string, T]) => Promise<void>) {
|
|
for (const promise of body) {
|
|
const step = await promise;
|
|
const stepName = step[0];
|
|
const lineNoPos = stepName.indexOf('@');
|
|
const lineNum = (lineNoPos === -1) ? null : stepName.slice(lineNoPos + 1);
|
|
step[0] = (lineNoPos === -1) ? stepName : stepName.slice(0, lineNoPos);
|
|
try {
|
|
await stepCallback(step);
|
|
} catch (e) {
|
|
e.message = "LINE " + lineNum + ": " + e.message;
|
|
throw e;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper that substitutes every instance of `from` value to `to` value. Iterates down the object.
|
|
*/
|
|
export function deepSubstitute(obj: any, from: any, to: any): any {
|
|
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;
|
|
}
|
|
}
|
|
|
|
export const fixturesRoot = path.resolve(getAppRoot(), 'test', 'fixtures');
|
|
|
|
export const 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.
|
|
*/
|
|
export async function useFixtureDoc(fileName: string, storageManager: any, alias: string = fileName) {
|
|
const srcPath = path.resolve(fixturesRoot, "docs", fileName);
|
|
const docName = await useLocalDoc(srcPath, storageManager, alias);
|
|
log.info("Using fixture %s as %s", fileName, docName + ".grist");
|
|
return docName;
|
|
}
|
|
|
|
/**
|
|
* Copy the given filename from srcPath to the storage manager root.
|
|
* @param {string} alias - Optional alias that lets you rename the document on disk.
|
|
*/
|
|
export async function useLocalDoc(srcPath: string, storageManager: any, alias: string = srcPath) {
|
|
let docName = path.basename(alias || srcPath, ".grist");
|
|
docName = await docUtils.createNumbered(
|
|
docName, "-",
|
|
(name: string) => docUtils.createExclusive(storageManager.getPath(name)));
|
|
await docUtils.copyFile(srcPath, storageManager.getPath(docName));
|
|
await storageManager.markAsChanged(docName);
|
|
return docName;
|
|
}
|
|
|
|
// an helper to copy a fixtures document to destPath
|
|
export async function copyFixtureDoc(docName: string, destPath: string) {
|
|
const srcPath = path.resolve(fixturesRoot, 'docs', docName);
|
|
await docUtils.copyFile(srcPath, destPath);
|
|
}
|
|
|
|
// a helper to read a fixtures document into memory
|
|
export async function readFixtureDoc(docName: string) {
|
|
const srcPath = path.resolve(fixturesRoot, 'docs', docName);
|
|
return fse.readFile(srcPath);
|
|
}
|
|
|
|
// a class to store a snapshot of environment variables, can be reverted to by
|
|
// calling .restore()
|
|
export class EnvironmentSnapshot {
|
|
private _oldEnv: NodeJS.ProcessEnv;
|
|
public constructor() {
|
|
this._oldEnv = clone(process.env);
|
|
}
|
|
|
|
// Reset environment variables.
|
|
public restore() {
|
|
Object.assign(process.env, this._oldEnv);
|
|
for (const key of Object.keys(process.env)) {
|
|
if (this._oldEnv[key] === undefined) {
|
|
delete process.env[key];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function getBuildFile(relativePath: string): Promise<string> {
|
|
if (await fse.pathExists(path.join('_build', relativePath))) {
|
|
return path.join('_build', relativePath);
|
|
}
|
|
return path.join('_build', 'core', relativePath);
|
|
}
|
|
|
|
export { assert };
|