const { promises: fs } = require('fs'); const { strict: assert } = require('assert'); const { until, error: webdriverError } = require('selenium-webdriver'); class Driver { /** * @param {!ThenableWebDriver} driver - A {@code WebDriver} instance * @param {string} browser - The type of browser this driver is controlling * @param {number} timeout */ constructor(driver, browser, extensionUrl, timeout = 10000) { this.driver = driver; this.browser = browser; this.extensionUrl = extensionUrl; this.timeout = timeout; } async delay(time) { await new Promise((resolve) => setTimeout(resolve, time)); } async wait(condition, timeout = this.timeout) { await this.driver.wait(condition, timeout); } async quit() { await this.driver.quit(); } // Element interactions async findElement(locator) { return await this.driver.wait(until.elementLocated(locator), this.timeout); } async findVisibleElement(locator) { const element = await this.findElement(locator); await this.driver.wait(until.elementIsVisible(element), this.timeout); return element; } async findClickableElement(locator) { const element = await this.findElement(locator); await Promise.all([ this.driver.wait(until.elementIsVisible(element), this.timeout), this.driver.wait(until.elementIsEnabled(element), this.timeout), ]); return element; } async findElements(locator) { return await this.driver.wait(until.elementsLocated(locator), this.timeout); } async findClickableElements(locator) { const elements = await this.findElements(locator); await Promise.all( elements.reduce((acc, element) => { acc.push( this.driver.wait(until.elementIsVisible(element), this.timeout), this.driver.wait(until.elementIsEnabled(element), this.timeout), ); return acc; }, []), ); return elements; } async clickElement(locator) { const element = await this.findClickableElement(locator); await element.click(); } async clickPoint(locator, x, y) { const element = await this.findElement(locator); await this.driver .actions() .move({ origin: element, x, y }) .click() .perform(); } async scrollToElement(element) { await this.driver.executeScript( 'arguments[0].scrollIntoView(true)', element, ); } async assertElementNotPresent(locator) { let dataTab; try { dataTab = await this.findElement(locator); } catch (err) { assert( err instanceof webdriverError.NoSuchElementError || err instanceof webdriverError.TimeoutError, ); } assert.ok(!dataTab, 'Found element that should not be present'); } // Navigation async navigate(page = Driver.PAGES.HOME) { return await this.driver.get(`${this.extensionUrl}/${page}.html`); } // Metrics async collectMetrics() { return await this.driver.executeScript(collectMetrics); } // Window management async openNewPage(url) { const newHandle = await this.driver.switchTo().newWindow(); await this.driver.get(url); return newHandle; } async switchToWindow(handle) { await this.driver.switchTo().window(handle); } async getAllWindowHandles() { return await this.driver.getAllWindowHandles(); } async waitUntilXWindowHandles(x, delayStep = 1000, timeout = 5000) { let timeElapsed = 0; let windowHandles = []; while (timeElapsed <= timeout) { windowHandles = await this.driver.getAllWindowHandles(); if (windowHandles.length === x) { return windowHandles; } await this.delay(delayStep); timeElapsed += delayStep; } throw new Error('waitUntilXWindowHandles timed out polling window handles'); } async switchToWindowWithTitle(title, windowHandles) { // eslint-disable-next-line no-param-reassign windowHandles = windowHandles || (await this.driver.getAllWindowHandles()); for (const handle of windowHandles) { await this.driver.switchTo().window(handle); const handleTitle = await this.driver.getTitle(); if (handleTitle === title) { return handle; } } throw new Error(`No window with title: ${title}`); } /** * Closes all windows except those in the given list of exceptions * @param {Array} exceptions - The list of window handle exceptions * @param {Array} [windowHandles] - The full list of window handles * @returns {Promise} */ async closeAllWindowHandlesExcept(exceptions, windowHandles) { // eslint-disable-next-line no-param-reassign windowHandles = windowHandles || (await this.driver.getAllWindowHandles()); for (const handle of windowHandles) { if (!exceptions.includes(handle)) { await this.driver.switchTo().window(handle); await this.delay(1000); await this.driver.close(); await this.delay(1000); } } } // Error handling async verboseReportOnFailure(title) { const artifactDir = `./test-artifacts/${this.browser}/${title}`; const filepathBase = `${artifactDir}/test-failure`; await fs.mkdir(artifactDir, { recursive: true }); const screenshot = await this.driver.takeScreenshot(); await fs.writeFile(`${filepathBase}-screenshot.png`, screenshot, { encoding: 'base64', }); const htmlSource = await this.driver.getPageSource(); await fs.writeFile(`${filepathBase}-dom.html`, htmlSource); const uiState = await this.driver.executeScript( () => window.getCleanAppState && window.getCleanAppState(), ); await fs.writeFile( `${filepathBase}-state.json`, JSON.stringify(uiState, null, 2), ); } async checkBrowserForConsoleErrors() { const ignoredLogTypes = ['WARNING']; const ignoredErrorMessages = [ // Third-party Favicon 404s show up as errors 'favicon.ico - Failed to load resource: the server responded with a status of 404 (Not Found)', ]; const browserLogs = await this.driver.manage().logs().get('browser'); const errorEntries = browserLogs.filter( (entry) => !ignoredLogTypes.includes(entry.level.toString()), ); const errorObjects = errorEntries.map((entry) => entry.toJSON()); return errorObjects.filter( (entry) => !ignoredErrorMessages.some((message) => entry.message.includes(message), ), ); } } function collectMetrics() { const results = { paint: {}, navigation: [], }; window.performance.getEntriesByType('paint').forEach((paintEntry) => { results.paint[paintEntry.name] = paintEntry.startTime; }); window.performance .getEntriesByType('navigation') .forEach((navigationEntry) => { results.navigation.push({ domContentLoaded: navigationEntry.domContentLoadedEventEnd, load: navigationEntry.loadEventEnd, domInteractive: navigationEntry.domInteractive, redirectCount: navigationEntry.redirectCount, type: navigationEntry.type, }); }); return results; } Driver.PAGES = { HOME: 'home', NOTIFICATION: 'notification', POPUP: 'popup', }; module.exports = Driver;