Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Using Selenium for browser testing as an alternative to Cypress.
/* =============================================================================
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")
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment