gristlabs_grist-core/test/nbrowser/testUtils.ts
Paul Fitzpatrick d99db8d016 (core) move more tests to grist-core
Summary:
 * Tie build and run-time docker base images to a consistent version (buster)
 * Extend the test login system activated by GRIST_TEST_LOGIN to ease porting tests that currently rely on cognito (many)
 * Make org resets work in absence of billing endpoints
 * When in-memory session caches are used, add missing invalidation steps
 * Pass org information through sign-ups/sign-ins more carefully
 * For CORS, explicitly trust GRIST_HOST origin when set
 * Move some fixtures and tests to core, focussing on tests that cover existing failures or are in the set of tests run on deployments
 * Retain regular `test` target to run the test suite directly, without docker
 * Add a `test:smoke` target to run a single simple test without `GRIST_TEST_LOGIN` activated
 * Add a `test:docker` target to run the tests against a grist-core docker image - since tests rely on certain fixture teams/docs, added `TEST_SUPPORT_API_KEY` and `TEST_ADD_SAMPLES` flags to ease porting

The tests ported were `nbrowser` tests: `ActionLog.ts` (the first test I tend to port to anything, out of habit), `Fork.ts` (exercises a lot of doc creation paths), `HomeIntro.ts` (a lot of DocMenu exercise), and `DuplicateDocument.ts` (covers a feature known to be failing prior to this diff, the CORS tweak resolves it).

Test Plan: Manually tested via `buildtools/build_core.sh`. In follow up, I want to add running the `test:docker` target in grist-core's workflows. In jenkins, only the smoke test is run. There'd be an argument for running all tests, but they include particularly slow tests, and are duplicates of tests already run (in different configuration admittedly), so I'd like to try first just using them in grist-core to gate updates to any packaged version of Grist (the docker image currently).

Reviewers: alexmojaki

Reviewed By: alexmojaki

Subscribers: alexmojaki

Differential Revision: https://phab.getgrist.com/D3176
2021-12-10 18:33:07 -05:00

263 lines
9.3 KiB
TypeScript

/**
* 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);
// Also, log out, to avoid logins interacting.
after(() => server.removeLogin());
// 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 (process.env.TEST_ADD_SAMPLES || !server.isExternalServer()) {
gu.shareSupportWorkspaceForSuite(); // TODO: Remove after the support workspace is removed from the backend.
gu.addSamplesForSuite();
}
}
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 a team site is available for tests.
if (options.team) {
await gu.addSupportUserIfPossible();
const api = gu.createHomeApi('support', 'docs');
for (const suffix of ['', '2'] as const) {
let orgName = `test${suffix}-grist`;
const deployment = process.env.GRIST_ID_PREFIX;
if (deployment) { orgName = `${orgName}-${deployment}`; }
let isNew: boolean = false;
try {
await api.newOrg({name: `Test${suffix} Grist`, 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();
try {
await billing.updateBillingManagers({
users: {
'gristoid+chimpy@gmail.com': 'managers',
}
});
} catch (e) {
// ignore if no billing endpoint
if (!String(e).match('404: Not Found')) {
throw e;
}
}
}
}
}
});
return cleanup;
}