gristlabs_grist-core/test/server/testUtils.ts

360 lines
12 KiB
TypeScript
Raw Permalink Normal View History

/**
* 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 { assert } from 'chai';
import {tmpdir} from 'os';
import * as path from 'path';
import * as fse from 'fs-extra';
import clone = require('lodash/clone');
import * as tmp from 'tmp-promise';
import {Options as TmpOptions} from 'tmp';
import * as winston from 'winston';
import { serialize } from 'winston/lib/winston/common';
import * as docUtils from 'app/server/lib/docUtils';
import log from 'app/server/lib/log';
import { getAppRoot } from 'app/server/lib/places';
/**
* Creates a temporary file with the given contents.
* @param {String} content. Data to store in the file.
* @param {[Boolean]} options.keep. 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, options: TmpOptions = {}) {
// 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({discardDescriptor: true, ...options});
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]} options.keep. 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, options: TmpOptions = {}) {
// Generate a bigger data file.
const data = [];
for (let i = 0; i < numLines; i++) {
data.push(i + " abcdefghijklmnopqrstuvwxyz\n");
}
return writeTmpFile(data.join(""), options);
}
/**
* 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(options);
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.
*/
(core) Add timeouts to prevent ActiveDoc bad state during shutdown. Summary: Add two shutdown-related timeouts. 1. One is to limit the duration of any work that happens once shutdown begins. In particular, waiting for an update to current time could block indefinitely if the data engine is unresponsive. Such awaits are now limited to 5 seconds. 2. The other is to allow documents to get shutdown for inactivity even when some work takes forever. Certain work (e.g. applying user actions) generally prevents a document from shutting down while it's pending. This prevention is now limited to 5 minutes. Shutting down a doc while something is pending may break some assumptions, and lead to errors. The timeout is long to let us assume that the work is stuck, and that errors are better than waiting forever. Other changes: - Periodic ActiveDoc work (intervals) is now started when a doc finishes loading rather than in the constructor. The difference only showed up in tests which makes the intervals much shorter. - Move timeoutReached() utility function to gutil, and use it for isLongerThan(), since they are basically identical. Also makes sure that the timer in these is cleared in all cases. - Remove duplicate waitForIt implementation (previously had a copy in both test/server and core/test/server). - Change testUtil.captureLog to pass messages to its callback, to allow asserts on messages within the callback. Test Plan: Added new unittests for the new shutdowns, including a replication of a bad state that was possible during shutdown. Reviewers: paulfitz Reviewed By: paulfitz Subscribers: paulfitz Differential Revision: https://phab.getgrist.com/D4040
2023-09-13 05:58:05 +00:00
export async function captureLog(
minLevel: string, callback: (messages: string[]) => void|Promise<void>,
options: {timestamp?: boolean, waitForFirstLog?: boolean} = {timestamp: false, waitForFirstLog: false}
(core) Add timeouts to prevent ActiveDoc bad state during shutdown. Summary: Add two shutdown-related timeouts. 1. One is to limit the duration of any work that happens once shutdown begins. In particular, waiting for an update to current time could block indefinitely if the data engine is unresponsive. Such awaits are now limited to 5 seconds. 2. The other is to allow documents to get shutdown for inactivity even when some work takes forever. Certain work (e.g. applying user actions) generally prevents a document from shutting down while it's pending. This prevention is now limited to 5 minutes. Shutting down a doc while something is pending may break some assumptions, and lead to errors. The timeout is long to let us assume that the work is stuck, and that errors are better than waiting forever. Other changes: - Periodic ActiveDoc work (intervals) is now started when a doc finishes loading rather than in the constructor. The difference only showed up in tests which makes the intervals much shorter. - Move timeoutReached() utility function to gutil, and use it for isLongerThan(), since they are basically identical. Also makes sure that the timer in these is cleared in all cases. - Remove duplicate waitForIt implementation (previously had a copy in both test/server and core/test/server). - Change testUtil.captureLog to pass messages to its callback, to allow asserts on messages within the callback. Test Plan: Added new unittests for the new shutdowns, including a replication of a bad state that was possible during shutdown. Reviewers: paulfitz Reviewed By: paulfitz Subscribers: paulfitz Differential Revision: https://phab.getgrist.com/D4040
2023-09-13 05:58:05 +00:00
): Promise<string[]> {
const messages: string[] = [];
const prevLogLevel = log.transports.file.level;
const name = _.uniqueId('CaptureLog');
const captureFirstLogPromise = new Promise((resolve) => {
function capture(level: string, msg: string, meta: any) {
if ((log as any).levels[level] <= (log as any).levels[minLevel]) { // winston types are off?
const timePrefix = options.timestamp ? new Date().toISOString() + ' ' : '';
messages.push(`${timePrefix}${level}: ${msg}${meta ? ' ' + serialize(meta) : ''}`);
resolve(null);
}
}
if (!process.env.VERBOSE) {
log.transports.file.level = -1 as any; // Suppress all log output.
}
log.add(CaptureTransport as any, { captureFunc: capture, name, level: minLevel}); // types are off.
});
try {
(core) Add timeouts to prevent ActiveDoc bad state during shutdown. Summary: Add two shutdown-related timeouts. 1. One is to limit the duration of any work that happens once shutdown begins. In particular, waiting for an update to current time could block indefinitely if the data engine is unresponsive. Such awaits are now limited to 5 seconds. 2. The other is to allow documents to get shutdown for inactivity even when some work takes forever. Certain work (e.g. applying user actions) generally prevents a document from shutting down while it's pending. This prevention is now limited to 5 minutes. Shutting down a doc while something is pending may break some assumptions, and lead to errors. The timeout is long to let us assume that the work is stuck, and that errors are better than waiting forever. Other changes: - Periodic ActiveDoc work (intervals) is now started when a doc finishes loading rather than in the constructor. The difference only showed up in tests which makes the intervals much shorter. - Move timeoutReached() utility function to gutil, and use it for isLongerThan(), since they are basically identical. Also makes sure that the timer in these is cleared in all cases. - Remove duplicate waitForIt implementation (previously had a copy in both test/server and core/test/server). - Change testUtil.captureLog to pass messages to its callback, to allow asserts on messages within the callback. Test Plan: Added new unittests for the new shutdowns, including a replication of a bad state that was possible during shutdown. Reviewers: paulfitz Reviewed By: paulfitz Subscribers: paulfitz Differential Revision: https://phab.getgrist.com/D4040
2023-09-13 05:58:05 +00:00
await callback(messages);
if (options.waitForFirstLog) {
await captureFirstLogPromise;
}
} 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();
/**
(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 16:52:03 +00:00
* 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;
(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 16:52:03 +00:00
}
/**
* 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 {
public static push() {
this._stack.push(new EnvironmentSnapshot());
}
public static pop() {
const snapshot = this._stack.pop();
if (!snapshot) {
throw new Error("EnvironmentSnapshot stack is empty");
}
snapshot.restore();
}
private static _stack: 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];
}
}
}
public get(key: string): string|undefined {
return this._oldEnv[key];
}
}
export async function createTestDir(suiteName: string): Promise<string> {
const tmpRootDir = process.env.TESTDIR || tmpdir();
const workerIdText = process.env.MOCHA_WORKER_ID || '0';
const username = process.env.USER || "nobody";
const testDir = path.join(tmpRootDir, `grist_test_${username}_${suiteName}_${workerIdText}`);
// Remove any previous tmp dir, and create the new one.
await fse.remove(testDir);
await fse.mkdirs(testDir);
log.warn(`Test logs and data are at: ${testDir}/`);
return testDir;
}
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 };