gristlabs_grist-core/test/server/testUtils.ts
Dmitry S 2705d41c34 (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-26 15:32:49 -04:00

336 lines
12 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 { 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();
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: (messages: string[]) => 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) : ''));
}
}
if (!process.env.VERBOSE) {
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(messages);
} 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];
}
}
}
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 };