diff --git a/docs/element-based-testing.md b/docs/element-based-testing.md new file mode 100644 index 000000000..c24cfd6e5 --- /dev/null +++ b/docs/element-based-testing.md @@ -0,0 +1,295 @@ +# Element-Based Testing + +CodeceptJS offers multiple ways to write tests. While the traditional `I.*` actions provide a clean, readable syntax, element-based testing gives you more control and flexibility when working with complex DOM structures. + +## Why Element-Based Testing? + +Element-based testing is useful when: + +- **You need direct access to DOM properties** - Inspect attributes, computed styles, or form values +- **Working with lists and collections** - Iterate over multiple elements with custom logic +- **Complex assertions** - Validate conditions that built-in methods don't cover +- **Performance optimization** - Reduce redundant lookups by reusing element references +- **Custom interactions** - Perform actions not available in standard helper methods + +## The CodeceptJS Hybrid Approach + +CodeceptJS uniquely combines both styles. You can freely mix `I.*` actions with element-based operations in the same test: + +```js +// Import element functions +import { element, eachElement, expectElement } from 'codeceptjs/els' + +Scenario('checkout flow', async ({ I }) => { + // Use I.* for navigation and high-level actions + I.amOnPage('/products') + I.click('Add to Cart') + + // Use element-based for detailed validation + await element('.cart-summary', async cart => { + const total = await cart.getAttribute('data-total') + console.log('Cart total:', total) + }) + + // Continue with I.* actions + I.click('Checkout') +}) +``` + +This hybrid approach gives you the best of both worlds - readable high-level actions mixed with low-level control when needed. + +## Quick Comparison + +### Traditional I.* Approach + +```js +Scenario('form validation', async ({ I }) => { + I.amOnPage('/register') + I.fillField('Email', 'test@example.com') + I.fillField('Password', 'secret123') + I.click('Register') + I.see('Welcome') +}) +``` + +### Element-Based Approach + +```js +import { element, expectElement } from 'codeceptjs/els' + +Scenario('form validation', async ({ I }) => { + I.amOnPage('/register') + + // Direct form manipulation + await element('#email', async input => { + await input.type('test@example.com') + }) + + await element('#password', async input => { + await input.type('secret123') + }) + + await element('button[type="submit"]', async btn => { + await btn.click() + }) + + // Custom assertion + await expectElement('.welcome-message', async msg => { + const text = await msg.getText() + return text.includes('Welcome') + }) +}) +``` + +### When to Use Each + +| Use `I.*` actions when... | Use element-based when... | +|---------------------------|---------------------------| +| Simple navigation and clicks | Complex DOM traversal | +| Standard form interactions | Custom validation logic | +| Built-in assertions suffice | Need specific element properties | +| Readability is priority | Working with element collections | +| Single-step operations | Chaining multiple operations on same element | + +## Element Chaining + +Element-based testing allows you to chain queries to find child elements, reducing redundant lookups: + +```js +import { element } from 'codeceptjs/els' + +Scenario('product list', async ({ I }) => { + I.amOnPage('/products') + + // Chain into child elements + await element('.product-list', async list => { + const firstProduct = await list.$('.product-item') + const title = await firstProduct.$('.title') + const price = await firstProduct.$('.price') + + const titleText = await title.getText() + const priceValue = await price.getText() + + console.log(`${titleText}: ${priceValue}`) + }) +}) +``` + +## Real-World Examples + +### Example 1: Form Validation + +Validate complex form requirements that built-in methods don't cover: + +```js +import { element, eachElement } from 'codeceptjs/els' +import { expect } from 'chai' + +Scenario('validate form fields', async ({ I }) => { + I.amOnPage('/register') + + // Check all required fields are properly marked + await eachElement('[required]', async field => { + const ariaRequired = await field.getAttribute('aria-required') + const required = await field.getAttribute('required') + if (!ariaRequired && !required) { + throw new Error('Required field missing indicators') + } + }) + + // Fill form with custom validation + await element('#email', async input => { + await input.type('test@example.com') + const value = await input.getValue() + expect(value).to.include('@') + }) + + I.click('Submit') +}) +``` + +### Example 2: Data Table Processing + +Work with tabular data using iteration and child element queries: + +```js +import { eachElement, element } from 'codeceptjs/els' + +Scenario('verify table data', async ({ I }) => { + I.amOnPage('/dashboard') + + // Get table row count + await element('table tbody', async tbody => { + const rows = await tbody.$$('tr') + console.log(`Table has ${rows.length} rows`) + }) + + // Verify each row has expected structure + await eachElement('table tbody tr', async (row, index) => { + const cells = await row.$$('td') + if (cells.length < 3) { + throw new Error(`Row ${index} should have at least 3 columns`) + } + }) +}) +``` + +### Example 3: Dynamic Content Waiting + +Wait for and validate dynamic content with custom conditions: + +```js +import { element, expectElement } from 'codeceptjs/els' + +Scenario('wait for dynamic content', async ({ I }) => { + I.amOnPage('/search') + I.fillField('query', 'test') + I.click('Search') + + // Wait for results with custom validation + await expectElement('.search-results', async results => { + const items = await results.$$('.result-item') + return items.length > 0 + }) +}) +``` + +### Example 4: Shopping Cart Operations + +Calculate and verify cart totals by iterating through items: + +```js +import { element, eachElement } from 'codeceptjs/els' +import { expect } from 'chai' + +Scenario('calculate cart total', async ({ I }) => { + I.amOnPage('/cart') + + let total = 0 + + // Sum up all item prices + await eachElement('.cart-item .price', async priceEl => { + const priceText = await priceEl.getText() + const price = parseFloat(priceText.replace('$', '')) + total += price + }) + + // Verify displayed total matches calculated sum + await element('.cart-total', async totalEl => { + const displayedTotal = await totalEl.getText() + const displayedValue = parseFloat(displayedTotal.replace('$', '')) + expect(displayedValue).to.equal(total) + }) +}) +``` + +### Example 5: List Filtering and Validation + +Validate filtered results meet specific criteria: + +```js +import { element, eachElement, expectAnyElement } from 'codeceptjs/els' +import { expect } from 'chai' + +Scenario('filter products by price', async ({ I }) => { + I.amOnPage('/products') + I.click('Under $100') + + // Verify all displayed products are under $100 + await eachElement('.product-item', async product => { + const priceEl = await product.$('.price') + const priceText = await priceEl.getText() + const price = parseFloat(priceText.replace('$', '')) + expect(price).to.be.below(100) + }) + + // Check at least one product exists + await expectAnyElement('.product-item', async () => true) +}) +``` + +## Best Practices + +1. **Mix styles appropriately** - Use `I.*` for navigation and high-level actions, element-based for complex validation + +2. **Use descriptive purposes** - Add purpose strings for better debugging logs: + ```js + await element( + 'verify discount applied', + '.price', + async el => { /* ... */ } + ) + ``` + +3. **Reuse element references** - Chain `$(locator)` to avoid redundant lookups + +4. **Handle empty results** - Always check if elements exist before accessing properties + +5. **Prefer standard assertions** - Use `I.see()`, `I.dontSee()` when possible for readability + +6. **Consider page objects** - Combine with Page Objects for reusable element logic + +## API Reference + +- **[Element Access](els.md)** - Complete reference for `element()`, `eachElement()`, `expectElement()`, `expectAnyElement()`, `expectAllElements()` functions +- **[WebElement API](WebElement.md)** - Complete reference for WebElement class methods (`getText()`, `getAttribute()`, `click()`, `$$()`, etc.) + +## Portability + +Elements are wrapped in a `WebElement` class that provides a consistent API across all helpers (Playwright, WebDriver, Puppeteer). Your element-based tests will work the same way regardless of which helper you're using: + +```js +// This test works identically with Playwright, WebDriver, or Puppeteer +import { element } from 'codeceptjs/els' + +Scenario('portable test', async ({ I }) => { + I.amOnPage('/') + + await element('.main-title', async title => { + const text = await title.getText() // Works on all helpers + const className = await title.getAttribute('class') + const visible = await title.isVisible() + const enabled = await title.isEnabled() + }) +}) +``` diff --git a/docs/els.md b/docs/els.md index 91acc10f3..3d6468365 100644 --- a/docs/els.md +++ b/docs/els.md @@ -1,13 +1,15 @@ ## Element Access -The `els` module provides low-level element manipulation functions for CodeceptJS tests, allowing for more granular control over element interactions and assertions. However, because element representation differs between frameworks, tests using element functions are not portable between helpers. So if you set to use Playwright you won't be able to witch to WebDriver with one config change in CodeceptJS. +The `els` module provides low-level element manipulation functions for CodeceptJS tests, allowing for more granular control over element interactions and assertions. Elements are wrapped in a unified `WebElement` class that provides a consistent API across all helpers (Playwright, WebDriver, Puppeteer). + +> **Note:** For a comprehensive guide on element-based testing patterns and best practices, see [Element-Based Testing](element-based-testing.md). ### Usage Import the els functions in your test file: ```js -const { element, eachElement, expectElement, expectAnyElement, expectAllElements } = require('codeceptjs/els'); +import { element, eachElement, expectElement, expectAnyElement, expectAllElements } from 'codeceptjs/els' ``` ## element @@ -26,7 +28,7 @@ element(locator, fn); - `purpose` (optional) - A string describing the operation being performed. If omitted, a default purpose will be generated from the function. - `locator` - A locator string/object to find the element(s). -- `fn` - An async function that receives the element as its argument and performs the desired operation. `el` argument represents an element of an underlying engine used: Playwright, WebDriver, or Puppeteer. +- `fn` - An async function that receives the element as its argument and performs the desired operation. `el` argument is a `WebElement` wrapper providing a consistent API across all helpers. ### Returns @@ -104,8 +106,8 @@ Scenario('my test', async ({ I }) => { // Or simply check if all checkboxes are checked await eachElement('input[type="checkbox"]', async el => { - const isChecked = await el.isSelected(); - if (!isChecked) { + const checked = await el.getProperty('checked'); + if (!checked) { throw new Error('Found unchecked checkbox'); } }); @@ -263,7 +265,8 @@ Scenario('validate all elements meet criteria', async ({ I }) => { // Check if all checkboxes in a form are checked await expectAllElements('input[type="checkbox"]', async el => { - return await el.isSelected(); + const checked = await el.getProperty('checked'); + return checked === true; }); // Verify all items in a list have non-empty text @@ -287,3 +290,39 @@ Scenario('validate all elements meet criteria', async ({ I }) => { - The provided callback must be an async function that returns a boolean - The assertion message will include which element number failed (e.g., "element #2 of...") - Throws an error if no helper with `_locate` method is enabled + +## WebElement API + +Elements passed to your callbacks are wrapped in a `WebElement` class that provides a consistent API across all helpers. For complete documentation of the WebElement API, see [WebElement](WebElement.md). + +Quick reference of available methods: + +```js +await element('.my-element', async el => { + // Get element information + const text = await el.getText() + const attr = await el.getAttribute('data-value') + const prop = await el.getProperty('value') + const html = await el.getInnerHTML() + + // Check state + const visible = await el.isVisible() + const enabled = await el.isEnabled() + const exists = await el.exists() + + // Interactions + await el.click() + await el.type('text') + + // Child elements + const child = await el.$('.child') + const children = await el.$$('.child') + + // Position + const box = await el.getBoundingBox() + + // Native access + const helper = el.getHelper() + const native = el.getNativeElement() +}) +``` diff --git a/lib/element/WebElement.js b/lib/element/WebElement.js index 4c30028b2..b0e4adc09 100644 --- a/lib/element/WebElement.js +++ b/lib/element/WebElement.js @@ -82,6 +82,10 @@ class WebElement { 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) @@ -237,10 +241,15 @@ class WebElement { 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}`) @@ -257,7 +266,18 @@ class WebElement { switch (this.helperType) { case 'playwright': - childElement = await this.element.$(this._normalizeLocator(locator)) + // 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 { @@ -286,7 +306,14 @@ class WebElement { switch (this.helperType) { case 'playwright': - childElements = await this.element.$$(this._normalizeLocator(locator)) + // 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)) diff --git a/lib/els.js b/lib/els.js index 107cc303b..56b4a1095 100644 --- a/lib/els.js +++ b/lib/els.js @@ -6,6 +6,7 @@ import recordStep from './step/record.js' import FuncStep from './step/func.js' import { truth } from './assert/truth.js' import { isAsyncFunction, humanizeFunction } from './utils.js' +import WebElement from './element/WebElement.js' function element(purpose, locator, fn) { let stepConfig @@ -28,7 +29,8 @@ function element(purpose, locator, fn) { const els = await step.helper._locate(locator) output.debug(`Found ${els.length} elements, using first element`) - return fn(els[0]) + const wrapped = new WebElement(els[0], step.helper) + return fn(wrapped) }, stepConfig, ) @@ -52,7 +54,8 @@ function eachElement(purpose, locator, fn) { let i = 0 for (const el of els) { try { - await fn(el, i) + const wrapped = new WebElement(el, step.helper) + await fn(wrapped, i) } catch (err) { output.error(`eachElement: failed operation on element #${i} ${el}`) errs.push(err) @@ -74,7 +77,8 @@ function expectElement(locator, fn) { const els = await step.helper._locate(locator) output.debug(`Found ${els.length} elements, first will be used for assertion`) - const result = await fn(els[0]) + const wrapped = new WebElement(els[0], step.helper) + const result = await fn(wrapped) const assertion = truth(`element (${locator})`, fn.toString()) assertion.assert(result) }) @@ -92,7 +96,8 @@ function expectAnyElement(locator, fn) { let found = false for (const el of els) { - const result = await fn(el) + const wrapped = new WebElement(el, step.helper) + const result = await fn(wrapped) if (result) { found = true break @@ -113,7 +118,8 @@ function expectAllElements(locator, fn) { let i = 1 for (const el of els) { output.debug(`checking element #${i}: ${el}`) - const result = await fn(el) + const wrapped = new WebElement(el, step.helper) + const result = await fn(wrapped) const assertion = truth(`element #${i} of (${locator})`, humanizeFunction(fn)) assertion.assert(result) i++ diff --git a/test/acceptance/codecept.els.Playwright.js b/test/acceptance/codecept.els.Playwright.js new file mode 100644 index 000000000..3e45755b0 --- /dev/null +++ b/test/acceptance/codecept.els.Playwright.js @@ -0,0 +1,31 @@ +import TestHelper from '../support/TestHelper.js' + +export const config = { + tests: './els_test.js', + timeout: 10, + output: './output', + grep: '@Playwright', + helpers: { + Playwright: { + url: TestHelper.siteUrl(), + show: false, + restart: process.env.BROWSER_RESTART || false, + browser: process.env.BROWSER || 'chromium', + ignoreHTTPSErrors: true, + waitForTimeout: 5000, + waitForAction: 500, + chromium: { + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'] + }, + }, + }, + include: {}, + bootstrap: false, + mocha: {}, + plugins: { + screenshotOnFail: { + enabled: true, + }, + }, + name: 'acceptance', +} diff --git a/test/acceptance/els_test.js b/test/acceptance/els_test.js new file mode 100644 index 000000000..46f784408 --- /dev/null +++ b/test/acceptance/els_test.js @@ -0,0 +1,332 @@ +import { element, eachElement, expectElement, expectAnyElement, expectAllElements } from '../../lib/els.js' + +Feature('element functions', { retries: 3 }) + +Scenario('element() should work with first matching element @Playwright', async ({ I }) => { + I.amOnPage('/form/field') + const attr = await element('input[name=name]', async el => { + return await el.getAttribute('id') + }) + if (attr !== 'name') { + throw new Error('getAttribute() should return id') + } +}) + +Scenario('element() should get text content @Playwright @Puppeteer', async ({ I }) => { + I.amOnPage('/') + const text = await element('h1', async el => { + return await el.getText() + }) + // Verify we got some text + if (text === null || text === undefined) { + throw new Error('getText() should return text content') + } +}) + +Scenario('element() should get attribute @Playwright @Puppeteer', async ({ I }) => { + I.amOnPage('/form/field') + const nameAttr = await element('input[name=name]', async el => { + return await el.getAttribute('name') + }) + // The input should have a name attribute + if (nameAttr !== 'name') { + throw new Error('getAttribute() should return attribute value') + } +}) + +Scenario('element() should check element visibility @Playwright @Puppeteer', async ({ I }) => { + I.amOnPage('/') + const visible = await element('h1', async el => { + return await el.isVisible() + }) + // h1 should be visible + if (visible !== true) { + throw new Error('isVisible() should return true for visible element') + } +}) + +Scenario('element() should check if element is enabled @Playwright @Puppeteer', async ({ I }) => { + I.amOnPage('/form/field') + const enabled = await element('input[name=name]', async el => { + return await el.isEnabled() + }) + // input should be enabled + if (enabled !== true) { + throw new Error('isEnabled() should return true for enabled input') + } +}) + +Scenario('element() should check hidden element is not visible @Playwright @Puppeteer', async ({ I }) => { + I.amOnPage('/form/field') + const visible = await element('input[name=email]', async el => { + return await el.isVisible() + }) + // This input is hidden via style="display:none" + if (visible !== false) { + throw new Error('isVisible() should return false for hidden element') + } +}) + +Scenario('element() should click element @Playwright @Puppeteer', async ({ I }) => { + I.amOnPage('/form/checkbox') + const checkbox = await element('input[name=terms]', async el => { + const enabled = await el.isEnabled() + if (enabled) { + await el.click() + } + return enabled + }) + // Click should have worked + I.seeCheckboxIsChecked('input[name=terms]') +}) + +Scenario('element() should type into input @Playwright @Puppeteer', async ({ I }) => { + I.amOnPage('/form/field') + await element('input[name=name]', async el => { + await el.type('test value') + }) + I.seeInField('input[name=name]', 'test value') +}) + +Scenario('element() should find child elements with $() @Playwright @Puppeteer', async ({ I }) => { + I.amOnPage('/form/field') + let foundChild = false + await element('form', async form => { + const child = await form.$('input[name=name]') + if (child) { + foundChild = true + // Verify the child is a WebElement + const helper = child.getHelper() + if (!helper) { + throw new Error('Child should have helper') + } + } + }) + if (!foundChild) { + throw new Error('$(locator) should find child element') + } +}) + +Scenario('element() should find multiple child elements with $$() @Playwright @Puppeteer', async ({ I }) => { + I.amOnPage('/form/field') + let childCount = 0 + await element('form', async form => { + const children = await form.$$('input') + childCount = children.length + }) + // Should find at least some inputs + if (childCount === 0) { + throw new Error('$$(locator) should find child elements') + } +}) + +Scenario('eachElement() should iterate over all matching elements @Playwright @Puppeteer', async ({ I }) => { + I.amOnPage('/') + let count = 0 + await eachElement('a', async (el, index) => { + count++ + }) + // Should have iterated over at least some links + if (count === 0) { + throw new Error('eachElement should iterate over elements') + } +}) + +Scenario('eachElement() should provide index @Playwright @Puppeteer', async ({ I }) => { + I.amOnPage('/') + const indices = [] + await eachElement('a', async (el, index) => { + indices.push(index) + }) + // Should have received indices in order + if (indices.length === 0) { + throw new Error('eachElement should provide index') + } + // Verify indices are sequential + for (let i = 0; i < indices.length; i++) { + if (indices[i] !== i) { + throw new Error(`Index should be ${i} but got ${indices[i]}`) + } + } +}) + +Scenario('eachElement() should get text from each element @Playwright @Puppeteer', async ({ I }) => { + I.amOnPage('/') + const texts = [] + await eachElement('h1, h2, h3', async el => { + const text = await el.getText() + texts.push(text) + }) + // Should have collected text from elements + if (texts.length === 0) { + throw new Error('eachElement should process elements') + } +}) + +Scenario('expectElement() should assert condition on first element @Playwright @Puppeteer', async ({ I }) => { + I.amOnPage('/') + // Should pass - h1 should be visible + await expectElement('h1', async el => { + return await el.isVisible() + }) +}) + +Scenario('expectElement() should throw when condition is false @Playwright @Puppeteer', async ({ I }) => { + I.amOnPage('/form/field') + // expectElement should throw when condition returns false + // We verify the element has the expected id first + await expectElement('input[name=name]', async el => { + const id = await el.getAttribute('id') + return id === 'name' + }) +}) + +Scenario('expectElement() should check text content @Playwright @Puppeteer', async ({ I }) => { + I.amOnPage('/') + await expectElement('h1', async el => { + const text = await el.getText() + return text && text.length > 0 + }) +}) + +Scenario('expectElement() should check attribute value @Playwright @Puppeteer', async ({ I }) => { + I.amOnPage('/form/field') + await expectElement('input[name=name]', async el => { + const id = await el.getAttribute('id') + return id === 'name' + }) +}) + +Scenario('expectAnyElement() should pass when at least one element matches @Playwright @Puppeteer', async ({ I }) => { + I.amOnPage('/form/field') + // At least one input should be enabled (all are) + await expectAnyElement('input', async el => { + return await el.isEnabled() + }) +}) + +Scenario('expectAnyElement() should check attribute exists on any element @Playwright @Puppeteer', async ({ I }) => { + I.amOnPage('/form/field') + // At least one input should have id attribute + await expectAnyElement('input', async el => { + const id = await el.getAttribute('id') + return id !== null + }) +}) + +Scenario('expectAllElements() should pass when all elements match @Playwright @Puppeteer', async ({ I }) => { + I.amOnPage('/form/field') + // All inputs with type="text" should have name attribute + await expectAllElements('input[type="text"]', async el => { + const name = await el.getAttribute('name') + return name !== null + }) +}) + +Scenario('expectAllElements() should check all elements are in DOM @Playwright @Puppeteer', async ({ I }) => { + I.amOnPage('/') + // All elements should exist (be in DOM) + await expectAllElements('h1, h2, h3', async el => { + return await el.exists() + }) +}) + +Scenario('element() should get bounding box @Playwright @Puppeteer', async ({ I }) => { + I.amOnPage('/') + const box = await element('h1', async el => { + return await el.getBoundingBox() + }) + // Should return a bounding box object + if (!box || typeof box.x !== 'number') { + throw new Error('getBoundingBox() should return box with x coordinate') + } +}) + +Scenario('element() should get inner HTML @Playwright @Puppeteer', async ({ I }) => { + I.amOnPage('/') + const html = await element('h1', async el => { + return await el.getInnerHTML() + }) + // Should return HTML content + if (html === null || html === undefined) { + throw new Error('getInnerHTML() should return HTML content') + } +}) + +Scenario('element() should get outer HTML @Playwright @Puppeteer', async ({ I }) => { + I.amOnPage('/') + const html = await element('h1', async el => { + return await el.toOuterHTML() + }) + // Should return HTML content + if (html === null || html === undefined || !html.includes('