├── .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 | - First
14 | - Second
15 | - Third
16 | - Third Third
17 | - Contain in word
18 | - Last
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 |
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 |
--------------------------------------------------------------------------------