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