(core) freshen grist-core build

Summary:
 * adds a smoke test to grist-core
 * fixes a problem with highlight.js failing to load correctly
 * skips survey for default user
 * freshens docker build

Utility files in test/nbrowser are moved to core/test/nbrowser, so that gristUtils are available there. This increased the apparent size of the diff as "./" import paths needed replacing with "test/nbrowser/" paths. The utility files are untouched, except for the code to start a server - it now has a small grist-core specific conditional in it.

Test Plan: adds test

Reviewers: dsagal

Reviewed By: dsagal

Differential Revision: https://phab.getgrist.com/D2768
This commit is contained in:
Paul Fitzpatrick 2021-04-02 19:11:27 -04:00
parent 351a717e6d
commit 9f234b758d
19 changed files with 6586 additions and 406 deletions

View File

@ -1,6 +1,7 @@
# explicitly list the files needed by docker. # explicitly list the files needed by docker.
* *
!package.json !package.json
!yarn.lock
!tsconfig.json !tsconfig.json
!stubs !stubs
!app !app
@ -9,3 +10,4 @@
!static !static
!bower_components !bower_components
!sandbox !sandbox
!test

View File

@ -6,7 +6,8 @@ FROM node:10 as builder
# Install all node dependencies. # Install all node dependencies.
ADD package.json package.json ADD package.json package.json
RUN npm i ADD yarn.lock yarn.lock
RUN yarn install --frozen-lockfile
# Build node code. # Build node code.
ADD tsconfig.json tsconfig.json ADD tsconfig.json tsconfig.json
@ -14,7 +15,8 @@ ADD app app
ADD stubs stubs ADD stubs stubs
ADD buildtools buildtools ADD buildtools buildtools
ADD static static ADD static static
RUN npm run build:prod ADD test/tsconfig.json test/tsconfig.json
RUN yarn run build:prod
# Install all python dependencies. # Install all python dependencies.
ADD sandbox/requirements.txt requirements.txt ADD sandbox/requirements.txt requirements.txt
@ -61,4 +63,4 @@ ENV GRIST_DATA_DIR=/persist/docs
ENV GRIST_SESSION_COOKIE=grist_core ENV GRIST_SESSION_COOKIE=grist_core
ENV TYPEORM_DATABASE=/persist/home.sqlite3 ENV TYPEORM_DATABASE=/persist/home.sqlite3
EXPOSE 8484 EXPOSE 8484
CMD npm run start:prod CMD yarn run start:prod

View File

@ -40,10 +40,10 @@ docker run -p 8484:8484 -v $PWD/persist:/persist -it gristlabs/grist
Here are the steps needed: Here are the steps needed:
```sh ```sh
npm install yarn install
npm run build:prod yarn run build:prod
npm run install:python yarn run install:python
npm start yarn start
# unauthenticated grist client available at http://localhost:8484 # unauthenticated grist client available at http://localhost:8484
# unauthenticated grist api available at http://localhost:8484/api/ # unauthenticated grist api available at http://localhost:8484/api/
``` ```

View File

@ -403,7 +403,12 @@ export class FlexServer implements GristServer {
name: 'You', name: 'You',
email: process.env.GRIST_DEFAULT_EMAIL, email: process.env.GRIST_DEFAULT_EMAIL,
}; };
await this.dbManager.getUserByLoginWithRetry(profile.email, profile); const user = await this.dbManager.getUserByLoginWithRetry(profile.email, profile);
if (user) {
// No need to survey this user!
user.isFirstTimeUser = false;
await user.save();
}
} }
// Report which database we are using, without sensitive credentials. // Report which database we are using, without sensitive credentials.
this.info.push(['database', getDatabaseUrl(this.dbManager.connection.options, false)]); this.info.push(['database', getDatabaseUrl(this.dbManager.connection.options, false)]);

View File

