/* ============================================================================= Selenium Framework Example: ``` it("browser test", async () => { await withBrowser(async browser => { await browser.visit("/login") await browser.clickText("Login with email") await browser.findElement("input[type='email']").type("chet@example.com").enter() //... }) }) ``` ============================================================================= */ // Importing chromedriver will add its exececutable script to the environment PATH. import "chromedriver" import { Builder, ThenableWebDriver, By, WebElement, Key, Condition, } from "selenium-webdriver" import { Options } from "selenium-webdriver/chrome" import * as _ from "lodash" import { IKey } from "selenium-webdriver/lib/input" const headless = true const baseUrl = "http://localhost:3000" function getUrl(url: string) { if (url.startsWith("/")) { return baseUrl + url } else { return url } } export async function withBrowser(fn: (browser: Browser) => Promise) { const driver = new Builder() .forBrowser("chrome") .setChromeOptions(headless ? new Options().headless() : new Options()) .build() try { await fn(new Browser(driver)) await driver.quit() } catch (error) { if (headless) { await driver.quit() } throw error } } /** * Stringifies a function to run inside the browser. */ async function executeScript( driver: ThenableWebDriver, arg: T, fn: (arg: T, callback: () => void) => void ) { try { await driver.executeAsyncScript( `try { (${fn.toString()}).apply({}, arguments) } catch (error) { console.error(error) }`, arg ) } catch (error) {} } /** * Wrap any promised coming from the Selenium driver so that we can * get stack traces that point to our code. */ async function wrapError(p: Promise) { const e = new Error() e["__wrapError"] = true try { const result = await p // Wait just a little bit in case the browser is about to navigate // or something. await new Promise(resolve => setTimeout(resolve, 20)) return result } catch (error) { if (error["__wrapError"]) { throw error } e.message = error.message throw e } } /** * Selenium will fail if an element is not immediately found. This makes it * easier to test asynchronous user interfaces, similar to how Cypress works. */ async function waitFor( driver: ThenableWebDriver, fn: () => Promise, timeout = 5000 ) { await driver.wait( new Condition("wait", async () => { try { const result = await fn() return Boolean(result) } catch (error) { return false } }), timeout ) } /** * Represents a single Selenium WebElement wrapped in an object with * various helper methods. */ class Element { private promise: Promise then: Promise["then"] catch: Promise["catch"] constructor( public driver: ThenableWebDriver, promise: Promise | WebElement ) { this.promise = Promise.resolve(promise) this.then = this.promise.then.bind(this.promise) this.catch = this.promise.catch.bind(this.promise) } /** Map in the monadic sense. */ map(fn: (elm: WebElement) => Promise) { return new Element( this.driver, wrapError( this.promise.then(async elm => { const result = await fn(elm) if (result) { return result } else { return elm } }) ) ) } waitFor( fn: (elm: WebElement) => Promise, timeout?: number ) { return this.map(elm => waitFor(this.driver, () => fn(elm), timeout)) } mapWait(fn: (elm: WebElement) => Promise, timeout?: number) { return this.waitFor(fn, timeout).map(fn) } click() { return this.map(elm => elm.click()) } clear() { return this.map(elm => elm.clear()) } type(text: string) { return this.map(elm => elm.sendKeys(text)) } enter() { return this.map(elm => elm.sendKeys(Key.RETURN)) } tab() { return this.map(elm => elm.sendKeys(Key.TAB)) } backspace() { return this.map(elm => elm.sendKeys(Key.BACK_SPACE)) } scrollIntoView() { return this.map(async elm => { const rect = await elm.getRect() const x = rect.x const y = rect.y await executeScript(this.driver, { x, y }, (arg, callback) => { const elm = document.elementFromPoint(arg.x, arg.y) as HTMLElement if (elm) { elm.scrollIntoView() } callback() }) return elm }) } find(selector: string) { return this.mapWait(elm => { return elm.findElement(By.css(selector)) }) } findAll(selector: string) { return new Elements( this.driver, this.promise.then(elm => { return waitFor(this.driver, () => elm.findElements(By.css(selector)) ).then(() => { return elm.findElements(By.css(selector)) }) }) ) } /** * Find an element with exact text. */ findText(text: string) { return this.mapWait(elm => { // TODO: escape text? // https://stackoverflow.com/questions/12323403/how-do-i-find-an-element-that-contains-specific-text-in-selenium-webdrive // https://github.com/seleniumhq/selenium/issues/3203#issue-193477218 return elm.findElement(By.xpath(`.//*[contains(text(), '${text}')]`)) }) } /** * Assert that the element text contains the given text. */ textExists(text: string, timeout?: number) { return this.mapWait(async elm => { const elmText = await elm.getText() if (elmText.indexOf(text) !== -1) { return elm } throw new Error("Text not found: '" + text + "'.") }, timeout) } clickText(text: string) { return this.findText(text).click() } hover() { return this.map(async elm => { const rect = await elm.getRect() const x = rect.x + rect.width / 2 const y = rect.y + rect.height / 2 await executeScript(this.driver, { x, y }, (arg, callback) => { const elm = document.elementFromPoint(arg.x, arg.y) if (elm) { elm.dispatchEvent( new Event("mousemove", { bubbles: true, cancelable: false }) ) } callback() }) return elm }) } /** * The find command should fail before ever getting to this error. But somehow * it feels right to write this in a test, otherwise the clause doesn't make sense. */ exists() { return this.map(async elm => { if (!elm) { throw new Error("Element not found.") } return elm }) } /** Useful for debugging */ halt(): Element { throw new Error("Halt") } } /** * Represents a multiple Selenium WebElements wrapped in an object with * various helper methods. */ class Elements { private promise: Promise> then: Promise>["then"] catch: Promise>["catch"] constructor( public driver: ThenableWebDriver, promise: Promise> | Array ) { this.promise = Promise.resolve(promise) this.then = this.promise.then.bind(this.promise) this.catch = this.promise.catch.bind(this.promise) } /** Map in the monadic sense. */ map( fn: ( elm: Array ) => Promise | undefined | void> ) { return new Elements( this.driver, wrapError( this.promise.then(async elms => { const result = await fn(elms) if (Array.isArray(result)) { return result } else { return elms } }) ) ) } waitFor(fn: (elm: Array) => Promise) { return this.map(elm => waitFor(this.driver, () => fn(elm))) } mapWait(fn: (elm: Array) => Promise>) { return this.waitFor(fn).map(fn) } atIndex(index: number) { return new Element( this.driver, wrapError( this.promise.then(elms => { const elm = elms[index] if (!elm) { throw new Error("Element not found!") } return elm }) ) ) } /** Useful for debugging */ halt(): Elements { throw new Error("Halt") } } /** * Represents a Selenium Browser wrapped in an object with various helper * methods. */ export class Browser { private promise: Promise then: Promise["then"] catch: Promise["catch"] constructor(public driver: ThenableWebDriver, promise?: Promise) { this.promise = Promise.resolve(promise) this.then = this.promise.then.bind(this.promise) this.catch = this.promise.catch.bind(this.promise) } visit(route: string) { return new Browser( this.driver, wrapError( this.promise.then(async () => { await this.driver.get(getUrl(route)) }) ) ) } refresh() { return new Browser( this.driver, wrapError( this.promise.then(async () => { await this.driver.navigate().refresh() }) ) ) } maximize() { return new Browser( this.driver, wrapError( this.promise.then(async () => { await this.driver .manage() .window() .maximize() }) ) ) } resize(x: number, y: number) { return new Browser( this.driver, wrapError( this.promise.then(async () => { await this.driver .manage() .window() .setSize(x, y) }) ) ) } find(selector: string) { return new Element( this.driver, wrapError( this.promise .then(() => { return waitFor(this.driver, async () => this.driver.findElement(By.css(selector)) ) }) .then(() => { return this.driver.findElement(By.css(selector)) }) ) ) } shortcut(modifiers: Array>, keys: Array) { return new Browser( this.driver, wrapError( this.promise.then(async () => { const chord = Key.chord( ...modifiers.map(modifier => Key[modifier]), ...keys ) await this.driver.findElement(By.tagName("html")).sendKeys(chord) }) ) ) } getClassName(className: string) { return this.find("." + className) } getTitle() { return this.driver.getTitle() } waitFor(fn: () => Promise, timeout = 5000) { return new Browser(this.driver, waitFor(this.driver, fn)) } waitToLeave(url: string) { return new Browser( this.driver, wrapError( waitFor( this.driver, async () => { const currentUrl = await this.driver.getCurrentUrl() return getUrl(url) !== currentUrl }, 10000 ) ) ) } waitToVisit(url: string) { return new Browser( this.driver, wrapError( waitFor( this.driver, async () => { const currentUrl = await this.driver.getCurrentUrl() return getUrl(url) === currentUrl }, 10000 ) ) ) } getCurrentUrl() { return this.driver.getCurrentUrl() } /** Useful for debugging */ halt(): Browser { throw new Error("Halt") } }