├── .gitignore ├── .npmignore ├── index.js ├── src ├── Component.ts ├── Selector.ts ├── parseTokens.ts ├── register.ts └── PO.ts ├── tests ├── component.spec.ts ├── setDriver.spec.ts ├── frame.html ├── waitForLoad.spec.ts ├── import.spec.ts ├── logger.spec.ts ├── test_page.html ├── samplePO.ts ├── parseToken.spec.ts └── po.spec.ts ├── vitest.config.ts ├── .github └── workflows │ ├── pull-request.yml │ └── npm-publish.yml ├── package.json ├── LICENSE ├── tsconfig.json ├── CHANGELOG.md ├── index.d.ts └── README.MD /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | lib/ 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | tests/ 2 | node_modules/ 3 | .github/ 4 | coverage/ 5 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const po = require('./lib/PO').default; 2 | const { $, $$ } = require('./lib/register'); 3 | const { Component } = require('./lib/Component'); 4 | const { Selector, NativeSelector } = require('./lib/Selector'); 5 | 6 | module.exports = { 7 | po, 8 | $, 9 | $$, 10 | Component, 11 | Selector, 12 | NativeSelector 13 | } 14 | -------------------------------------------------------------------------------- /src/Component.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Utility class that simplifies page object creation 3 | */ 4 | export class Component { 5 | 6 | selector?: string | object; 7 | /** 8 | * @param {object | string} selector - component selector 9 | */ 10 | constructor(selector?: object | string) { 11 | this.selector = selector; 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /tests/component.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from 'vitest'; 2 | import { Component } from '../src/Component'; 3 | test('extend class', () => { 4 | class CustomComponent extends Component {} 5 | const customComponentInstance = new CustomComponent('.selector'); 6 | //@ts-ignore 7 | expect(customComponentInstance.selector).toEqual('.selector'); 8 | }); 9 | -------------------------------------------------------------------------------- /tests/setDriver.spec.ts: -------------------------------------------------------------------------------- 1 | import {test, describe, expect} from 'vitest'; 2 | import po from '../src/PO'; 3 | import {Page} from 'playwright'; 4 | 5 | describe('setDriver', () => { 6 | 7 | test('set driver', async () => { 8 | const driver = {} as Page; 9 | po.setDriver(driver); 10 | expect(po.driver).toEqual(driver); 11 | }); 12 | 13 | }); 14 | 15 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | coverage: { 6 | provider: 'v8', 7 | include: ["src/**/*.ts"], 8 | exclude: ["/lib/", "/node_modules/"], 9 | branches: 80, 10 | functions: 90, 11 | lines: 90, 12 | statements: -10, 13 | }, 14 | testTimeout: 20000 15 | } 16 | }) 17 | -------------------------------------------------------------------------------- /tests/frame.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | Document 9 | 10 | 11 |
I am in iframe
12 | 13 | 14 | -------------------------------------------------------------------------------- /.github/workflows/pull-request.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: browser-actions/setup-chrome@latest 13 | - run: chrome --version 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-node@v4 16 | with: 17 | node-version: 22 18 | - run: npm ci 19 | - run: npx playwright install 20 | - run: npm run build 21 | - run: npm run test 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@qavajs/po-playwright", 3 | "version": "1.0.0", 4 | "description": "library for plain-english access to page object", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "vitest --coverage run", 8 | "build": "tsc" 9 | }, 10 | "authors": [ 11 | "Alexander Galichenko", 12 | "Alexandr Legchilov" 13 | ], 14 | "license": "MIT", 15 | "devDependencies": { 16 | "@types/node": "^22.10.5", 17 | "@vitest/coverage-v8": "^2.1.8", 18 | "playwright": "^1.49.1", 19 | "typescript": "^5.7.2", 20 | "vitest": "^2.1.8" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/waitForLoad.spec.ts: -------------------------------------------------------------------------------- 1 | import {test, describe, expect, vi} from 'vitest'; 2 | import po from '../src/PO'; 3 | import {Page} from 'playwright'; 4 | 5 | describe('wait for load state', () => { 6 | 7 | test('wait for load state', async () => { 8 | const driver = { 9 | waitForLoadState: vi.fn() 10 | } as unknown as Page; 11 | //@ts-ignore 12 | po.getEl = vi.fn(() => [{}, {}]); 13 | po.init(driver, { waitForLoadState: true }); 14 | po.register({}); 15 | await po.getElement('X'); 16 | expect(driver.waitForLoadState).toBeCalled(); 17 | }); 18 | 19 | }); 20 | 21 | -------------------------------------------------------------------------------- /tests/import.spec.ts: -------------------------------------------------------------------------------- 1 | import {test, expect} from 'vitest'; 2 | import {po, $, $$, Component, Selector, NativeSelector} from '../index'; 3 | 4 | test('po', () => { 5 | expect(po.init).toBeInstanceOf(Function); 6 | expect(po.getElement).toBeInstanceOf(Function); 7 | }); 8 | 9 | test('$', () => { 10 | expect($).toBeInstanceOf(Function); 11 | }); 12 | 13 | test('$$', () => { 14 | expect($$).toBeInstanceOf(Function); 15 | }); 16 | 17 | test('Component', () => { 18 | expect(Component).toBeInstanceOf(Function); 19 | }); 20 | 21 | test('Selector', () => { 22 | expect(Selector).toBeInstanceOf(Function); 23 | }); 24 | 25 | test('NativeSelector', () => { 26 | expect(NativeSelector).toBeInstanceOf(Function); 27 | }); 28 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: Publish 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | publish-npm: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-node@v4 16 | with: 17 | node-version: 22 18 | registry-url: https://registry.npmjs.org/ 19 | - run: npm ci 20 | - run: npm run build 21 | - run: npm publish --access public 22 | env: 23 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 24 | -------------------------------------------------------------------------------- /src/Selector.ts: -------------------------------------------------------------------------------- 1 | import { Locator, Page } from 'playwright'; 2 | 3 | /** 4 | * Function to define dynamic selector 5 | * @param {selectorFunction: (arg: string) => string | Object} selectorFunction 6 | * @example 7 | * class App { 8 | * DynamicElement = $(Selector(index => `.element:nth(${index})`)); 9 | * } 10 | * 11 | * When I click 'Dynamic Element (3)' 12 | */ 13 | export function NativeSelector(selectorFunction: (page: Page, parent: Locator) => Locator) { 14 | return { 15 | isNativeSelector: true, 16 | selectorFunction 17 | } 18 | } 19 | 20 | /** 21 | * Function to obtain element in framework native way 22 | * @param {selectorFunction: (page: Page, parent: Locator) => Locator} selectorFunction 23 | * @example 24 | * class App { 25 | * NativeElement = $(NativeSelector(page => page.getByText('some text')); 26 | * } 27 | * 28 | * When I click 'NativeElement' 29 | */ 30 | export function Selector(selectorFunction: (arg: string) => string | Object) { 31 | return { 32 | isSelectorFunction: true, 33 | selectorFunction 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 @qavajs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "src/**/*" 4 | ], 5 | "exclude": [ 6 | "test", 7 | "lib", 8 | "node_modules" 9 | ], 10 | "compilerOptions": { 11 | "baseUrl": ".", 12 | "target": "es2018", 13 | "module": "commonjs", 14 | "moduleResolution": "node", 15 | "outDir": "./lib", 16 | "esModuleInterop": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "strict": true, 19 | "sourceMap": false, 20 | "experimentalDecorators": true, 21 | "resolveJsonModule": true, 22 | "pretty": true, 23 | "allowJs": true, 24 | "checkJs": true, 25 | "downlevelIteration": true, 26 | "lib": [ 27 | "dom", 28 | "es2015", 29 | "es2016", 30 | "es2017", 31 | "esnext", 32 | "es2020.string" 33 | ], 34 | "types": [ 35 | "node", 36 | ], 37 | "paths": { /* Specify a set of entries that re-map imports to additional lookup locations. */ 38 | "@verify":["src/verify.ts"], 39 | "@parameterTypeTransformer":["src/parameterTypeTransformer.ts"], 40 | "@parameterTypes":["src/parameterTypes.ts"], 41 | "@src/*":["src/*"] 42 | }, 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/parseTokens.ts: -------------------------------------------------------------------------------- 1 | const SPLIT_TOKENS_REGEXP = /\s*>\s*/; 2 | const PARSE_TOKEN_REGEXP = /^(?[#@/])(?.+)\s(?of|in)\s(?.+)$/; 3 | 4 | export class Token { 5 | elementName: string; 6 | value?: string; 7 | prefix?: string; 8 | suffix?: string; 9 | param?: string[]; 10 | 11 | constructor({ elementName, value, prefix, suffix }: { elementName: string, value?: string, prefix?: string, suffix?: string }) { 12 | this.elementName = elementName; 13 | this.value = value; 14 | this.prefix = prefix; 15 | this.suffix = suffix; 16 | if (prefix === '/' && value && value[value.length - 1] === '/') { 17 | this.value = value.slice(0, value.length - 1); 18 | } 19 | if (elementName.includes('(')) { 20 | const [name, param] = elementName.split(/\s+\(/, 2); 21 | this.elementName = name; 22 | this.param = [param.replace(/(\)$)/g, '')]; 23 | } 24 | } 25 | } 26 | 27 | export default function parseTokens(path: string): Array { 28 | const tokens = path.split(SPLIT_TOKENS_REGEXP); 29 | return tokens.map(token) 30 | } 31 | 32 | function token(value: string): Token { 33 | if (PARSE_TOKEN_REGEXP.test(value)) { 34 | const { groups } = PARSE_TOKEN_REGEXP.exec(value) as { groups: Object }; 35 | return new Token(groups as { elementName: string, value?: string, prefix?: string, suffix?: string }) 36 | } 37 | return new Token({ elementName: value }) 38 | } 39 | -------------------------------------------------------------------------------- /tests/logger.spec.ts: -------------------------------------------------------------------------------- 1 | import {test, beforeAll, afterAll, describe, expect, beforeEach} from 'vitest'; 2 | import {Browser, chromium} from 'playwright'; 3 | import {resolve} from 'path'; 4 | import poLogger from '../src/PO'; 5 | import samplePO from './samplePO'; 6 | 7 | let browser: Browser; 8 | 9 | describe('logger', () => { 10 | const logger: { logs: string[], log: (value: string) => void, clean: () => void } = { 11 | logs: [], 12 | log(value: string) { 13 | this.logs.push(value); 14 | }, 15 | clean() { 16 | this.logs = []; 17 | } 18 | }; 19 | 20 | beforeAll(async () => { 21 | browser = await chromium.launch(); 22 | const context = await browser.newContext(); 23 | const driver = await context.newPage(); 24 | 25 | logger.clean(); 26 | poLogger.init(driver, {timeout: 5000, logger}); 27 | poLogger.register(samplePO); 28 | const fileName = resolve('./tests/test_page.html'); 29 | await driver.goto('file://' + fileName); 30 | }); 31 | 32 | beforeEach(() => { 33 | logger.clean(); 34 | }); 35 | 36 | test('get single element', async () => { 37 | const element = await poLogger.getElement('Single Element'); 38 | expect(logger.logs).toEqual(['SingleElement -> .single-element']); 39 | }); 40 | 41 | test('get child element', async () => { 42 | const element = await poLogger.getElement('Single Component > Child Item'); 43 | expect(logger.logs).toEqual(['SingleComponent -> .container', 'ChildItem -> .child-item']); 44 | }); 45 | 46 | test('get Selector element', async () => { 47 | const element = await poLogger.getElement('Async Component By Selector (#async-list-components)'); 48 | expect(logger.logs).toEqual(['AsyncComponentBySelector -> #async-list-components']); 49 | }); 50 | 51 | afterAll(async () => { 52 | await browser.close(); 53 | }); 54 | 55 | }); 56 | 57 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to the "@qavajs/po-playwright" will be documented in this file. 4 | 5 | Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file. 6 | 7 | :rocket: - new feature 8 | :beetle: - bugfix 9 | :x: - deprecation 10 | 11 | ## [1.0.0] 12 | - :beetle: fixed parameter parser to support parenthesis 13 | 14 | ## [0.16.1] 15 | - :beetle: fixed issue preventing using NativeSelector with components 16 | 17 | ## [0.16.0] 18 | - :rocket: added _setDriver_ method 19 | 20 | ## [0.15.0] 21 | - :rocket: made waitForLoad state in getElement disabled by default 22 | 23 | ## [0.14.0] 24 | - :rocket: added capability to pass parent element to NativeSelector 25 | ```javascript 26 | const { NativeSelector } = require('@qavajs/po-playwright'); 27 | 28 | class App { 29 | Element = $(NativeSelector(((page, parent) => parent.getByText(`some text`)))); 30 | } 31 | ``` 32 | 33 | ## [0.13.0] 34 | - :x: disabled auto-split arguments in selector function 35 | 36 | ## [0.12.0] 37 | - :rocket: added capability to use driver-built selector 38 | ```javascript 39 | const { NativeSelector } = require('@qavajs/po-playwright'); 40 | 41 | class App { 42 | Element = $(NativeSelector(page => page.getByText(`some text`))); 43 | } 44 | ``` 45 | 46 | ## [0.11.3] 47 | - :beetle: fixed logging Selector functions 48 | 49 | ## [0.11.2] 50 | - :beetle: added logger option to po.init 51 | 52 | ## [0.11.1] 53 | - :beetle: made call of `this.driver.waitForLoadState` optional (to enable electron support) 54 | 55 | ## [0.11.0] 56 | - :rocket: added capability to provide logger 57 | 58 | ## [0.10.0] 59 | - :rocket: added capability to dynamically generate selectors 60 | 61 | ## [0.0.9] 62 | - :beetle: removed check existence method 63 | 64 | ## [0.0.8] 65 | - :rocket: added text selector by regexp 66 | 67 | ## [0.0.7] 68 | - :rocket: made selector property as optional 69 | 70 | ## [0.0.6] 71 | - :beetle: fix imports 72 | 73 | ## [0.0.5] 74 | - :rocket: added capability to ignore hierarchy 75 | -------------------------------------------------------------------------------- /src/register.ts: -------------------------------------------------------------------------------- 1 | export interface DefinitionOptions { 2 | ignoreHierarchy: boolean 3 | } 4 | export interface Definition { 5 | selector: any; 6 | isCollection: boolean; 7 | ignoreHierarchy: boolean; 8 | isSelectorFunction?: boolean; 9 | isNativeSelector?: boolean; 10 | selectorFunction?: Function; 11 | resolvedSelector?: any; 12 | } 13 | 14 | /** 15 | * Register element in page object 16 | * @param {string|object} definition 17 | * @param {boolean} isCollection 18 | * @param {DefinitionOptions} options 19 | * @returns {{ definition, isCollection, ignoreHierarchy }} 20 | */ 21 | export function register( 22 | definition: string | Object, 23 | isCollection: boolean, 24 | options: DefinitionOptions = { ignoreHierarchy: false }): Definition 25 | { 26 | if (!definition) throw new Error('Selector or component should be passed!'); 27 | if (typeof definition === 'object' && !((definition as any).isSelectorFunction)) { 28 | return { 29 | ...definition, 30 | isCollection, 31 | ...options 32 | } as Definition 33 | } 34 | return { 35 | selector: definition, 36 | isCollection, 37 | ...options 38 | } 39 | } 40 | 41 | /** 42 | * Define element or component 43 | * @param {string | Object} definition - selector 44 | * @param {DefinitionOptions} options - additional options 45 | * @example 46 | * class App { 47 | * Element = $('#element'); 48 | * Panel = $(new Panel('#panel')); 49 | * } 50 | */ 51 | export function $(definition: string | Object, options?: DefinitionOptions): Definition { 52 | return register(definition, false, options) 53 | } 54 | /** 55 | * Define collection 56 | * @param {string | Object} definition - selector 57 | * @param {DefinitionOptions} options - additional options 58 | * @example 59 | * class App { 60 | * Collection = $$('#collection'); 61 | * Panels = $$(new Panel('#panel')); 62 | * } 63 | */ 64 | export function $$(definition: string | Object, options?: DefinitionOptions): Definition { 65 | return register(definition, true, options) 66 | } 67 | -------------------------------------------------------------------------------- /tests/test_page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Title 7 | 8 | 9 |
10 | 11 |
12 |
    13 |
  1. First
  2. 14 |
  3. Second
  4. 15 |
  5. Third
  6. 16 |
  7. Third Third
  8. 17 |
  9. Contain in word
  10. 18 |
  11. Last
  12. 19 |
20 |
21 | text of single element 22 |
23 |
24 |
25 | text of first child item 26 |
27 |
28 |
    29 |
  • first inner
  • 30 |
  • second inner
  • 31 |
  • third inner
  • 32 |
33 |
    34 |
  • 35 |
      36 |
    • x11
    • 37 |
    • x12
    • 38 |
    • x13
    • 39 |
    40 |
  • 41 |
  • 42 |
      43 |
    • x21
    • 44 |
    • x22
    • 45 |
    • x23
    • 46 |
    47 |
  • 48 |
  • 49 |
      50 |
    • x31
    • 51 |
    • x32
    • 52 |
    • x33
    • 53 |
    54 |
  • 55 |
56 |
    57 |
58 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /tests/samplePO.ts: -------------------------------------------------------------------------------- 1 | import { $, $$ } from '../src/register'; 2 | import { Component } from '../src/Component'; 3 | import { Selector, NativeSelector } from '../src/Selector'; 4 | 5 | class MultipleComponent extends Component { 6 | ChildItem = $('div'); 7 | } 8 | 9 | class SingleComponent { 10 | selector = '.container'; 11 | 12 | ChildItem = $('.child-item'); 13 | UniqDeepElement = $('.child-item'); 14 | IgnoreHierarchyItem = $('.list-components > li:first-child', { ignoreHierarchy: true }); 15 | } 16 | 17 | class AsyncComponent extends Component { 18 | ChildItems = $$('li'); 19 | ChildItemByIndex = $(Selector((index: string) => `li:nth-child(${index})`)) 20 | } 21 | 22 | class Level2Elements { 23 | selector = 'ul.level2'; 24 | 25 | ListItems = $$('li > span'); 26 | } 27 | 28 | class Level1Elements { 29 | selector = 'ul.level1'; 30 | 31 | Level2Elements = $$(new Level2Elements()); 32 | } 33 | 34 | class NotExistingComponent { 35 | selector = 'not-exist'; 36 | 37 | Item = $('div'); 38 | Items = $$('li > span'); 39 | } 40 | 41 | class ComponentWithoutSelector { 42 | SingleElement = $('.single-element'); 43 | List = $$('.list li'); 44 | } 45 | 46 | class IframeContainer extends Component { 47 | IframeElement = $(NativeSelector( 48 | (_, parent) => parent 49 | .frameLocator('#iframe') 50 | .locator('#iframeElement') 51 | )); 52 | } 53 | 54 | class App { 55 | SingleElement = $('.single-element'); 56 | List = $$('.list li'); 57 | ParametrizedList = $$(Selector((index: string) => `.list li:nth-child(${index})`)); 58 | SingleComponent = $(new SingleComponent()); 59 | MultipleComponents = $$(new MultipleComponent('.list-components li')); 60 | AsyncComponent = $(new AsyncComponent('#async-list-components')); 61 | AsyncComponentBySelector = $(new AsyncComponent(Selector((selector: any) => selector))); 62 | AsyncComponentByNativeSelector = $(new AsyncComponent(NativeSelector(page => page.locator('#async-list-components')))); 63 | Level1Elements = $(new Level1Elements()); 64 | NotExistingComponent = $(new NotExistingComponent()); 65 | ComponentWithoutSelector = $(new ComponentWithoutSelector()); 66 | ComponentsWithoutSelector = $$(new ComponentWithoutSelector()); 67 | NativeSelectorSingleElement = $(NativeSelector(page => page.locator('.single-element'))); 68 | NativeSelectorList = $$(NativeSelector(page => page.locator('.list li'))); 69 | IframeContainer = $(new IframeContainer('#iframeContainer')); 70 | } 71 | 72 | export default new App(); 73 | -------------------------------------------------------------------------------- /tests/parseToken.spec.ts: -------------------------------------------------------------------------------- 1 | import {test, expect} from 'vitest'; 2 | import parseToken from '../src/parseTokens'; 3 | [ 4 | { 5 | query: 'Element', 6 | tokens: [{ 7 | elementName: 'Element', 8 | prefix: undefined, 9 | suffix: undefined, 10 | value: undefined 11 | }] 12 | }, 13 | { 14 | query: 'Component > Element', 15 | tokens: [{ 16 | elementName: 'Component', 17 | prefix: undefined, 18 | suffix: undefined, 19 | value: undefined 20 | }, { 21 | elementName: 'Element', 22 | prefix: undefined, 23 | suffix: undefined, 24 | value: undefined 25 | }] 26 | }, 27 | { 28 | query: '#1 of Collection', 29 | tokens: [{ 30 | elementName: 'Collection', 31 | prefix: '#', 32 | suffix: 'of', 33 | value: '1' 34 | }] 35 | }, 36 | { 37 | query: '#text in Collection', 38 | tokens: [{ 39 | elementName: 'Collection', 40 | prefix: '#', 41 | suffix: 'in', 42 | value: 'text' 43 | }] 44 | }, 45 | { 46 | query: '#text three words in Collection', 47 | tokens: [{ 48 | elementName: 'Collection', 49 | prefix: '#', 50 | suffix: 'in', 51 | value: 'text three words' 52 | }] 53 | }, 54 | { 55 | query: '@text three words in Collection', 56 | tokens: [{ 57 | elementName: 'Collection', 58 | prefix: '@', 59 | suffix: 'in', 60 | value: 'text three words' 61 | }] 62 | }, 63 | { 64 | query: '/^text$/ in Collection', 65 | tokens: [{ 66 | elementName: 'Collection', 67 | prefix: '/', 68 | suffix: 'in', 69 | value: '^text$' 70 | }] 71 | }, 72 | { 73 | query: 'Parameter (22)', 74 | tokens: [{ 75 | elementName: 'Parameter', 76 | prefix: undefined, 77 | suffix: undefined, 78 | value: undefined, 79 | param: ['22'] 80 | }] 81 | }, 82 | { 83 | query: '#1 of Parameter Collection (22)', 84 | tokens: [{ 85 | elementName: 'Parameter Collection', 86 | prefix: '#', 87 | suffix: 'of', 88 | value: '1', 89 | param: ['22'] 90 | }] 91 | }, 92 | ].forEach(data => { 93 | test(data.query, () => { 94 | expect(parseToken(data.query)).to.eql(data.tokens) 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import { Locator, Page } from 'playwright'; 2 | declare type SelectorOptions = { 3 | ignoreHierarchy?: boolean 4 | } 5 | declare interface Logger { 6 | log(value: any): void; 7 | } 8 | /** 9 | * Define element or component 10 | * @param {string | Object} selector - selector 11 | * @param {SelectorOptions} options - additional options 12 | * @example 13 | * class App { 14 | * Element = $('#element'); 15 | * Panel = $(new Panel('#panel')); 16 | * } 17 | */ 18 | export declare function $(selector: string | Object, options?: SelectorOptions): Object; 19 | /** 20 | * Define collection 21 | * @param {string | Object} selector - selector 22 | * @param {SelectorOptions} options - additional options 23 | * @example 24 | * class App { 25 | * Collection = $$('#element'); 26 | * Panels = $$(new Panel('#panel')); 27 | * } 28 | */ 29 | export declare function $$(selector: string | Object, options?: SelectorOptions): Object; 30 | declare type PageObject = { 31 | /** 32 | * driver instance 33 | */ 34 | driver: Page; 35 | /** 36 | * Init page object instance 37 | * @param {Page} driver 38 | * @param {{timeout?: number, logger?: Logger}} options - additional options 39 | * @example 40 | * po.init(page, {timeout: 5000, logger: new Logger()}); 41 | */ 42 | init(driver: Page, options: { timeout?: number, logger?: Logger }): void; 43 | /** 44 | * Register page object tree 45 | * @param pageObject 46 | * @example 47 | * po.register(new PageObject()); 48 | */ 49 | register(pageObject: Object): void; 50 | /** 51 | * Get element 52 | * @param {string} path - page object select 53 | * @example 54 | * const element = await po.getElement('Element'); 55 | * const collection = await po.getElement('Component > Collection'); 56 | */ 57 | getElement(path: string): Promise; 58 | /** 59 | * Set new driver instance 60 | * @param {Page} page 61 | * @example 62 | * po.setDriver(page); 63 | */ 64 | setDriver(page: Page): void; 65 | } 66 | /** 67 | * Instance of page object 68 | * @type {PageObject} 69 | */ 70 | export declare let po: PageObject; 71 | /** 72 | * Component class 73 | * @example 74 | * class Panel extends Component { 75 | * Element = $('#element'); 76 | * } 77 | * class App { 78 | * Panel = $(new Panel('#panel')); 79 | * } 80 | */ 81 | export declare class Component { 82 | constructor(selector?: any) 83 | } 84 | /** 85 | * Function to define dynamic selector 86 | * @param {selectorFunction: (arg: string) => string | Object} selectorFunction 87 | * @example 88 | * class App { 89 | * DynamicElement = $(Selector(index => `.element:nth(${index})`)); 90 | * } 91 | * 92 | * When I click 'Dynamic Element (3)' 93 | */ 94 | export declare function Selector(selectorFunction: (arg: string) => string | Object): any 95 | /** 96 | * Function to obtain element in framework native way 97 | * @param {selectorFunction: (page: Page, parent: Locator) => Locator} selectorFunction 98 | * @example 99 | * class App { 100 | * NativeElement = $(NativeSelector(page => page.getByText('some text')); 101 | * } 102 | * 103 | * When I click 'NativeElement' 104 | */ 105 | export declare function NativeSelector(selectorFunction: (page: Page, parent: Locator) => Locator): any 106 | -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | ## @qavajs/po-playwright 2 | 3 | @qavajs library provides the ability to create hierarchical page objects and access elements using plain-english selectors. 4 | Works on top of Playwright. 5 | 6 | `npm install @qavajs/po-playwright` 7 | 8 | ## Usage 9 | 10 | Lib provides getElement method that resolves plain-english selector and return playwright locator. 11 | ```javascript 12 | const { po } = require('@qavajs/po-playwright'); 13 | 14 | When(/^click '(.+)'$/, async function (alias) { 15 | const element = await po.getElement(alias); 16 | await element.click(); 17 | }); 18 | ``` 19 | 20 | ```gherkin 21 | When click '#1 of Multiple Component > Child Item' 22 | ``` 23 | 24 | Lib provides capability to get single element from collection by index (#index of Collection) or inner text (#text in Collection). 25 | 26 | ## Create page object 27 | 28 | Lib provides two methods $ and $$ that allow registering elements and collections. 29 | An element can be defined in form of webdriverIO selector or as an instance of the component class. 30 | 31 | Each not top-level component should have selector element in form of webdriverIO selector. 32 | ```javascript 33 | const { $, $$ } = require('@qavajs/po-playwright'); 34 | 35 | class MultipleComponent { 36 | selector = '.list-components li'; 37 | ChildItem = $('div'); 38 | } 39 | 40 | class SingleComponent { 41 | selector = '.container'; 42 | ChildItem = $('.child-item'); 43 | } 44 | 45 | class App { 46 | SingleElement = $('.single-element'); 47 | List = $$('.list li'); 48 | SingleComponent = $(new SingleComponent()); 49 | MultipleComponents = $$(new MultipleComponent()); 50 | } 51 | 52 | module.exports = new App(); 53 | ``` 54 | ## Register PO 55 | Before using po object need to be initiated and hierarchy of elements needs to be registered 56 | The best place to do it is cucumber-js Before hook 57 | 58 | ```javascript 59 | const { po } = require('@qavajs/po-playwright'); 60 | const pos = require('./app.js'); 61 | Before(async function() { 62 | po.init(page, { timeout: 10000 }); // page is an instance of playwright page 63 | po.register(pos); // pos is page object hierarchy 64 | }); 65 | ``` 66 | 67 | ## Ignore hierarchy 68 | In case if child element and parent component doesn't have hierarchy dependency 69 | it's possible to pass extra parameter _ignoreHierarchy_ parameter to start traverse from root 70 | 71 | ```javascript 72 | class ComponentThatDescribesNotWellDesignedDOMTree { 73 | selector = '.container'; 74 | //ignoreHierarchy will ignore component selector .container and start traverse from root 75 | ChildItem = $('.child-item', { ignoreHierarchy: true }); 76 | } 77 | ``` 78 | 79 | ## Optional selector property 80 | If selector property is not provided for Component then parent element will be returned 81 | 82 | ```javascript 83 | class ParentComponent { 84 | selector = '.container'; 85 | ChildComponent = $(new ChildComponent()); 86 | } 87 | 88 | class ChildComponent { 89 | //Element will be searched in parent .container element 90 | Element = $('.someElement'); 91 | } 92 | ``` 93 | 94 | ## Dynamic selectors 95 | In case you need to generate selector based on some data you can use dynamic selectors 96 | 97 | ```javascript 98 | const { Selector } = require('@qavajs/po-playwright'); 99 | 100 | class Component { 101 | selector = '.container'; 102 | Element = $(Selector((index => `div:nth-child(${index})`))); // function should return valid selector 103 | } 104 | ``` 105 | 106 | Then you can pass parameter to this function from Gherkin file 107 | 108 | ```gherkin 109 | When I click 'Component > Element (2)' 110 | ``` 111 | 112 | ## Native framework selectors 113 | It is also possible to use driver-built capabilities to return element. You can pass handler that has access to 114 | current `page` and current locator. 115 | 116 | ```javascript 117 | const { NativeSelector } = require('@qavajs/po-playwright'); 118 | 119 | class Component { 120 | selector = '.container'; 121 | Element = $(NativeSelector(page => page.getByText(`some text`))); 122 | IFrame = $(NativeSelector((_, parent) => parent.frameLocator('#iframe').getByText(`some text`))); 123 | } 124 | ``` 125 | -------------------------------------------------------------------------------- /src/PO.ts: -------------------------------------------------------------------------------- 1 | import parseTokens, { Token } from './parseTokens'; 2 | import { Page, Locator } from 'playwright'; 3 | import { Definition } from './register'; 4 | 5 | interface PageObject { 6 | selector?: string; 7 | } 8 | 9 | interface ExtendedLocator extends Locator { 10 | alias: string; 11 | } 12 | 13 | type Locatable = Page | Locator; 14 | 15 | interface Logger { 16 | log(value?: string): void; 17 | } 18 | 19 | const defaultLogger: Logger = { 20 | log() {} 21 | }; 22 | 23 | class PO { 24 | 25 | public driver: Page | null = null; 26 | private config: { timeout?: number } = {}; 27 | private logger: Logger = defaultLogger; 28 | private waitForLoadState: boolean = false; 29 | 30 | public init(driver: Page, options: { 31 | timeout?: number, 32 | logger?: Logger, 33 | waitForLoadState?: boolean 34 | } = { timeout: 2000, waitForLoadState: false }) { 35 | this.driver = driver; 36 | this.config.timeout = options.timeout ?? 2000; 37 | this.logger = options.logger ?? defaultLogger; 38 | this.waitForLoadState = options.waitForLoadState ?? false; 39 | } 40 | 41 | /** 42 | * Get element from page object 43 | * @public 44 | * @param {string} alias - element to locate 45 | * @returns {Locator} 46 | */ 47 | public async getElement(alias: string): Promise { 48 | if (!this.driver) throw new Error('Driver is not attached. Call po.init(driver)') 49 | const tokens: Array = parseTokens(alias); 50 | let element: Locatable = this.driver; 51 | let po: PO | PageObject = this; 52 | if (this.waitForLoadState && this.driver.waitForLoadState) { 53 | await this.driver.waitForLoadState(); 54 | } 55 | while (tokens.length > 0) { 56 | const token = tokens.shift() as Token; 57 | [element, po] = await this.getEl(element, po, token); 58 | } 59 | const extendedElement = element as ExtendedLocator; 60 | extendedElement.alias = alias; 61 | return extendedElement 62 | } 63 | 64 | /** 65 | * Register page object map 66 | * @param {Object} pageObject - page object to register 67 | */ 68 | public register(pageObject: Object) { 69 | for (const prop in pageObject) { 70 | // @ts-ignore 71 | this[prop] = pageObject[prop] 72 | } 73 | }; 74 | 75 | /** 76 | * Set page instance 77 | * @param {Page} page - page 78 | */ 79 | public setDriver(page: Page) { 80 | this.driver = page; 81 | } 82 | 83 | /** 84 | * Get element by provided page object and token 85 | * @private 86 | * @param {Page | Locator} element 87 | * @param {Object} po 88 | * @param {Token} token 89 | * @returns 90 | */ 91 | private async getEl(element: Locatable, po: {[prop: string]: any}, token: Token): Promise<[Locatable, Object] | undefined> { 92 | const elementName: string = token.elementName.replace(/\s/g, ''); 93 | const newPo: Definition = po[elementName]; 94 | if (!newPo) throw new Error(`${token.elementName} is not found`); 95 | const currentElement = (newPo.ignoreHierarchy ? this.driver : element) as Locatable; 96 | if (newPo.isNativeSelector) return [await (newPo.selectorFunction as Function)(this.driver, currentElement), newPo]; 97 | if (newPo.selector?.isNativeSelector) return [await (newPo.selector.selectorFunction as Function)(this.driver, currentElement), newPo]; 98 | if (!newPo.isCollection && token.suffix) throw new Error(`Unsupported operation.\n${token.elementName} is not collection`); 99 | if (newPo.isCollection && !newPo.selector) throw new Error(`Unsupported operation.\n${token.elementName} selector property is required as it is collection`); 100 | if (!newPo.selector) return [currentElement, newPo]; 101 | newPo.resolvedSelector = this.resolveSelector(newPo.selector, token.param); 102 | this.logger.log(`${elementName} -> ${newPo.resolvedSelector}`); 103 | if (newPo.isCollection && token.suffix === 'in') return [ 104 | await this.getElementByText(currentElement, newPo, token), 105 | newPo 106 | ]; 107 | if (newPo.isCollection && token.suffix === 'of') return [ 108 | await this.getElementByIndex(currentElement, newPo, token), 109 | newPo 110 | ]; 111 | return [await this.getSingleElement(currentElement, newPo.resolvedSelector), newPo] 112 | } 113 | 114 | /** 115 | * @private 116 | * @param {Locatable} element - element to get 117 | * @param {Definition} po - page object 118 | * @param {Token} token - token 119 | * @returns 120 | */ 121 | private async getElementByText(element: Locatable, po: Definition, token: Token): Promise { 122 | const tokenValue = token.value as string; 123 | if (token.prefix === '#') { 124 | return element.locator(po.resolvedSelector, { hasText: tokenValue }).nth(0); 125 | } 126 | if (token.prefix === '@') { 127 | return element.locator(po.resolvedSelector, { hasText: new RegExp(`^${tokenValue}$`) }).nth(0); 128 | } 129 | if (token.prefix === '/') { 130 | return element.locator(po.resolvedSelector, { hasText: new RegExp(tokenValue) }).nth(0); 131 | } 132 | throw new Error(`${token.prefix} is not supported`) 133 | } 134 | 135 | /** 136 | * @private 137 | * @param {Locatable} element - element to get 138 | * @param {Definition} po - page object 139 | * @param {Token} token - token 140 | * @returns 141 | */ 142 | private async getElementByIndex(element: Locatable, po: Definition, token: Token): Promise { 143 | const index = parseInt(token.value as string) - 1; 144 | return element.locator(po.resolvedSelector).nth(index); 145 | } 146 | 147 | /** 148 | * @private 149 | * @param {Locatable} element - element to get 150 | * @param {string} selector - selector 151 | * @returns 152 | */ 153 | private async getSingleElement(element: Locatable, selector: string) { 154 | return element.locator(selector); 155 | } 156 | 157 | private resolveSelector(selector: any, param?: string[]) { 158 | return selector.isSelectorFunction ? selector.selectorFunction(...param as string[]) : selector 159 | } 160 | 161 | } 162 | 163 | export default new PO(); 164 | -------------------------------------------------------------------------------- /tests/po.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, beforeAll, afterAll, expect } from 'vitest'; 2 | import { Browser, chromium } from 'playwright'; 3 | import { resolve } from 'path'; 4 | import po from '../src/PO'; 5 | import samplePO from './samplePO'; 6 | import { $ } from '../src/register'; 7 | 8 | let browser: Browser; 9 | beforeAll(async () => { 10 | browser = await chromium.launch(); 11 | const context = await browser.newContext(); 12 | const driver = await context.newPage(); 13 | 14 | po.init(driver, { timeout: 5000 }); 15 | po.register(samplePO); 16 | const fileName = resolve('./tests/test_page.html'); 17 | await driver.goto('file://' + fileName); 18 | }); 19 | 20 | test('get single element', async () => { 21 | const element = await po.getElement('Single Element'); 22 | expect(await element.innerText()).toBe('text of single element'); 23 | }); 24 | 25 | test('get collection', async () => { 26 | const collection = await po.getElement('List'); 27 | expect(await collection.count()).toBe(6); 28 | }); 29 | 30 | test('get element from collection by index', async () => { 31 | const element = await po.getElement('#2 of List'); 32 | expect(await element.innerText()).toBe('Second'); 33 | }); 34 | 35 | test('get element from collection by partial text', async () => { 36 | const element = await po.getElement('#Thi in List'); 37 | expect(await element.innerText()).toBe('Third'); 38 | }); 39 | 40 | test('get element from collection by exact text', async () => { 41 | const element = await po.getElement('@Third in List'); 42 | expect(await element.innerText()).toBe('Third'); 43 | }); 44 | 45 | test('get element from collection by regexp text', async () => { 46 | const element = await po.getElement('/Thi/ in List'); 47 | expect(await element.innerText()).toBe('Third'); 48 | }); 49 | 50 | test('get element from component', async () => { 51 | const element = await po.getElement('Single Component > Child Item'); 52 | expect(await element.innerText()).toBe('text of first child item'); 53 | }); 54 | 55 | test('get element from multiple component item by index', async () => { 56 | const element = await po.getElement('#2 of Multiple Components > ChildItem'); 57 | expect(await element.innerText()).toBe('second inner'); 58 | }); 59 | 60 | test('get element from multiple component item by partials text', async () => { 61 | const element = await po.getElement('#second in Multiple Components > Child Item'); 62 | expect(await element.innerText()).toBe('second inner'); 63 | }); 64 | 65 | test('get element from multiple component item by exact text', async () => { 66 | const element = await po.getElement('@third inner in Multiple Components > Child Item'); 67 | expect(await element.innerText()).toBe('third inner'); 68 | }); 69 | 70 | test('get child item of each element of collection', async () => { 71 | const collection = await po.getElement('Multiple Components > Child Item'); 72 | expect(await collection.count()).toBe(3); 73 | expect(await collection.nth(0).innerText()).toBe('first inner'); 74 | }); 75 | 76 | test('get element from collection by partial text containing in', async () => { 77 | const element = await po.getElement('#Contain in in List'); 78 | expect(await element.innerText()).toBe('Contain in word'); 79 | }); 80 | 81 | test('get element that not exist in collection by text', async () => { 82 | const element = await po.getElement('#notexist in List'); 83 | expect(await element.isVisible()).toBe(false); 84 | }); 85 | 86 | test('get element that not exist in collection by index', async () => { 87 | const element = await po.getElement('#42 of List'); 88 | expect(await element.isVisible()).toBe(false); 89 | }); 90 | 91 | test('get element from async collection', async () => { 92 | const element = await po.getElement('Async Component > #2 of Child Items'); 93 | expect(await element.innerText()).toBe('async 2'); 94 | }); 95 | 96 | test('get collection from collection', async () => { 97 | const elements = await po.getElement('Level 1 Elements > Level 2 Elements > List Items'); 98 | const text7 = await elements.nth(6).innerText(); 99 | expect(text7).toBe('x31'); 100 | expect(await elements.count()).toBe(9); 101 | }); 102 | 103 | //TODO not supported currently 104 | test.skip('get collection element from collection', async () => { 105 | const elements = await po.getElement('Level 1 Elements > Level 2 Elements > #2 of List Items'); 106 | const text12 = await elements.nth(0).innerText(); 107 | const text22 = await elements.nth(0).innerText(); 108 | const text32 = await elements.nth(0).innerText(); 109 | expect(text12).toBe('x12'); 110 | expect(text22).toBe('x22'); 111 | expect(text32).toBe('x32'); 112 | expect(await elements.count()).toBe(3); 113 | }); 114 | 115 | test('alias is added to returned element', async () => { 116 | const element = await po.getElement('Single Element'); 117 | expect(element.alias).toBe('Single Element'); 118 | }); 119 | 120 | test('ignore hierarchy flag', async () => { 121 | const element = await po.getElement('Single Component > Ignore Hierarchy Item'); 122 | expect(await element.innerText()).toBe('first inner'); 123 | }); 124 | 125 | test('get not existing element', async () => { 126 | const shouldThrow = async () => await po.getElement('Not Existing Element'); 127 | await expect(shouldThrow).rejects.toThrow('Not Existing Element is not found'); 128 | }); 129 | 130 | test('throw error if params are not passed into register function', () => { 131 | // @ts-ignore 132 | const shouldThrow = () => $(); 133 | expect(shouldThrow).toThrow('Selector or component should be passed!'); 134 | }); 135 | 136 | test('get element from component without selector', async () => { 137 | const element = await po.getElement('Component Without Selector > Single Element'); 138 | const text = await element.innerText(); 139 | expect(text).toEqual('text of single element'); 140 | }); 141 | 142 | test('get element from collection from component without selector', async () => { 143 | const element = await po.getElement('Component Without Selector > #2 of List'); 144 | expect(await element.innerText()).toEqual('Second'); 145 | }); 146 | 147 | test('throw an error if component without selector registered as collection', async () => { 148 | const shouldThrow = async () => await po.getElement('#1 of Components Without Selector > #2 of List'); 149 | await expect(shouldThrow).rejects.toThrow('Unsupported operation.\nComponents Without Selector selector property is required as it is collection'); 150 | }); 151 | 152 | test('get element by parametrised selector', async () => { 153 | const element = await po.getElement('Async Component > Child Item By Index (2)'); 154 | expect(await element.innerText()).toEqual('async 2'); 155 | }); 156 | 157 | test('get component by parametrised selector', async () => { 158 | const element = await po.getElement('Async Component By Selector (#async-list-components) > #2 of Child Items'); 159 | expect(await element.innerText()).toEqual('async 2'); 160 | }); 161 | 162 | test('get component by native selector', async () => { 163 | const element = await po.getElement('Async Component By Native Selector > #2 of Child Items'); 164 | expect(await element.innerText()).toEqual('async 2'); 165 | }); 166 | 167 | test('get collection by parametrised selector', async () => { 168 | const element = await po.getElement('Parametrized List (odd)'); 169 | expect(await element.count()).toEqual(3); 170 | }); 171 | 172 | test('get native single element', async () => { 173 | const element = await po.getElement('Native Selector Single Element'); 174 | expect(await element.innerText()).toBe('text of single element'); 175 | }); 176 | 177 | test('get native collection', async () => { 178 | const collection = await po.getElement('Native Selector List'); 179 | expect(await collection.count()).toBe(6); 180 | }); 181 | 182 | test('native element from parent', async () => { 183 | const element = await po.getElement('Iframe Container > Iframe Element'); 184 | expect(await element.innerText()).toBe('I am in iframe'); 185 | }); 186 | 187 | test('parenthesis in parameter', async () => { 188 | const element = await po.getElement('Async Component By Selector (#async-list-components li:nth-child(1))'); 189 | expect(await element.innerText()).toEqual('async 1'); 190 | }); 191 | 192 | afterAll(async () => { 193 | await browser.close(); 194 | }); 195 | --------------------------------------------------------------------------------