gristlabs_grist-core/test/nbrowser/gristUtil-nbrowser.js

673 lines
22 KiB
JavaScript
Raw Permalink Normal View History

import * as _ from 'lodash';
import { assert, driver, Key, stackWrapFunc, WebElement,
WebElementPromise } from 'mocha-webdriver';
import { driverCompanion, findOldTimey, waitImpl,
webdriverjqWrapper } from 'test/nbrowser/webdriverjq-nbrowser';
import * as guBase from 'test/nbrowser/gristUtils';
import { setupTestSuite } from 'test/nbrowser/testUtils';
import { server } from 'test/nbrowser/testServer';
// Simulate the old "$" object.
const _webdriverjqFactory = webdriverjqWrapper(driver);
function $(key) {
if (typeof key !== 'string') {
return key;
}
return _webdriverjqFactory(key);
}
// The "$" object needs some setup done asynchronously.
// We do that later, during test initialization.
async function applyPatchesToJquerylikeObject($) {
$.MOD = await guBase.modKey();
$.SELECT_ALL = await guBase.selectAllKey();
$.getPage = async (url) => {
return driver.get(url);
};
$.wait = (timeoutMs, conditionFunc) => {
return waitImpl(timeoutMs, conditionFunc);
}
for (const key of Object.keys(Key)) {
$[key] = Key[key];
}
// We need to tweak driver object a bit too (really?)
driverCompanion.$ = $;
// driver.testHost = server.getHost();
// driver.waitImpl = waitImpl;
}
// Adapt common old setup.
const test = {
setupTestSuite(self, ...args) {
self.timeout(40000);
return setupTestSuite(...args);
},
};
// Add some methods to the grist utils that are used by old tests.
// This could be cleaned up further, but translating to newer ways
// of doing things or, if the method is really useful, adding the
// method to the new grist utils.
const waitForServer = guBase.waitForServer;
let patchesApplied = false;
let session;
const gu = {
...guBase,
// Apply all needed patches, async initialization, and log in.
async supportOldTimeyTestCode() {
if (!patchesApplied) {
applyPatchesToWebElements();
applyPatchesToAssert();
applyPatchesToJquerylikeObject($);
}
patchesApplied = true;
// Login as someone so old code doesn't have to be upgraded to do it.
session = await gu.session().user('userz');
const dbManager = await server.getDatabase();
const profile = {email: session.email, name: session.name};
await dbManager.getUserByLogin(session.email, {profile});
await gu.setApiKey(session.name);
await session.login();
},
// getCell with old-timey arguments.
getCellRC(r, c) {
return gu.getCell(c, r + 1);
},
// clickCell with old-timey arguments.
async clickCellRC(r, c) {
const cell = gu.getCell(c, r + 1);
await cell.click();
return cell;
},
// sendKeys variant that accepts arrays in place of Key.chord.
sendKeys(...args) {
return guBase.sendKeys(...args.map(
a => Array.isArray(a) ? Key.chord(...a) : a
));
},
/**
* When doing type conversion in the side pane, this clicks the 'Apply' button and waits for the
* conversion to complete.
*/
async applyTypeConversion() {
await $('.test-type-transform-apply').wait().scrollIntoView({
block: 'nearest',
inline: 'nearest',
}).click();
await $('.test-type-transform-apply').waitDrop(assert.isPresent, false);
return gu.waitForServer();
},
async clickColumnMenuItem(colName, itemText, optRightClick) {
await gu.openColumnMenu(colName);
return gu.actions.selectFloatingOption(itemText);
},
getOpenEditingLabel(parentElem) {
return driver.find('.test-column-title-label');
},
enterGridValues(startRowIndex, startColIndex, dataMatrix) {
const transpose = dataMatrix[0].map(
(_, colIndex) => dataMatrix.map(row => row[colIndex])
);
return gu.enterGridRows({col: startColIndex,
rowNum: startRowIndex + 1}, transpose);
},
async openSidePane(tabName) {
if (['log', 'validate', 'repl', 'code'].includes(tabName)) {
await guBase.toggleSidePanel('right', 'open');
return $(`.test-tools-${tabName}`).wait().click();
} else if (tabName === 'field') {
await guBase.toggleSidePanel('right', 'open');
return $('.test-right-tab-field').click();
} else if (tabName === 'view') {
await guBase.toggleSidePanel('right', 'open');
return $('.test-right-tab-pagewidget').wait().click();
}
},
getGridValues(...options) {
return gu.getVisibleGridCells(...options);
},
/**
* Returns the text in the row header of the last row in a Grid section, scrolling to the
* bottom, but not moving the cursor.
* @param {String} options.section: Optional section name to use instead of the active section.
*/
async getGridLastRowText(options) {
if (options?.section) {
await gu.actions.viewSection(options.section).selectSection();
}
return String(await gu.getGridRowCount());
},
getAddRowNumber() {
return gu.getGridRowCount();
},
async getGridLabels(sectionName) {
return gu.getSection(sectionName).findAll('[data-test-id="GridView_columnLabel"] .kf_elabel_text',
label => label.getText());
},
/**
* Given a cell in grist GridView, returns whether it contains the cursor. You may use it as
* hasCursor(getCell(...)) or getCell(...).waitFor(hasCursor, optTimeout).
*/
hasCursor(cellElem) {
return cellElem.find('.selected_cursor').isDisplayed();
},
async useFixtureDoc(cleanup, fname, flag) {
return session.tempDoc(cleanup, fname);
},
async copyDoc(docId, flag) {
const result = await guBase.copyDoc(session.name, 'docs', 'Home', docId);
await session.loadDoc(`/doc/${result.id}`);
return result;
},
async clickCell(rowIndexOrPosOrCell, colIndex) {
if (typeof rowIndexOrPosOrCell === 'object' && 'driver_' in rowIndexOrPosOrCell) {
return rowIndexOrPosOrCell.click();
}
// Best just to force a rewrite of clickCell, e.g. to clickCellRC,
// since newer gristUtils interprets arguments entirely differently.
if (typeof rowIndexOrPosOrCell === 'number') {
throw new Error("ambiguous row/col");
}
const cell = guBase.getCell(rowIndexOrPosOrCell, colIndex);
await cell.click();
},
/**
* Sets the visibleCol of the currently selected field to value.
*/
async setVisibleCol(value) {
await gu.openSidePane('field');
await $('.test-fbuilder-ref-col-select').click();
await $(`.test-select-menu .test-select-row:contains(${value})`).wait().click();
return waitForServer();
},
/**
* Asserts the type of the currently selected field.
*/
async assertType(value) {
await gu.openSidePane('field');
assert.equal(await $('.test-fbuilder-type-select .test-select-row').getText(), value);
},
closeSidePane() {
return gu.toggleSidePanel('right', 'close');
},
clickVisibleDetailCells(column, rowNums, section) {
return gu.getDetailCell(column, rowNums[0], section).click();
},
async clickRowMenuItem(rowNum, item) {
await (await gu.openRowMenu(rowNum)).findContent('li', item).click();
},
/**
* Selects rows starting from rowStart and ending at rowEnd (1-based) by clicking and dragging or
* shift clicking (Defaults to dragging)
* @param {int} rowStart: 1-based row number
* @param {int} rowEnd: 1-based row number.
* @param {String} optMethod: if 'shift' then shift clicking is used to select rows otherwise it
* defaults to drag to select.
*/
async selectRows(rowStart, rowEnd, optMethod) {
let start = await driver.findContent('.active_section .gridview_data_row_num', gu.exactMatch(rowStart.toString()));
let end = await driver.findContent('.active_section .gridview_data_row_num', gu.exactMatch(rowEnd.toString()));
if (optMethod === 'shift') {
await driver.withActions(a => a.click(start).keyDown($.SHIFT).click(end).keyUp($.SHIFT));
} else {
await driver.withActions(a => a.move({origin: start}).press().move({origin: end}).release());
}
},
async _fieldSettingsClickOption(isCommonToSeparate, optionSubstring) {
assert.include(await $('.fieldbuilder_settings_button').text(), isCommonToSeparate ? 'Common' : 'Separate');
await $('.fieldbuilder_settings_button').click();
await gu.actions.selectFloatingOption(optionSubstring);
await waitForServer();
assert.include(await $('.fieldbuilder_settings_button').text(), isCommonToSeparate ? 'Separate' : 'Common');
},
fieldSettingsUseSeparate: () => gu._fieldSettingsClickOption(true, 'Use separate'),
fieldSettingsSaveAsCommon: () => gu._fieldSettingsClickOption(false, 'Save as common'),
fieldSettingsRevertToCommon: () => gu._fieldSettingsClickOption(false, 'Revert to common'),
/**
* Changes date format for date and datetime editor or returns current format
* @param {string} value Date format
*/
async dateFormat(value) {
if (!value) {
return $('$Widget_dateFormat .test-select-row').text();
}
await $('$Widget_dateFormat').wait().click();
await $(`.test-select-menu .test-select-row:contains(${value})`).wait().click();
},
/**
* Changes time format for datetime editor or returns current format
* @param {string} value Time format
*/
async timeFormat(value) {
if (!value) {
return $('$Widget_timeFormat .test-select-row').getText();
}
await $('$Widget_timeFormat').wait().click();
await $(`.test-select-menu .test-select-row:contains(${value})`).wait().click();
},
async getDetailValues(...options) {
return gu.getVisibleDetailCells(...options);
},
/**
* Selects all cells in a GridView between and including startCell and endCell
* @param {Array} startCell:
* startCell[0]: 1-based row index.
* startCell[1]: 0-based column index.
* @param {Array} endCell:
* endCell[0]: 1-based row index.
* endCell[1]: 0-based column index.
**/
async selectGridArea(startCell, endCell) {
const [startRowNum, startCol] = startCell;
const [endRowNum, endCol] = endCell;
if (startRowNum === endRowNum && startCol === endCol) {
await gu.getCell({rowNum: endRowNum, col: endCol}).click();
} else {
const start = await gu.getCell({rowNum: startRowNum, col: startCol});
const end = await gu.getCell({rowNum: endRowNum, col: endCol});
await driver.withActions(a => a.click(start).keyDown($.SHIFT).click(end).keyUp($.SHIFT));
}
},
/**
* Returns text of the cells for the given rows and columns of a viewSection.
* @param {String} option.section: Optional section name instead of active.
* @param {Array<Number>} option.rowNums: Array of row numbers (1-based)
* @param {Array<Number>} option.cols: Array of column indices (0-based) or labels.
* @param [Number: Function] option.cellFunc: a function that returns cells given an array of
* columns, rows and optionally a viewsection (defaults to the currently active section)
* @param [Number: Function] option.valueFunc: Optional function, or an object mapping column
* index or label (as in options.cols) to function, with the function mapping a cell to
* its value (by default, cell => cell.text()).
* @returns {Promise<Array>} Returns array of values for each requested cell, as all values from
* the first row, followed by values from the second, etc.
*/
async getSectionValues(options) {
var opts = { section: options.section };
var defaultValueFunc = (cell => cell.text());
var valueFunc;
if (options.valueFunc && !_.isFunction(options.valueFunc)) {
valueFunc = (col => options.valueFunc[col] || defaultValueFunc);
} else {
valueFunc = _.constant(options.valueFunc || defaultValueFunc);
}
const colValues = [];
for (const col of options.cols) {
const colValue = await valueFunc(col)(options.cellFunc(col, options.rowNums, opts).array());
colValues.push(colValue);
}
return _.flatten(_.zip.apply(_, colValues), true);
},
/**
* Asserts the widget of the currently selected field.
*/
async assertWidget(value) {
await gu.openSidePane('field');
assert.equal(await $('.test-fbuilder-widget-select .test-select-row').getText(), value);
},
/**
* Sets the widget of the currently selected field to value.
*/
async setWidget(value) {
await gu.openSidePane('field');
const selector = $('.test-fbuilder-widget-select');
const btnChildren = await selector.elem().findAll('.test-select-button');
if (btnChildren.length > 0) {
// This is a button select.
await selector.findOldTimey(`.test-select-button:contains(${value})`).click();
} else {
// This is a dropdown select.
await selector.click();
await $(`.test-select-menu .test-select-row:contains(${value})`).click();
}
await gu.waitForServer();
},
/**
* Adds a new record to the grid. Takes an array of values that matches column positions.
*/
async addRecord(values) {
await gu.sendKeys([$.MOD, $.UP]);
await gu.sendKeys([$.MOD, $.DOWN]);
await gu.sendKeys([$.LEFT]);
await gu.sendKeys([$.LEFT]);
await gu.sendKeys([$.LEFT]);
await gu.sendKeys([$.LEFT]);
await gu.sendKeys([$.LEFT]);
await driver.sleep(1000);
// For each value, type it, followed by Tab.
for (const [i, value] of values.entries()) {
await gu.waitAppFocus(true);
await gu.sendKeys(value, $.TAB);
await gu.waitForServer();
if (i === 0) {
// The very first value triggers add-record, but the creation of the new row isn't
// immediate, so give it a moment.
await driver.sleep(250);
}
}
// Return a promise that can be awaited; it will wait for all the previously queued ones.
return driver.sleep(0);
},
actions: {
createNewDoc: async (optDocName) => {
await gu.simulateLogin("Chimpy", "chimpy@getgrist.com", "nasa");
const docId = await gu.createNewDoc('chimpy', 'nasa', 'Horizon', optDocName || 'Untitled');
await gu.loadDoc(`/o/nasa/doc/${docId}`);
},
getDocTitle: () => {
return $('.test-bc-doc').val();
},
getActiveTab: () => {
return $('.test-treeview-itemHeader.selected .test-docpage-label').wait();
},
getTabs: () => {
return $('.test-docpage-label');
},
renameDoc: (newName) => {
$('.test-bc-doc').click();
$.driver.sendKeys(newName, $.ENTER);
return $.wait(1000, () => $.driver.getTitle().startsWith(newName + ' - '));
},
selectTabView: async (viewTitle) => {
const isOpen = await gu.isSidePanelOpen('left');
if (!isOpen) {
await gu.toggleSidePanel('left', 'open');
}
await gu.openPage(viewTitle);
if (!isOpen) {
await gu.toggleSidePanel('left', 'close');
}
},
addNewTable: async () => {
await $('.test-dp-add-new').wait().click();
await $('.test-dp-empty-table').click();
// if we selected a new table, there will be a popup for a name
const prompts = await $(".test-modal-prompt");
const prompt = prompts[0];
if (prompt) {
await await $(".test-modal-confirm").click();
}
return gu.waitForServer();
},
addNewSection: (tableId, sectionType) => {
return gu.addNewSection(sectionType, tableId);
},
addNewSummarySection: async (tableId, groupByArr, sectionType, sectionName) => {
await gu.addNewSection(sectionType, tableId, {summarize: groupByArr});
await gu.waitForServer();
await gu.renameActiveSection(sectionName);
await gu.waitForServer();
},
addNewView: (tableId, sectionType) => {
return gu.addNewPage(sectionType, tableId);
},
selectFloatingOption: async (optionName) => {
// Sometimes the element is there but "not interactable". Work around that.
await gu.waitToPass(async () => {
await $(`.grist-floating-menu li:contains(${optionName})`).click();
});
},
/**
* Actions related to view section. To use, pass in the section name.
* @param {string} sectionName - Title of the view section
* @return Object<string, function>} Collection of methods for the view section.
*
* @example
* gu.actions.viewSection('Table1 record').selectMenuOption('Insert section');
*/
viewSection: (sectionName) => {
let section = gu.getSection(sectionName);
return {
/**
* Clicks inside to make the current section active.
*/
selectSection: function () {
return gu.selectSectionByTitle(sectionName);
},
/**
* Opens the view section drop-down menu.
* @param {string} which - Which menu to open, coud be: 'sortAndFilter' or 'viewLayout'
*/
openMenu: async function (which) {
await driver.withActions(a => a.move({origin: section.find('.viewsection_title')})); // to display menu buttons on hover
const item = section.find(`.test-section-menu-${which}`);
await gu.waitToPass(() => item.click());
},
/**
* Opens the section drop-down menu and select option matching param.
* @param {string} which - Which menu to open, coud be: 'sortAndFilter' or 'viewLayout'
* @param {string} optionName
*/
selectMenuOption: function (which, optionName) {
this.openMenu(which);
return gu.actions.selectFloatingOption(optionName);
}
};
},
tableView: (tableName, viewName) => {
return {
select: () => {
return gu.getPageItem(tableName).click();
},
selectOption: async optionName => {
await gu.openPageMenu(tableName);
return gu.actions.selectFloatingOption(optionName);
}
};
}
}
};
/**
* This monkey-patches the WebElement class to make it look enough like
* jquery that a lot of old test code can be used without modification.
*/
function applyPatchesToWebElements() {
WebElement.prototype.wait = function(fn, ...args) {
if (fn) {
return gu.waitToPass(async () => {
return fn.apply(null, [this, ...args]);
}).then(() => true);
} else {
return new WebElementPromise(
driver,
gu.waitToPass(async () => {
if (!(await this.isPresent())) {
throw new Error('not present');
}
}).then(() => this));
}
}
WebElement.prototype.selected = function(val) {
return driver.executeScript((elem, val) => {
elem.selected = val;
}, this, val);
}
WebElement.prototype.attr = function(key, val) {
if (val !== undefined) {
return driver.executeScript((elem, key, val) => {
elem.setAttribute(key, val);
}, this, key, val);
}
return this.getAttribute(key);
}
WebElement.prototype.classList = async function() {
return (await this.getAttribute('className')).split(' ');
}
// Lists of WebElements work differently - if we did a find() we
// already have just the first match.
WebElement.prototype.first = function() {
return this;
}
WebElement.prototype.text = function() {
return this.getText();
}
WebElement.prototype.val = function(newVal) {
if (newVal === undefined) {
return this.getAttribute('value');
}
return gu.setValue(this, newVal);
}
WebElement.prototype.css = function(key, val) {
if (val === undefined) {
return this.getCssValue(key);
}
return new WebElementPromise(
driver,
driver.executeScript(elem => {
elem.style[key] = val;
}, this)
);
}
WebElement.prototype.is = function(selector) {
return this.matches(selector);
}
WebElement.prototype.hasClass = async function(className) {
return (await this.classList()).includes(className);
}
WebElement.prototype.scrollIntoView = function(opts) {
opts = opts || {behavior: 'auto'};
return new WebElementPromise(
driver,
driver.executeScript((elem, opts) => elem.scrollIntoView(opts),
this, opts).then(() => this));
}
WebElement.prototype.parent = function() {
return new WebElementPromise(
driver,
driver.executeScript(elem => {
return elem.parentNode.closest('*');
}, this)
);
}
WebElement.prototype.closest = function(key) {
return this.findClosest(key);
}
WebElement.prototype.children = async function(mapper) {
// Collect children.
let result = await driver.executeScript(elem => {
return [...elem.children].map(c => c.closest('*'));
}, this);
// Fix up type.
result = result.map(v => new WebElementPromise(
driver,
Promise.resolve(v),
));
// Apply mapper if available.
if (mapper) {
result = result.map(mapper);
}
// Result is a single promise.
return Promise.all(result);
}
WebElement.prototype.trimmedText = async function() {
const text = await this.getText();
return text.trim();
}
// A version of find() that supports some old timey syntax.
WebElement.prototype.findOldTimey = function(key) {
return findOldTimey(this, key);
}
WebElement.prototype.findAllOldTimey = function(key, mapper) {
return findOldTimey(this, key, true, mapper);
}
}
/**
* This monkey-patches assert to add some methods that are very
* commonly used.
*/
function applyPatchesToAssert() {
assert.hasClass = stackWrapFunc(async function(elem, className, present) {
if (present === undefined) {
present = true;
}
const c = await elem.getAttribute('class');
if (present) {
await assert.include(c.split(' '), className);
} else {
await assert.notInclude(c.split(' '), className);
}
});
assert.isPresent = stackWrapFunc(async function(elem, present) {
if (present === undefined) {
present = true;
}
let current = false;
try {
current = await elem.isPresent();
} catch (e) {
// $ object may fail if elem is non-existent.
}
await assert.equal(current, present);
return true;
});
assert.isDisplayed = stackWrapFunc(async function(elem, displayed) {
if (displayed === undefined) {
displayed = true;
}
await assert.equal(await elem.isDisplayed(), displayed);
return true;
});
}
exports.$ = $;
exports.gu = gu;
exports.server = server;
exports.test = test;