@ -327,7 +327,9 @@ export class NSandboxCreator implements ISandboxCreator {
// In this case, expect to find library files in a virtualenv built by core // In this case, expect to find library files in a virtualenv built by core
// buildtools/prepare_python.sh // buildtools/prepare_python.sh
const pythonVersion = 'python2.7'; const pythonVersion = 'python2.7';
const libraryPath = `grist:../venv/lib/${pythonVersion}/site-packages`; const libraryPath =
path.join(process.cwd(), 'sandbox', 'grist') + ':' +
path.join(process.cwd(), 'venv', 'lib', pythonVersion, 'site-packages');
const args = [options.entryPoint || defaultEntryPoint]; const args = [options.entryPoint || defaultEntryPoint];
if (!options.entryPoint && options.comment) { if (!options.entryPoint && options.comment) {
// When using default entry point, we can add on a comment as an argument - it isn't // When using default entry point, we can add on a comment as an argument - it isn't

View File

@ -9,7 +9,8 @@
"start": "tsc --build -w --preserveWatchOutput & catw app/client/*.css app/client/*/*.css -o static/bundle.css -v & webpack --config buildtools/webpack.config.js --mode development --watch --hide-modules & NODE_PATH=_build:_build/stubs nodemon -w _build/app/server -w _build/app/common _build/stubs/app/server/server.js & wait", "start": "tsc --build -w --preserveWatchOutput & catw app/client/*.css app/client/*/*.css -o static/bundle.css -v & webpack --config buildtools/webpack.config.js --mode development --watch --hide-modules & NODE_PATH=_build:_build/stubs nodemon -w _build/app/server -w _build/app/common _build/stubs/app/server/server.js & wait",
"install:python": "buildtools/prepare_python.sh", "install:python": "buildtools/prepare_python.sh",
"build:prod": "tsc --build && webpack --config buildtools/webpack.config.js --mode production && cat app/client/*.css app/client/*/*.css > static/bundle.css", "build:prod": "tsc --build && webpack --config buildtools/webpack.config.js --mode production && cat app/client/*.css app/client/*/*.css > static/bundle.css",
"start:prod": "NODE_PATH=_build:_build/stubs node _build/stubs/app/server/server.js" "start:prod": "NODE_PATH=_build:_build/stubs node _build/stubs/app/server/server.js",
"test": "NODE_PATH=_build:_build/stubs mocha _build/test/nbrowser/Smoke.js"
}, },
"keywords": [ "keywords": [
"grist", "grist",
@ -23,14 +24,19 @@
"private": false, "private": false,
"devDependencies": { "devDependencies": {
"@types/backbone": "1.3.43", "@types/backbone": "1.3.43",
"@types/chai": "4.1.7",
"@types/chai-as-promised": "7.1.0",
"@types/content-disposition": "0.5.2", "@types/content-disposition": "0.5.2",
"@types/diff-match-patch": "1.0.32", "@types/diff-match-patch": "1.0.32",
"@types/double-ended-queue": "2.1.0", "@types/double-ended-queue": "2.1.0",
"@types/express": "4.16.0", "@types/express": "4.16.0",
"@types/form-data": "2.2.1",
"@types/fs-extra": "5.0.4", "@types/fs-extra": "5.0.4",
"@types/image-size": "0.0.29", "@types/image-size": "0.0.29",
"@types/js-yaml": "3.11.2", "@types/js-yaml": "3.11.2",
"@types/lodash": "4.14.117", "@types/lodash": "4.14.117",
"@types/mime-types": "2.1.0",
"@types/mocha": "5.2.5",
"@types/moment-timezone": "0.5.9", "@types/moment-timezone": "0.5.9",
"@types/node": "^10", "@types/node": "^10",
"@types/node-fetch": "2.1.2", "@types/node-fetch": "2.1.2",
@ -38,12 +44,18 @@
"@types/pidusage": "2.0.1", "@types/pidusage": "2.0.1",
"@types/plotly.js": "1.44.15", "@types/plotly.js": "1.44.15",
"@types/redlock": "3.0.2", "@types/redlock": "3.0.2",
"@types/selenium-webdriver": "4.0.0",
"@types/sqlite3": "3.1.6", "@types/sqlite3": "3.1.6",
"@types/tmp": "0.0.33", "@types/tmp": "0.0.33",
"@types/uuid": "3.4.4", "@types/uuid": "3.4.4",
"catw": "1.0.1", "catw": "1.0.1",
"chai": "4.2.0",
"chai-as-promised": "7.1.1",
"mocha": "5.2.0",
"mocha-webdriver": "0.2.8",
"moment-locales-webpack-plugin": "^1.2.0", "moment-locales-webpack-plugin": "^1.2.0",
"nodemon": "^2.0.4", "nodemon": "^2.0.4",
"selenium-webdriver": "3.6.0",
"source-map-loader": "^0.2.4", "source-map-loader": "^0.2.4",
"stats-webpack-plugin": "^0.7.0", "stats-webpack-plugin": "^0.7.0",
"typescript": "3.9.3", "typescript": "3.9.3",
@ -75,6 +87,7 @@
"fs-extra": "7.0.0", "fs-extra": "7.0.0",
"grain-rpc": "0.1.6", "grain-rpc": "0.1.6",
"grainjs": "1.0.1", "grainjs": "1.0.1",
"highlight.js": "9.13.1",
"i18n-iso-countries": "6.1.0", "i18n-iso-countries": "6.1.0",
"image-size": "0.6.3", "image-size": "0.6.3",
"jquery": "2.2.1", "jquery": "2.2.1",

View File

@ -4,8 +4,12 @@
* By default, starts up on port 8484. * By default, starts up on port 8484.
*/ */
import {isAffirmative} from 'app/common/gutil';
const debugging = isAffirmative(process.env.DEBUG) || isAffirmative(process.env.VERBOSE);
// Set log levels before importing anything. // Set log levels before importing anything.
if (!process.env.DEBUG) { if (!debugging) {
// Be a lot less noisy by default. // Be a lot less noisy by default.
setDefaultEnv('GRIST_LOG_LEVEL', 'error'); setDefaultEnv('GRIST_LOG_LEVEL', 'error');
setDefaultEnv('GRIST_LOG_SKIP_HTTP', 'true'); setDefaultEnv('GRIST_LOG_SKIP_HTTP', 'true');
@ -32,7 +36,7 @@ function setDefaultEnv(name: string, value: string) {
// tslint:disable:no-console // tslint:disable:no-console
export async function main() { export async function main() {
console.log('Welcome to Grist.'); console.log('Welcome to Grist.');
if (!process.env.DEBUG) { if (!debugging) {
console.log(`In quiet mode, see http://localhost:${G.port} to use.`); console.log(`In quiet mode, see http://localhost:${G.port} to use.`);
console.log('For full logs, re-run with DEBUG=1'); console.log('For full logs, re-run with DEBUG=1');
} }
@ -45,7 +49,10 @@ export async function main() {
// Make a blank db if needed. // Make a blank db if needed.
await updateDb(); await updateDb();
// Launch single-port, self-contained version of Grist. // Launch single-port, self-contained version of Grist.
await mergedServerMain(G.port, ["home", "docs", "static"]); const server = await mergedServerMain(G.port, ["home", "docs", "static"]);
if (process.env.GRIST_TESTING_SOCKET) {
await server.addTestingHooks();
}
} }
if (require.main === module) { if (require.main === module) {

View File

@ -0,0 +1,46 @@
/**
* Settings that affect tests using mocha-webdriver. This module is imported by any run of mocha,
* by being listed in test/mocha.opts. (Keep in mind that it's imported by non-browser tests, such
* as test/common, as well.)
*/
// This determines when a failed assertion shows a diff with details or
// "expected [ Array(3) ] to deeply equal [ Array(3) ]".
// Increase the threshhold since the default (of 40 characters) is often too low.
// You can override it using CHAI_TRUNCATE_THRESHOLD env var; 0 disables it.
require('chai').config.truncateThreshold = process.env.CHAI_TRUNCATE_THRESHOLD ?
parseFloat(process.env.CHAI_TRUNCATE_THRESHOLD) : 200;
// Set an explicit window size (if not set by an external variable), to ensure that manully-run
// and Jenkins-run tests, headless or not, use a consistent size. (Not that height is still not
// identical between regular and headless browsers.)
//
// The size is picked to be on the small size, to ensure we test issues caused by constrained
// space (e.g. scrolling when needed). 1024x640 is a slight increase over 900x600 we used before.
// Note that https://www.hobo-web.co.uk/best-screen-size/ lists 1366×768 as most common desktop
// size, so it's reasonable to assume a browser that takes up most but not all of such a screen.
if (!process.env.MOCHA_WEBDRIVER_WINSIZE) {
process.env.MOCHA_WEBDRIVER_WINSIZE = "1024x640";
}
// Enable enhanced stacktraces by default. Disable by running with MOCHA_WEBDRIVER_STACKTRACES="".
if (process.env.MOCHA_WEBDRIVER_STACKTRACES === undefined) {
process.env.MOCHA_WEBDRIVER_STACKTRACES = "1";
}
// Default to chrome for mocha-webdriver testing. Override by setting SELENIUM_BROWSER, as usual.
if (!process.env.SELENIUM_BROWSER) {
process.env.SELENIUM_BROWSER = "chrome";
}
// Don't fail on mismatched Chrome versions. Disable with MOCHA_WEBDRIVER_IGNORE_CHROME_VERSION="".
if (process.env.MOCHA_WEBDRIVER_IGNORE_CHROME_VERSION === undefined) {
process.env.MOCHA_WEBDRIVER_IGNORE_CHROME_VERSION = "1";
}
// don't show "Chrome is controlled by..." banner since at time of writing it can
// swallow early clicks on page reload.
if (process.env.MOCHA_WEBDRIVER_NO_CONTROL_BANNER === undefined) {
process.env.MOCHA_WEBDRIVER_NO_CONTROL_BANNER = "1";
}

2
test/mocha.opts Normal file
View File

@ -0,0 +1,2 @@
--require source-map-support/register
test/init-mocha-webdriver

46
test/nbrowser/Smoke.ts Normal file
View File

@ -0,0 +1,46 @@
/**
*
* This is a minimal test to make sure documents can be created, edited, and
* reopened. Grist has a very extensive test set that has not yet been ported
* to the grist-core.
*
*/
import { assert, driver } from 'mocha-webdriver';
import { server, setupTestSuite } from 'test/nbrowser/testUtils';
import * as gu from 'test/nbrowser/gristUtils';
async function openMainPage() {
await driver.get(`${server.getHost()}`);
while (true) { // eslint-disable-line no-constant-condition
try {
const url = await driver.getCurrentUrl();
if (url.match(/welcome\//)) {
await driver.findContent('button', /Continue/).click();
}
if (await driver.findContent('button', /Create Empty Document/).isPresent()) {
return;
}
} catch (e) {
// don't worry about transients.
}
await driver.sleep(10);
}
}
describe("Smoke", function() {
this.timeout(20000);
setupTestSuite();
it('can create, edit, and reopen a document', async function() {
this.timeout(20000);
await openMainPage();
await driver.findContent('button', /Create Empty Document/).click();
await gu.waitForDocToLoad(20000);
await gu.getCell('A', 1).click();
await gu.enterCell('123');
await driver.navigate().refresh();
await gu.waitForDocToLoad();
assert.equal(await gu.getCell('A', 1).getText(), '123');
});
});

1401
test/nbrowser/gristUtils.ts Normal file

File diff suppressed because it is too large Load Diff

329
test/nbrowser/homeUtil.ts Normal file
View File

@ -0,0 +1,329 @@
/**
* Contains some non-webdriver functionality needed by tests.
*/
import * as FormData from 'form-data';
import * as fse from 'fs-extra';
import defaults = require('lodash/defaults');
import {WebElement} from 'mocha-webdriver';
import fetch from 'node-fetch';
import * as path from 'path';
import {WebDriver} from 'selenium-webdriver';
import {UserProfile} from 'app/common/LoginSessionAPI';
import {DocWorkerAPI, UserAPI, UserAPIImpl} from 'app/common/UserAPI';
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
import * as log from 'app/server/lib/log';
import {TestingHooksClient} from 'app/server/lib/TestingHooks';
export interface Server {
driver: WebDriver;
getTestingHooks(): Promise<TestingHooksClient>;
getHost(): string;
getUrl(team: string, relPath: string): string;
getDatabase(): Promise<HomeDBManager>;
isExternalServer(): boolean;
}
export class HomeUtil {
// Cache api keys of test users. It is often convenient to have various instances
// of the home api available while making browser tests.
private _apiKey = new Map<string, string>();
constructor(public fixturesRoot: string, public server: Server) {}
public get driver(): WebDriver { return this.server.driver; }
/**
* Set current session to a simulated login with the given name and email. Available options
* include:
* - `loginMethod`: when provided will store in the database which method the
* user nominally logged in with (e.g. 'Email + Password' or 'Google').
* - `isFirstLogin`: when provided will cause user to be redirected or not to the
* welcome pages.
* - `freshAccount`: when true will cause the user account to be deleted and
* recreated if it already existed.
* - `cacheCredentials`: when true will result in the user's api key being stored
* (after having been created if necessary), so that their home api can be later
* instantiated without page loads.
* When testing against an external server, the simulated login is in fact genuine,
* done via Cognito.
*/
public async simulateLogin(name: string, email: string, org: string = "", options: {
loginMethod?: UserProfile['loginMethod'],
freshAccount?: boolean,
isFirstLogin?: boolean,
cacheCredentials?: boolean,
} = {}) {
const {loginMethod, isFirstLogin} = defaults(options, {loginMethod: 'Email + Password'});
// For regular tests, we can log in through a testing hook.
if (!this.server.isExternalServer()) {
if (options.freshAccount) { await this._deleteUserByEmail(email); }
if (isFirstLogin !== undefined) { await this._setFirstLogin(email, isFirstLogin); }
// TestingHooks communicates via JSON, so it's impossible to send an `undefined` value for org
// through it. Using the empty string happens to work though.
const testingHooks = await this.server.getTestingHooks();
await testingHooks.setLoginSessionProfile(await this.getGristSid(), {name, email, loginMethod}, org);
} else {
if (loginMethod && loginMethod !== 'Email + Password') {
throw new Error('only Email + Password logins supported for external server tests');
}
// Make sure we revisit page in case login is changing.
await this.driver.get('about:blank');
// When running against an external server, we log in through Cognito.
await this.driver.get(this.server.getUrl(org, ""));
if (!(await this.isOnLoginPage())) {
// Explicitly click sign-in link if necessary.
await this.driver.findWait('.test-user-signin', 4000).click();
await this.driver.findContentWait('.grist-floating-menu a', 'Sign in', 500).click();
}
await this.checkLoginPage();
await this.fillLoginForm(email);
if (!(await this.isWelcomePage()) && (options.freshAccount || options.isFirstLogin)) {
await this._recreateCurrentUser(email, org);
}
if (isFirstLogin === false) {
await this._fillWelcomePageIfPresent(name);
}
}
if (options.cacheCredentials) {
// Take this opportunity to cache access info.
if (!this._apiKey.has(email)) {
await this.driver.get(this.server.getUrl(org, ''));
this._apiKey.set(email, await this._getApiKey());
}
}
}
/**
* Remove any simulated login from the current session (for the given org, if specified).
* For testing against an external server, all logins are removed, since there's no way
* to be more nuanced.
*/
public async removeLogin(org: string = "") {
if (!this.server.isExternalServer()) {
const testingHooks = await this.server.getTestingHooks();
await testingHooks.setLoginSessionProfile(await this.getGristSid(), null, org);
} else {
await this.driver.get(`${this.server.getHost()}/logout`);
}
}
// Check if the url looks like a welcome page. The check is weak, but good enough
// for testing.
public async isWelcomePage() {
const url = await this.driver.getCurrentUrl();
return Boolean(url.match(/\/welcome\//));
}
// Fill up a Cognito login page. If on a signup page, switch to a login page.
// TEST_ACCOUNT_PASSWORD must be set, or a password provided. Should be on a Cognito
// login/signup page before calling this method.
public async fillLoginForm(email: string, password?: string) {
if (!password) {
password = process.env.TEST_ACCOUNT_PASSWORD;
if (!password) {
throw new Error('TEST_ACCOUNT_PASSWORD not set');
}
}
await this.checkLoginPage();
if ((await this.driver.getCurrentUrl()).match(/signup\?/)) {
await this.driver.findWait('a[href*="login?"]', 4000).click();
}
await this.driver.findWait('div.modal-content-desktop input[name="username"]', 4000);
await this.setValue(this.driver.findWait('div.modal-content-desktop input[name="username"]', 4000),
email);
await this.setValue(this.driver.findWait('div.modal-content-desktop input[name="password"]', 4000),
password);
await this.driver.find('div.modal-content-desktop input[name="signInSubmitButton"]').click();
}
/**
* Delete the currently logged in user.
*/
public async deleteCurrentUser() {
const apiKey = await this._getApiKey();
const api = this._createHomeApiUsingApiKey(apiKey);
const info = await api.getSessionActive();
await api.deleteUser(info.user.id, info.user.name);
}
/**
* Returns the current Grist session-id (for the selenium browser accessing this server).
*/
public async getGristSid(): Promise<string> {
// Load a cheap page on our server to get the session-id cookie from browser.
await this.driver.get(`${this.server.getHost()}/test/session`);
const cookie = await this.driver.manage().getCookie('grist_sid');
return decodeURIComponent(cookie.value);
}
/**
* Create a new document.
*/
public async createNewDoc(username: string, org: string, workspace: string, docName: string,
options: {email?: string} = {}) {
const homeApi = this.createHomeApi(username, org, options.email);
const workspaceId = await this.getWorkspaceId(homeApi, workspace);
return await homeApi.newDoc({name: docName}, workspaceId);
}
/**
* Import a fixture doc into a workspace.
*/
public async importFixturesDoc(username: string, org: string, workspace: string,
filename: string, options: {newName?: string, email?: string} = {}) {
const homeApi = this.createHomeApi(username, org, options.email);
const docWorker = await homeApi.getWorkerAPI('import');
const workspaceId = await this.getWorkspaceId(homeApi, workspace);
const uploadId = await this.uploadFixtureDoc(docWorker, filename, options.newName);
return docWorker.importDocToWorkspace(uploadId, workspaceId);
}
/**
* Create a copy of a doc. Similar to importFixturesDoc, but starts with an existing docId.
*/
public async copyDoc(username: string, org: string, workspace: string,
docId: string, options: {newName?: string} = {}) {
const homeApi = this.createHomeApi(username, org);
const docWorker = await homeApi.getWorkerAPI('import');
const workspaceId = await this.getWorkspaceId(homeApi, workspace);
const uploadId = await docWorker.copyDoc(docId);
return docWorker.importDocToWorkspace(uploadId, workspaceId);
}
// upload fixture document to the doc worker at url.
public async uploadFixtureDoc(docWorker: DocWorkerAPI, filename: string, newName: string = filename) {
const filepath = path.resolve(this.fixturesRoot, "docs", filename);
if (!await fse.pathExists(filepath)) {
throw new Error(`Can't find file: ${filepath}`);
}
const fileStream = fse.createReadStream(filepath);
// node-fetch can upload streams, although browser fetch can't
return docWorker.upload(fileStream as any, newName);
}
// A helper that find a workspace id by name for a given username and org.
public async getWorkspaceId(homeApi: UserAPIImpl, workspace: string): Promise<number> {
return (await homeApi.getOrgWorkspaces('current')).find((w) => w.name === workspace)!.id;
}
// A helper that returns the list of names of all documents within a workspace.
public async listDocs(homeApi: UserAPI, wid: number): Promise<string[]> {
const workspace = await homeApi.getWorkspace(wid);
return workspace.docs.map(d => d.name);
}
// A helper to create a UserAPI instance for a given useranme and org, that targets the home server
// Username can be null for anonymous access.
public createHomeApi(username: string|null, org: string, email?: string): UserAPIImpl {
const name = (username || '').toLowerCase();
const apiKey = username && ((email && this._apiKey.get(email)) || `api_key_for_${name}`);
return this._createHomeApiUsingApiKey(apiKey, org);
}
/**
* Set the value of an input element. This is to be used when the input element appears with its
* content already selected which can create some flakiness when using the normal approach based on
* `driver.sendKeys`. This is due to the fact that the implementation of such behaviour relies on a
* timeout that there is no easy way to listen to with selenium, so when sending keys, the
* `<element>.select()` could happens anytime on the client, which results in the value being
* truncated.
*/
public async setValue(inputEl: WebElement, value: string) {
await this.driver.executeScript(
(input: HTMLInputElement, val: string) => { input.value = val; },
inputEl, value
);
}
public async openUserProfile() {
await this.driver.findWait('.test-dm-account', 1000).click();
await this.driver.findContent('.grist-floating-menu li', 'Profile Settings').click();
await this.driver.findWait('.test-login-method', 5000);
}
/**
* Returns whether we are currently on the Cognito login page.
*/
public async isOnLoginPage() {
return /gristlogin\./.test(await this.driver.getCurrentUrl());
}
/**
* Waits for browser to navigate to Cognito login page.
*/
public async checkLoginPage(waitMs: number = 2000) {
await this.driver.wait(this.isOnLoginPage.bind(this), waitMs);
}
/**
* Delete and recreate the user, via the specified org. The specified user must be
* currently logged in!
*/
private async _recreateCurrentUser(email: string, org: string) {
await this.deleteCurrentUser();
await this.removeLogin(org);
await this.driver.get(this.server.getUrl(org, ""));
await this.driver.findWait('.test-user-signin', 4000).click();
await this.driver.findContentWait('.grist-floating-menu a', 'Sign in', 500).click();
await this.checkLoginPage();
await this.fillLoginForm(email);
}
private async _getApiKey(): Promise<string> {
return this.driver.wait(() => this.driver.executeAsyncScript<string>((done: (key: string) => void) => {
const app = (window as any).gristApp;
if (!app) { done(""); return; }
const api: UserAPI = app.topAppModel.api;
return api.fetchApiKey().then(key => {
if (key) { return key; }
return api.createApiKey();
}).then(done).catch(() => done(""));
}), 4000);
}
// Delete a user using their email address. Requires access to the database.
private async _deleteUserByEmail(email: string) {
if (this.server.isExternalServer()) { throw new Error('not supported'); }
const dbManager = await this.server.getDatabase();
const user = await dbManager.getUserByLogin(email);
if (user) { await dbManager.deleteUser({userId: user.id}, user.id, user.name); }
}
// Set whether this is the user's first time logging in. Requires access to the database.
private async _setFirstLogin(email: string, isFirstLogin: boolean) {
if (this.server.isExternalServer()) { throw new Error('not supported'); }
const dbManager = await this.server.getDatabase();
const user = await dbManager.getUserByLogin(email);
if (user) {
user.isFirstTimeUser = isFirstLogin;
await user.save();
}
}
// Get past the user welcome page if it is present.
private async _fillWelcomePageIfPresent(name?: string) {
// TODO: check treatment of welcome/team page when necessary.
if (await this.isWelcomePage()) {
if (name) {
await this.setValue(await this.driver.findWait('input[name="username"]', 4000), name);
}
const url = await this.driver.getCurrentUrl();
await this.driver.findWait('button.test-continue-button', 4000).click();
// Wait for the navigation to take place.
await this.driver.wait(async () => (await this.driver.getCurrentUrl()) !== url, 4000);
}
}
// Make a home api instance with the given api key, for the specified org.
// If no api key given, work anonymously.
private _createHomeApiUsingApiKey(apiKey: string|null, org?: string): UserAPIImpl {
const headers = apiKey ? {Authorization: `Bearer ${apiKey}`} : undefined;
return new UserAPIImpl(org ? this.server.getUrl(org, '') : this.server.getHost(), {
headers,
fetch: fetch as any,
newFormData: () => new FormData() as any, // form-data isn't quite type compatible
logger: log});
}
}

261
test/nbrowser/testServer.ts Normal file
View File

@ -0,0 +1,261 @@
/**
* NOTE: this server is also exposed via test/nbrowser/testUtils; it's only moved into its own
* file to untangle dependencies between gristUtils and testUtils.
*
* Exports `server` to be used with mocha-webdriver's useServer(). This is normally set up using
* `setupTestSuite` from test/nbrowser/testUtils.
*
* Includes server.testingHooks and some useful methods that rely on them.
*
* Run with VERBOSE=1 in the environment to see the server log on the console. Normally it goes
* into a file whose path is printed when server starts.
*/
import {encodeUrl, IGristUrlState, parseSubdomain} from 'app/common/gristUrls';
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
import * as log from 'app/server/lib/log';
import {getAppRoot} from 'app/server/lib/places';
import {makeGristConfig} from 'app/server/lib/sendAppPage';
import {exitPromise} from 'app/server/lib/serverUtils';
import {connectTestingHooks, TestingHooksClient} from 'app/server/lib/TestingHooks';
import {ChildProcess, execFileSync, spawn} from 'child_process';
import * as fse from 'fs-extra';
import {driver, IMochaServer, WebDriver} from 'mocha-webdriver';
import fetch from 'node-fetch';
import {tmpdir} from 'os';
import * as path from 'path';
import {HomeUtil} from 'test/nbrowser/homeUtil';
export class TestServerMerged implements IMochaServer {
public testDir: string;
public testDocDir: string;
public testingHooks: TestingHooksClient;
// These have been moved to HomeUtil, and get set here when HomeUtil is created.
public simulateLogin: HomeUtil["simulateLogin"];
public removeLogin: HomeUtil["removeLogin"];
private _serverUrl: string;
private _server: ChildProcess;
private _exitPromise: Promise<number|string>;
private _starts: number = 0;
private _dbManager: HomeDBManager;
private _driver: WebDriver;
// The name is used to name the directory for server logs and data.
constructor(private _name: string) {}
public async start() {
await this.restart(true);
}
/**
* Restart the server. If reset is set, the database is cleared. If reset is not set,
* the database is preserved, and the temporary directory is unchanged.
*/
public async restart(reset: boolean = false) {
if (this.isExternalServer()) { return; }
if (this._starts > 0) {
await this.resume();
await this.stop();
}
this._starts++;
if (reset) {
if (process.env.TESTDIR) {
this.testDir = process.env.TESTDIR;
} else {
// Create a testDir of the form grist_test_{USER}_{SERVER_NAME}, removing any previous one.
const username = process.env.USER || "nobody";
this.testDir = path.join(tmpdir(), `grist_test_${username}_${this._name}`);
await fse.remove(this.testDir);
}
}
this.testDocDir = path.join(this.testDir, "data");
await fse.mkdirs(this.testDocDir);
log.warn(`Test logs and data are at: ${this.testDir}/`);
const nodeLogPath = path.join(this.testDir, 'node.log');
const nodeLogFd = await fse.open(nodeLogPath, 'a');
// The server isn't set up to close the testing socket cleanly and
// immediately. It is simplest to use a diffent socket each time
// we restart.
const testingSocket = path.join(this.testDir, `testing-${this._starts}.socket`);
const stubCmd = '_build/stubs/app/server/server';
const isCore = await fse.pathExists(stubCmd + '.js');
const cmd = isCore ? stubCmd : '_build/core/app/server/devServerMain';
// The reason we fork a process rather than start a server within the same process is mainly
// logging. Server code uses a global logger, so it's hard to separate out (especially so if
// we ever run different servers for different tests).
const serverLog = process.env.VERBOSE ? 'inherit' : nodeLogFd;
const env = {
TYPEORM_DATABASE: this._getDatabaseFile(),
TEST_CLEAN_DATABASE: reset ? 'true' : '',
GRIST_DATA_DIR: this.testDocDir,
GRIST_INST_DIR: this.testDir,
// uses the test installed plugins folder as the user installed plugins.
GRIST_USER_ROOT: path.resolve(getAppRoot(), 'test/fixtures/plugins/browserInstalledPlugins/'),
GRIST_TESTING_SOCKET: testingSocket,
// Set low limits for uploads, for testing.
GRIST_MAX_UPLOAD_IMPORT_MB: '1',
GRIST_MAX_UPLOAD_ATTACHMENT_MB: '2',
// Run with HOME_PORT, STATIC_PORT, DOC_PORT, DOC_WORKER_COUNT in the environment to override.
...(isCore ? {
HOME_PORT: '8095',
STATIC_PORT: '8095',
DOC_PORT: '8095',
DOC_WORKER_COUNT: '1',
PORT: '8095',
} : {
HOME_PORT: '8095',
STATIC_PORT: '8096',
DOC_PORT: '8100',
DOC_WORKER_COUNT: '5',
PORT: '0',
}),
// This skips type-checking when running server, but reduces startup time a lot.
TS_NODE_TRANSPILE_ONLY: 'true',
...process.env,
};
if (!process.env.REDIS_URL) {
// Multiple doc workers only possible when redis is available.
log.warn('Running without redis and without multiple doc workers');
delete env.DOC_WORKER_COUNT;
}
this._server = spawn('node', [cmd], {
env,
stdio: ['inherit', serverLog, serverLog],
});
this._exitPromise = exitPromise(this._server);
const port = parseInt(env.HOME_PORT, 10);
this._serverUrl = `http://localhost:${port}`;
log.info(`Waiting for node server to respond at ${this._serverUrl}`);
// Try to be more helpful when server exits by printing out the tail of its log.
this._exitPromise.then((code) => {
if (this._server.killed) { return; }
log.error("Server died unexpectedly, with code", code);
const output = execFileSync('tail', ['-30', nodeLogPath]);
log.info(`\n===== BEGIN SERVER OUTPUT ====\n${output}\n===== END SERVER OUTPUT =====`);
})
.catch(() => undefined);
await this.waitServerReady(60000);
// Prepare testingHooks for certain behind-the-scenes interactions with the server.
this.testingHooks = await connectTestingHooks(testingSocket);
}
public async stop() {
if (this.isExternalServer()) { return; }
log.info("Stopping node server");
this._server.kill();
if (this.testingHooks) {
this.testingHooks.close();
}
await this._exitPromise;
}
/**
* Set server on pause and call `callback()`. Callback must returned a promise and server will
* resume normal activity when that promise resolves. This is useful to test behavior when a
* request takes a long time.
*/
public async pauseUntil(callback: () => Promise<void>) {
log.info("Pausing node server");
this._server.kill('SIGSTOP');
try {
await callback();
} finally {
log.info("Resuming node server");
this.resume();
}
}
public resume() {
if (this.isExternalServer()) { return; }
this._server.kill('SIGCONT');
}
public getHost(): string {
if (this.isExternalServer()) { return process.env.HOME_URL!; }
return this._serverUrl;
}
public getUrl(team: string, relPath: string) {
if (!this.isExternalServer()) {
return `${this.getHost()}/o/${team}${relPath}`;
}
const state: IGristUrlState = { org: team };
const baseDomain = parseSubdomain(new URL(this.getHost()).hostname).base;
const gristConfig = makeGristConfig(this.getHost(), {}, baseDomain);
const url = encodeUrl(gristConfig, state, new URL(this.getHost())).replace(/\/$/, "");
return `${url}${relPath}`;
}
/**
* Returns whether the server is up and responsive.
*/
public async isServerReady(): Promise<boolean> {
try {
return (await fetch(`${this._serverUrl}/status/hooks`, {timeout: 1000})).ok;
} catch (err) {
return false;
}
}
/**
* Wait for the server to be up and responsitve, for up to `ms` milliseconds.
*/
public async waitServerReady(ms: number): Promise<void> {
await this.driver.wait(() => Promise.race([
this.isServerReady(),
this._exitPromise.then(() => { throw new Error("Server exited while waiting for it"); }),
]), ms);
}
/**
* Returns a connection to the database.
*/
public async getDatabase(): Promise<HomeDBManager> {
if (!this._dbManager) {
const origTypeormDB = process.env.TYPEORM_DATABASE;
process.env.TYPEORM_DATABASE = this._getDatabaseFile();
this._dbManager = new HomeDBManager();
await this._dbManager.connect();
await this._dbManager.initializeSpecialIds();
if (origTypeormDB) {
process.env.TYPEORM_DATABASE = origTypeormDB;
}
}
return this._dbManager;
}
public get driver() {
return this._driver || driver;
}
// substitute a custom driver
public setDriver(customDriver: WebDriver = driver) {
this._driver = customDriver;
}
public async getTestingHooks() {
return this.testingHooks;
}
public isExternalServer() {
return Boolean(process.env.HOME_URL);
}
/**
* Returns the path to the database.
*/
private _getDatabaseFile(): string {
return path.join(this.testDir, 'landing.db');
}
}
export const server = new TestServerMerged("merged");

272
test/nbrowser/testUtils.ts Normal file
View File

@ -0,0 +1,272 @@
/**
* Exports `server`, set up to start using setupTestSuite(), e.g.
*
* import {assert, driver} from 'mocha-webdriver';
* import {server, setupTestSuite} from 'test/nbrowser/testUtils';
*
* describe("MyTest", function() {
* this.timeout(20000); // Needed because we wait for server for up to 15s.
* setupTestSuite();
* });
*
* Run with VERBOSE=1 in the environment to see the server log on the console. Normally it goes
* into a file whose path is printed when server starts.
*
* Run `bin/mocha 'test/nbrowser/*.ts' -b --no-exit` to open a command-line prompt on
* first-failure for debugging and quick reruns.
*/
import * as log from 'app/server/lib/log';
import {addToRepl, assert, driver, enableDebugCapture, Key, setOptionsModifyFunc, useServer} from 'mocha-webdriver';
import * as gu from 'test/nbrowser/gristUtils';
import {server} from 'test/nbrowser/testServer';
// Exports the server object with useful methods such as getHost(), waitServerReady(),
// simulateLogin(), etc.
export {server};
setOptionsModifyFunc(({chromeOpts, firefoxOpts}) => {
// Set "kiosk" printing that saves to PDF without offering any dialogs. This applies to regular
// (non-headless) Chrome. On headless Chrome, no dialog or output occurs regardless.
chromeOpts.addArguments("--kiosk-printing");
chromeOpts.setUserPreferences({
// Don't show popups to save passwords, which are shown when running against a deployment when
// we use a login form.
"credentials_enable_service": false,
"profile.password_manager_enabled" : false,
// These preferences are my best effort to set up "print to pdf" that saves into the test's temp
// dir, based on discussion here: https://bugs.chromium.org/p/chromedriver/issues/detail?id=2821.
// On headless, it's ignored (no files are saved). When run manually, it would work EXCEPT with
// kiosk-printing (i.e. also ignored), so look for your downloaded PDFs elsewhere (perhaps
// ~/Downloads). Leaving it here in case it works better some day.
"printing.default_destination_selection_rules": JSON.stringify({
kind: "local",
namePattern: "Save as PDF",
}),
"printing.print_preview_sticky_settings.appState": JSON.stringify({
recentDestinations: [{
id: 'Save as PDF',
origin: 'local',
account: '',
}],
version: 2
}),
"download.default_directory": server.testDir,
"savefile.default_directory": server.testDir,
});
});
interface TestSuiteOptions {
samples?: boolean;
team?: boolean;
// If set, clear user preferences for all test users at the end of the suite. It should be used
// for suites that modify preferences. Not that it only works in dev, not in deployment tests.
clearUserPrefs?: boolean;
}
// Sets up the test suite to use the Grist server, and also to record logs and screenshots after
// failed tests (if MOCHA_WEBDRIVER_LOGDIR var is set).
//
// Returns a Cleanup instance as a convenience, for use scheduling any clean-up that would have
// the same scope as the test suite.
export function setupTestSuite(options?: TestSuiteOptions) {
useServer(server);
enableDebugCapture();
addToRepl('gu', gu, 'gristUtils, grist-specific helpers');
addToRepl('Key', Key, 'key values such as Key.ENTER');
addToRepl('server', server, 'test server');
// After every suite, assert it didn't leave new browser windows open.
checkForExtraWindows();
// After every suite, clear sessionStorage and localStorage to avoid affecting other tests.
after(clearCurrentWindowStorage);
// If requested, clear user preferences for all test users after this suite.
if (options?.clearUserPrefs) {
after(clearTestUserPreferences);
}
// Though unlikely it is possible that the server was left paused by a previous test, so let's
// always call resume.
afterEach(() => server.resume());
return setupRequirement({team: true, ...options});
}
// Clean up any browser windows after the test suite that didn't exist at its start.
function checkForExtraWindows() {
let origHandles: string[];
before(async () => {
origHandles = await driver.getAllWindowHandles();
});
after(async () => {
assert.deepEqual(await driver.getAllWindowHandles(), origHandles);
});
}
// Clean up any browser windows after the test suite that didn't exist at its start.
// Call this BEFORE setupTestSuite() when the test is expected to create new windows, so that they
// may get cleaned up before the check for extraneous windows runs.
export function cleanupExtraWindows() {
let origHandles: string[];
before(async () => {
origHandles = await driver.getAllWindowHandles();
});
after(async () => {
const newHandles = await driver.getAllWindowHandles();
for (const w of newHandles) {
if (!origHandles.includes(w)) {
await driver.switchTo().window(w);
await driver.close();
}
}
await driver.switchTo().window(newHandles[0]);
});
}
async function clearCurrentWindowStorage() {
if ((await driver.getCurrentUrl()).startsWith('http')) {
try {
await driver.executeScript('window.sessionStorage.clear(); window.localStorage.clear();');
} catch (err) {
log.info("Could not clear window storage after the test ended: %s", err.message);
}
}
}
async function clearTestUserPreferences() {
// After every suite, clear user preferences for all test users.
const dbManager = await server.getDatabase();
let emails = Object.keys(gu.TestUserEnum).map(user => gu.translateUser(user as any).email);
emails = [...new Set(emails)]; // Remove duplicates.
await dbManager.testClearUserPrefs(emails);
}
export type CleanupFunc = (() => void|Promise<void>);
/**
* Helper to run cleanup callbacks created in a test case. See setupCleanup() below for usage.
*/
export class Cleanup {
private _callbacksAfterAll: CleanupFunc[] = [];
private _callbacksAfterEach: CleanupFunc[] = [];
public addAfterAll(cleanupFunc: CleanupFunc) {
this._callbacksAfterAll.push(cleanupFunc);
}
public addAfterEach(cleanupFunc: CleanupFunc) {
this._callbacksAfterEach.push(cleanupFunc);
}
public async runCleanup(which: 'all'|'each') {
const callbacks = which === 'all' ? this._callbacksAfterAll : this._callbacksAfterEach;
const list = callbacks.splice(0); // Get a copy of the list AND clear it out.
for (const f of list) {
await f();
}
}
}
/**
* Helper to run cleanup callbacks created in the course of running a test.
* Usage:
* const cleanup = setupCleanup();
* it("should do stuff", function() {
* cleanup.addAfterAll(() => { ...doSomething1()... });
* cleanup.addAfterEach(() => { ...doSomething2()... });
* });
*
* Here, doSomething1() is called at the end of a suite, while doSomething2() is called at the end
* of the current test case.
*/
export function setupCleanup() {
const cleanup = new Cleanup();
after(() => cleanup.runCleanup('all'));
afterEach(() => cleanup.runCleanup('each'));
return cleanup;
}
/**
* Implement some optional requirements for a test, such as having an example document
* present, or a team site to run tests in. These requirements should be automatically
* satisfied by staging/prod deployments, and only need doing in self-contained tests
* or tests against dev servers.
*
* Returns a Cleanup instance for any cleanup that would have the same scope as the
* requirement.
*/
export function setupRequirement(options: TestSuiteOptions) {
const cleanup = setupCleanup();
if (options.samples) {
if (!server.isExternalServer()) {
gu.shareSupportWorkspaceForSuite();
}
}
before(async function() {
if (new URL(server.getHost()).hostname !== 'localhost') {
// Non-dev servers should already meet the requirements; in any case we should not
// fiddle with them here.
return;
}
// Optionally ensure that at least one example document is present.
if (options.samples) {
const homeApi = gu.createHomeApi('support', 'docs');
const wss = await homeApi.getOrgWorkspaces('current');
const exampleWs = wss.find(ws => ws.name === 'Examples & Templates');
if (!exampleWs) {
throw new Error('missing example workspace');
}
// Only add the example if one isn't already there.
if (!exampleWs.docs.some((doc) => (doc.name === 'My Lightweight CRM'))) {
const exampleDocId = (await gu.importFixturesDoc('support', 'docs', 'Examples & Templates',
'video/Lightweight CRM.grist', {load: false, newName: 'My Lightweight CRM.grist'})).id;
// Remove it after the suite.
cleanup.addAfterAll(() => homeApi.deleteDoc(exampleDocId));
}
await homeApi.updateWorkspacePermissions(exampleWs.id, {users: {
'everyone@getgrist.com': 'viewers',
'anon@getgrist.com': 'viewers',
}});
}
// Optionally ensure that a team site is available for tests.
if (options.team) {
const api = gu.createHomeApi('support', 'docs');
let orgName = 'test-grist';
const deployment = process.env.GRIST_ID_PREFIX;
if (deployment) { orgName = `${orgName}-${deployment}`; }
let isNew: boolean = false;
try {
await api.newOrg({name: orgName, domain: orgName});
isNew = true;
} catch (e) {
// Assume the org already exists.
}
if (isNew) {
await api.updateOrgPermissions(orgName, {
users: {
'gristoid+chimpy@gmail.com': 'owners',
}
});
// Recreate the api for the correct org, then update billing.
const api2 = gu.createHomeApi('support', orgName);
const billing = api2.getBillingAPI();
await billing.updateBillingManagers({
users: {
'gristoid+chimpy@gmail.com': 'managers',
}
});
}
}
});
return cleanup;
}

278
test/server/testUtils.js Normal file
View File

@ -0,0 +1,278 @@
/**
* 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 provided copyPath.
* @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);
var docName = path.basename(alias ? alias : fileName, ".grist");
return docUtils.createNumbered(docName, "-",
name => docUtils.createExclusive(storageManager.getPath(name))
)
.tap(docName => log.info("Using fixture %s as %s", fileName, docName + ".grist"))
.tap(docName => docUtils.copyFile(srcPath, storageManager.getPath(docName)))
.tap(docName => storageManager.markAsChanged(docName));
}
exports.useFixtureDoc = useFixtureDoc;
exports.assert = assert;

14
test/tsconfig.json Normal file
View File

@ -0,0 +1,14 @@
{
"extends": "../buildtools/tsconfig-base.json",
"include": [
"**/*",
"../app/server/declarations.d.ts",
"../app/server/declarations/**/*.d.ts",
"../stubs/app/server/declarations.d.ts",
"../stubs/app/server/declarations/**/*.d.ts"
],
"references": [
{ "path": "../app" },
{ "path": "../stubs/app" },
]
}

137
test/xunit-file.js Normal file
View File

@ -0,0 +1,137 @@
// Based on https://github.com/peerigon/xunit-file, with changes that are impossible to
// monkey-patch. Also refactored, but not converted to typescript, to avoid slowing down mocha
// runs with ts-node.
//
// Respects the following environment variables:
// XUNIT_FILE: path of output XML file (default: xunit.xml)
// XUNIT_SILENT: suppress human-friendly logging to the console
// XUNIT_SUITE_NAME: name to use for the top-level <testsuite> (default: "Mocha Tests")
// XUNIT_CLASS_PREFIX: prefix to use for <testcase classname=...> attribute (default: "")
const fse = require('fs-extra');
const {reporters, utils} = require('mocha');
const path = require('path');
const escape = utils.escape;
const filePath = process.env.XUNIT_FILE || "xunit.xml";
const consoleOutput = !process.env.XUNIT_SILENT;
const suiteName = process.env.XUNIT_SUITE_NAME || 'Mocha Tests';
const classPrefix = process.env.XUNIT_CLASS_PREFIX || '';
/**
* Save reference to avoid Sinon interfering (see GH-237).
*/
const MDate = global.Date;
// Special marker for tag() to produce an unclosed opening XML tag.
const UNCLOSED = Symbol('UNCLOSED');
function logToConsole(msg) {
if (consoleOutput) { console.log(msg); }
}
const failureNumbers = new Map(); // Maps test object to failure number.
/**
* Initialize a new `XUnitFile` reporter.
*/
class XUnitFile extends reporters.Base {
constructor(runner) {
super(runner);
const stats = this.stats;
const tests = [];
fse.mkdirpSync(path.dirname(filePath));
const fd = fse.openSync(filePath, 'w', 0o0755);
runner.on('suite', (suite) => {
logToConsole(suite.fullTitle());
});
runner.on('pass', (test) => {
logToConsole(` ${reporters.Base.symbols.ok} ${test.fullTitle()}`);
tests.push(test);
});
runner.on('fail', (test) => {
failureNumbers.set(test, failureNumbers.size + 1);
logToConsole(` (${failureNumbers.get(test)}) ${test.fullTitle()}`);
logToConsole(` ERROR: ${test.err}`);
tests.push(test);
});
runner.on('pending', (test) => {
logToConsole(` - ${test.fullTitle()}`);
tests.push(test);
});
runner.once('end', () => {
const timestampStr = new MDate().toISOString().split('.', 1)[0];
appendLine(fd, tag('testsuite', {
name: suiteName,
tests: stats.tests,
failures: stats.failures,
errors: stats.failures,
skipped: stats.tests - stats.failures - stats.passes,
timestamp: timestampStr,
time: (stats.duration || 0) / 1000
}, UNCLOSED));
logToConsole("");
for (const test of tests) {
writeTest(fd, test);
}
appendLine(fd, '</testsuite>');
fse.closeSync(fd);
});
}
}
/**
* Output tag for the given `test.`
*/
function writeTest(fd, test) {
const classname = classPrefix + test.parent.fullTitle();
const name = test.title;
const time = (test.duration || 0) / 1000;
if (test.state === 'failed') {
const err = test.err;
appendLine(fd,
tag('testcase', {classname, name, time},
tag('failure', {message: err.message}, cdata(err.stack))));
logToConsole(`***\n(${failureNumbers.get(test)}) ${test.fullTitle()}`);
logToConsole(err.stack + '\n');
} else if (test.pending) {
appendLine(fd, tag('testcase', {classname, name}, tag('skipped', {})));
} else {
appendLine(fd, tag('testcase', {classname, name, time}) );
}
}
/**
* HTML tag helper.
* content may be undefined, a string, or the symbol UNCLOSED to produce just an opening tag.
*/
function tag(name, attrs, content) {
const attrStr = Object.keys(attrs).map((key) => ` ${key}="${escape(String(attrs[key]))}"`).join('');
return (
content === undefined ? `<${name}${attrStr}/>` :
content === UNCLOSED ? `<${name}${attrStr}>` :
`<${name}${attrStr}>${content}</${name}>`
);
}
/**
* Return cdata escaped CDATA `str`.
*/
function cdata(str) {
return '<![CDATA[' + escape(str) + ']]>';
}
function appendLine(fd, line) {
fse.writeSync(fd, line + "\n", null, 'utf8');
}
module.exports = XUnitFile;

View File

@ -5,6 +5,6 @@
"references": [ "references": [
{ "path": "./app" }, { "path": "./app" },
{ "path": "./stubs/app" }, { "path": "./stubs/app" },
// { "path": "./test" }, { "path": "./test" },
] ]
} }

4147
yarn.lock

File diff suppressed because it is too large Load Diff