gristlabs_grist-core/test/nbrowser/webdriverjq-nbrowser.js
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

555 lines
19 KiB
JavaScript

/**
* This is webdriverjq, modified to in fact no longer
* use jquery, but rather to work in conjunction with
* mocha-webdriver. Works in conjunction with
* gristUtils-nbrowser.
*
* Not everything webdriverjq could do is supported,
* just enough to make porting tests easier. The
* promise manager mechanism selenium used to have
* that old tests depends upon is gone (and good
* riddance).
* https://github.com/SeleniumHQ/selenium/blob/trunk/javascript/node/selenium-webdriver/CHANGES.md#api-changes-2
*
* Changes are minimized here, no modernization unless
* strictly needed, so diff with webdriverjq.js is
* easier to read.
*/
var _ = require('underscore');
var util = require('util');
import { driver, error, stackWrapFunc,
WebElement, WebElementPromise } from 'mocha-webdriver';
export const driverCompanion = {
$: null,
};
export function webdriverjqWrapper(driver) {
/**
* Returns a new WebdriverJQ instance.
*/
function $(selector) {
return new WebdriverJQ($, selector);
}
$.driver = driver;
$.getPage = function(url) {
return $.driver.get(url);
};
return $;
}
/**
* The object obtained by $(...) calls. It supports nearly all the JQuery and WebElement methods,
* and allows chaining whenever it makes sense.
* @param {JQ} $: The $ object used to create this instance.
* @param {String|WebElement} selector: A string selector, or a WebElement (or promise for one).
*/
function WebdriverJQ($, selector) {
this.$ = $;
this.driver = $.driver;
this.selector = selector;
this._selectorDesc = selector;
this._callChain = [];
}
/**
* $(...).method(...) invokes the corresponding method on the client side. Methods may be chained.
* In reality, method calls aren't performed until the first non-chainable call. For example,
* $(".foo").parent().toggleClass("active").text() is translated to a single call.
*
* Note that a few methods are overridden later: click() and submit() are performed on the server
* as WebDriver actions instead of on the client.
*
* The methods listed below are all the methods from http://api.jquery.com/.
*/
var JQueryMethodNames = [
"add", "addBack", "addClass", "after", "ajaxComplete", "ajaxError", "ajaxSend", "ajaxStart",
"ajaxStop", "ajaxSuccess", "andSelf", "animate", "append", "appendTo", "attr", "before", "bind",
"blur", "change", "children", "clearQueue", "click", "clone", "closest", "contents",
"contextmenu", "css", "data", "dblclick", "delay", "delegate", "dequeue", "detach", "die",
"each", "empty", "end", "eq", "error", "fadeIn", "fadeOut", "fadeTo", "fadeToggle", "filter",
"find", "finish", "first", "focus", "focusin", "focusout", "get", "has", "hasClass", "height",
"hide", "hover", "html", "index", "innerHeight", "innerWidth", "insertAfter", "insertBefore",
"is", "keydown", "keypress", "keyup", "last", "live", "load", "load", "map", "mousedown",
"mouseenter", "mouseleave", "mousemove", "mouseout", "mouseover", "mouseup", "next", "nextAll",
"nextUntil", "not", "off", "offset", "offsetParent", "on", "one", "outerHeight", "outerWidth",
"parent", "parents", "parentsUntil", "position", "prepend", "prependTo", "prev", "prevAll",
"prevUntil", "promise", "prop", "pushStack", "queue", "ready", "remove", "removeAttr",
"removeClass", "removeData", "removeProp", "replaceAll", "replaceWith", "resize", "scroll",
"scrollLeft", "scrollTop", "select", "serialize", "serializeArray", "show", "siblings",
"slice", "slideDown", "slideToggle", "slideUp", "stop", "submit", "text", "toArray", "toggle",
"toggle", "toggleClass", "trigger", "triggerHandler", "unbind", "undelegate", "unload",
"unwrap", "val", "width", "wrap", "wrapAll", "wrapInner",
// Extra methods (defined below, injected into client, available to these tests only).
"trimmedText", "classList", "subset", "filterExactText", "filterText", "getAttribute",
"findOldTimey", // nbrowser: added
];
// Maps method name to an array of argument types. If called with matching types, this method does
// NOT return another JQuery object (i.e. it's not a chainable call).
var nonChainableMethods = {
"attr": ["string"],
"css": ["string"],
"data": ["string"],
"get": null,
"hasClass": ["string"],
"height": [],
"html": [],
"index": null,
"innerHeight": [],
"innerWidth": [],
"is": null,
"offset": [],
"outerHeight": [],
"outerWidth": [],
"position": [],
"prop": ["string"],
"scrollLeft": [],
"scrollTop": [],
"serialize": null,
"serializeArray": null,
"text": [],
"toArray": null,
"val": [],
"width": [],
"trimmedText": null,
"classList": null,
"getAttribute": null,
};
function isNonChainable(methodName, args) {
var argTypes = nonChainableMethods[methodName];
return argTypes === null ||
_.isEqual(argTypes, args.map(function(a) { return typeof a; }));
}
JQueryMethodNames.forEach(function(methodName) {
WebdriverJQ.prototype[methodName] = function() {
var args = Array.prototype.slice.call(arguments, 0);
var methodCallInfo = [methodName].concat(args);
var newObj = this.clone();
newObj._callChain.push(methodCallInfo);
if (isNonChainable(methodName, args)) {
return newObj.resolve();
} else {
return newObj;
}
};
});
// .length property is a special case.
Object.defineProperty(WebdriverJQ.prototype, 'length', {
configurable: false,
enumerable: false,
get: function() {
var newObj = this.clone();
newObj._callChain.push(['length']);
return newObj.resolve();
}
});
/**
* WebdriverJQ objects also support various WebDriver.WebElement methods, namely the ones
* below. These operate on the first of the selected elements. Those without a meaningful return
* value may be chained. E.g. $(".foo").click().val() will trigger a click on the selected
* element, then return its 'value' property.
*/
// This maps each supported method name to whether or not it should be chainable.
var WebElementMethodNames = {
"getId": false,
"getRawId": false,
"click": true,
"sendKeys": true,
"getTagName": false,
"getCssValue": false,
"getText": false,
"getSize": false,
"getLocation": false,
"isEnabled": false,
"isSelected": false,
"submit": true,
"clear": true,
"isDisplayed": false,
"isPresent": false, // nbrowser: added
"getOuterHtml": false,
"getInnerHtml": false,
"scrollIntoView": true,
"sendNewText": true, // Added below.
};
Object.keys(WebElementMethodNames).forEach(function(methodName) {
function runMethod(self, methodName, elem, ...argList) {
var result = elem[methodName].apply(elem, argList)
.then(function(value) {
// Chrome makes some values unprintable by including a bogus .toString property.
if (value && typeof value.toString !== 'function') { delete value.toString; }
return value;
}, function(err) {
throw err;
});
return result;
}
const runThisMethod = stackWrapFunc(runMethod.bind(null, this, methodName));
WebdriverJQ.prototype[methodName] = function() {
const elem = this.elem();
const result = runThisMethod(elem, ...arguments);
if (WebElementMethodNames[methodName]) {
// If chainable, create a new WebdriverJQ object (that waits for the result).
return this._chain(() => elem, result);
} else {
// If not a chainable method, then we are done.
return result;
}
};
});
/**
* Helper for chaining. Creates a new WebdriverJQ instance from the current one for the given
* element, but which resolves only when the given promise resolves.
*/
WebdriverJQ.prototype._chain = function(elemFn, optPromise) {
const getElemAndUpdateDesc = () => {
const elem = elemFn();
// Let the chained object start with the previous object's description, but once we have
// resolved the element, update it with the resolved element.
chainable._selectorDesc = this._selectorDesc + " [pending]";
elem.then(function(resolvedElem) {
chainable._selectorDesc = resolvedElem.toString();
}, function(err) {});
return elem;
};
var chainable = new WebdriverJQ(this.$,
optPromise ? optPromise.then(getElemAndUpdateDesc) : getElemAndUpdateDesc());
return chainable;
};
/**
* Return a friendly string representation of this WebdriverJQ instance.
* E.g. $('.foo').next() will be represented as "$('.foo').next()".
*/
WebdriverJQ.prototype.toString = function() {
var sel = this._selectorDesc;
if (typeof sel === 'string') {
sel = "'" + sel.replace(/'/g, "\\'") + "'";
} else {
sel = sel.toString();
}
var desc = "$(" + sel + ")";
desc += this._callChain.map(function(methodCallInfo) {
var method = methodCallInfo[0], args = util.inspect(methodCallInfo.slice(1));
return "." + method + "(" + args.slice(1, args.length - 1).trim() + ")";
}).join("");
return desc;
};
/**
* Returns a copy of the WebdriverJQ object.
*/
WebdriverJQ.prototype.clone = function() {
var newObj = new WebdriverJQ(this.$, this.selector);
newObj._selectorDesc = this._selectorDesc;
newObj._callChain = this._callChain.slice(0);
return newObj;
};
/**
* Convert the matched elements to an array and apply all further chained calls to each elemet of
* the array separately, so that the result will be an array.
*
* E.g. $(".foo").array().text() will return an array of text for each of the elements matching
* ".foo", and $(".foo").array().height() will return an array of heights (unlike
* $(".foo").height() which would return the height of the first matching element).
*/
WebdriverJQ.prototype.array = function() {
this._callChain.push(["array"]);
return this;
};
/**
* Make the call to the browser, returning a promise for the values (typically an array) returned
* by the browser.
*/
WebdriverJQ.prototype.resolve = function() {
var self = this;
if (isPromise(this.selector)) {
// Update our selector description once we know what it resolves to.
this.selector.then(function(resolvedSelector) {
self._selectorDesc = resolvedSelector.toString();
}, function(err) {});
if (this._callChain.length === 0) {
// If the selector is a promise and there are no chained calls, there is no need to execute
// anything, we just need for the promise to resolve.
return this.selector.then(function(value) {
return Array.isArray(value) ? value : [value];
});
}
}
return executeChain(this.selector, this._callChain);
};
/**
* Make a call to the browser now, expecting a single element returned, and returning
* WebElementPromise for that element. If no elements match, the promise will be rejected.
*/
WebdriverJQ.prototype.elem = function() {
const doElem = stackWrapFunc(() => {
var self = this;
// TODO: we could limit results to a single value for efficiency.
var result = this.resolve().then(function(elems) {
if (!elems[0]) { throw new Error(self + " matched no element"); }
return elems[0];
});
return result;
});
return new WebElementPromise(driver, doElem());
};
/**
* Check if the element is considered stale by WebDriver. An element is considered stale once it
* is removed from the DOM, or a new page has loaded.
*/
WebdriverJQ.prototype.isStale = function() {
return this.getTagName().then(function() { return false; }, function(err) {
if (err instanceof error.StaleElementReferenceError) {
return true;
}
throw err;
});
};
/**
* Helper that allows a WebdriverJQ to act as a promise, but really just forwarding calls to
* this.resolve().then(...)
*/
WebdriverJQ.prototype.then = function(success, failure) {
// In selenium-webdriver 2.46, it was important not to call this.resolve().then(...) directly,
// but to start a new promise chain, to avoid a deadlock in ControlFlow. In selenium-webdriver
// 2.48, starting a new promise chain is wrong since the webdriver doesn't wait for the new
// promise chain on errors, and fails without a chance to catch them.
return this.resolve().then(success, failure);
};
// webdriver.promise.Thenable.addImplementation(WebdriverJQ);
/**
* Wait for a condition, represented by func(elem) returning true. The function may use asserts to
* indicate that the condition isn't met yet. E.g.
*
* $(...).wait().click() // Wait for a matching element to be present, then click it.
* $(...).wait(assert.isPresent).click() // Equivalent to the previous line.
* $(...).wait(assert.isDisplayed) // Wait for the matching element to be displayed.
* $(...).wait(assert.isDisplayed, false) // Wait for the element to NOT be displayed.
* $(...).wait(assert.hasClass, 'foo') // Wait for the element to have class 'foo'
* $(...).wait(assert.hasClass, 'foo', false) // Wait for the element to NOT have class 'foo'
*
* @param [Number] optTimeoutMs: First numerical argument is interpreted as a timeout in seconds.
* Default is 10. You may pass in a longer timeout, but infinite wait isn't supported.
* @param [Function] func: Optional condition function called as func(wjq, args...), where `wjq`
* is a WebdriverJQ instance. If a string is given, then `wjq[func](args...)` is called
* instead. If omitted, wait until the selector matches at least one element.
* The function must not return undefined, but may throw chai.AssertionError.
* @param [Objects] args...: Optional additional arguments to pass to func.
* @returns WebdriverJQ, which may be chained further.
*/
WebdriverJQ.prototype.waitCore = function(chained, optTimeoutSec, func, ...extraArgs) {
var timeoutMs;
if (typeof optTimeoutSec === 'number') {
timeoutMs = optTimeoutSec * 1000;
if (arguments.length === 2) { func = null; }
} else {
timeoutMs = 10000;
extraArgs.unshift(func);
func = (arguments.length === 1) ? null : optTimeoutSec;
}
if (_.isUndefined(func)) {
var failed = Promise.reject(
new Error("WebdriverJQ: wait called with undefined condition"));
return this._chain(() => failed);
}
func = func || "isPresent";
var self = this;
async function conditionFunc() {
const result = await (typeof func === 'string' ?
self[func].apply(self, extraArgs) :
func.apply(null, [self].concat(extraArgs)));
return result === undefined ? true : result;
}
var waitPromise = waitImpl(timeoutMs, conditionFunc);
return chained ? this._chain(() => this.resolve(), waitPromise) :
waitPromise;
};
WebdriverJQ.prototype.wait = function(...args) {
return this.waitCore(true, ...args);
}
WebdriverJQ.prototype.waitDrop = function(...args) {
return this.waitCore(false, ...args);
}
/**
* Send keyboard keys to the element as if typed by the user. This allows the use of arrays as
* arguments to scope modifier keys. The method allows chaining.
*/
WebdriverJQ.prototype.sendKeys = function(varKeys) {
var keys = processKeys(Array.prototype.slice.call(arguments, 0));
var elem = this.elem();
return this._chain(() => elem, elem.sendKeys.apply(elem, keys));
};
/**
* Replaces the value in a text <input> by sending the keys to select all text, type
* the given string, and hit Enter.
*/
WebdriverJQ.prototype.sendNewText = function(string) {
return this.sendKeys(this.$.SELECT_ALL || driverCompanion.$.SELECT_ALL,
string,
this.$.ENTER || driverCompanion.$.ENTER);
};
/**
* Transform the given array of keys to prepare for sending to the browser:
* (1) If an argument is an array, its elements are grouped using webdriver.Key.chord()
* (in other words, modifier keys present in the array are released after the array).
* (2) Transforms certain keys to work around a selenium bug where they don't get processed
* properly.
*/
function processKeys(keyArray) {
return keyArray.map(function(arg) {
if (Array.isArray(arg)) {
arg = driver.Key.chord.apply(driver.Key, arg);
}
return arg;
});
}
//----------------------------------------------------------------------
// Enhancements to webdriver's own objects.
//----------------------------------------------------------------------
WebElement.prototype.toString = function() {
if (this._description) {
return "<" + this._description + ">";
} else {
return "<WebElement>";
}
};
//----------------------------------------------------------------------
// "Client"-side code.
//----------------------------------------------------------------------
// Run a command chain. Basically just find the initial element, and
// apply methods to it. We try to match quirks of old system, which
// are a bit hard to explain, and can't easily be matched exactly -
// but well enough to cover a lot of test code it seems (and the
// rest can just be rewritten).
async function executeChain(selector, callChain) {
const cc = callChain.map(c => c[0]);
let result = selector;
if (typeof selector === 'string') {
result = await findOldTimey(driver, selector,
cc.includes('array') ||
cc.includes('length') ||
cc.includes('toArray') ||
cc.includes('eq') ||
cc.includes('last'));
}
result = await applyCallChain(callChain, result);
if (result instanceof WebElement) {
result = [result];
}
return result;
}
async function applyCallChain(callChain, value) {
value = await value;
for (var i = 0; i < callChain.length; i++) {
var method = callChain[i][0], args = callChain[i].slice(1).map(translateTestId);
if (method === "toArray") {
return Promise.all(value);
} else if (method === "array") {
return Promise.all(value.map(applyCallChain.bind(null, callChain.slice(i + 1))));
} else if (method === "last") {
return applyCallChain(callChain.slice(i + 1), value[value.length - 1]);
} else if (method === "eq") {
const idx = args[0];
return applyCallChain(callChain.slice(i + 1), value[idx]);
} else if (method === "length") {
return value.length;
} else {
if (!value[method] && value[0]?.[method]) {
value = value[0];
}
value = await value[method].apply(value, args);
}
}
return value;
}
function translateTestId(selector) {
return (typeof selector === 'string' ?
selector.replace(/\$(\w+)/, '[data-test-id="$1"]', 'g') :
selector);
}
export function findOldTimey(obj, key, multiple, multipleOptions) {
key = translateTestId(key);
const contains = key.split(':contains');
if (contains.length === 2) {
const content = contains[1].replace(/["'()]/g, '');
return obj.findContent(contains[0], content);
}
if (multiple) {
return obj.findAll(key, multipleOptions);
}
return obj.find(key);
}
// Similar to gu.waitToPass (copied to simplify import structure).
export async function waitImpl(timeoutMs, conditionFunc) {
try {
await driver.wait(async () => {
try {
return await conditionFunc();
} catch (e) {
return false;
}
}, timeoutMs);
} catch (e) {
await conditionFunc();
}
}
function isPromise(obj) {
if (typeof obj !== 'object') {
return false;
}
if (typeof obj['then'] !== 'function') {
return false;
}
return true;
}