Skip to content

Instantly share code, notes, and snippets.

@ccorcos
Created September 19, 2019 17:52
Show Gist options
  • Select an option

  • Save ccorcos/5372e1f946927d5043f070fb9260fcea to your computer and use it in GitHub Desktop.

Select an option

Save ccorcos/5372e1f946927d5043f070fb9260fcea to your computer and use it in GitHub Desktop.

Revisions

  1. ccorcos created this gist Sep 19, 2019.
    509 changes: 509 additions & 0 deletions selenium-framework.ts
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,509 @@
    /* =============================================================================
    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<void>) {
    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<T>(
    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<T>(p: Promise<T>) {
    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<boolean | object>,
    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<WebElement>
    then: Promise<WebElement>["then"]
    catch: Promise<WebElement>["catch"]

    constructor(
    public driver: ThenableWebDriver,
    promise: Promise<WebElement> | 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<WebElement | undefined | void>) {
    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<boolean | object>,
    timeout?: number
    ) {
    return this.map(elm => waitFor(this.driver, () => fn(elm), timeout))
    }

    mapWait(fn: (elm: WebElement) => Promise<WebElement>, 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<Array<WebElement>>
    then: Promise<Array<WebElement>>["then"]
    catch: Promise<Array<WebElement>>["catch"]

    constructor(
    public driver: ThenableWebDriver,
    promise: Promise<Array<WebElement>> | Array<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: Array<WebElement>
    ) => Promise<Array<WebElement> | 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<WebElement>) => Promise<boolean | object>) {
    return this.map(elm => waitFor(this.driver, () => fn(elm)))
    }

    mapWait(fn: (elm: Array<WebElement>) => Promise<Array<WebElement>>) {
    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<void>
    then: Promise<void>["then"]
    catch: Promise<void>["catch"]

    constructor(public driver: ThenableWebDriver, promise?: Promise<void>) {
    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<keyof Omit<IKey, "chord">>, keys: Array<string>) {
    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<boolean>, 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")
    }
    }