gristlabs_grist-core/test/server/testUtils.ts
Dmitry S a91d493ffc (core) Fix issue with 'UNEXPECTED ORDER OF CALLBACKS' in Client.ts.
Summary:
- Substantial refactoring of the logic when the server fails to send some
  messages to a client.
- Add seqId numbers to server messages to ensure reliable order.
- Add a needReload flag in clientConnect for a clear indication whent the
  browser client needs to reload the app.
- Reproduce some potential failure scenarios in a test case (some of which
  previously could have led to incorrectly ordered messages).
- Convert other Comm tests to typescript.
- Tweak logging of Comm and Client to be slightly more concise (in particular,
  avoid logging sessionId)

Note that despite the big refactoring, this only addresses a fairly rare
situation, with websocket failures while server is trying to send to the
client. It includes no improvements for failures while the client is sending to
the server.

(I looked for an existing library that would take care of these issues. A relevant article I found is https://docs.microsoft.com/en-us/azure/azure-web-pubsub/howto-develop-reliable-clients, but it doesn't include a library for both ends, and is still in review. Other libraries with similar purposes did not inspire enough confidence.)

Test Plan: New test cases, which reproduce some previously problematic scenarios.

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D3470
2022-06-16 23:51:14 -04:00

320 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) : ''));
}
}
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();
} 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 };