gristlabs_grist-core/test/nbrowser/testUtils.ts
Dmitry S 3fa5125cf7 (core) Highlight rows used as a selector in linking, but do not show 'inactive' cursors.
Summary:
1. Introduces another highlight for link-selector rows, with the same color as
   regular selection, and allowing to overlap with regular selection.
2. Don't show "secondary" cursors (those in inactive sections), to keep a single
   cursor on the screen, since having multiple (which different in color) could
   cause confusion.
3. An unrelated improvement (prompted by a new fixture doc) is to default the
   active section to the top-left one (rather than the one with smallest rowId).
4. Another unrelated improvement (prompted by a test affected by the previous unrelated improvement) is to skip chart widgets when searching (previously search would step through those with an invisible "cursor").

Includes also tweaks for better testing on Arm-based Macs:
- Add support for TEST_CHROME_BINARY_PATH environment variable (helpful for a Mac arm64 architecture workaround)
- Remove unsetting of SELENIUM_REMOTE_URL when running headless (unlikely to affect anyone, and can be done outside the script, but interferes with the Mac workaround)

Test Plan: Added a new test case that cursor and linking-selector CSS classes are present or absent appropriately. Fixed test affected by the fix to default active section.

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D3891
2023-06-21 12:21:19 -04:00

290 lines
10 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 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}) => {
if (process.env.TEST_CHROME_BINARY_PATH) {
chromeOpts.setChromeBinaryPath(process.env.TEST_CHROME_BINARY_PATH);
}
// 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": {
content_settings: {
exceptions: {
clipboard: {
'*': {
// Grant access to the system clipboard. This applies to regular (non-headless)
// Chrome. On headless Chrome, this has no effect.
setting: 1,
}
},
},
},
// Don't show popups to save passwords.
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.
if (!process.env.NO_CLEANUP) {
after(clearCurrentWindowStorage);
}
// Also, log out, to avoid logins interacting, unless NO_CLEANUP is requested (useful for
// debugging tests).
if (!process.env.NO_CLEANUP) {
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());
// Close database until next test explicitly needs it, to avoid conflicts
// with tests that don't use the same server.
after(async () => server.closeDatabase());
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;
}