From b054594840b0b7739e94f490826a3b41effdb3d6 Mon Sep 17 00:00:00 2001 From: Paul Fitzpatrick Date: Fri, 21 Jul 2023 15:05:43 +0100 Subject: [PATCH] factor out low-dependency browser test code useful for grist-widget (#576) It would be useful to write browser tests that use Grist for some of our other repositories (e.g. grist-widget, grist-static). This is a first baby step to factor out some useful code that has no code dependencies beyond mocha-webdriver, for use in grist-widget. --- test/nbrowser/gristUtils.ts | 218 ++------------------------ test/nbrowser/gristWebDriverUtils.ts | 223 +++++++++++++++++++++++++++ 2 files changed, 236 insertions(+), 205 deletions(-) create mode 100644 test/nbrowser/gristWebDriverUtils.ts diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index 9409f292..7a1ff4ac 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -22,6 +22,7 @@ import { Organization } from 'app/gen-server/entity/Organization'; import { Product } from 'app/gen-server/entity/Product'; import { create } from 'app/server/lib/create'; +import { GristWebDriverUtils, PageWidgetPickerOptions, WindowDimensions } from 'test/nbrowser/gristWebDriverUtils'; import { HomeUtil } from 'test/nbrowser/homeUtil'; import { server } from 'test/nbrowser/testServer'; import { Cleanup } from 'test/nbrowser/testUtils'; @@ -49,6 +50,7 @@ export function currentDriver() { return driver; } export function setDriver(customDriver?: WebDriver) { _driver = customDriver; } const homeUtil = new HomeUtil(testUtils.fixturesRoot, server); +const webdriverUtils = new GristWebDriverUtils(driver); export const createNewDoc = homeUtil.createNewDoc.bind(homeUtil); // importFixturesDoc has a custom implementation that supports 'load' flag. @@ -67,6 +69,17 @@ export const checkLoginPage = homeUtil.checkLoginPage.bind(homeUtil); export const checkGristLoginPage = homeUtil.checkGristLoginPage.bind(homeUtil); export const copyDoc = homeUtil.copyDoc.bind(homeUtil); +export const isSidePanelOpen = webdriverUtils.isSidePanelOpen.bind(webdriverUtils); +export const waitForServer = webdriverUtils.waitForServer.bind(webdriverUtils); +export const waitForSidePanel = webdriverUtils.waitForSidePanel.bind(webdriverUtils); +export const toggleSidePanel = webdriverUtils.toggleSidePanel.bind(webdriverUtils); +export const getWindowDimensions = webdriverUtils.getWindowDimensions.bind(webdriverUtils); +export const addNewSection = webdriverUtils.addNewSection.bind(webdriverUtils); +export const selectWidget = webdriverUtils.selectWidget.bind(webdriverUtils); +export const dismissBehavioralPrompts = webdriverUtils.dismissBehavioralPrompts.bind(webdriverUtils); +export const toggleSelectable = webdriverUtils.toggleSelectable.bind(webdriverUtils); +export const waitToPass = webdriverUtils.waitToPass.bind(webdriverUtils); + export const fixturesRoot: string = testUtils.fixturesRoot; // it is sometimes useful in debugging to turn off automatic cleanup of docs and workspaces. @@ -780,25 +793,6 @@ export async function waitForDocMenuToLoad(): Promise { await driver.wait(() => driver.find('.test-dm-doclist').isDisplayed(), 2000); } -export async function waitToPass(check: () => Promise, timeMs: number = 4000) { - try { - let delay: number = 10; - await driver.wait(async () => { - try { - await check(); - } catch (e) { - // Throttle operations a little bit. - await driver.sleep(delay); - if (delay < 50) { delay += 10; } - return false; - } - return true; - }, timeMs); - } catch (e) { - await check(); - } -} - // Checks if we are configured to store docs in s3, and returns access to s3 if so. // For this to be useful in tests against deployments, s3-related env variables should // be set to match the deployment. @@ -944,23 +938,6 @@ export async function waitForLabelInput(): Promise { await driver.wait(async () => (await driver.findWait('.test-column-title-label', 100).hasFocus()), 300); } -/** - * Waits for all pending comm requests from the client to the doc worker to complete. This taps into - * Grist's communication object in the browser to get the count of pending requests. - * - * Simply call this after some request has been made, and when it resolves, you know that request - * has been processed. - * @param optTimeout: Timeout in ms, defaults to 2000. - */ -export async function waitForServer(optTimeout: number = 2000) { - await driver.wait(() => driver.executeScript( - "return window.gristApp && (!window.gristApp.comm || !window.gristApp.comm.hasActiveRequests())" - + " && window.gristApp.testNumPendingApiRequests() === 0", - optTimeout, - "Timed out waiting for server requests to complete" - )); -} - /** * Sends UserActions using client api from the browser. */ @@ -1085,18 +1062,6 @@ export async function addNewTable(name?: string) { await waitForServer(); } -export interface PageWidgetPickerOptions { - tableName?: string; - /** Optional pattern of SELECT BY option to pick. */ - selectBy?: RegExp|string; - /** Optional list of patterns to match Group By columns. */ - summarize?: (RegExp|string)[]; - /** If true, configure the widget selection without actually adding to the page. */ - dontAdd?: boolean; - /** If true, dismiss any tooltips that are shown. */ - dismissTips?: boolean; -} - // Add a new page using the 'Add New' menu and wait for the new page to be shown. export async function addNewPage( typeRe: RegExp|'Table'|'Card'|'Card List'|'Chart'|'Custom', @@ -1115,97 +1080,11 @@ export async function addNewPage( await driver.wait(async () => (await driver.getCurrentUrl()) !== url, 2000); } -type SectionTypes = 'Table'|'Card'|'Card List'|'Chart'|'Custom'; - -// Add a new widget to the current page using the 'Add New' menu. -export async function addNewSection( - typeRe: RegExp|SectionTypes, tableRe: RegExp|string, options?: PageWidgetPickerOptions -) { - // Click the 'Add widget to page' entry in the 'Add New' menu - await driver.findWait('.test-dp-add-new', 2000).doClick(); - await driver.findWait('.test-dp-add-widget-to-page', 500).doClick(); - - // add widget - await selectWidget(typeRe, tableRe, options); -} - export async function openAddWidgetToPage() { await driver.findWait('.test-dp-add-new', 2000).doClick(); await driver.findWait('.test-dp-add-widget-to-page', 2000).doClick(); } -// Select type and table that matches respectively typeRe and tableRe and save. The widget picker -// must be already opened when calling this function. -export async function selectWidget( - typeRe: RegExp|string, - tableRe: RegExp|string = '', - options: PageWidgetPickerOptions = {} -) { - if (options.dismissTips) { await dismissBehavioralPrompts(); } - - // select right type - await driver.findContent('.test-wselect-type', typeRe).doClick(); - - if (options.dismissTips) { await dismissBehavioralPrompts(); } - - if (tableRe) { - const tableEl = driver.findContent('.test-wselect-table', tableRe); - - // unselect all selected columns - for (const col of (await driver.findAll('.test-wselect-column[class*=-selected]'))) { - await col.click(); - } - - // let's select table - await tableEl.click(); - - if (options.dismissTips) { await dismissBehavioralPrompts(); } - - const pivotEl = tableEl.find('.test-wselect-pivot'); - if (await pivotEl.isPresent()) { - await toggleSelectable(pivotEl, Boolean(options.summarize)); - } - - if (options.summarize) { - for (const columnEl of await driver.findAll('.test-wselect-column')) { - const label = await columnEl.getText(); - // TODO: Matching cols with regexp calls for trouble and adds no value. I think function should be - // rewritten using string matching only. - const goal = Boolean(options.summarize.find(r => label.match(r))); - await toggleSelectable(columnEl, goal); - } - } - - if (options.selectBy) { - // select link - await driver.find('.test-wselect-selectby').doClick(); - await driver.findContent('.test-wselect-selectby option', options.selectBy).doClick(); - } - } - - - if (options.dontAdd) { - return; - } - - // add the widget - await driver.find('.test-wselect-addBtn').doClick(); - - // if we selected a new table, there will be a popup for a name - const prompts = await driver.findAll(".test-modal-prompt"); - const prompt = prompts[0]; - if (prompt) { - if (options.tableName) { - await prompt.doClear(); - await prompt.click(); - await driver.sendKeys(options.tableName); - } - await driver.find(".test-modal-confirm").click(); - } - - await waitForServer(); -} - export type WidgetType = 'Table' | 'Card' | 'Card List' | 'Chart' | 'Custom'; @@ -1216,17 +1095,6 @@ export async function changeWidget(type: WidgetType) { await waitForServer(); } -/** - * Toggle elem if not selected. Expects elem to be clickable and to have a class ending with - * -selected when selected. - */ -async function toggleSelectable(elem: WebElement, goal: boolean) { - const isSelected = await elem.matches('[class*=-selected]'); - if (goal !== isSelected) { - await elem.click(); - } -} - /** * Rename the given page to a new name. The oldName can be a full string name or a RegExp. */ @@ -1396,38 +1264,6 @@ export async function checkForErrors() { assert.deepEqual(errors, []); } -export function isSidePanelOpen(which: 'right'|'left'): Promise { - return driver.find(`.test-${which}-panel`).matches('[class*=-open]'); -} - -/* - * Toggles (opens or closes) the right or left panel and wait for the transition to complete. An optional - * argument can specify the desired state. - */ -export async function toggleSidePanel(which: 'right'|'left', goal: 'open'|'close'|'toggle' = 'toggle') { - if ((goal === 'open' && await isSidePanelOpen(which)) || - (goal === 'close' && !await isSidePanelOpen(which))) { - return; - } - - // Adds '-ns' when narrow screen - const suffix = (await getWindowDimensions()).width < 768 ? '-ns' : ''; - - // click the opener and wait for the duration of the transition - await driver.find(`.test-${which}-opener${suffix}`).doClick(); - await waitForSidePanel(); -} - -export async function waitForSidePanel() { - // 0.4 is the duration of the transition setup in app/client/ui/PagePanels.ts for opening the - // side panes - const transitionDuration = 0.4; - - // let's add an extra delay of 0.1 for even more robustness - const delta = 0.1; - await driver.sleep((transitionDuration + delta) * 1000); -} - /** * Opens a Creator Panel on Widget/Table settings tab. */ @@ -2450,19 +2286,6 @@ export async function selectColumn(col: string) { await getColumnHeader({col}).click(); } -export interface WindowDimensions { - width: number; - height: number; -} - -/** - * Gets browser window dimensions. - */ - export async function getWindowDimensions(): Promise { - const {width, height} = await driver.manage().window().getRect(); - return {width, height}; -} - /** * Sets browser window dimensions. */ @@ -3014,21 +2837,6 @@ export async function refreshDismiss() { await waitForDocToLoad(); } -/** - * Dismisses all behavioral prompts that are present. - */ -export async function dismissBehavioralPrompts() { - let i = 0; - const max = 10; - - // Keep dismissing prompts until there are no more, up to a maximum of 10 times. - while (i < max && await driver.find('.test-behavioral-prompt').isPresent()) { - await driver.find('.test-behavioral-prompt-dismiss').click(); - await waitForServer(); - i += 1; - } -} - /** * Dismisses any tutorial card that might be active. */ diff --git a/test/nbrowser/gristWebDriverUtils.ts b/test/nbrowser/gristWebDriverUtils.ts new file mode 100644 index 00000000..ed1b394d --- /dev/null +++ b/test/nbrowser/gristWebDriverUtils.ts @@ -0,0 +1,223 @@ +/** + * Utilities that simplify writing browser tests against Grist, which + * have only mocha-webdriver as a code dependency. Separated out to + * make easier to borrow for grist-widget repo. + * + * If you are seeing this code outside the grist-core repo, please don't + * edit it, it is just a copy and local changes will prevent updating it + * easily. + */ + +import { WebDriver, WebElement } from 'mocha-webdriver'; + +type SectionTypes = 'Table'|'Card'|'Card List'|'Chart'|'Custom'; + +export class GristWebDriverUtils { + public constructor(public driver: WebDriver) { + } + + public isSidePanelOpen(which: 'right'|'left'): Promise { + return this.driver.find(`.test-${which}-panel`).matches('[class*=-open]'); + } + + /** + * Waits for all pending comm requests from the client to the doc worker to complete. This taps into + * Grist's communication object in the browser to get the count of pending requests. + * + * Simply call this after some request has been made, and when it resolves, you know that request + * has been processed. + * @param optTimeout: Timeout in ms, defaults to 2000. + */ + public async waitForServer(optTimeout: number = 2000) { + await this.driver.wait(() => this.driver.executeScript( + "return window.gristApp && (!window.gristApp.comm || !window.gristApp.comm.hasActiveRequests())" + + " && window.gristApp.testNumPendingApiRequests() === 0", + optTimeout, + "Timed out waiting for server requests to complete" + )); + } + + public async waitForSidePanel() { + // 0.4 is the duration of the transition setup in app/client/ui/PagePanels.ts for opening the + // side panes + const transitionDuration = 0.4; + + // let's add an extra delay of 0.1 for even more robustness + const delta = 0.1; + await this.driver.sleep((transitionDuration + delta) * 1000); + } + + /* + * Toggles (opens or closes) the right or left panel and wait for the transition to complete. An optional + * argument can specify the desired state. + */ + public async toggleSidePanel(which: 'right'|'left', goal: 'open'|'close'|'toggle' = 'toggle') { + if ((goal === 'open' && await this.isSidePanelOpen(which)) || + (goal === 'close' && !await this.isSidePanelOpen(which))) { + return; + } + + // Adds '-ns' when narrow screen + const suffix = (await this.getWindowDimensions()).width < 768 ? '-ns' : ''; + + // click the opener and wait for the duration of the transition + await this.driver.find(`.test-${which}-opener${suffix}`).doClick(); + await this.waitForSidePanel(); + } + + /** + * Gets browser window dimensions. + */ + public async getWindowDimensions(): Promise { + const {width, height} = await this.driver.manage().window().getRect(); + return {width, height}; + } + + + // Add a new widget to the current page using the 'Add New' menu. + public async addNewSection( + typeRe: RegExp|SectionTypes, tableRe: RegExp|string, options?: PageWidgetPickerOptions + ) { + // Click the 'Add widget to page' entry in the 'Add New' menu + await this.driver.findWait('.test-dp-add-new', 2000).doClick(); + await this.driver.findWait('.test-dp-add-widget-to-page', 500).doClick(); + + // add widget + await this.selectWidget(typeRe, tableRe, options); + } + + // Select type and table that matches respectively typeRe and tableRe and save. The widget picker + // must be already opened when calling this function. + public async selectWidget( + typeRe: RegExp|string, + tableRe: RegExp|string = '', + options: PageWidgetPickerOptions = {} + ) { + const driver = this.driver; + if (options.dismissTips) { await this.dismissBehavioralPrompts(); } + + // select right type + await driver.findContent('.test-wselect-type', typeRe).doClick(); + + if (options.dismissTips) { await this.dismissBehavioralPrompts(); } + + if (tableRe) { + const tableEl = driver.findContent('.test-wselect-table', tableRe); + + // unselect all selected columns + for (const col of (await driver.findAll('.test-wselect-column[class*=-selected]'))) { + await col.click(); + } + + // let's select table + await tableEl.click(); + + if (options.dismissTips) { await this.dismissBehavioralPrompts(); } + + const pivotEl = tableEl.find('.test-wselect-pivot'); + if (await pivotEl.isPresent()) { + await this.toggleSelectable(pivotEl, Boolean(options.summarize)); + } + + if (options.summarize) { + for (const columnEl of await driver.findAll('.test-wselect-column')) { + const label = await columnEl.getText(); + // TODO: Matching cols with regexp calls for trouble and adds no value. I think function should be + // rewritten using string matching only. + const goal = Boolean(options.summarize.find(r => label.match(r))); + await this.toggleSelectable(columnEl, goal); + } + } + + if (options.selectBy) { + // select link + await driver.find('.test-wselect-selectby').doClick(); + await driver.findContent('.test-wselect-selectby option', options.selectBy).doClick(); + } + } + + + if (options.dontAdd) { + return; + } + + // add the widget + await driver.find('.test-wselect-addBtn').doClick(); + + // if we selected a new table, there will be a popup for a name + const prompts = await driver.findAll(".test-modal-prompt"); + const prompt = prompts[0]; + if (prompt) { + if (options.tableName) { + await prompt.doClear(); + await prompt.click(); + await driver.sendKeys(options.tableName); + } + await driver.find(".test-modal-confirm").click(); + } + + await this.waitForServer(); + } + + /** + * Dismisses all behavioral prompts that are present. + */ + public async dismissBehavioralPrompts() { + let i = 0; + const max = 10; + + // Keep dismissing prompts until there are no more, up to a maximum of 10 times. + while (i < max && await this.driver.find('.test-behavioral-prompt').isPresent()) { + await this.driver.find('.test-behavioral-prompt-dismiss').click(); + await this.waitForServer(); + i += 1; + } + } + + /** + * Toggle elem if not selected. Expects elem to be clickable and to have a class ending with + * -selected when selected. + */ + public async toggleSelectable(elem: WebElement, goal: boolean) { + const isSelected = await elem.matches('[class*=-selected]'); + if (goal !== isSelected) { + await elem.click(); + } + } + + public async waitToPass(check: () => Promise, timeMs: number = 4000) { + try { + let delay: number = 10; + await this.driver.wait(async () => { + try { + await check(); + } catch (e) { + // Throttle operations a little bit. + await this.driver.sleep(delay); + if (delay < 50) { delay += 10; } + return false; + } + return true; + }, timeMs); + } catch (e) { + await check(); + } + } +} + +export interface WindowDimensions { + width: number; + height: number; +} + +export interface PageWidgetPickerOptions { + tableName?: string; + /** Optional pattern of SELECT BY option to pick. */ + selectBy?: RegExp|string; + /** Optional list of patterns to match Group By columns. */ + summarize?: (RegExp|string)[]; + /** If true, configure the widget selection without actually adding to the page. */ + dontAdd?: boolean; + /** If true, dismiss any tooltips that are shown. */ + dismissTips?: boolean; +}