codeceptjs
Version:
Supercharged End 2 End Testing Framework for NodeJS
575 lines (536 loc) • 18.3 kB
JavaScript
import assert from 'assert'
import { simplifyHtmlElement } from '../html.js'
/**
* Unified WebElement class that wraps native element instances from different helpers
* and provides a consistent API across all supported helpers (Playwright, WebDriver, Puppeteer).
*/
class WebElement {
constructor(element, helper) {
this.element = element
this.helper = helper
this.helperType = this._detectHelperType(helper)
}
_detectHelperType(helper) {
if (!helper) return 'unknown'
let ctor = helper.constructor
while (ctor && ctor.name) {
if (ctor.name === 'Playwright') return 'playwright'
if (ctor.name === 'WebDriver') return 'webdriver'
if (ctor.name === 'Puppeteer') return 'puppeteer'
ctor = Object.getPrototypeOf(ctor)
}
return 'unknown'
}
/**
* Get the native element instance
* @returns {ElementHandle|WebElement|ElementHandle} Native element
*/
getNativeElement() {
return this.element
}
/**
* Get the helper instance
* @returns {Helper} Helper instance
*/
getHelper() {
return this.helper
}
/**
* Get text content of the element
* @returns {Promise<string>} Element text content
*/
async getText() {
switch (this.helperType) {
case 'playwright':
return this.element.textContent()
case 'webdriver':
return this.element.getText()
case 'puppeteer':
return this.element.evaluate(el => el.textContent)
default:
throw new Error(`Unsupported helper type: ${this.helperType}`)
}
}
/**
* Get attribute value of the element
* @param {string} name Attribute name
* @returns {Promise<string|null>} Attribute value
*/
async getAttribute(name) {
switch (this.helperType) {
case 'playwright':
return this.element.getAttribute(name)
case 'webdriver':
return this.element.getAttribute(name)
case 'puppeteer':
return this.element.evaluate((el, attrName) => el.getAttribute(attrName), name)
default:
throw new Error(`Unsupported helper type: ${this.helperType}`)
}
}
/**
* Get property value of the element
* @param {string} name Property name
* @returns {Promise<any>} Property value
*/
async getProperty(name) {
switch (this.helperType) {
case 'playwright':
// For Locator objects, use inputValue() for the 'value' property
if (name === 'value' && this.element.inputValue) {
return this.element.inputValue()
}
return this.element.evaluate((el, propName) => el[propName], name)
case 'webdriver':
return this.element.getProperty(name)
case 'puppeteer':
return this.element.evaluate((el, propName) => el[propName], name)
default:
throw new Error(`Unsupported helper type: ${this.helperType}`)
}
}
/**
* Get innerHTML of the element
* @returns {Promise<string>} Element innerHTML
*/
async getInnerHTML() {
switch (this.helperType) {
case 'playwright':
return this.element.innerHTML()
case 'webdriver':
return this.element.getProperty('innerHTML')
case 'puppeteer':
return this.element.evaluate(el => el.innerHTML)
default:
throw new Error(`Unsupported helper type: ${this.helperType}`)
}
}
/**
* Get value of the element (for input elements)
* @returns {Promise<string>} Element value
*/
async getValue() {
switch (this.helperType) {
case 'playwright':
return this.element.inputValue()
case 'webdriver':
return this.element.getValue()
case 'puppeteer':
return this.element.evaluate(el => el.value)
default:
throw new Error(`Unsupported helper type: ${this.helperType}`)
}
}
/**
* Check if element is visible
* @returns {Promise<boolean>} True if element is visible
*/
async isVisible() {
switch (this.helperType) {
case 'playwright':
return this.element.isVisible()
case 'webdriver':
return this.element.isDisplayed()
case 'puppeteer':
return this.element.evaluate(el => {
const style = window.getComputedStyle(el)
return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'
})
default:
throw new Error(`Unsupported helper type: ${this.helperType}`)
}
}
/**
* Check if element is enabled
* @returns {Promise<boolean>} True if element is enabled
*/
async isEnabled() {
switch (this.helperType) {
case 'playwright':
return this.element.isEnabled()
case 'webdriver':
return this.element.isEnabled()
case 'puppeteer':
return this.element.evaluate(el => !el.disabled)
default:
throw new Error(`Unsupported helper type: ${this.helperType}`)
}
}
/**
* Check if element exists in DOM
* @returns {Promise<boolean>} True if element exists
*/
async exists() {
try {
switch (this.helperType) {
case 'playwright':
// For Playwright, if we have the element, it exists
return await this.element.evaluate(el => !!el)
case 'webdriver':
// For WebDriver, if we have the element, it exists
return true
case 'puppeteer':
// For Puppeteer, if we have the element, it exists
return await this.element.evaluate(el => !!el)
default:
throw new Error(`Unsupported helper type: ${this.helperType}`)
}
} catch (e) {
return false
}
}
/**
* Get bounding box of the element
* @returns {Promise<Object>} Bounding box with x, y, width, height properties
*/
async getBoundingBox() {
switch (this.helperType) {
case 'playwright':
return this.element.boundingBox()
case 'webdriver':
const rect = await this.element.getRect()
return {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
}
case 'puppeteer':
return this.element.boundingBox()
default:
throw new Error(`Unsupported helper type: ${this.helperType}`)
}
}
/**
* Click the element
* @param {Object} options Click options
* @returns {Promise<void>}
*/
async click(options = {}) {
switch (this.helperType) {
case 'playwright':
return this.element.click(options)
case 'webdriver':
return this.element.click()
case 'puppeteer':
return this.element.click(options)
default:
throw new Error(`Unsupported helper type: ${this.helperType}`)
}
}
/**
* Type text into the element
* @param {string} text Text to type
* @param {Object} options Type options
* @returns {Promise<void>}
*/
async type(text, options = {}) {
switch (this.helperType) {
case 'playwright':
// Playwright Locator objects use fill() instead of type()
if (this.element.fill) {
return this.element.fill(text, options)
}
return this.element.type(text, options)
case 'webdriver':
return this.element.setValue(text)
case 'puppeteer':
await this.element.evaluate(el => { el.value = '' })
return this.element.type(text, options)
default:
throw new Error(`Unsupported helper type: ${this.helperType}`)
}
}
/**
* Run a function in the browser with this element as the first argument.
* @param {Function} fn Browser-side function. Receives the element, then extra args.
* @param {...any} args Additional arguments passed to the function
* @returns {Promise<any>} Value returned by fn
*/
async evaluate(fn, ...args) {
switch (this.helperType) {
case 'playwright':
case 'puppeteer':
return this.element.evaluate(fn, ...args)
case 'webdriver':
return this.helper.executeScript(fn, this.element, ...args)
default:
throw new Error(`Unsupported helper type: ${this.helperType}`)
}
}
/**
* Focus the element.
* @returns {Promise<void>}
*/
async focus() {
switch (this.helperType) {
case 'playwright':
return this.element.focus()
case 'puppeteer':
if (this.element.focus) return this.element.focus()
return this.element.evaluate(el => el.focus())
case 'webdriver':
return this.helper.executeScript(el => el.focus(), this.element)
default:
throw new Error(`Unsupported helper type: ${this.helperType}`)
}
}
/**
* Type characters via the page/browser keyboard into the focused element.
* Unlike `type()`, this does not call `.fill()`/`.setValue()`, so it works
* with contenteditable nodes, iframe bodies, and editor-owned hidden textareas.
* @param {string} text Text to send
* @param {Object} [options] Options (e.g. `{ delay }`)
* @returns {Promise<void>}
*/
async typeText(text, options = {}) {
const s = String(text)
switch (this.helperType) {
case 'playwright':
case 'puppeteer':
return this.helper.page.keyboard.type(s, options)
case 'webdriver': {
const ENTER = '\uE007'
const parts = s.split('\n')
for (let i = 0; i < parts.length; i++) {
if (parts[i]) await this.helper.browser.keys(parts[i])
if (i < parts.length - 1) await this.helper.browser.keys(ENTER)
}
return
}
default:
throw new Error(`Unsupported helper type: ${this.helperType}`)
}
}
/**
* Select all content in the focused field and delete it via keyboard input.
* Sends Ctrl+A and Meta+A (so it works across platforms) followed by Backspace.
* @returns {Promise<void>}
*/
async selectAllAndDelete() {
switch (this.helperType) {
case 'playwright':
await this.helper.page.keyboard.press('Control+a').catch(() => {})
await this.helper.page.keyboard.press('Meta+a').catch(() => {})
await this.helper.page.keyboard.press('Backspace')
return
case 'puppeteer':
for (const mod of ['Control', 'Meta']) {
try {
await this.helper.page.keyboard.down(mod)
await this.helper.page.keyboard.press('KeyA')
await this.helper.page.keyboard.up(mod)
} catch (e) {}
}
await this.helper.page.keyboard.press('Backspace')
return
case 'webdriver': {
const b = this.helper.browser
await b.keys(['Control', 'a']).catch(() => {})
await b.keys(['Meta', 'a']).catch(() => {})
await b.keys(['Backspace'])
return
}
default:
throw new Error(`Unsupported helper type: ${this.helperType}`)
}
}
/**
* Treat this element as an iframe; invoke `fn` with a WebElement wrapping
* the iframe body. For WebDriver this switches the browser into the frame
* for the duration of the callback and switches back on exit.
* @param {(body: WebElement) => Promise<any>} fn
* @returns {Promise<any>} Return value of fn
*/
async inIframe(fn) {
switch (this.helperType) {
case 'playwright':
case 'puppeteer': {
const frame = await this.element.contentFrame()
const body = await frame.$('body')
return fn(new WebElement(body, this.helper))
}
case 'webdriver': {
const browser = this.helper.browser
await browser.switchFrame(this.element)
try {
const body = await browser.$('body')
return await fn(new WebElement(body, this.helper))
} finally {
await browser.switchFrame(null)
}
}
default:
throw new Error(`Unsupported helper type: ${this.helperType}`)
}
}
/**
* Find first child element matching the locator
* @param {string|Object} locator Element locator
* @returns {Promise<WebElement|null>} WebElement instance or null if not found
*/
async $(locator) {
let childElement
switch (this.helperType) {
case 'playwright':
// Playwright Locator objects use locator() method
if (this.element.locator) {
const childLocator = this.element.locator(this._normalizeLocator(locator))
// Get the element handle from the locator
try {
childElement = await childLocator.elementHandle()
} catch (e) {
return null
}
} else {
childElement = await this.element.$(this._normalizeLocator(locator))
}
break
case 'webdriver':
try {
childElement = await this.element.$(this._normalizeLocator(locator))
} catch (e) {
return null
}
break
case 'puppeteer':
childElement = await this.element.$(this._normalizeLocator(locator))
break
default:
throw new Error(`Unsupported helper type: ${this.helperType}`)
}
return childElement ? new WebElement(childElement, this.helper) : null
}
/**
* Find all child elements matching the locator
* @param {string|Object} locator Element locator
* @returns {Promise<WebElement[]>} Array of WebElement instances
*/
async $$(locator) {
let childElements
switch (this.helperType) {
case 'playwright':
// Playwright Locator objects use locator() method
if (this.element.locator) {
const childLocator = this.element.locator(this._normalizeLocator(locator))
// Get all element handles from the locator
childElements = await childLocator.elementHandles()
} else {
childElements = await this.element.$$(this._normalizeLocator(locator))
}
break
case 'webdriver':
childElements = await this.element.$$(this._normalizeLocator(locator))
break
case 'puppeteer':
childElements = await this.element.$$(this._normalizeLocator(locator))
break
default:
throw new Error(`Unsupported helper type: ${this.helperType}`)
}
return childElements.map(el => new WebElement(el, this.helper))
}
/**
* Normalize locator for element search
* @param {string|Object} locator Locator to normalize
* @returns {string} Normalized CSS selector
* @private
*/
async toAbsoluteXPath() {
const xpathFn = (el) => {
const parts = []
let current = el
while (current && current.nodeType === Node.ELEMENT_NODE) {
let index = 0
let sibling = current.previousSibling
while (sibling) {
if (sibling.nodeType === Node.ELEMENT_NODE && sibling.tagName === current.tagName) {
index++
}
sibling = sibling.previousSibling
}
const tagName = current.tagName.toLowerCase()
const pathIndex = index > 0 ? `[${index + 1}]` : ''
parts.unshift(`${tagName}${pathIndex}`)
current = current.parentElement
}
return '//' + parts.join('/')
}
switch (this.helperType) {
case 'playwright':
return this.element.evaluate(xpathFn)
case 'puppeteer':
return this.element.evaluate(xpathFn)
case 'webdriver':
return this.helper.browser.execute(xpathFn, this.element)
default:
throw new Error(`Unsupported helper type: ${this.helperType}`)
}
}
async toOuterHTML() {
switch (this.helperType) {
case 'playwright':
return this.element.evaluate(el => el.outerHTML)
case 'puppeteer':
return this.element.evaluate(el => el.outerHTML)
case 'webdriver':
return this.helper.browser.execute(el => el.outerHTML, this.element)
default:
throw new Error(`Unsupported helper type: ${this.helperType}`)
}
}
async toSimplifiedHTML(maxLength = 300) {
const outerHTML = await this.toOuterHTML()
return simplifyHtmlElement(outerHTML, maxLength)
}
/**
* Plain-object snapshot of the element — text, simplified HTML, visibility,
* enabled state, and a curated set of attributes. Each underlying call is
* isolated so a single failure (e.g. detached element) doesn't poison the
* rest. Suitable for JSON.stringify, log output, MCP tool responses.
*
* @param {object} [opts]
* @param {number} [opts.maxHtmlLength=300] passed through to toSimplifiedHTML
* @param {string[]} [opts.attrs] attribute names to surface
* @returns {Promise<{text?: string, html?: string, visible?: boolean, enabled?: boolean, attrs?: object}>}
*/
async describe({ maxHtmlLength = 300, attrs = ['id', 'class', 'name', 'role', 'type', 'href', 'value', 'aria-label', 'placeholder', 'data-testid'] } = {}) {
const out = {}
await Promise.all([
this.toSimplifiedHTML(maxHtmlLength).then(v => { if (v) out.html = v }, () => {}),
this.getText().then(v => { const t = v?.trim(); if (t) out.text = t }, () => {}),
this.isVisible().then(v => { out.visible = v }, () => {}),
this.isEnabled().then(v => { out.enabled = v }, () => {}),
])
const collected = {}
await Promise.all(attrs.map(async name => {
try {
const v = await this.getAttribute(name)
if (v != null && v !== '') collected[name] = v
} catch {}
}))
if (Object.keys(collected).length) out.attrs = collected
return out
}
// Make accidental JSON.stringify (e.g. returning a WebElement from MCP run_code)
// produce a usable hint instead of `{}` — the underlying handle isn't
// serializable. Use .describe() for the real plain-object snapshot.
toJSON() {
return `[WebElement ${this.helperType} — call .describe() for a plain-object snapshot or .toSimplifiedHTML() for HTML]`
}
_normalizeLocator(locator) {
if (typeof locator === 'string') {
return locator
}
if (typeof locator === 'object') {
// Handle CodeceptJS locator objects
if (locator.css) return locator.css
if (locator.xpath) return locator.xpath
if (locator.id) return `#${locator.id}`
if (locator.name) return `[name="${locator.name}"]`
if (locator.className) return `.${locator.className}`
}
return locator.toString()
}
}
export default WebElement