diff --git a/docs/helpers/Playwright.md b/docs/helpers/Playwright.md index 99bf03983..fb63eadcc 100644 --- a/docs/helpers/Playwright.md +++ b/docs/helpers/Playwright.md @@ -80,6 +80,7 @@ Type: [object][6] - `bypassCSP` **[boolean][26]?** bypass Content Security Policy or CSP - `highlightElement` **[boolean][26]?** highlight the interacting elements. Default: false. Note: only activate under verbose mode (--verbose). - `recordHar` **[object][6]?** record HAR and will be saved to `output/har`. See more of [HAR options][3]. +- `testIdAttribute` **[string][9]?** locate elements based on the testIdAttribute. See more of [locate by test id][49]. @@ -2775,3 +2776,5 @@ Returns **void** automatically synchronized promise through #recorder [47]: https://playwright.dev/docs/browsers/#google-chrome--microsoft-edge [48]: https://playwright.dev/docs/api/class-consolemessage#console-message-type + +[49]: https://playwright.dev/docs/locators#locate-by-test-id diff --git a/docs/locators.md b/docs/locators.md index b7e4ae906..2349bc705 100644 --- a/docs/locators.md +++ b/docs/locators.md @@ -14,10 +14,11 @@ CodeceptJS provides flexible strategies for locating elements: * [Custom Locator Strategies](#custom-locators): by data attributes or whatever you prefer. * [Shadow DOM](/shadow): to access shadow dom elements * [React](/react): to access React elements by component names and props +* Playwright: to access locator supported by Playwright, namely [_react](https://playwright.dev/docs/other-locators#react-locator), [_vue](https://playwright.dev/docs/other-locators#vue-locator), [data-testid](https://playwright.dev/docs/locators#locate-by-test-id) Most methods in CodeceptJS use locators which can be either a string or an object. -If the locator is an object, it should have a single element, with the key signifying the locator type (`id`, `name`, `css`, `xpath`, `link`, `react`, `class` or `shadow`) and the value being the locator itself. This is called a "strict" locator. +If the locator is an object, it should have a single element, with the key signifying the locator type (`id`, `name`, `css`, `xpath`, `link`, `react`, `class`, `shadow` or `pw`) and the value being the locator itself. This is called a "strict" locator. Examples: @@ -26,6 +27,7 @@ Examples: * {css: 'input[type=input][value=foo]'} matches `` * {xpath: "//input[@type='submit'][contains(@value, 'foo')]"} matches `` * {class: 'foo'} matches `
` +* { pw: '_react=t[name = "="]' } Writing good locators can be tricky. The Mozilla team has written an excellent guide titled [Writing reliable locators for Selenium and WebDriver tests](https://blog.mozilla.org/webqa/2013/09/26/writing-reliable-locators-for-selenium-and-webdriver-tests/). diff --git a/lib/helper/Playwright.js b/lib/helper/Playwright.js index 2e1603c1d..a56d2314d 100644 --- a/lib/helper/Playwright.js +++ b/lib/helper/Playwright.js @@ -33,7 +33,7 @@ const ElementNotFound = require('./errors/ElementNotFound'); const RemoteBrowserConnectionRefused = require('./errors/RemoteBrowserConnectionRefused'); const Popup = require('./extras/Popup'); const Console = require('./extras/Console'); -const { findReact, findVue } = require('./extras/PlaywrightReactVueLocator'); +const { findReact, findVue, findByPlaywrightLocator } = require('./extras/PlaywrightReactVueLocator'); let playwright; let perfTiming; @@ -100,6 +100,7 @@ const pathSeparator = path.sep; * @prop {boolean} [bypassCSP] - bypass Content Security Policy or CSP * @prop {boolean} [highlightElement] - highlight the interacting elements. Default: false. Note: only activate under verbose mode (--verbose). * @prop {object} [recordHar] - record HAR and will be saved to `output/har`. See more of [HAR options](https://playwright.dev/docs/api/class-browser#browser-new-context-option-record-har). + * @prop {string} [testIdAttribute=data-testid] - locate elements based on the testIdAttribute. See more of [locate by test id](https://playwright.dev/docs/locators#locate-by-test-id). */ const config = {}; @@ -379,6 +380,7 @@ class Playwright extends Helper { highlightElement: false, }; + process.env.testIdAttribute = 'data-testid'; config = Object.assign(defaults, config); if (availableBrowsers.indexOf(config.browser) < 0) { @@ -464,6 +466,7 @@ class Playwright extends Helper { try { await playwright.selectors.register('__value', createValueEngine); await playwright.selectors.register('__disabled', createDisabledEngine); + if (process.env.testIdAttribute) await playwright.selectors.setTestIdAttribute(process.env.testIdAttribute); } catch (e) { console.warn(e); } @@ -3455,6 +3458,7 @@ function buildLocatorString(locator) { async function findElements(matcher, locator) { if (locator.react) return findReact(matcher, locator); if (locator.vue) return findVue(matcher, locator); + if (locator.pw) return findByPlaywrightLocator.call(this, matcher, locator); locator = new Locator(locator, 'css'); return matcher.locator(buildLocatorString(locator)).all(); @@ -3462,6 +3466,8 @@ async function findElements(matcher, locator) { async function findElement(matcher, locator) { if (locator.react) return findReact(matcher, locator); + if (locator.vue) return findVue(matcher, locator); + if (locator.pw) return findByPlaywrightLocator.call(this, matcher, locator); locator = new Locator(locator, 'css'); return matcher.locator(buildLocatorString(locator)).first(); @@ -3517,6 +3523,7 @@ async function proceedClick(locator, context = null, options = {}) { async function findClickable(matcher, locator) { if (locator.react) return findReact(matcher, locator); if (locator.vue) return findVue(matcher, locator); + if (locator.pw) return findByPlaywrightLocator.call(this, matcher, locator); locator = new Locator(locator); if (!locator.isFuzzy()) return findElements.call(this, matcher, locator); diff --git a/lib/helper/extras/PlaywrightReactVueLocator.js b/lib/helper/extras/PlaywrightReactVueLocator.js index 59f8a26eb..c761db4bb 100644 --- a/lib/helper/extras/PlaywrightReactVueLocator.js +++ b/lib/helper/extras/PlaywrightReactVueLocator.js @@ -20,6 +20,11 @@ async function findVue(matcher, locator) { return matcher.locator(_locator).all(); } +async function findByPlaywrightLocator(matcher, locator) { + if (locator && locator.toString().includes(process.env.testIdAttribute)) return matcher.getByTestId(locator.pw.value.split('=')[1]); + return matcher.locator(locator.pw).all(); +} + function propBuilder(props) { let _props = ''; @@ -35,4 +40,4 @@ function propBuilder(props) { return _props; } -module.exports = { findReact, findVue }; +module.exports = { findReact, findVue, findByPlaywrightLocator }; diff --git a/lib/locator.js b/lib/locator.js index a08549b10..9c6efd53f 100644 --- a/lib/locator.js +++ b/lib/locator.js @@ -3,7 +3,7 @@ const { sprintf } = require('sprintf-js'); const { xpathLocator } = require('./utils'); -const locatorTypes = ['css', 'by', 'xpath', 'id', 'name', 'fuzzy', 'frame', 'shadow']; +const locatorTypes = ['css', 'by', 'xpath', 'id', 'name', 'fuzzy', 'frame', 'shadow', 'pw']; /** @class */ class Locator { /** @@ -51,6 +51,9 @@ class Locator { if (isShadow(locator)) { this.type = 'shadow'; } + if (isPlaywrightLocator(locator)) { + this.type = 'pw'; + } Locator.filters.forEach(f => f(locator, this)); } @@ -71,6 +74,8 @@ class Locator { return this.value; case 'shadow': return { shadow: this.value }; + case 'pw': + return { pw: this.value }; } return this.value; } @@ -115,6 +120,13 @@ class Locator { return this.type === 'css'; } + /** + * @returns {boolean} + */ + isPlaywrightLocator() { + return this.type === 'pw'; + } + /** * @returns {boolean} */ @@ -522,6 +534,16 @@ function removePrefix(xpath) { .replace(/^(\.|\/)+/, ''); } +/** + * @private + * check if the locator is a Playwright locator + * @param {string} locator + * @returns {boolean} + */ +function isPlaywrightLocator(locator) { + return locator.includes('_react') || locator.includes('_vue') || locator.includes('data-testid'); +} + /** * @private * @param {CodeceptJS.LocatorOrString} locator diff --git a/test/acceptance/react_test.js b/test/acceptance/react_test.js index 86913cb92..5f5e16b38 100644 --- a/test/acceptance/react_test.js +++ b/test/acceptance/react_test.js @@ -1,6 +1,8 @@ +const { I } = inject(); + Feature('React Selectors'); -Scenario('props @Puppeteer @Playwright', ({ I }) => { +Scenario('props @Puppeteer @Playwright', () => { I.amOnPage('https://codecept.io/test-react-calculator/'); I.click('7'); I.click({ react: 't', props: { name: '=' } }); @@ -11,10 +13,21 @@ Scenario('props @Puppeteer @Playwright', ({ I }) => { I.seeElement({ react: 't', props: { value: '10' } }); }); -Scenario('component name @Puppeteer @Playwright', ({ I }) => { +Scenario('component name @Puppeteer @Playwright', () => { I.amOnPage('http://negomi.github.io/react-burger-menu/'); I.click({ react: 'BurgerIcon' }); I.waitForVisible('#slide', 10); I.click('Alerts'); I.seeElement({ react: 'Demo' }); }); + +Scenario('using playwright locator @Playwright', () => { + I.amOnPage('https://codecept.io/test-react-calculator/'); + I.click('7'); + I.click({ pw: '_react=t[name = "="]' }); + I.seeElement({ pw: '_react=t[value = "7"]' }); + I.click({ pw: '_react=t[name = "+"]' }); + I.click({ pw: '_react=t[name = "3"]' }); + I.click({ pw: '_react=t[name = "="]' }); + I.seeElement({ pw: '_react=t[value = "10"]' }); +}); diff --git a/test/data/app/view/index.php b/test/data/app/view/index.php index c25c9565e..1c42159ff 100755 --- a/test/data/app/view/index.php +++ b/test/data/app/view/index.php @@ -2,7 +2,7 @@ TestEd Beta 2.0 -

Welcome to test app!

+

Welcome to test app!

With special space chars

diff --git a/test/helper/Playwright_test.js b/test/helper/Playwright_test.js index cfa4393c2..b7e8701aa 100644 --- a/test/helper/Playwright_test.js +++ b/test/helper/Playwright_test.js @@ -1678,3 +1678,39 @@ describe('Playwright - HAR', () => { }); }); }); + +describe('using data-testid attribute', () => { + before(() => { + global.codecept_dir = path.join(__dirname, '/../data'); + global.output_dir = path.join(`${__dirname}/../data/output`); + + I = new Playwright({ + url: siteUrl, + windowSize: '500x700', + show: false, + restart: true, + browser: 'chromium', + }); + I._init(); + return I._beforeSuite(); + }); + + beforeEach(async () => { + return I._before().then(() => { + page = I.page; + browser = I.browser; + }); + }); + + afterEach(async () => { + return I._after(); + }); + + it('should find element by data-testid attribute', async () => { + await I.amOnPage('/'); + + const webElements = await I.grabWebElements({ pw: '[data-testid="welcome"]' }); + assert.equal(webElements[0]._selector, '[data-testid="welcome"] >> nth=0'); + assert.equal(webElements.length, 1); + }); +}); diff --git a/test/unit/locator_test.js b/test/unit/locator_test.js index 34bac83d0..cc8d1a382 100644 --- a/test/unit/locator_test.js +++ b/test/unit/locator_test.js @@ -243,6 +243,27 @@ describe('Locator', () => { expect(l.value).to.equal('foo'); expect(l.toString()).to.equal('foo'); }); + + it('should create playwright locator - _react', () => { + const l = new Locator({ pw: '_react=button' }); + expect(l.type).to.equal('pw'); + expect(l.value).to.equal('_react=button'); + expect(l.toString()).to.equal('{pw: _react=button}'); + }); + + it('should create playwright locator - _vue', () => { + const l = new Locator({ pw: '_vue=button' }); + expect(l.type).to.equal('pw'); + expect(l.value).to.equal('_vue=button'); + expect(l.toString()).to.equal('{pw: _vue=button}'); + }); + + it('should create playwright locator - data-testid', () => { + const l = new Locator({ pw: '[data-testid="directions"]' }); + expect(l.type).to.equal('pw'); + expect(l.value).to.equal('[data-testid="directions"]'); + expect(l.toString()).to.equal('{pw: [data-testid="directions"]}'); + }); }); describe('with object argument', () => { diff --git a/typings/index.d.ts b/typings/index.d.ts index c13bc1d98..03ef854a0 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -442,8 +442,8 @@ declare namespace CodeceptJS { | { react: string } | { vue: string } | { shadow: string[] } - | { custom: string }; - + | { custom: string } + | { pw: string }; interface CustomLocators {} interface OtherLocators { props?: object } type LocatorOrString =