mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(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:
46
test/nbrowser/Smoke.ts
Normal file
46
test/nbrowser/Smoke.ts
Normal 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
1401
test/nbrowser/gristUtils.ts
Normal file
File diff suppressed because it is too large
Load Diff
329
test/nbrowser/homeUtil.ts
Normal file
329
test/nbrowser/homeUtil.ts
Normal 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
261
test/nbrowser/testServer.ts
Normal 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
272
test/nbrowser/testUtils.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user