gristlabs_grist-core/test/nbrowser/homeUtil.ts
Paul Fitzpatrick bcbf57d590 (core) bump mocha version to allow parallel tests; move more tests to core
Summary:
This uses a newer version of mocha in grist-core so that tests can be run in parallel. That allows more tests to be moved without slowing things down overall. Tests moved are venerable browser tests; only the ones that "just work" or worked without too much trouble to are moved, in order to keep the diff from growing too large. Will wrestle with more in follow up.

Parallelism is at the file level, rather than the individual test.

The newer version of mocha isn't needed for grist-saas repo; tests are parallelized in our internal CI by other means. I've chosen to allocate files to workers in a cruder way than our internal CI, based on initial characters rather than an automated process. The automated process would need some reworking to be compatible with mocha running in parallel mode.

Test Plan: this diff was tested first on grist-core, then ported to grist-saas so saas repo history will correctly track history of moved files.

Reviewers: jarek

Reviewed By: jarek

Subscribers: jarek

Differential Revision: https://phab.getgrist.com/D3927
2023-06-27 02:55:34 -04:00

477 lines
19 KiB
TypeScript

/**
* Contains some non-webdriver functionality needed by tests.
*/
import FormData from 'form-data';
import * as fse from 'fs-extra';
import defaults = require('lodash/defaults');
import {Key, WebDriver, WebElement} from 'mocha-webdriver';
import fetch from 'node-fetch';
import {authenticator} from 'otplib';
import * as path from 'path';
import {UserProfile} from 'app/common/LoginSessionAPI';
import {BehavioralPrompt, UserPrefs, WelcomePopup} from 'app/common/Prefs';
import {DocWorkerAPI, UserAPI, UserAPIImpl} from 'app/common/UserAPI';
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
import log from 'app/server/lib/log';
import {TestingHooksClient} from 'app/server/lib/TestingHooks';
import EventEmitter = require('events');
export interface Server extends EventEmitter {
driver: WebDriver;
getTestingHooks(): Promise<TestingHooksClient>;
getHost(): string;
getUrl(team: string, relPath: string): string;
getDatabase(): Promise<HomeDBManager>;
isExternalServer(): boolean;
}
const ALL_TIPS_ENABLED = {
behavioralPrompts: {
dontShowTips: false,
dismissedTips: [],
},
dismissedWelcomePopups: [],
};
const ALL_TIPS_DISABLED = {
behavioralPrompts: {
dontShowTips: true,
dismissedTips: BehavioralPrompt.values,
},
dismissedWelcomePopups: WelcomePopup.values.map(id => {
return {
id,
lastDismissedAt: 0,
nextAppearanceAt: null,
timesDismissed: 1,
};
}),
};
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) {
server.on('stop', () => {
this._apiKey.clear();
});
}
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 the Grist login page.
*/
public async simulateLogin(name: string, email: string, org: string = "", options: {
loginMethod?: UserProfile['loginMethod'],
freshAccount?: boolean,
isFirstLogin?: boolean,
showGristTour?: boolean,
showTips?: boolean,
cacheCredentials?: boolean,
} = {}) {
const {loginMethod, isFirstLogin, showTips} = defaults(options, {
loginMethod: 'Email + Password',
showTips: false,
});
const showGristTour = options.showGristTour ?? (options.freshAccount ?? isFirstLogin);
// 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); }
if (showGristTour !== undefined) { await this._initShowGristTour(email, showGristTour); }
if (showTips) {
await this.enableTips(email);
} else {
await this.disableTips(email);
}
// 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();
const sid = await this.getGristSid();
if (!sid) { throw new Error('no session available'); }
await testingHooks.setLoginSessionProfile(sid, {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 the Grist login page.
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();
}
// Fill the login form (either test or Grist).
if (await this.isOnTestLoginPage()) {
await this.fillTestLoginForm(email, name);
} else {
await this.fillGristLoginForm(email);
}
if (!await this.isWelcomePage() && (options.freshAccount || options.isFirstLogin)) {
await this._recreateCurrentUser(email, org, name);
}
}
if (options.freshAccount) {
this._apiKey.delete(email);
}
if (options.cacheCredentials) {
// Take this opportunity to cache access info.
if (!this._apiKey.has(email)) {
await this.driver.get(this.server.getUrl(org || 'docs', ''));
const apiKey = await this._getApiKey();
this._apiKey.set(email, apiKey);
}
}
}
/**
* 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 cursor is on field editor, escape before remove login
await this.driver.sendKeys(Key.ESCAPE);
if (!this.server.isExternalServer()) {
const testingHooks = await this.server.getTestingHooks();
const sid = await this.getGristSid();
if (sid) { await testingHooks.setLoginSessionProfile(sid, null, org); }
} else {
await this.driver.get(`${this.server.getHost()}/logout`);
}
}
public async enableTips(email: string) {
await this._toggleTips(true, email);
}
public async disableTips(email: string) {
await this._toggleTips(false, email);
}
// 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 the Grist test login page.
*
* TEST_ACCOUNT_PASSWORD must be set.
*/
public async fillTestLoginForm(email: string, name?: string) {
const password = process.env.TEST_ACCOUNT_PASSWORD;
if (!password) { throw new Error('TEST_ACCOUNT_PASSWORD not set'); }
const form = await this.driver.find('div.modal-content-desktop');
await this.setValue(form.find('input[name="username"]'), email);
if (name) { await this.setValue(form.find('input[name="name"]'), name); }
await this.setValue(form.find('input[name="password"]'), password);
await form.find('input[name="signInSubmitButton"]').click();
}
/**
* Fill up the Grist login page form, and submit. If called with a user that
* has TOTP-based 2FA enabled, TEST_ACCOUNT_TOTP_SECRET must be set for a valid
* code to be submitted on the following form.
*
* Should be on the Grist login or sign-up page before calling this method. If
* `password` is not passed in, TEST_ACCOUNT_PASSWORD must be set.
*/
public async fillGristLoginForm(email: string, password?: string) {
if (!password) {
password = process.env.TEST_ACCOUNT_PASSWORD;
if (!password) {
throw new Error('TEST_ACCOUNT_PASSWORD not set');
}
}
await this.checkGristLoginPage();
if ((await this.driver.getCurrentUrl()).match(/signup\?/)) {
await this.driver.findWait('a[href*="login?"]', 4000).click();
}
await this.driver.findWait('input[name="email"]', 4000).sendKeys(email);
await this.driver.find('input[name="password"]').sendKeys(password);
await this.driver.find('.test-lp-sign-in').click();
await this.driver.wait(async () => !await this.isOnGristLoginPage(), 4000);
if (!await this.driver.findContent('.test-mfa-title', 'Almost there!').isPresent()) {
return;
}
const secret = process.env.TEST_ACCOUNT_TOTP_SECRET;
if (!secret) { throw new Error('TEST_ACCOUNT_TOTP_SECRET not set'); }
const code = authenticator.generate(secret);
await this.driver.find('input[name="verificationCode"]').sendKeys(code);
await this.driver.find('.test-mfa-submit').click();
await this.driver.wait(
async () => {
return !await this.driver.findContent('.test-mfa-title', 'Almost there!').isPresent();
},
4000,
'Possible reason: verification code is invalid or expired (i.e. was recently used to log in)'
);
}
/**
* 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),
* or null if there is no session.
*/
public async getGristSid(): Promise<string|null> {
// 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(process.env.GRIST_SESSION_COOKIE || 'grist_sid');
if (!cookie) { return null; }
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
);
}
/**
* Returns whether we are currently on any login page (including the test page).
*/
public async isOnLoginPage() {
return await this.isOnGristLoginPage() || await this.isOnTestLoginPage();
}
/**
* Returns whether we are currently on a Grist login page.
*/
public async isOnGristLoginPage() {
const isOnSignupPage = await this.driver.find('.test-sp-heading').isPresent();
const isOnLoginPage = await this.driver.find('.test-lp-heading').isPresent();
return isOnSignupPage || isOnLoginPage;
}
/**
* Returns whether we are currently on the test login page.
*/
public isOnTestLoginPage() {
return this.driver.findContent('h1', 'A Very Credulous Login Page').isPresent();
}
/**
* Waits for browser to navigate to any login page (including the test page).
*/
public async checkLoginPage(waitMs: number = 2000) {
await this.driver.wait(this.isOnLoginPage.bind(this), waitMs);
}
/**
* Waits for browser to navigate to a Grist login page.
*/
public async checkGristLoginPage(waitMs: number = 2000) {
await this.driver.wait(this.isOnGristLoginPage.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, name?: 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();
// Fill the login form (either test or Grist).
if (await this.isOnTestLoginPage()) {
await this.fillTestLoginForm(email, name);
} else {
await this.fillGristLoginForm(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();
}
}
private async _initShowGristTour(email: string, showGristTour: 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.personalOrg) {
const userOrgPrefs = {showGristTour};
await dbManager.updateOrg({userId: user.id}, user.personalOrg.id, {userOrgPrefs});
}
}
// 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});
}
private async _toggleTips(enabled: boolean, email: string) {
if (this.server.isExternalServer()) {
// Unsupported due to lack of access to the database.
return;
}
const dbManager = await this.server.getDatabase();
const user = await dbManager.getUserByLogin(email);
if (!user) { return; }
if (user.personalOrg) {
const org = await dbManager.getOrg({userId: user.id}, user.personalOrg.id);
const userPrefs = (org.data as any)?.userPrefs ?? {};
const newUserPrefs: UserPrefs = {
...userPrefs,
...(enabled ? ALL_TIPS_ENABLED : ALL_TIPS_DISABLED),
};
await dbManager.updateOrg({userId: user.id}, user.personalOrg.id, {userPrefs: newUserPrefs});
} else {
await this.driver.executeScript(`
const userPrefs = JSON.parse(localStorage.getItem('userPrefs:u=${user.id}') || '{}');
localStorage.setItem('userPrefs:u=${user.id}', JSON.stringify({
...userPrefs,
...${JSON.stringify(enabled ? ALL_TIPS_ENABLED : ALL_TIPS_DISABLED)},
}));
`);
}
}
}