├── .changeset ├── README.md └── config.json ├── .editorconfig ├── .github ├── CODEOWNERS └── workflows │ ├── main.yaml │ ├── publish.yaml │ └── test.yaml ├── .gitignore ├── .npmignore ├── .vscode ├── extensions.json ├── playwright-snippets.code-snippets └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── biome.json ├── docs ├── BaseApi-explanation.md ├── BasePage-explanation.md ├── LocatorSchema-explanation.md ├── LocatorSchemaPath-explanation.md ├── PlaywrightReportLogger-explanation.md ├── get-locator-methods-explanation.md ├── intro-to-using-pomwright.md ├── sessionStorage-methods-explanation.md └── tips-folder-structure.md ├── index.ts ├── intTest ├── .env ├── .gitignore ├── fixtures │ ├── withOptions.ts │ └── withoutOptions.ts ├── package.json ├── page-object-models │ └── testApp │ │ ├── with-options │ │ ├── base │ │ │ └── baseWithOptions.page.ts │ │ └── pages │ │ │ ├── testPage.page.ts │ │ │ ├── testPath │ │ │ ├── [color] │ │ │ │ ├── color.locatorSchema.ts │ │ │ │ └── color.page.ts │ │ │ ├── testPath.locatorSchema.ts │ │ │ └── testPath.page.ts │ │ │ └── testfilters │ │ │ ├── testfilters.locatorSchema.ts │ │ │ └── testfilters.page.ts │ │ └── without-options │ │ ├── base │ │ └── base.page.ts │ │ └── pages │ │ ├── testPage.locatorSchema.ts │ │ └── testPage.page.ts ├── playwright.config.ts ├── pnpm-lock.yaml ├── server.js ├── test-data │ └── staticPage │ │ ├── index.html │ │ └── w3images │ │ ├── avatar2.png │ │ ├── avatar3.png │ │ ├── avatar5.png │ │ ├── avatar6.png │ │ ├── forest.jpg │ │ ├── lights.jpg │ │ ├── mountains.jpg │ │ ├── nature.jpg │ │ └── snow.jpg ├── tests │ ├── tsconfig.json │ ├── with-options │ │ ├── locatorSchema │ │ │ ├── getLocator.spec.ts │ │ │ ├── getNestedLocator.filter.spec.ts │ │ │ ├── getNestedLocator.spec.ts │ │ │ ├── subPath.spec.ts │ │ │ ├── update.spec.ts │ │ │ └── updates.spec.ts │ │ ├── optionalDynamicUrlTypes.spec.ts │ │ ├── sessionStorage.spec.ts │ │ ├── testFilters.spec.ts │ │ └── testPage.spec.ts │ └── without-options │ │ ├── locatorSchema │ │ ├── getLocator.spec.ts │ │ ├── getNestedLocator.spec.ts │ │ ├── update.spec.ts │ │ └── updates.spec.ts │ │ ├── sessionStorage.spec.ts │ │ └── testPage.spec.ts └── tsconfig.json ├── pack-test.sh ├── package.json ├── pnpm-lock.yaml ├── src ├── api │ └── baseApi.ts ├── basePage.ts ├── fixture │ └── base.fixtures.ts ├── helpers │ ├── getBy.locator.ts │ ├── getLocatorBase.ts │ ├── locatorSchema.interface.ts │ ├── playwrightReportLogger.ts │ └── sessionStorage.actions.ts └── utils │ └── selectorEngines.ts ├── test ├── api │ └── .gitkeep ├── basePage.test.poc.ts ├── basePage.test.ts ├── fixture │ └── .gitkeep ├── helpers │ └── getLocatorBase.test.ts └── utils │ └── .gitkeep ├── tsconfig.json └── vitest.config.ts /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", 3 | "changelog": ["@changesets/changelog-github", { "repo": "DyHex/POMWright" }], 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # This .editorconfig file is used to ensure consistent display of code in GitHub, especially regarding indentation. 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # All files 7 | [*] 8 | indent_style = tab 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # CODEOWNERS file 2 | * @dyhex @shtian @jostein-skaar @traatl @hannathevik @mira-pls 3 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - "**" 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: pnpm/action-setup@v4 13 | with: 14 | version: 9.12.0 15 | - uses: actions/setup-node@v4 16 | with: 17 | node-version: 24.x 18 | cache: "pnpm" 19 | 20 | - run: pnpm install --frozen-lockfile 21 | - run: pnpm vitest run 22 | - run: pnpm run lint && pnpm run build -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | workflow_run: 4 | workflows: [CI] 5 | branches: [main] 6 | types: [completed] 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | permissions: 11 | contents: write 12 | pull-requests: write 13 | 14 | jobs: 15 | publish: 16 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: pnpm/action-setup@v4 21 | with: 22 | version: 9.12.0 23 | - uses: actions/setup-node@v4 24 | with: 25 | node-version: 24.x 26 | cache: "pnpm" 27 | 28 | - run: pnpm install --frozen-lockfile 29 | - name: Create Release Pull Request or Publish 30 | id: changesets 31 | uses: changesets/action@v1 32 | with: 33 | publish: pnpm run release 34 | env: 35 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 36 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Run POMWright Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'main' 7 | pull_request: 8 | types: 9 | - opened # Triggers for new PRs and draft PRs 10 | - reopened # Triggers for reopened PRs and draft PRs 11 | - synchronize # Triggers on new commits to PRs and draft PRs 12 | branches: 13 | - '**' # Triggers for PRs and draft PRs targeting any branch 14 | 15 | jobs: 16 | test: 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - name: Check out the repository 21 | uses: actions/checkout@v4 22 | 23 | - name: Set up pnpm 24 | uses: pnpm/action-setup@v4 25 | with: 26 | version: '9.12.0' 27 | 28 | - name: Set up Node.js 29 | uses: actions/setup-node@v4 30 | with: 31 | node-version: '24.x' 32 | cache: 'pnpm' 33 | 34 | - name: Generate Playwright Report Name 35 | if: always() 36 | shell: bash 37 | run: | 38 | # Determine branch name: use github.head_ref for PRs, fallback to github.ref_name for pushes 39 | branchName="${{ github.head_ref || github.ref_name }}" 40 | # Replace any character that isn't a letter, number, or hyphen with '-' 41 | normalizedBranchName="${branchName//[^a-zA-Z0-9æÆøØåÅ\-]/-}" 42 | timestamp=$(date +"%d_%m_%Y_%H%M") 43 | echo "PLAYWRIGHT_REPORT_NAME=playwright-report-$normalizedBranchName-$timestamp" >> $GITHUB_ENV 44 | 45 | - name: Run test script 46 | id: test 47 | run: pnpm pack-test 48 | env: 49 | CI: true 50 | 51 | - name: Upload Playwright Test Report Artifact on failure 52 | if: failure() && steps.test.outcome == 'failure' 53 | uses: actions/upload-artifact@v4 54 | with: 55 | name: ${{ env.PLAYWRIGHT_REPORT_NAME }} 56 | path: intTest/playwright-report 57 | retention-days: 30 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .changeset 2 | .github 3 | .vscode 4 | example 5 | src 6 | test 7 | intTest 8 | .editorconfig 9 | .gitignore 10 | pnpm-lock.yaml 11 | tsconfig.json 12 | *.test.ts 13 | vitest.config.ts 14 | pack-test.sh 15 | biome.json 16 | pomwright-*.tgz -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["biomejs.biome"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/playwright-snippets.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "Playwright Import": { 3 | "prefix": "pw-import", 4 | "body": [ 5 | "import { test, expect } from \"@fixtures/$1\";" 6 | ], 7 | "description": "Import Playwright Test through our Fixtures" 8 | }, 9 | "Playwright Describe": { 10 | "prefix": "pw-describe", 11 | "body": [ 12 | "test.describe(\"$1\", () => {", 13 | " $2", 14 | "});" 15 | ], 16 | "description": "Playwright Test describe block" 17 | }, 18 | "Playwright Test": { 19 | "prefix": "pw-test", 20 | "body": [ 21 | "test(\"$1\", async ({ $2 }) => {", 22 | " $3", 23 | "});" 24 | ], 25 | "description": "Playwright Test test block" 26 | }, 27 | "Playwright Step": { 28 | "prefix": "pw-step", 29 | "body": [ 30 | "await test.step(`${$1.pocName}: $2`, async () => {", 31 | " $3", 32 | "});" 33 | ], 34 | "description": "Playwright Test step block" 35 | }, 36 | "Playwright Use": { 37 | "prefix": "pw-use", 38 | "body": [ 39 | "test.use({", 40 | " $1", 41 | "});" 42 | ], 43 | "description": "Playwright Test use block" 44 | }, 45 | "Playwright beforeEach": { 46 | "prefix": "pw-beforeEach", 47 | "body": [ 48 | "test.beforeEach(async ({ $1 }) => {", 49 | " $2", 50 | "});" 51 | ], 52 | "description": "Playwright Test beforeEach block" 53 | }, 54 | "Playwright afterEach": { 55 | "prefix": "pw-afterEach", 56 | "body": [ 57 | "test.afterEach(async ({ $1 }) => {", 58 | " $2", 59 | "});" 60 | ], 61 | "description": "Playwright Test afterEach block" 62 | }, 63 | "Playwright beforeAll": { 64 | "prefix": "pw-beforeAll", 65 | "body": [ 66 | "test.beforeAll(async ({ $1 }) => {", 67 | " $2", 68 | "});" 69 | ], 70 | "description": "Playwright Test beforeAll block" 71 | }, 72 | "Playwright afterAll": { 73 | "prefix": "pw-afterAll", 74 | "body": [ 75 | "test.afterAll(async ({ $1 }) => {", 76 | " $2", 77 | "});" 78 | ], 79 | "description": "Playwright Test afterAll block" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "biomejs.biome", 3 | "editor.formatOnSave": true, 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.biome": "explicit", 6 | "source.organizeImports.biome": "explicit" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # POMWright 2 | 3 | ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/DyHex/POMWright/main.yaml?label=CI%20on%20main) 4 | [![NPM Version](https://img.shields.io/npm/v/pomwright)](https://www.npmjs.com/package/pomwright) 5 | ![NPM Downloads](https://img.shields.io/npm/dt/pomwright) 6 | ![GitHub License](https://img.shields.io/github/license/DyHex/POMWright) 7 | [![NPM dev or peer Dependency Version](https://img.shields.io/npm/dependency-version/pomwright/peer/%40playwright%2Ftest)](https://www.npmjs.com/package/playwright) 8 | [![Static Badge](https://img.shields.io/badge/created%40-ICE-ffcd00)](https://www.ice.no/) 9 | 10 | POMWright is a lightweight TypeScript framework that layers the Page Object Model on top of Playwright. It keeps locators, page objects, and fixtures organised so that UI tests stay readable and maintainable. 11 | 12 | POMWright provides a way of abstracting the implementation details of a web page and encapsulating them into a reusable page object. This approach makes the tests easier to read, write, and maintain, and helps reduce duplicated code by breaking down the code into smaller, reusable components, making the code more maintainable and organized. 13 | 14 | ## Key capabilities 15 | 16 | - Quickly build maintainable Page Object Classes. 17 | - Define any Playwright Locator through type-safe LocatorSchemas. 18 | - Automatic chaining of locators with dot-delimited paths that map directly to Playwright locator methods. 19 | - Auto-completion of LocatorSchemaPaths and sub-paths. 20 | - Adjust locators on the fly without mutating the original definitions. 21 | - Attach structured logs directly to the Playwright HTML report. 22 | - Manage `sessionStorage` for browser setup and state hand‑off. 23 | - Multiple Domains/BaseURLs 24 | - Validate elements position in DOM through chaining 25 | 26 | ## Why POMWright? 27 | 28 | - **Stronger reliability** – centralised locator definitions reduce brittle inline selectors and make refactors visible at compile time. 29 | - **Faster authoring** – strongly typed schema paths provide instant auto-complete, even for deeply nested segments and reusable fragments. 30 | - **Easier maintenance** – shared helper patterns keep page objects, fixtures, and API clients aligned so teams can extend coverage without duplicating boilerplate. 31 | - **Incremental adoption** – each helper sits on top of Playwright primitives, making it straightforward to migrate existing tests component by component. 32 | 33 | ## Installation 34 | 35 | Install POMWright alongside Playwright: 36 | 37 | ```bash 38 | pnpm add -D pomwright 39 | # or 40 | npm install --save-dev pomwright 41 | ``` 42 | 43 | ## Documentation 44 | 45 | Start with the introduction and continue through the topics: 46 | 47 | 1. [Intro to using POMWright](./docs/intro-to-using-pomwright.md) 48 | 2. [BasePage](./docs/BasePage-explanation.md) 49 | 3. [LocatorSchemaPath](./docs/LocatorSchemaPath-explanation.md) 50 | 4. [LocatorSchema](./docs/LocatorSchema-explanation.md) 51 | 5. [Locator schema helper methods](./docs/get-locator-methods-explanation.md) 52 | 6. [BaseApi](./docs/BaseApi-explanation.md) 53 | 7. [PlaywrightReportLogger](./docs/PlaywrightReportLogger-explanation.md) 54 | 8. [Session storage helpers](./docs/sessionStorage-methods-explanation.md) 55 | 9. [Tips for structuring locator files](./docs/tips-folder-structure.md) 56 | 57 | ## Troubleshooting and Support 58 | 59 | If you encounter any issues or have questions, please check our [issues page](https://github.com/DyHex/POMWright/issues) or reach out to us directly. 60 | 61 | ## Contributing 62 | 63 | Pull Requests are welcome! Please open an issue or submit a pull request for any enhancements or bug fixes. 64 | 65 | ## License 66 | 67 | POMWright is open-source software licensed under the [Apache-2.0 license](https://github.com/DyHex/POMWright/blob/main/LICENSE). 68 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/2.2.4/schema.json", 3 | "assist": { 4 | "actions": { 5 | "source": { 6 | "organizeImports": "on" 7 | } 8 | } 9 | }, 10 | "linter": { 11 | "enabled": true, 12 | "rules": { 13 | "recommended": true, 14 | "style": { 15 | "noParameterAssign": "error", 16 | "useAsConstAssertion": "error", 17 | "useDefaultParameterLast": "error", 18 | "useEnumInitializers": "error", 19 | "useSelfClosingElements": "error", 20 | "useSingleVarDeclarator": "error", 21 | "noUnusedTemplateLiteral": "error", 22 | "useNumberNamespace": "error", 23 | "noInferrableTypes": "error", 24 | "noUselessElse": "error" 25 | } 26 | } 27 | }, 28 | "formatter": { 29 | "enabled": true, 30 | "formatWithErrors": true, 31 | "indentStyle": "tab", 32 | "indentWidth": 2, 33 | "lineWidth": 120, 34 | "includes": ["**"] 35 | }, 36 | "files": { 37 | "includes": [ 38 | "**", 39 | "!**/dist", 40 | "!**/node_modules", 41 | "!**/intTest/node_modules", 42 | "!**/intTest/playwright-report", 43 | "!**/intTest/test-results" 44 | ] 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /docs/BaseApi-explanation.md: -------------------------------------------------------------------------------- 1 | # BaseApi 2 | 3 | The `BaseApi` class offers a minimal foundation for writing API helpers in POMWright. It wraps Playwright's `APIRequestContext` and integrates with the shared [`PlaywrightReportLogger`](./PlaywrightReportLogger-explanation.md). 4 | 5 | ## Constructor 6 | 7 | ```ts 8 | constructor(baseUrl: string, apiName: string, context: APIRequestContext, pwrl: PlaywrightReportLogger) 9 | ``` 10 | 11 | * `baseUrl` – root address used to build request URLs. 12 | * `apiName` – human‑readable identifier; also used as a logging prefix. 13 | * `context` – Playwright [`APIRequestContext`](https://playwright.dev/docs/api/class-apirequestcontext). 14 | * `pwrl` – logger instance supplied by the test fixtures. A child logger is created for the API class. 15 | 16 | The class stores these values on the instance and exposes the `request` and `log` properties for use in subclasses. 17 | 18 | ## Example 19 | 20 | ```ts 21 | import type { APIRequestContext } from "@playwright/test"; 22 | import { BaseApi, type PlaywrightReportLogger } from "pomwright"; 23 | 24 | class MyApi extends BaseApi { 25 | constructor(baseUrl: string, context: APIRequestContext, pwrl: PlaywrightReportLogger) { 26 | super(baseUrl, MyApi.name, context, pwrl); 27 | } 28 | 29 | v1 = { 30 | users: { 31 | id: { 32 | get: async (id: string, status: number = 200) => { 33 | const response = await this.request.get(`/api/users/${id}`); 34 | expect(response.status(), "should have status code: 200").toBe(status); 35 | const body = await response.json(); 36 | return body; 37 | }, 38 | //...etc. 39 | } 40 | }, 41 | products: { 42 | //...etc. 43 | } 44 | } 45 | } 46 | ``` 47 | 48 | Then in a test you'd do: 49 | 50 | ```ts 51 | test("some test", { tag: ["@api", "@user"] }, async ({ myApi }) => { 52 | // Create user 53 | 54 | const existingUser = await myApi.v1.users.id.get(userId); // 200 (default) user found 55 | 56 | // Delete user 57 | 58 | const deletedUser = await myApi.v1.users.id.get(userId, 204); // 204 user not found successfully 59 | 60 | }) 61 | ``` 62 | 63 | `BaseApi` does not dictate how requests are made; you are free to add domain‑specific methods using the provided `request` context and logger. 64 | -------------------------------------------------------------------------------- /docs/BasePage-explanation.md: -------------------------------------------------------------------------------- 1 | # BasePage 2 | 3 | `BasePage` is the foundation for all Page Object Classes (POCs) in POMWright. It provides common plumbing for Playwright pages, logging, locator management and session storage. 4 | 5 | ## Generics 6 | 7 | ```ts 8 | BasePage 9 | ``` 10 | 11 | * **LocatorSchemaPathType** – union of valid locator paths for the POC. 12 | * **Options** – optional configuration that allows the `baseUrl` or `urlPath` to be typed as a `RegExp` when dynamic values are required for mapping certain resource paths. See [intTest/page-object-models/testApp/with-options/base/baseWithOptions.page.ts](../intTest/page-object-models/testApp/with-options/base/baseWithOptions.page.ts) 13 | * **LocatorSubstring** – internal type used when working with sub paths; normally left undefined. 14 | 15 | ## Constructor 16 | 17 | ```ts 18 | constructor( 19 | page: Page, 20 | testInfo: TestInfo, 21 | baseUrl: ExtractBaseUrlType, 22 | urlPath: ExtractUrlPathType, 23 | pocName: string, 24 | pwrl: PlaywrightReportLogger 25 | ) 26 | ``` 27 | 28 | The base class stores these values and derives `fullUrl`. A child [`PlaywrightReportLogger`](./PlaywrightReportLogger-explanation.md) is created for the POC and a [`SessionStorage`](./sessionStorage-methods-explanation.md) helper is exposed as `sessionStorage`. 29 | 30 | ## Required implementation 31 | 32 | Every POC extending `BasePage` must: 33 | 34 | 1. Define a `LocatorSchemaPath` union describing available locators. 35 | 2. Implement the `initLocatorSchemas()` method which adds schemas to the internal `GetLocatorBase` instance. 36 | 37 | ```ts 38 | // login.page.ts 39 | import type { Page, TestInfo } from "@playwright/test"; 40 | import { BasePage, type PlaywrightReportLogger } from "pomwright"; 41 | import { test } from "@fixtures/all.fixtures"; 42 | import { type LocatorSchemaPath, initLocatorSchemas } from "./login.locatorSchema"; 43 | 44 | export default class Login extends BasePage { 45 | constructor(page: Page, testInfo: TestInfo, pwrl: PlaywrightReportLogger) { 46 | super(page, testInfo, "https://example.com", "/login", Login.name, pwrl); 47 | } 48 | 49 | protected initLocatorSchemas() { 50 | initLocatorSchemas(this.locators); 51 | } 52 | 53 | public async login(user: string, pass: string) { 54 | await test.step(`${this.pocName}: Fill login form and login`, async () => { 55 | 56 | await test.step(`${this.pocName}: Fill username field`, async () => { 57 | const locator = await this.getNestedLocator("main.section@login.form.textbox@username"); 58 | await locator.fill(user); 59 | await locator.blur(); 60 | }); 61 | 62 | await test.step(`${this.pocName}: Fill password field`, async () => { 63 | const locator = await this.getNestedLocator("main.section@login.form.textbox@password"); 64 | await locator.fill(pass); 65 | await locator.blur(); 66 | }); 67 | 68 | await test.step(`${this.pocName}: Click login button`, async () => { 69 | const locator = await this.getNestedLocator("main.section@login.button@login"); 70 | await locator.click(); 71 | }); 72 | 73 | /** 74 | * We probably don't want to await navigation here, instead we do it in the test, 75 | * using the POC representing the page we are navigated to (e.g. /profile) 76 | */ 77 | }); 78 | } 79 | } 80 | ``` 81 | 82 | ## Helper methods 83 | 84 | `BasePage` exposes a small API built around `LocatorSchema` definitions: 85 | 86 | * `getLocatorSchema(path)` – returns a chainable object; see [locator methods](./get-locator-methods-explanation.md). 87 | * `getLocator(path)` – wrapper method for `getLocatorSchema(path).getLocator();` - resolves the locator for the final segment of a path, e.g. the Locator created from the LocatorSchema the full LocatorSchemaPath references. 88 | * `getNestedLocator(path, indices?)` – wrapper method for `getLocatorSchema(path).getNestedLocator();` - automatically chains locators along the path and optionally selects nth occurrences. 89 | 90 | All locators are strongly typed, giving compile‑time suggestions and preventing typos in `LocatorSchemaPath` strings. 91 | 92 | ## Recommendation 93 | 94 | Instead of extending each of your POC's with BasePage from POMWright, opt instead to create an abstract BasePage class for your domain and have it extend BasePage from POMWright. Then have each of your POC's extend your abstract POC instead. This allows us to easily sentralize common helper methods, LocatorSchema, etc. and reuse it across all our POC's for the given domain. 95 | 96 | For examples see [intTest/page-object-models/testApp](../intTest/page-object-models/testApp) 97 | -------------------------------------------------------------------------------- /docs/LocatorSchema-explanation.md: -------------------------------------------------------------------------------- 1 | # LocatorSchema 2 | 3 | A `LocatorSchema` describes how to find an element in the DOM. Instead of storing Playwright `Locator` objects directly, POMWright keeps these schema definitions and resolves them only when a test requests the locator. The same schema can be reused across multiple page objects and test files without risking stale references. 4 | 5 | Every schema must set `locatorMethod` to one of the [`GetByMethod`](../src/helpers/locatorSchema.interface.ts) enum values. The matching selector fields listed below determine which Playwright API call is executed under the hood. You can provide more than one selector field, but the method chosen by `locatorMethod` is the one that will be used when the locator is created. 6 | 7 | When you register a schema with `GetLocatorBase.addSchema(path, schemaDetails)` the `locatorSchemaPath` is stored automatically and becomes part of the structured logs produced by POMWright. 8 | 9 | ## Selector strategies 10 | 11 | > Note: You can define both "role" and "locator" on the same LocatorSchema, but which one is used is dictated by "locatorMethod". Too keep things clean I recommend only defining the actual type of Playwright Locator you'll be using. 12 | 13 | Each selector strategy maps directly to a Playwright locator helper. The snippets below show the POMWright configuration alongside the equivalent raw Playwright call. 14 | 15 | ### `role` and `roleOptions` 16 | 17 | Use [ARIA roles](https://playwright.dev/docs/api/class-page#page-get-by-role) as your primary selector. The optional `roleOptions` map to the options object accepted by `page.getByRole()`. 18 | 19 | ```ts 20 | locators.addSchema("content.button@edit", { 21 | role: "button", 22 | roleOptions: { name: "Edit" }, 23 | locatorMethod: GetByMethod.role 24 | }); 25 | ``` 26 | 27 | Playwright equivalent: 28 | 29 | ```ts 30 | page.getByRole("button", { name: "Edit" }); 31 | ``` 32 | 33 | ### `text` and `textOptions` 34 | 35 | Match visible text using the same semantics as [`page.getByText()`](https://playwright.dev/docs/api/class-page#page-get-by-text). 36 | 37 | ```ts 38 | locators.addSchema("content.link.help", { 39 | text: "Need help?", 40 | textOptions: { exact: true }, 41 | locatorMethod: GetByMethod.text 42 | }); 43 | ``` 44 | 45 | Playwright equivalent: 46 | 47 | ```ts 48 | page.getByText("Need help?", { exact: true }); 49 | ``` 50 | 51 | ### `label` and `labelOptions` 52 | 53 | Reference form controls by their accessible label. `labelOptions` accepts the same arguments as [`page.getByLabel()`](https://playwright.dev/docs/api/class-page#page-get-by-label). 54 | 55 | ```ts 56 | locators.addSchema("content.form.password", { 57 | label: "Password", 58 | locatorMethod: GetByMethod.label 59 | }); 60 | ``` 61 | 62 | Playwright equivalent: 63 | 64 | ```ts 65 | page.getByLabel("Password"); 66 | ``` 67 | 68 | ### `placeholder` and `placeholderOptions` 69 | 70 | Target elements by placeholder text via [`page.getByPlaceholder()`](https://playwright.dev/docs/api/class-page#page-get-by-placeholder). 71 | 72 | ```ts 73 | locators.addSchema("content.form.search", { 74 | placeholder: "Search", 75 | placeholderOptions: { exact: true }, 76 | locatorMethod: GetByMethod.placeholder 77 | }); 78 | ``` 79 | 80 | Playwright equivalent: 81 | 82 | ```ts 83 | page.getByPlaceholder("Search", { exact: true }); 84 | ``` 85 | 86 | ### `altText` and `altTextOptions` 87 | 88 | Resolve images and other elements with alternative text via [`page.getByAltText()`](https://playwright.dev/docs/api/class-page#page-get-by-alt-text). 89 | 90 | ```ts 91 | locators.addSchema("content.hero.image", { 92 | altText: "Company logo", 93 | locatorMethod: GetByMethod.altText 94 | }); 95 | ``` 96 | 97 | Playwright equivalent: 98 | 99 | ```ts 100 | page.getByAltText("Company logo"); 101 | ``` 102 | 103 | ### `title` and `titleOptions` 104 | 105 | Tap into the [`title` attribute](https://playwright.dev/docs/api/class-page#page-get-by-title) of an element. 106 | 107 | ```ts 108 | locators.addSchema("content.tooltip.info", { 109 | title: "More information", 110 | locatorMethod: GetByMethod.title 111 | }); 112 | ``` 113 | 114 | Playwright equivalent: 115 | 116 | ```ts 117 | page.getByTitle("More information"); 118 | ``` 119 | 120 | ### `locator` and `locatorOptions` 121 | 122 | Fallback to raw selectors or locator chaining with [`page.locator()`](https://playwright.dev/docs/api/class-page#page-locator). `locatorOptions` is passed straight through to the Playwright call. 123 | 124 | ```ts 125 | locators.addSchema("content.main", { 126 | locator: "main", 127 | locatorOptions: { hasText: "Profile" }, 128 | locatorMethod: GetByMethod.locator 129 | }); 130 | ``` 131 | 132 | Playwright equivalent: 133 | 134 | ```ts 135 | page.locator("main", { hasText: "Profile" }); 136 | ``` 137 | 138 | ### `frameLocator` 139 | 140 | Create [`FrameLocator`](https://playwright.dev/docs/api/class-page#page-frame-locator) instances when your DOM interaction starts inside an iframe. 141 | 142 | ```ts 143 | locators.addSchema("iframe.login", { 144 | frameLocator: "#auth-frame", 145 | locatorMethod: GetByMethod.frameLocator 146 | }); 147 | ``` 148 | 149 | Playwright equivalent: 150 | 151 | ```ts 152 | page.frameLocator("#auth-frame"); 153 | ``` 154 | 155 | ### `testId` 156 | 157 | Call [`page.getByTestId()`](https://playwright.dev/docs/api/class-page#page-get-by-test-id) by setting the `testId` field. 158 | 159 | ```ts 160 | locators.addSchema("content.button.reset", { 161 | testId: "reset-btn", 162 | locatorMethod: GetByMethod.testId 163 | }); 164 | ``` 165 | 166 | Playwright equivalent: 167 | 168 | ```ts 169 | page.getByTestId("reset-btn"); 170 | ``` 171 | 172 | ### `dataCy` 173 | 174 | `dataCy` is a POMWright convenience for the legacy `data-cy` attribute used in Cypress-based projects. Under the hood it still calls `page.locator()` with the [`data-cy=` selector engine](https://playwright.dev/docs/extensibility#custom-selector-engines) registered by `BasePage`. 175 | 176 | ```ts 177 | locators.addSchema("content.card.actions", { 178 | dataCy: "card-actions", 179 | locatorMethod: GetByMethod.dataCy 180 | }); 181 | ``` 182 | 183 | Playwright equivalent: 184 | 185 | ```ts 186 | page.locator("data-cy=card-actions"); 187 | ``` 188 | 189 | If you prefer to control the selector string yourself you can pass the full expression (`"data-cy=card-actions"`) and it will be used verbatim. 190 | 191 | ### `id` 192 | 193 | `id` is the second POMWright-specific helper. Provide either the raw id string or a `RegExp`. Strings are normalised to CSS id selectors, so `"details"`, `"#details"`, and `"id=details"` all resolve to the same element. A regular expression matches the start of the `id` attribute, allowing you to capture generated identifiers. 194 | 195 | ```ts 196 | locators.addSchema("content.region.details", { 197 | id: "profile-details", 198 | locatorMethod: GetByMethod.id 199 | }); 200 | 201 | locators.addSchema("content.region.dynamic", { 202 | id: /^region-/, 203 | locatorMethod: GetByMethod.id 204 | }); 205 | ``` 206 | 207 | Playwright equivalent: 208 | 209 | ```ts 210 | page.locator("#profile-details"); 211 | page.locator('*[id^="region-"]'); 212 | ``` 213 | 214 | ### `filter` 215 | 216 | Any schema can define a `filter` object with the same shape as [`locator.filter()`](https://playwright.dev/docs/api/class-locator#locator-filter). Filters are applied immediately after the initial Playwright locator is created. 217 | 218 | ```ts 219 | locators.addSchema("content.list.item", { 220 | role: "listitem", 221 | locatorMethod: GetByMethod.role, 222 | filter: { hasText: /Primary Colors/i } 223 | }); 224 | ``` 225 | 226 | Playwright equivalent: 227 | 228 | ```ts 229 | page.getByRole("listitem").filter({ hasText: /Primary Colors/i }); 230 | ``` 231 | 232 | ## Putting it together 233 | 234 | Schemas are typically registered inside `initLocatorSchemas()` of a `BasePage` subclass. The example below demonstrates reusable fragments and multiple selector strategies working together. 235 | 236 | ```ts 237 | import { GetByMethod, type GetLocatorBase, type LocatorSchemaWithoutPath } from "pomwright"; 238 | type LocatorSchemaPath = 239 | | "main" 240 | | "main.button" 241 | | "main.button@continue" 242 | | "main.button@back"; 243 | 244 | export function initLocatorSchemas(locators: GetLocatorBase) { 245 | locators.addSchema("main", { 246 | locator: "main", 247 | locatorMethod: GetByMethod.locator 248 | }); 249 | 250 | const button: LocatorSchemaWithoutPath = { 251 | role: "button", 252 | locatorMethod: GetByMethod.role 253 | }; 254 | 255 | locators.addSchema("main.button", { 256 | ...button 257 | }); 258 | 259 | locators.addSchema("main.button@continue", { 260 | ...button, 261 | roleOptions: { name: "Continue" } 262 | }); 263 | 264 | locators.addSchema("main.button@back", { 265 | ...button, 266 | roleOptions: { name: "Back" } 267 | }); 268 | } 269 | ``` 270 | 271 | Once registered, the schemas can be consumed through the BasePage helpers documented in [`docs/get-locator-methods-explanation.md`](./get-locator-methods-explanation.md). 272 | -------------------------------------------------------------------------------- /docs/LocatorSchemaPath-explanation.md: -------------------------------------------------------------------------------- 1 | # LocatorSchemaPath 2 | 3 | A `LocatorSchemaPath` is the unique key that identifies a `LocatorSchema`. Paths let POMWright build nested Playwright locators while keeping the definitions type‑safe and searchable. 4 | 5 | ## Path syntax 6 | 7 | A `LocatorSchemaPath` is declared as a union of string literals. 8 | 9 | ```ts 10 | type LocatorSchemaPath = "main"; 11 | ``` 12 | 13 | Each word or segment of a string are separated by `.` (dot), where each dot represents a new element in the chain. Thus we end up with paths describing the hierarchy of elements on a page through dot notation. 14 | 15 | ```ts 16 | type LocatorSchemaPath = 17 | | "main" 18 | | "main.heading"; 19 | ``` 20 | 21 | In other words, each `.` (dot) separates a new segment in the chain. A LocatorSchemaPath string: 22 | 23 | - Cannot be empty. 24 | - The string cannot start or end with `.`. 25 | - The string cannot contain consecutive dots. 26 | 27 | A path must start and end with a "word", a word is any combination of characters except `.` (dot). The following paths will throw during runtime: 28 | 29 | ```ts 30 | // These throw at runtime 31 | type LocatorSchemaPath = 32 | | "" // empty string 33 | | ".main" // starting with a dot 34 | | "main." // ending with a dot 35 | | "main..heading" // consecutive dots 36 | ``` 37 | 38 | Every path represents a chain of locators that describe the hierarchy of elements on the page. POMWright enforces uniqueness, so registering the same path twice causes fixture initialisation to fail before any test using them can runs. 39 | 40 | ```ts 41 | import { GetByMethod, type GetLocatorBase } from "pomwright"; 42 | 43 | export type LocatorSchemaPath = 44 | | "main" 45 | | "main.heading"; 46 | 47 | export function initLocatorSchemas(locators: GetLocatorBase) { 48 | locators.addSchema("main.heading", { /* ... */ }); 49 | locators.addSchema("main.heading", { /* ... */ }); // duplicate – throws 50 | } 51 | ``` 52 | 53 | ## Referencing schemas 54 | 55 | Each path declared in the type must be implemented with `addSchema` inside `initLocatorSchemas`. Missing implementations raise a `not implemented` error when the fixtures are created, helping you catch mistakes early. 56 | 57 | Locator paths can be shared across Page Object Classes by re‑exporting the union of strings: 58 | 59 | ```ts 60 | import { GetByMethod, type GetLocatorBase } from "pomwright"; 61 | import { 62 | type LocatorSchemaPath as common, 63 | initLocatorSchemas as initCommon 64 | } from "../page-components/common.locatorSchema"; 65 | 66 | export type LocatorSchemaPath = 67 | | common 68 | | "main.heading"; 69 | 70 | export function initLocatorSchemas(locators: GetLocatorBase) { 71 | initCommon(locators); 72 | 73 | locators.addSchema("main.heading", { 74 | role: "heading", 75 | roleOptions: { name: "Welcome!" }, 76 | locatorMethod: GetByMethod.role 77 | }); 78 | } 79 | ``` 80 | 81 | ## Descriptive segments 82 | 83 | A path may include segments that exist purely for readability. POMWright skips any missing intermediate keys when chaining the locator. 84 | 85 | ```ts 86 | import { GetByMethod, type GetLocatorBase } from "pomwright"; 87 | 88 | type LocatorSchemaPath = 89 | | "main" 90 | | "main.button.continue"; // "button" communicates intent 91 | 92 | export function initLocatorSchemas(locators: GetLocatorBase) { 93 | 94 | locators.addSchema("main", { 95 | locator: "main", 96 | locatorMethod: GetByMethod.locator 97 | }); 98 | 99 | locators.addSchema("main.button.continue", { 100 | role: "button", 101 | roleOptions: { name: "Continue" }, 102 | locatorMethod: GetByMethod.role 103 | }); 104 | } 105 | ``` 106 | 107 | You can also suffix segments with a friendly name using `@`. POMWright treats `@` like any other character—it simply improves readability for humans. You're free to use any special character to improve readability. 108 | 109 | ```ts 110 | import { GetByMethod, type GetLocatorBase, type LocatorSchemaWithoutPath } from "pomwright"; 111 | 112 | type LocatorSchemaPath = 113 | | "main" 114 | | "main.button" 115 | | "main.button@continue" 116 | | "main.button@back"; // "button" is here used for human context 117 | 118 | export function initLocatorSchemas(locators: GetLocatorBase) { 119 | 120 | locators.addSchema("main", { 121 | locator: "main", 122 | locatorMethod: GetByMethod.locator 123 | }); 124 | 125 | const button: LocatorSchemaWithoutPath = { 126 | role: "button", 127 | locatorMethod: GetByMethod.role 128 | } 129 | 130 | locators.addSchema("main.button", { 131 | ...button, 132 | }); 133 | 134 | locators.addSchema("main.button@continue", { 135 | ...button, 136 | roleOptions: { name: "Continue" } 137 | }); 138 | 139 | locators.addSchema("main.button@back", { 140 | ...button, 141 | roleOptions: { name: "Back" } 142 | }); 143 | } 144 | ``` 145 | 146 | > Use something like `main.button` when you need a broad locator (for example, to count buttons) and `main.button@continue` or `main.button@back` when you need a specific instance. 147 | 148 | The portion before `@` usually describes the element type (`section`, `button`), while the part after `@` is a friendly identifier (`playground`, `reset`). This makes long chains readable while still conveying intent. 149 | 150 | > **Note:** There is nothing special about the character `@`, in the eyes of POMWright it's just another character in a word. The only "special" character is `.` dot. 151 | 152 | Remember: the goal is not to mirror the DOM structure 1:1. Just enough to ensure a descriptive and unique path to the elements you interact with and validate through the use of simple Locators. 153 | 154 | ## LocatorSchemaPath, Sub-paths and IntelliSense 155 | 156 | Every dot‑delimited segment forms a sub path. Sub paths power IntelliSense and let you scope updates or filters to a specific part of the chain. 157 | 158 | ```ts 159 | const resetBtn = await profile 160 | .getLocatorSchema("body.section@playground.button@reset") 161 | .addFilter("body.section@playground", { hasText: /Primary Colors/i }) 162 | .getNestedLocator(); 163 | ``` 164 | 165 | The TypeScript union of all paths enables autocomplete, prevents typos, and makes refactors simple. Update a single `LocatorSchema` definition and every test using that path immediately benefits from the change. 166 | -------------------------------------------------------------------------------- /docs/PlaywrightReportLogger-explanation.md: -------------------------------------------------------------------------------- 1 | # PlaywrightReportLogger 2 | 3 | `PlaywrightReportLogger` records log messages during a test and attaches them to Playwright's HTML report. All POMWright fixtures and classes share the same log level and entry list for the duration of a test. 4 | 5 | ## Log levels 6 | 7 | `debug`, `info`, `warn`, `error` 8 | 9 | Changing the level affects every child logger because they reference the same state. 10 | 11 | ## Creating and using loggers 12 | 13 | The `@fixtures/*` test fixtures expose a `log` instance. Child loggers add context to messages without losing shared state: 14 | 15 | ```ts 16 | import { test } from "@fixtures/withoutOptions"; 17 | 18 | test("logging", async ({ log }) => { 19 | const pageLog = log.getNewChildLogger("LoginPage"); 20 | pageLog.info("filling form"); 21 | }); 22 | ``` 23 | 24 | Inside a Page Object the constructor typically receives the root logger and creates a child: 25 | 26 | ```ts 27 | constructor(page: Page, testInfo: TestInfo, pwrl: PlaywrightReportLogger) { 28 | super(page, testInfo, baseUrl, urlPath, MyPage.name, pwrl); 29 | // this.log is already a child logger for MyPage 30 | } 31 | ``` 32 | 33 | ## Attaching to the report 34 | 35 | At the end of each test the fixture calls `attachLogsToTest(testInfo)`. Entries are sorted by timestamp and attached to the Playwright report with the prefix and log level: 36 | 37 | ```text 38 | 20:49:52 05.05.2023 - INFO : [TestCase -> LoginPage] navigating to /login 39 | ``` 40 | 41 | You can change verbosity during a test: 42 | 43 | ```ts 44 | log.setLogLevel("debug"); 45 | ``` 46 | 47 | or temporarily: 48 | 49 | ```ts 50 | const level = log.getCurrentLogLevel(); 51 | log.setLogLevel("error"); 52 | // ... 53 | log.setLogLevel(level); 54 | ``` 55 | 56 | The logger is lightweight but powerful enough to trace locator chains or custom helper behaviour while keeping the information in the final HTML report. 57 | -------------------------------------------------------------------------------- /docs/get-locator-methods-explanation.md: -------------------------------------------------------------------------------- 1 | # Working with Locator Schemas 2 | 3 | `BasePage` exposes two layers of helpers for working with locator schemas: 4 | 5 | 1. **Wrapper shortcuts** – `getLocator()` and `getNestedLocator()` resolve locators in a single call. They are great when you just need a locator and do not have to tweak the schema. 6 | 2. **`getLocatorSchema()` chains** – return a deep copy of the schemas that make up the requested path so you can update selectors, add filters, or reuse the chain with different parameters. 7 | 8 | The sections below show how both approaches fit together. 9 | 10 | ## Example set-up 11 | 12 | The snippets that follow use the simple page object and fixture below. 13 | 14 | ### Create a Page Object Class 15 | 16 | ```ts 17 | import { Page, TestInfo } from "@playwright/test"; 18 | import { BasePage, GetByMethod, PlaywrightReportLogger } from "pomwright"; 19 | 20 | type LocatorSchemaPath = 21 | | "content" 22 | | "content.heading" 23 | | "content.region.details" 24 | | "content.region.details.button.edit"; 25 | 26 | export default class Profile extends BasePage { 27 | constructor(page: Page, testInfo: TestInfo, pwrl: PlaywrightReportLogger) { 28 | super(page, testInfo, "https://someDomain.com", "/profile", Profile.name, pwrl); 29 | } 30 | 31 | protected initLocatorSchemas() { 32 | this.locators.addSchema("content", { 33 | locator: ".main-content", 34 | locatorMethod: GetByMethod.locator 35 | }); 36 | 37 | this.locators.addSchema("content.heading", { 38 | role: "heading", 39 | roleOptions: { 40 | name: "Your Profile" 41 | }, 42 | locatorMethod: GetByMethod.role 43 | }); 44 | 45 | this.locators.addSchema("content.region.details", { 46 | role: "region", 47 | roleOptions: { 48 | name: "Profile Details" 49 | }, 50 | locatorMethod: GetByMethod.role 51 | }); 52 | 53 | this.locators.addSchema("content.region.details.button.edit", { 54 | role: "button", 55 | roleOptions: { 56 | name: "Edit" 57 | }, 58 | locatorMethod: GetByMethod.role 59 | }); 60 | } 61 | } 62 | ``` 63 | 64 | ### Provide the page object through a fixture 65 | 66 | ```ts 67 | import { test as base } from "pomwright"; 68 | import Profile from "./profile"; 69 | 70 | type Fixtures = { 71 | profile: Profile; 72 | }; 73 | 74 | export const test = base.extend({ 75 | profile: async ({ page, log }, use, testInfo) => { 76 | const profile = new Profile(page, testInfo, log); 77 | await use(profile); 78 | } 79 | }); 80 | ``` 81 | 82 | ### Use the fixture in a test 83 | 84 | ```ts 85 | import { test } from "./fixtures"; 86 | 87 | test("load profile", async ({ profile }) => { 88 | await profile.page.goto(profile.fullUrl); 89 | }); 90 | ``` 91 | 92 | ## BasePage wrapper shortcuts 93 | 94 | The wrapper methods call `getLocatorSchema(path)` internally and immediately resolve the locator. Use them when the stored schema already matches the element you need. 95 | 96 | ### `getLocator(path)` 97 | 98 | Returns the locator for the final schema in the chain. This is equivalent to `getLocatorSchema(path).getLocator()`. 99 | 100 | ```ts 101 | test("click edit button with a single locator", async ({ profile }) => { 102 | await profile.page.waitForURL(profile.fullUrl); 103 | 104 | const editButton = await profile.getLocator("content.region.details.button.edit"); 105 | await editButton.click(); 106 | }); 107 | ``` 108 | 109 | ### `getNestedLocator(path, indices?)` 110 | 111 | Builds a nested locator by chaining every schema that makes up the path. Optional indices let you pick a specific occurrence of any segment. 112 | 113 | ```ts 114 | test("click edit button with a nested locator", async ({ profile }) => { 115 | await profile.page.waitForURL(profile.fullUrl); 116 | 117 | const editButton = await profile.getNestedLocator("content.region.details.button.edit"); 118 | await editButton.click(); 119 | }); 120 | ``` 121 | 122 | When a path represents repeating elements, provide sub-path keys to select the right instance: 123 | 124 | ```ts 125 | test("specify index for nested locators", async ({ profile }) => { 126 | await profile.page.waitForURL(profile.fullUrl); 127 | 128 | const editButton = await profile.getNestedLocator( 129 | "content.region.details.button.edit", 130 | { 131 | "content.region.details": 0, 132 | "content.region.details.button.edit": 1 133 | } 134 | ); 135 | 136 | await editButton.click(); 137 | }); 138 | ``` 139 | 140 | > Legacy numeric indices are still supported but will be removed in a future major version. Prefer keyed sub paths as shown above. 141 | 142 | ## Building chains with `getLocatorSchema()` 143 | 144 | `getLocatorSchema(path)` returns a deep copy of every schema that makes up the `path` plus chainable helpers for refining the locator. All manipulations happen on the copy, so the original definitions inside the page object stay immutable. 145 | 146 | ```ts 147 | const chain = profile.getLocatorSchema("content.region.details.button.edit"); 148 | ``` 149 | 150 | ### `update(subPath, partial)` 151 | 152 | Override one or more properties of any schema in the chain. Calls can be chained and are applied in order. This is how you adapt selectors to dynamic states without mutating the stored definitions. 153 | 154 | ```ts 155 | const editButton = await profile 156 | .getLocatorSchema("content.region.details.button.edit") 157 | .update("content.region.details.button.edit", { 158 | roleOptions: { name: "Edit details" } 159 | }) 160 | .getNestedLocator(); 161 | ``` 162 | 163 | You can also update intermediate segments without touching the rest of the path: 164 | 165 | ```ts 166 | await profile 167 | .getLocatorSchema("content.region.details.button.edit") 168 | .update("content.region.details", { 169 | locator: ".profile-details", 170 | locatorMethod: GetByMethod.locator 171 | }) 172 | .getNestedLocator(); 173 | ``` 174 | 175 | Chain multiple `update` calls when you need to adjust different LocatorSchema in the chain: 176 | 177 | ```ts 178 | test("make multiple versions of a locator", async ({ profile }) => { 179 | await profile.page.waitForURL(profile.fullUrl); 180 | 181 | const edgeCaseNestedLocator = profile 182 | .getLocatorSchema("content.region.details.button.edit") 183 | .update("content.region.details", { 184 | roleOptions: { name: "Payment Info" } 185 | }) 186 | .update("content.region.details.button.edit", { 187 | roleOptions: { name: "Update" } 188 | }) 189 | .getNestedLocator(); 190 | 191 | await edgeCaseNestedLocator.click(); 192 | }); 193 | ``` 194 | 195 | ### `addFilter(subPath, filterOptions)` 196 | 197 | Adds filters in the same way as Playwright's [`locator.filter()`](https://playwright.dev/docs/api/class-locator#locator-filter). Multiple filters are merged in the order you call them. 198 | 199 | ```ts 200 | const resetButton = await profile 201 | .getLocatorSchema("content.region.details.button.edit") 202 | .addFilter("content.region.details", { hasText: /Profile Details/i }) 203 | // .addFilter(...) 204 | .getNestedLocator(); 205 | ``` 206 | 207 | ### `getNestedLocator(indices?)` 208 | 209 | Resolves the full chain to a Playwright locator. Provide optional indices if you need to target a specific occurrence. This method is the same one the wrapper shortcut delegates to, but it keeps any updates or filters you applied earlier in the chain. 210 | 211 | ```ts 212 | const nestedLocator = await profile 213 | .getLocatorSchema("content.region.details.button.edit") 214 | //.update(...) 215 | //.addFilter(...) 216 | .getNestedLocator({ "content.region": 1 }); // equivalent to .nth(1) (second occurance) 217 | ``` 218 | 219 | ### `getLocator()` 220 | 221 | Resolves only the final schema in the chain, i.e. the LocatorSchema which the full LocatorSchemaPath points to. The resulting locator is identical to calling `getNestedLocator()` on a chain that contains a single schema, but it is often more expressive when you only care about the last segment or need to manually chain given some edge case. Same as getNestedLocator, it keeps any updates or filters you applied earlier in the chain. 222 | 223 | ```ts 224 | const badge = await profile 225 | .getLocatorSchema("content.region.details.button.edit") 226 | //.update(...) 227 | //.addFilter(...) 228 | .getLocator(); 229 | ``` 230 | 231 | ### Reusing a chain 232 | 233 | Because each call to `getLocatorSchema()` returns a deep copy, you can derive multiple locators without affecting the original definitions. 234 | 235 | ```ts 236 | const editButtonSchema = profile.getLocatorSchema("content.region.details.button.edit"); 237 | 238 | const editButton = await editButtonSchema.getLocator(); 239 | await editButton.click(); 240 | 241 | editButtonSchema.update("content.region.details.button.edit", { 242 | roleOptions: { name: "Edit details" } 243 | }); 244 | 245 | const editButtonUpdated = await editButtonSchema.getNestedLocator(); 246 | await editButtonUpdated.click(); 247 | 248 | // Calling profile.getLocatorSchema("content.region.details.button.edit") again 249 | // returns a fresh deep copy of the original schema chain. 250 | ``` 251 | 252 | ## Migrating from deprecated helpers 253 | 254 | Earlier versions exposed index-based helpers. They continue to work but are deprecated in favour of the sub-path syntax described above. 255 | 256 | ```ts 257 | // old 258 | await profile.getNestedLocator("content.region.details.button.save", { 4: 2 }); 259 | 260 | // new 261 | await profile.getNestedLocator("content.region.details.button.save", { 262 | "content.region.details.button.save": 2 263 | }); 264 | ``` 265 | 266 | Switching to sub-path keys makes updates easier when `LocatorSchemaPath` strings change, and TypeScript will warn you if you mistype a path. While improving readability. 267 | -------------------------------------------------------------------------------- /docs/sessionStorage-methods-explanation.md: -------------------------------------------------------------------------------- 1 | # SessionStorage helper 2 | 3 | Every `BasePage` exposes a `sessionStorage` property that wraps common operations on `window.sessionStorage`. The helper records each action as a Playwright `test.step` for better reporting. 4 | 5 | ## set(states, reload) 6 | 7 | Writes key/value pairs to session storage. Passing `true` for `reload` refreshes the page to apply the new state immediately. 8 | 9 | ```ts 10 | await page.sessionStorage.set({ token: "abc", theme: "dark" }, true); 11 | ``` 12 | 13 | ## setOnNextNavigation(states) 14 | 15 | Queues values that are written just before the next navigation event. Multiple calls merge their state. 16 | 17 | ```ts 18 | await login.sessionStorage.setOnNextNavigation({ token: "abc" }); 19 | await login.page.goto(login.fullUrl); // queued values are applied before navigation completes 20 | ``` 21 | 22 | ## get(keys?) 23 | 24 | Retrieves data from session storage. When `keys` are provided only those values are returned; otherwise all stored keys are returned. 25 | 26 | ```ts 27 | const { theme } = await page.sessionStorage.get(["theme"]); 28 | ``` 29 | 30 | ## clear() 31 | 32 | Removes everything from session storage: 33 | 34 | ```ts 35 | await page.sessionStorage.clear(); 36 | ``` 37 | 38 | These helpers are especially useful when tests need to prime application state without going through the UI. 39 | -------------------------------------------------------------------------------- /docs/tips-folder-structure.md: -------------------------------------------------------------------------------- 1 | # Folder Structure 2 | 3 | How you structure your project is ultimately up to you and what makes sense often differ between projects, but I've found the following structure works quite well in most cases. 4 | 5 | ```text 6 | ./playwright 7 | /fixtures 8 | /app1 9 | app1.fixtures.ts 10 | /app2 11 | /automatic 12 | /custom-expects 13 | all.fixtures.ts 14 | /pom 15 | /app1 16 | /common 17 | /basePage 18 | app1.basePage.ts 19 | /helpers 20 | /pages 21 | homepage.locatorSchema.ts 22 | homepage.page.ts 23 | /profile 24 | profile.locatorSchema.ts 25 | profile.page.ts 26 | /app2 27 | /tests 28 | /app1 29 | /byFeature 30 | login.spec.ts 31 | /byResourcePath 32 | homepage.spec.ts 33 | /profile 34 | profile.spec.ts 35 | /e2e 36 | /app2 37 | /e2e 38 | ``` 39 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BasePage, 3 | type BasePageOptions, 4 | type ExtractBaseUrlType, 5 | type ExtractFullUrlType, 6 | type ExtractUrlPathType, 7 | } from "./src/basePage"; 8 | export { BasePage, type BasePageOptions, type ExtractBaseUrlType, type ExtractFullUrlType, type ExtractUrlPathType }; 9 | 10 | import { test } from "./src/fixture/base.fixtures"; 11 | export { test }; 12 | 13 | import { PlaywrightReportLogger } from "./src/helpers/playwrightReportLogger"; 14 | export { PlaywrightReportLogger }; 15 | 16 | import { type AriaRoleType, GetByMethod, type LocatorSchema } from "./src/helpers/locatorSchema.interface"; 17 | export { GetByMethod, type LocatorSchema, type AriaRoleType }; 18 | 19 | import { GetLocatorBase, type LocatorSchemaWithoutPath } from "./src/helpers/getLocatorBase"; 20 | export { GetLocatorBase, type LocatorSchemaWithoutPath }; 21 | 22 | import { BaseApi } from "./src/api/baseApi"; 23 | export { BaseApi }; 24 | -------------------------------------------------------------------------------- /intTest/.env: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DyHex/POMWright/ea50553daa07bd62462636047578f359f3eb76c6/intTest/.env -------------------------------------------------------------------------------- /intTest/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | /test-results/ 3 | /playwright-report/ 4 | /blob-report/ 5 | /playwright/.cache/ 6 | !.env -------------------------------------------------------------------------------- /intTest/fixtures/withOptions.ts: -------------------------------------------------------------------------------- 1 | import TestPage from "@page-object-models/testApp/with-options/pages/testPage.page"; 2 | import Color from "@page-object-models/testApp/with-options/pages/testPath/[color]/color.page"; 3 | import TestPath from "@page-object-models/testApp/with-options/pages/testPath/testPath.page"; 4 | import TestFilters from "@page-object-models/testApp/with-options/pages/testfilters/testfilters.page"; 5 | import { expect } from "@playwright/test"; 6 | import { test as base } from "pomwright"; 7 | 8 | type fixtures = { 9 | testPage: TestPage; 10 | testPath: TestPath; 11 | color: Color; 12 | testFilters: TestFilters; 13 | }; 14 | 15 | const test = base.extend({ 16 | testPage: async ({ page, log }, use, testInfo) => { 17 | const testPage = new TestPage(page, testInfo, log); 18 | await use(testPage); 19 | }, 20 | 21 | testPath: async ({ page, log }, use, testInfo) => { 22 | const testPath = new TestPath(page, testInfo, log); 23 | await use(testPath); 24 | }, 25 | 26 | color: async ({ page, log }, use, testInfo) => { 27 | const color = new Color(page, testInfo, log); 28 | await use(color); 29 | }, 30 | 31 | testFilters: async ({ page, log }, use, testInfo) => { 32 | const testFilters = new TestFilters(page, testInfo, log); 33 | await use(testFilters); 34 | }, 35 | }); 36 | 37 | export { expect, test }; 38 | -------------------------------------------------------------------------------- /intTest/fixtures/withoutOptions.ts: -------------------------------------------------------------------------------- 1 | import TestPage from "@page-object-models/testApp/without-options/pages/testPage.page"; 2 | import { expect } from "@playwright/test"; 3 | import { test as base } from "pomwright"; 4 | 5 | type fixtures = { 6 | testPage: TestPage; 7 | }; 8 | 9 | const test = base.extend({ 10 | testPage: async ({ page, log }, use, testInfo) => { 11 | const testPage = new TestPage(page, testInfo, log); 12 | await use(testPage); 13 | }, 14 | }); 15 | 16 | export { expect, test }; 17 | -------------------------------------------------------------------------------- /intTest/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "startOld": "http-server ./test-data/staticPage/ -p 8080", 8 | "start": "node ./server.js", 9 | "postinstall": "npx -y only-allow pnpm && npx playwright install-deps" 10 | }, 11 | "keywords": [], 12 | "devDependencies": { 13 | "@playwright/test": "1.55.0", 14 | "@types/express": "^5.0.3", 15 | "@types/node": "^24.5.2", 16 | "express": "^5.1.0", 17 | "pomwright": "^1.2.0" 18 | }, 19 | "dependencies": { 20 | "dotenv": "^17.2.2" 21 | }, 22 | "packageManager": "pnpm@9.12.0" 23 | } 24 | -------------------------------------------------------------------------------- /intTest/page-object-models/testApp/with-options/base/baseWithOptions.page.ts: -------------------------------------------------------------------------------- 1 | import type { Page, TestInfo } from "@playwright/test"; 2 | import { BasePage, type BasePageOptions, type ExtractUrlPathType, type PlaywrightReportLogger } from "pomwright"; 3 | 4 | // BaseWithOptions extends BasePage and enforces baseUrlType as string 5 | export default abstract class BaseWithOptions< 6 | LocatorSchemaPathType extends string, 7 | Options extends BasePageOptions = { urlOptions: { baseUrlType: string; urlPathType: string } }, 8 | > extends BasePage< 9 | LocatorSchemaPathType, 10 | { urlOptions: { baseUrlType: string; urlPathType: ExtractUrlPathType } } 11 | > { 12 | constructor( 13 | page: Page, 14 | testInfo: TestInfo, 15 | urlPath: ExtractUrlPathType<{ urlOptions: { urlPathType: ExtractUrlPathType } }>, // Ensure the correct type for urlPath 16 | pocName: string, 17 | pwrl: PlaywrightReportLogger, 18 | ) { 19 | // Pass baseUrl as a string and let urlPath be flexible 20 | super(page, testInfo, "http://localhost:8080", urlPath, pocName, pwrl); 21 | 22 | // Initialize additional properties if needed 23 | } 24 | 25 | // Add any helper methods here, if needed 26 | } 27 | -------------------------------------------------------------------------------- /intTest/page-object-models/testApp/with-options/pages/testPage.page.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type LocatorSchemaPath, 3 | initLocatorSchemas, 4 | } from "@page-object-models/testApp/without-options/pages/testPage.locatorSchema"; // same page, same locator schema 5 | import type { Page, TestInfo } from "@playwright/test"; 6 | import type { PlaywrightReportLogger } from "pomwright"; 7 | import BaseWithOptions from "../base/baseWithOptions.page"; 8 | 9 | // Note, if BasePageOptions aren't specified, default options are used 10 | export default class TestPage extends BaseWithOptions { 11 | constructor(page: Page, testInfo: TestInfo, pwrl: PlaywrightReportLogger) { 12 | super(page, testInfo, "/", TestPage.name, pwrl); 13 | } 14 | 15 | protected initLocatorSchemas() { 16 | initLocatorSchemas(this.locators); 17 | } 18 | 19 | // add your helper methods here... 20 | } 21 | -------------------------------------------------------------------------------- /intTest/page-object-models/testApp/with-options/pages/testPath/[color]/color.locatorSchema.ts: -------------------------------------------------------------------------------- 1 | import { GetByMethod, type GetLocatorBase } from "pomwright"; 2 | 3 | /** 4 | * The following locatorSchema implementation is total overkill for the Color POC, but serves as one possible 5 | * solution of how you can define a lot of similar locators in a locatorSchema file. 6 | * 7 | * In a real-world scenario, if a page has a lot of similar elements, but with different contexts or variations, 8 | * generating locators this way can save us a lot of time, reduce the chance of mistakes, and make the code easier to 9 | * maintain. Though renaming generated locatorSchemaPaths such as these across all files referencing them might require 10 | * some manual work, depending on how you implement it. 11 | * 12 | * In this example, we define locators for a table with two variations: one generic table, and one context specific 13 | * table. Thus we can use "body.table", "body.table.row.cell" etc. for resolving any table or cell on the page, 14 | * or any page for that matter, and "body.table@hexCode", "body.table@hexCode.row.cell" etc. for resolving the specific 15 | * table/cells that displays hex code information, saving us from having to define/update the generic table locators in 16 | * our test code. 17 | * 18 | * Alternatively, we could just define the generic table locators and create helper methods in the page object class to 19 | * update a generic table locator to a specific table locator, but depending on the POC you could end up with a lot of 20 | * such methods. 21 | * 22 | * Note: This is a contrived example and may not be the ideal solution for every situation. Use your best judgement. 23 | */ 24 | 25 | // Define constant table locator paths for reusability 26 | const tableVariants = ["body.table", "body.table@hexCode"] as const; 27 | 28 | type TableVariants = (typeof tableVariants)[number]; 29 | type TableChildren = "row" | "row.rowheader" | "row.cell"; 30 | 31 | // Define allowed roles based on ARIA roles 32 | type AllowedRoles = "table" | "row" | "rowheader" | "cell"; 33 | 34 | export type LocatorSchemaPath = "body" | "body.heading" | TableVariants | `${TableVariants}.${TableChildren}`; 35 | 36 | export function initLocatorSchemas(locators: GetLocatorBase) { 37 | // Add body and heading locators 38 | locators.addSchema("body", { 39 | locator: "body", 40 | locatorMethod: GetByMethod.locator, 41 | }); 42 | 43 | locators.addSchema("body.heading", { 44 | role: "heading", 45 | roleOptions: { 46 | name: "Your Random Color is:", 47 | }, 48 | locatorMethod: GetByMethod.role, 49 | }); 50 | 51 | // Add table locators using a map to streamline variations 52 | const tableLocatorMap = new Map([ 53 | ["body.table", { role: "table" }], 54 | ["body.table@hexCode", { role: "table", roleOptions: { name: "Hex Code Information" } }], 55 | ]); 56 | 57 | for (const [locatorPath, schema] of tableLocatorMap) { 58 | locators.addSchema(locatorPath, { 59 | role: schema.role, 60 | locatorMethod: GetByMethod.role, 61 | ...{ roleOptions: schema.roleOptions }, 62 | }); 63 | } 64 | 65 | // Add row and cell locators for each table variant using for...of 66 | for (const tableVariant of tableVariants) { 67 | locators.addSchema(`${tableVariant}.row`, { 68 | role: "row", 69 | locatorMethod: GetByMethod.role, 70 | }); 71 | 72 | locators.addSchema(`${tableVariant}.row.rowheader`, { 73 | role: "rowheader", 74 | locatorMethod: GetByMethod.role, 75 | }); 76 | 77 | locators.addSchema(`${tableVariant}.row.cell`, { 78 | role: "cell", 79 | locatorMethod: GetByMethod.role, 80 | }); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /intTest/page-object-models/testApp/with-options/pages/testPath/[color]/color.page.ts: -------------------------------------------------------------------------------- 1 | import { test } from "@fixtures/withOptions"; 2 | import BaseWithOptions from "@page-object-models/testApp/with-options/base/baseWithOptions.page"; 3 | import { type Page, type TestInfo, expect } from "@playwright/test"; 4 | import type { PlaywrightReportLogger } from "pomwright"; 5 | import { type LocatorSchemaPath, initLocatorSchemas } from "./color.locatorSchema"; 6 | 7 | // By providing the urlOptions, the urlPath property now has RegExp type instead of string type (default) for this POC 8 | export default class Color extends BaseWithOptions { 9 | constructor(page: Page, testInfo: TestInfo, pwrl: PlaywrightReportLogger) { 10 | /** 11 | * Matches "/testpath/randomcolor/" followed by a valid 3 or 6-character hex color code. 12 | */ 13 | const urlPathRegex = /\/testpath\/([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/; 14 | 15 | super(page, testInfo, urlPathRegex, Color.name, pwrl); 16 | } 17 | 18 | protected initLocatorSchemas() { 19 | initLocatorSchemas(this.locators); 20 | } 21 | 22 | async expectThisPage() { 23 | await test.step("Expect color page", async () => { 24 | await this.page.waitForURL(this.fullUrl); 25 | 26 | const heading = await this.getNestedLocator("body.heading"); 27 | await heading.waitFor({ state: "visible" }); 28 | }); 29 | } 30 | 31 | async validateColorPage() { 32 | await test.step("Verify hex code", async () => { 33 | const currentUrl = this.page.url(); 34 | const hexCode = currentUrl.split("/").pop(); 35 | 36 | await test.step("Verify hex code in table equals hex code in urlPath", async () => { 37 | const hexCodeTableCell = await this.getNestedLocator("body.table@hexCode.row.cell"); 38 | await expect(hexCodeTableCell).toContainText(`#${hexCode}`, { useInnerText: true, ignoreCase: true }); 39 | }); 40 | 41 | await test.step("Verify hex code in urlPath is the page background color", async () => { 42 | const body = await this.getNestedLocator("body"); 43 | // await expect(body).toHaveAttribute("style", `background-color: #${hexCode};`); 44 | // or 45 | const bgColor = await body.getAttribute("style"); 46 | expect(bgColor).toContain(`background-color: #${hexCode}`); 47 | }); 48 | }); 49 | } 50 | 51 | // add your helper methods here... 52 | } 53 | -------------------------------------------------------------------------------- /intTest/page-object-models/testApp/with-options/pages/testPath/testPath.locatorSchema.ts: -------------------------------------------------------------------------------- 1 | import { GetByMethod, type GetLocatorBase } from "pomwright"; 2 | 3 | export type LocatorSchemaPath = "body" | "body.link@color"; 4 | 5 | export function initLocatorSchemas(locators: GetLocatorBase) { 6 | locators.addSchema("body", { 7 | locator: "body", 8 | locatorMethod: GetByMethod.locator, 9 | }); 10 | 11 | locators.addSchema("body.link@color", { 12 | role: "link", 13 | roleOptions: { 14 | name: "Random Color Link", 15 | }, 16 | locatorMethod: GetByMethod.role, 17 | }); 18 | } 19 | -------------------------------------------------------------------------------- /intTest/page-object-models/testApp/with-options/pages/testPath/testPath.page.ts: -------------------------------------------------------------------------------- 1 | import { test } from "@fixtures/withOptions"; 2 | import BaseWithOptions from "@page-object-models/testApp/with-options/base/baseWithOptions.page"; 3 | import { type Page, type TestInfo, expect } from "@playwright/test"; 4 | import type { PlaywrightReportLogger } from "pomwright"; 5 | import { type LocatorSchemaPath, initLocatorSchemas } from "./testPath.locatorSchema"; 6 | 7 | // Note, if BasePageOptions aren't specified, default options are used 8 | export default class TestPath extends BaseWithOptions { 9 | constructor(page: Page, testInfo: TestInfo, pwrl: PlaywrightReportLogger) { 10 | super(page, testInfo, "/testpath", TestPath.name, pwrl); 11 | } 12 | 13 | protected initLocatorSchemas() { 14 | initLocatorSchemas(this.locators); 15 | } 16 | 17 | async expectThisPage() { 18 | await test.step(`Expect Page: ${this.urlPath}`, async () => { 19 | await this.page.waitForURL(this.fullUrl); 20 | }); 21 | } 22 | 23 | // add your helper methods here... 24 | } 25 | -------------------------------------------------------------------------------- /intTest/page-object-models/testApp/with-options/pages/testfilters/testfilters.locatorSchema.ts: -------------------------------------------------------------------------------- 1 | import type { Locator } from "@playwright/test"; 2 | import { GetByMethod, type GetLocatorBase, type LocatorSchemaWithoutPath } from "pomwright"; 3 | 4 | export type LocatorSchemaPath = 5 | | "body" 6 | | "body.section" 7 | | "body.section.heading" 8 | | "body.section.button" 9 | | "body.section@playground" 10 | | "body.section@playground.heading" 11 | | "body.section@playground.button" 12 | | "body.section@playground.button@red" 13 | | "body.section@playground.button@reset" 14 | /** 15 | * Fictional LocatorSchema do not exist in the DOM of the /testfilters page: 16 | */ 17 | | "fictional.filter@undefined" 18 | | "fictional.filter@optionsUndefined" 19 | | "fictional.locatorWithfilter@allOptions" 20 | | "fictional.locatorAndOptionsWithfilter@allOptions" 21 | | "fictional.filter@has" 22 | | "fictional.filter@hasNot" 23 | | "fictional.filter@hasText" 24 | | "fictional.filter@hasNotText" 25 | | "fictional.filter@hasNotText.filter@hasText" 26 | | "fictional.filter@hasNotText.filter@hasText.filter@hasNotText" 27 | | "fictional.filter@hasNotText.filter@hasText.filter@hasNotText.filter@hasText"; 28 | 29 | export function initLocatorSchemas(locators: GetLocatorBase) { 30 | locators.addSchema("body", { 31 | locator: "body", 32 | locatorMethod: GetByMethod.locator, 33 | }); 34 | 35 | const bodySectionSchema: LocatorSchemaWithoutPath = { locator: "section", locatorMethod: GetByMethod.locator }; 36 | locators.addSchema("body.section", bodySectionSchema); 37 | 38 | const bodySectionHeadingSchema: LocatorSchemaWithoutPath = { 39 | role: "heading", 40 | roleOptions: { 41 | level: 2, 42 | }, 43 | locatorMethod: GetByMethod.role, 44 | }; 45 | locators.addSchema("body.section.heading", bodySectionHeadingSchema); 46 | 47 | const bodySectionButtonSchema: LocatorSchemaWithoutPath = { role: "button", locatorMethod: GetByMethod.role }; 48 | locators.addSchema("body.section.button", bodySectionButtonSchema); 49 | 50 | locators.addSchema("body.section@playground", { 51 | ...bodySectionSchema, 52 | filter: { hasText: /Playground/i }, 53 | }); 54 | 55 | locators.addSchema("body.section@playground.heading", { 56 | ...bodySectionHeadingSchema, 57 | roleOptions: { 58 | name: "Primary Colors Playground", 59 | level: 2, 60 | exact: true, 61 | }, 62 | }); 63 | 64 | locators.addSchema("body.section@playground.button", { ...bodySectionButtonSchema }); 65 | 66 | locators.addSchema("body.section@playground.button@red", { 67 | ...bodySectionButtonSchema, 68 | roleOptions: { 69 | name: "Red", 70 | }, 71 | }); 72 | 73 | locators.addSchema("body.section@playground.button@reset", { 74 | ...bodySectionButtonSchema, 75 | roleOptions: { 76 | name: "Reset Color", 77 | }, 78 | }); 79 | 80 | /** --------------------------- Fictional LocatorSchema --------------------------- 81 | * The following LocatorSchema DO NOT exist in the DOM of the /testfilters page 82 | * 83 | * They are used for testing that the getNestedLocator/getLocator methods correctly 84 | * produces correct locator selector strings for LocatorSchema with/without filter. 85 | */ 86 | 87 | const allLocatorTypes: LocatorSchemaWithoutPath = { 88 | role: "button", 89 | text: "text", 90 | label: "label", 91 | placeholder: "placeholder", 92 | altText: "altText", 93 | title: "title", 94 | locator: "locator", 95 | frameLocator: 'iframe[title="name"]', 96 | dataCy: "dataCy", 97 | testId: "testId", 98 | id: "id", 99 | locatorMethod: GetByMethod.role, 100 | }; 101 | 102 | const allLocatorTypesWithOptions: LocatorSchemaWithoutPath = { 103 | ...allLocatorTypes, 104 | roleOptions: { 105 | name: "roleOptions", 106 | }, 107 | textOptions: { 108 | exact: true, 109 | }, 110 | labelOptions: { 111 | exact: true, 112 | }, 113 | placeholderOptions: { 114 | exact: true, 115 | }, 116 | altTextOptions: { 117 | exact: true, 118 | }, 119 | titleOptions: { 120 | exact: true, 121 | }, 122 | locatorOptions: { 123 | hasText: "locatorOptionsHasText", 124 | hasNotText: "locatorOptionshasNotText", 125 | }, 126 | }; 127 | 128 | locators.addSchema("fictional.filter@undefined", { 129 | ...allLocatorTypes, 130 | }); 131 | 132 | locators.addSchema("fictional.filter@optionsUndefined", { 133 | ...allLocatorTypes, 134 | filter: { 135 | has: undefined, 136 | hasNot: undefined, 137 | hasText: undefined, 138 | hasNotText: undefined, 139 | }, 140 | }); 141 | 142 | locators.addSchema("fictional.locatorWithfilter@allOptions", { 143 | ...allLocatorTypes, 144 | filter: { 145 | has: "has" as unknown as Locator, 146 | hasNot: "hasNot" as unknown as Locator, 147 | hasText: "hasText", 148 | hasNotText: "hasNotText", 149 | }, 150 | }); 151 | 152 | locators.addSchema("fictional.locatorAndOptionsWithfilter@allOptions", { 153 | ...allLocatorTypesWithOptions, 154 | filter: { 155 | has: "has" as unknown as Locator, 156 | hasNot: "hasNot" as unknown as Locator, 157 | hasText: "hasText", 158 | hasNotText: "hasNotText", 159 | }, 160 | }); 161 | 162 | locators.addSchema("fictional.filter@has", { 163 | ...allLocatorTypes, 164 | filter: { 165 | has: "has" as unknown as Locator, 166 | }, 167 | }); 168 | 169 | locators.addSchema("fictional.filter@hasNot", { 170 | ...allLocatorTypes, 171 | filter: { 172 | hasNot: "hasNot" as unknown as Locator, 173 | }, 174 | }); 175 | 176 | locators.addSchema("fictional.filter@hasText", { 177 | ...allLocatorTypes, 178 | filter: { 179 | hasText: "hasText", 180 | }, 181 | }); 182 | 183 | locators.addSchema("fictional.filter@hasNotText", { 184 | ...allLocatorTypesWithOptions, 185 | filter: { 186 | hasNotText: "hasNotText", 187 | }, 188 | }); 189 | 190 | locators.addSchema("fictional.filter@hasNotText.filter@hasText", { 191 | ...allLocatorTypesWithOptions, 192 | filter: { 193 | hasText: "hasText", 194 | }, 195 | }); 196 | 197 | locators.addSchema("fictional.filter@hasNotText.filter@hasText.filter@hasNotText", { 198 | ...allLocatorTypesWithOptions, 199 | filter: { 200 | hasNotText: "hasNotText", 201 | }, 202 | }); 203 | 204 | locators.addSchema("fictional.filter@hasNotText.filter@hasText.filter@hasNotText.filter@hasText", { 205 | ...allLocatorTypesWithOptions, 206 | filter: { 207 | hasText: "hasText", 208 | }, 209 | }); 210 | } 211 | -------------------------------------------------------------------------------- /intTest/page-object-models/testApp/with-options/pages/testfilters/testfilters.page.ts: -------------------------------------------------------------------------------- 1 | import { test } from "@fixtures/withOptions"; 2 | import BaseWithOptions from "@page-object-models/testApp/with-options/base/baseWithOptions.page"; 3 | import { type Page, type TestInfo, expect } from "@playwright/test"; 4 | import type { PlaywrightReportLogger } from "pomwright"; 5 | import { type LocatorSchemaPath, initLocatorSchemas } from "./testfilters.locatorSchema"; 6 | 7 | // Note, if BasePageOptions aren't specified, default options are used 8 | export default class TestFilters extends BaseWithOptions { 9 | constructor(page: Page, testInfo: TestInfo, pwrl: PlaywrightReportLogger) { 10 | super(page, testInfo, "/testfilters", TestFilters.name, pwrl); 11 | } 12 | 13 | protected initLocatorSchemas() { 14 | initLocatorSchemas(this.locators); 15 | } 16 | 17 | async expectThisPage() { 18 | await test.step(`Expect Page: ${this.urlPath}`, async () => { 19 | await this.page.waitForURL(this.fullUrl); 20 | }); 21 | } 22 | 23 | // add your helper methods here... 24 | } 25 | -------------------------------------------------------------------------------- /intTest/page-object-models/testApp/without-options/base/base.page.ts: -------------------------------------------------------------------------------- 1 | import type { Page, TestInfo } from "@playwright/test"; 2 | import { BasePage, type PlaywrightReportLogger } from "pomwright"; 3 | // import helper methods / classes etc, here... (To be used in the Base POC) 4 | 5 | export default abstract class Base extends BasePage { 6 | // add properties here (available to all POCs extending this abstract Base POC) 7 | 8 | constructor(page: Page, testInfo: TestInfo, urlPath: string, pocName: string, pwrl: PlaywrightReportLogger) { 9 | super(page, testInfo, "http://localhost:8080", urlPath, pocName, pwrl); 10 | 11 | // initialize properties here (available to all POCs extending this abstract Base POC) 12 | } 13 | 14 | // add helper methods here (available to all POCs extending this abstract Base POC) 15 | } 16 | -------------------------------------------------------------------------------- /intTest/page-object-models/testApp/without-options/pages/testPage.locatorSchema.ts: -------------------------------------------------------------------------------- 1 | import { GetByMethod, type GetLocatorBase } from "pomwright"; 2 | 3 | export type LocatorSchemaPath = 4 | | "topMenu" 5 | | "topMenu.logo" 6 | | "topMenu.news" 7 | | "topMenu.accountSettings" 8 | | "topMenu.messages" 9 | | "topMenu.notifications" 10 | | "topMenu.notifications.button" 11 | | "topMenu.notifications.button.countBadge" 12 | | "topMenu.notifications.dropdown" 13 | | "topMenu.notifications.dropdown.item" 14 | | "topMenu.myAccount"; 15 | 16 | export function initLocatorSchemas(locators: GetLocatorBase) { 17 | locators.addSchema("topMenu", { 18 | locator: ".w3-top", 19 | locatorMethod: GetByMethod.locator, 20 | }); 21 | 22 | locators.addSchema("topMenu.logo", { 23 | role: "link", 24 | roleOptions: { 25 | name: /Logo/, 26 | }, 27 | locatorMethod: GetByMethod.role, 28 | }); 29 | 30 | locators.addSchema("topMenu.news", { 31 | title: "News", 32 | locatorMethod: GetByMethod.title, 33 | }); 34 | 35 | locators.addSchema("topMenu.accountSettings", { 36 | title: "Account Settings", 37 | locatorMethod: GetByMethod.title, 38 | }); 39 | 40 | locators.addSchema("topMenu.messages", { 41 | title: "Messages", 42 | locatorMethod: GetByMethod.title, 43 | }); 44 | 45 | locators.addSchema("topMenu.notifications", { 46 | locator: ".w3-dropdown-hover", 47 | locatorMethod: GetByMethod.locator, 48 | }); 49 | 50 | locators.addSchema("topMenu.notifications.button", { 51 | role: "button", 52 | locatorMethod: GetByMethod.role, 53 | }); 54 | 55 | locators.addSchema("topMenu.notifications.button.countBadge", { 56 | locator: ".w3-badge", 57 | locatorMethod: GetByMethod.locator, 58 | }); 59 | 60 | locators.addSchema("topMenu.notifications.dropdown", { 61 | locator: ".w3-dropdown-content", 62 | locatorMethod: GetByMethod.locator, 63 | }); 64 | 65 | locators.addSchema("topMenu.notifications.dropdown.item", { 66 | locator: ".w3-bar-item", 67 | locatorMethod: GetByMethod.locator, 68 | }); 69 | 70 | locators.addSchema("topMenu.myAccount", { 71 | title: "My Account", 72 | locatorMethod: GetByMethod.title, 73 | }); 74 | } 75 | -------------------------------------------------------------------------------- /intTest/page-object-models/testApp/without-options/pages/testPage.page.ts: -------------------------------------------------------------------------------- 1 | import type { Page, TestInfo } from "@playwright/test"; 2 | import type { PlaywrightReportLogger } from "pomwright"; 3 | import Base from "../base/base.page"; 4 | import { type LocatorSchemaPath, initLocatorSchemas } from "./testPage.locatorSchema"; 5 | 6 | export default class TestPage extends Base { 7 | constructor(page: Page, testInfo: TestInfo, pwrl: PlaywrightReportLogger) { 8 | super(page, testInfo, "/", TestPage.name, pwrl); 9 | } 10 | 11 | protected initLocatorSchemas() { 12 | initLocatorSchemas(this.locators); 13 | } 14 | 15 | // add your helper methods here... 16 | } 17 | -------------------------------------------------------------------------------- /intTest/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from "@playwright/test"; 2 | import dotenv from "dotenv"; 3 | 4 | // Environment variables ./.env 5 | dotenv.config({ override: false, quiet: true }); 6 | 7 | /** 8 | * See https://playwright.dev/docs/test-configuration. 9 | */ 10 | export default defineConfig({ 11 | testDir: "./tests", 12 | globalTimeout: 60_000 * 5, 13 | timeout: 60_000 * 1, 14 | expect: { 15 | timeout: 5_000, 16 | }, 17 | fullyParallel: true, 18 | forbidOnly: !!process.env.CI, 19 | retries: process.env.CI ? 1 : 1, 20 | workers: process.env.CI ? "50%" : 4, 21 | reporter: process.env.CI 22 | ? [ 23 | ["html", { open: "never" }], 24 | ["github", { printSteps: false }], 25 | ] 26 | : [ 27 | ["html", { open: "on-failure" }], 28 | ["list", { printSteps: false }], 29 | ], 30 | use: { 31 | actionTimeout: 5_000, 32 | navigationTimeout: 10_000, 33 | headless: true, 34 | viewport: { width: 1280, height: 720 }, 35 | ignoreHTTPSErrors: false, 36 | video: "retry-with-video", // system taxing 37 | screenshot: { mode: "only-on-failure", fullPage: true, omitBackground: false }, 38 | trace: "on-all-retries", // system taxing 39 | testIdAttribute: "data-testid", // Playwright default 40 | }, 41 | 42 | /* Configure projects for major browsers */ 43 | projects: [ 44 | { 45 | name: "chromium", 46 | use: { ...devices["Desktop Chrome"] }, 47 | }, 48 | 49 | { 50 | name: "firefox", 51 | use: { ...devices["Desktop Firefox"] }, 52 | }, 53 | 54 | { 55 | name: "webkit", 56 | use: { ...devices["Desktop Safari"] }, 57 | }, 58 | ], 59 | 60 | webServer: process.env.CI 61 | ? [ 62 | { 63 | command: "pnpm start", 64 | url: "http://localhost:8080/", 65 | timeout: 5 * 60 * 1000, 66 | reuseExistingServer: false, 67 | ignoreHTTPSErrors: false, 68 | // stdout: "pipe", 69 | // stderr: "pipe", 70 | }, 71 | ] 72 | : [ 73 | { 74 | command: "pnpm start", 75 | url: "http://localhost:8080/", 76 | timeout: 5 * 60 * 1000, 77 | reuseExistingServer: true, 78 | ignoreHTTPSErrors: false, 79 | // stdout: "pipe", 80 | // stderr: "pipe", 81 | }, 82 | ], 83 | }); 84 | -------------------------------------------------------------------------------- /intTest/server.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const path = require("node:path"); 3 | 4 | const app = express(); 5 | const port = 8080; 6 | 7 | // Serve static files from the "test-data/staticPage" directory 8 | app.use(express.static(path.join(__dirname, "test-data/staticPage"))); 9 | 10 | // Route to handle "/testpath/:color" 11 | app.get("/testpath/:color", (req, res) => { 12 | const color = req.params.color; 13 | 14 | // Ensure the color is a valid 3 or 6-character hex code 15 | if (!/^([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(color)) { 16 | res.status(400).send("Invalid color code."); 17 | return; 18 | } 19 | 20 | // Return a simple HTML page with the background set to the color 21 | res.send(` 22 | 23 | 24 |

Your Random Color is:

25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 |
Hex Code:
#${color}
33 | 34 | 35 | `); 36 | }); 37 | 38 | // Route to handle "/testpath" with a link that generates a new random color on click 39 | app.get("/testpath", (req, res) => { 40 | res.send(` 41 | 42 | 43 | Go to Random Color Page 44 | 45 | 62 | 63 | 64 | `); 65 | }); 66 | 67 | // Route to handle "/testfilters" 68 | app.get("/testfilters", (req, res) => { 69 | res.send(` 70 | 71 | 72 |
73 |

Primary Colors Section

74 | 75 | 76 | 77 | 78 |
79 | 80 |
81 |

Primary Colors Explorer

82 | 83 | 84 | 85 | 86 |
87 | 88 |
89 |

Primary Colors Test

90 | 91 | 92 | 93 | 94 |
95 | 96 |
97 |

Primary Colors Playground

98 | 99 | 100 | 101 | 102 |
103 | 104 | 113 | 114 | 115 | `); 116 | }); 117 | 118 | // Start the server 119 | app.listen(port, () => { 120 | console.log(`Server running at http://localhost:${port}/`); 121 | }); 122 | -------------------------------------------------------------------------------- /intTest/test-data/staticPage/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | W3.CSS Template 5 | 6 | 7 | 8 | 9 | 10 | 11 | 14 | 15 | 16 | 17 | 18 |
19 |
20 | 21 | Logo 22 | 23 | 24 | 25 | 33 | 34 | Avatar 35 | 36 |
37 |
38 | 39 | 40 | 46 | 47 | 48 |
49 | 50 |
51 | 52 |
53 | 54 |
55 |
56 |

My Profile

57 |

Avatar

58 |
59 |

Designer, UI

60 |

London, UK

61 |

April 1, 1988

62 |
63 |
64 |
65 | 66 | 67 |
68 |
69 | 70 |
71 |

Some text..

72 |
73 | 74 |
75 |

Some other text..

76 |
77 | 78 |
79 |
80 |
81 |
82 | 83 |
84 |
85 | 86 |
87 |
88 | 89 |
90 |
91 | 92 |
93 |
94 | 95 |
96 |
97 | 98 |
99 |
100 |
101 |
102 |
103 |
104 | 105 | 106 |
107 |
108 |

Interests

109 |

110 | News 111 | W3Schools 112 | Labels 113 | Games 114 | Friends 115 | Games 116 | Friends 117 | Food 118 | Design 119 | Art 120 | Photos 121 |

122 |
123 |
124 |
125 | 126 | 127 |
128 | 129 | 130 | 131 |

Hey!

132 |

People are looking at your profile. Find out who.

133 |
134 | 135 | 136 |
137 | 138 | 139 |
140 | 141 |
142 |
143 |
144 |
145 |
Social Media template by w3.css
146 |

Status: Feeling Blue

147 | 148 |
149 |
150 |
151 |
152 | 153 |

154 | Avatar 155 | 1 min 156 |

John Doe


157 |
158 |

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

159 |
160 |
161 | Northern Lights 162 |
163 |
164 | Nature 165 |
166 |
167 | 168 | 169 |
170 | 171 |

172 | Avatar 173 | 16 min 174 |

Jane Doe


175 |
176 |

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

177 | 178 | 179 |
180 | 181 |

182 | Avatar 183 | 32 min 184 |

Angie Jane


185 |
186 |

Have you seen this?

187 | 188 |

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

189 | 190 | 191 |
192 | 193 | 194 |
195 | 196 | 197 |
198 |
199 |
200 |

Upcoming Events:

201 | Forest 202 |

Holiday

203 |

Friday 15:00

204 |

205 |
206 |
207 |
208 | 209 |
210 |
211 |

Friend Request

212 | Avatar
213 | Jane Doe 214 |
215 |
216 | 217 |
218 |
219 | 220 |
221 |
222 |
223 |
224 |
225 | 226 |
227 |

ADS

228 |
229 |
230 | 231 |
232 |

233 |
234 | 235 | 236 |
237 | 238 | 239 |
240 | 241 | 242 |
243 |
244 | 245 | 246 |
247 |
Footer
248 |
249 | 250 |
251 |

Powered by w3.css

252 |
253 | 254 | 278 | 279 | 280 | 281 | -------------------------------------------------------------------------------- /intTest/test-data/staticPage/w3images/avatar2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DyHex/POMWright/ea50553daa07bd62462636047578f359f3eb76c6/intTest/test-data/staticPage/w3images/avatar2.png -------------------------------------------------------------------------------- /intTest/test-data/staticPage/w3images/avatar3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DyHex/POMWright/ea50553daa07bd62462636047578f359f3eb76c6/intTest/test-data/staticPage/w3images/avatar3.png -------------------------------------------------------------------------------- /intTest/test-data/staticPage/w3images/avatar5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DyHex/POMWright/ea50553daa07bd62462636047578f359f3eb76c6/intTest/test-data/staticPage/w3images/avatar5.png -------------------------------------------------------------------------------- /intTest/test-data/staticPage/w3images/avatar6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DyHex/POMWright/ea50553daa07bd62462636047578f359f3eb76c6/intTest/test-data/staticPage/w3images/avatar6.png -------------------------------------------------------------------------------- /intTest/test-data/staticPage/w3images/forest.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DyHex/POMWright/ea50553daa07bd62462636047578f359f3eb76c6/intTest/test-data/staticPage/w3images/forest.jpg -------------------------------------------------------------------------------- /intTest/test-data/staticPage/w3images/lights.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DyHex/POMWright/ea50553daa07bd62462636047578f359f3eb76c6/intTest/test-data/staticPage/w3images/lights.jpg -------------------------------------------------------------------------------- /intTest/test-data/staticPage/w3images/mountains.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DyHex/POMWright/ea50553daa07bd62462636047578f359f3eb76c6/intTest/test-data/staticPage/w3images/mountains.jpg -------------------------------------------------------------------------------- /intTest/test-data/staticPage/w3images/nature.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DyHex/POMWright/ea50553daa07bd62462636047578f359f3eb76c6/intTest/test-data/staticPage/w3images/nature.jpg -------------------------------------------------------------------------------- /intTest/test-data/staticPage/w3images/snow.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DyHex/POMWright/ea50553daa07bd62462636047578f359f3eb76c6/intTest/test-data/staticPage/w3images/snow.jpg -------------------------------------------------------------------------------- /intTest/tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | // strict options are enabled in ./tsconfig.json, but we want to disable some for tests 5 | "noImplicitAny": false, 6 | "strictNullChecks": false, 7 | "baseUrl": "..", 8 | "paths": { 9 | "@fixtures/*": ["fixtures/*"], 10 | "@page-object-models/*": ["page-object-models/*"] 11 | } 12 | }, 13 | "include": ["**/*.spec.ts"] 14 | } 15 | -------------------------------------------------------------------------------- /intTest/tests/with-options/locatorSchema/getLocator.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@fixtures/withOptions"; 2 | 3 | test.afterEach(async ({ testPage }) => { 4 | await testPage.page.goto(testPage.fullUrl); 5 | }); 6 | 7 | test("getLocator should return the single locator the complete LocatorSchemaPath resolves to", async ({ testPage }) => { 8 | const locator = await testPage.getLocator("topMenu.notifications.dropdown.item"); 9 | expect(locator).not.toBeNull(); 10 | expect(locator).not.toBeUndefined(); 11 | expect(`${locator}`).toEqual("locator('.w3-bar-item')"); 12 | }); 13 | 14 | test("should be able to manually chain locators returned by getLocator", async ({ testPage }) => { 15 | const topMenu = await testPage.getLocator("topMenu"); 16 | 17 | const topMenuNotifications = topMenu.locator(await testPage.getLocator("topMenu.notifications")); 18 | 19 | const topMenuNotificationsDropdown = topMenuNotifications.locator( 20 | await testPage.getLocator("topMenu.notifications.dropdown"), 21 | ); 22 | 23 | const topMenuNotificationsDropdownItem = topMenuNotificationsDropdown.locator( 24 | await testPage.getLocator("topMenu.notifications.dropdown.item"), 25 | ); 26 | 27 | expect(topMenuNotificationsDropdownItem).not.toBeNull(); 28 | expect(topMenuNotificationsDropdownItem).not.toBeUndefined(); 29 | expect(`${topMenuNotificationsDropdownItem}`).toEqual( 30 | "locator('.w3-top').locator(locator('.w3-dropdown-hover')).locator(locator('.w3-dropdown-content')).locator(locator('.w3-bar-item'))", 31 | ); 32 | }); 33 | -------------------------------------------------------------------------------- /intTest/tests/with-options/locatorSchema/getNestedLocator.filter.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@fixtures/withOptions"; 2 | import { GetByMethod } from "pomwright"; 3 | 4 | test("given the same locatorSchemaPath, getNestedLocator should return the equvalent of manually chaining with getLocator", async ({ 5 | testPage, 6 | }) => { 7 | const topMenu = await testPage.getLocator("topMenu"); 8 | 9 | const topMenuNotifications = topMenu.locator(await testPage.getLocator("topMenu.notifications")); 10 | 11 | const topMenuNotificationsDropdown = topMenuNotifications.locator( 12 | await testPage.getLocator("topMenu.notifications.dropdown"), 13 | ); 14 | 15 | const topMenuNotificationsDropdownItem = topMenuNotificationsDropdown.locator( 16 | await testPage.getLocator("topMenu.notifications.dropdown.item"), 17 | ); 18 | 19 | expect(topMenuNotificationsDropdownItem).not.toBeNull(); 20 | expect(topMenuNotificationsDropdownItem).not.toBeUndefined(); 21 | expect(`${topMenuNotificationsDropdownItem}`).toEqual( 22 | "locator('.w3-top').locator(locator('.w3-dropdown-hover')).locator(locator('.w3-dropdown-content')).locator(locator('.w3-bar-item'))", 23 | ); 24 | 25 | const automaticallyChainedLocator = await testPage.getNestedLocator("topMenu.notifications.dropdown.item"); 26 | expect(automaticallyChainedLocator).not.toBeNull(); 27 | expect(automaticallyChainedLocator).not.toBeUndefined(); 28 | expect(`${automaticallyChainedLocator}`).toEqual(`${topMenuNotificationsDropdownItem}`); 29 | }); 30 | 31 | test.describe("getNestedLocator for locatorSchema with filter property", () => { 32 | type testCase = { 33 | getByMethod: GetByMethod; 34 | expected: string; 35 | }; 36 | 37 | const testCases: testCase[] = [ 38 | { getByMethod: GetByMethod.role, expected: "getByRole('button')" }, 39 | { getByMethod: GetByMethod.text, expected: "getByText('text')" }, 40 | { getByMethod: GetByMethod.label, expected: "getByLabel('label')" }, 41 | { getByMethod: GetByMethod.placeholder, expected: "getByPlaceholder('placeholder')" }, 42 | { getByMethod: GetByMethod.altText, expected: "getByAltText('altText')" }, 43 | { getByMethod: GetByMethod.title, expected: "getByTitle('title')" }, 44 | { getByMethod: GetByMethod.locator, expected: "locator('locator')" }, 45 | { getByMethod: GetByMethod.dataCy, expected: "locator('data-cy=dataCy')" }, 46 | { getByMethod: GetByMethod.testId, expected: "getByTestId('testId')" }, 47 | { getByMethod: GetByMethod.id, expected: "locator('#id')" }, 48 | ]; 49 | 50 | for (const { getByMethod, expected } of testCases) { 51 | test(`GetByMethod.${getByMethod}: should apply filter `, async ({ testFilters }) => { 52 | const undefinedFilter = await testFilters 53 | .getLocatorSchema("fictional.filter@undefined") 54 | .update({ locatorMethod: GetByMethod[getByMethod] }) 55 | .getNestedLocator(); 56 | 57 | expect(`${undefinedFilter}`).toEqual(expected); 58 | }); 59 | } 60 | 61 | test("GetByMethod.frameLocator: should NOT apply filter", async ({ testFilters }) => { 62 | const chainWithFilter = testFilters 63 | .getLocatorSchema("fictional.filter@hasNotText.filter@hasText") 64 | .update({ locatorMethod: GetByMethod.frameLocator }); 65 | 66 | expect.soft(chainWithFilter.filter.hasText).toEqual("hasText"); 67 | 68 | const chainedFrameLocator = await chainWithFilter.getNestedLocator(); 69 | 70 | expect(`${chainedFrameLocator}`).toEqual( 71 | 'internal:role=button[name="roleOptions"i] >> internal:has-not-text="hasNotText"i >> internal:chain=undefined', 72 | ); 73 | }); 74 | 75 | test("multiple nesting/chaining", async ({ testFilters }) => { 76 | const multiChainWithFilter = await testFilters 77 | .getLocatorSchema("fictional.filter@hasNotText.filter@hasText.filter@hasNotText.filter@hasText") 78 | .updates({ 79 | 1: { locatorMethod: GetByMethod.role }, 80 | 2: { locatorMethod: GetByMethod.locator }, 81 | 3: { locatorMethod: GetByMethod.testId }, 82 | 4: { locatorMethod: GetByMethod.label }, 83 | }) 84 | .getNestedLocator(); 85 | 86 | expect(`${multiChainWithFilter}`).toEqual( 87 | "getByRole('button', { name: 'roleOptions' }).filter({ hasNotText: 'hasNotText' }).locator(locator('locator').filter({ hasText: 'locatorOptionsHasText' }).filter({ hasNotText: 'locatorOptionshasNotText' })).filter({ hasText: 'hasText' }).locator(getByTestId('testId')).filter({ hasNotText: 'hasNotText' }).locator(getByLabel('label', { exact: true })).filter({ hasText: 'hasText' })", 88 | ); 89 | }); 90 | 91 | test("filter with has: locator", async ({ testFilters }) => { 92 | const heading = await testFilters.getLocator("body.section.heading"); 93 | 94 | const multiChainWithFilter = await testFilters 95 | .getLocatorSchema("fictional.filter@hasNotText") 96 | .update({ locatorMethod: GetByMethod.role, filter: { has: heading } }) 97 | .getNestedLocator(); 98 | 99 | expect(`${multiChainWithFilter}`).toEqual( 100 | "getByRole('button', { name: 'roleOptions' }).filter({ hasNotText: 'hasNotText' }).filter({ has: getByRole('heading', { level: 2 }) })", 101 | ); 102 | }); 103 | 104 | test("filter with hasText", async ({ testFilters }) => { 105 | await testFilters.page.goto(testFilters.fullUrl); 106 | 107 | const playgroundRed = await testFilters.getNestedLocator("body.section@playground.button@red"); 108 | await playgroundRed.click(); 109 | 110 | const reset0 = await testFilters.getNestedLocator("body.section@playground.button@reset"); 111 | 112 | expect(`${reset0}`).toEqual( 113 | "locator('body').locator(locator('section')).filter({ hasText: /Playground/i }).locator(getByRole('button', { name: 'Reset Color' }))", 114 | ); 115 | 116 | const reset1 = await testFilters 117 | .getLocatorSchema("body.section@playground.button@reset") 118 | .addFilter("body.section@playground", { hasText: /Primary Colors/i }) 119 | .addFilter("body.section@playground.button@reset", { hasText: /Reset/i }) 120 | .addFilter("body.section@playground.button@reset", { hasText: /Color/i }) 121 | .getNestedLocator(); 122 | 123 | expect(`${reset1}`).not.toEqual( 124 | "locator('body').locator(locator('section')).filter({ hasText: /Playground/i }).filter({ hasText: /Primary Colors/i }).first().locator(getByRole('button', { name: 'Reset Color' })).filter({ hasText: /Reset/i }).filter({ hasText: /Color/i }).first()", 125 | ); 126 | 127 | const reset2 = await testFilters.getNestedLocator("body.section@playground.button@reset"); 128 | 129 | expect(`${reset2}`).toEqual( 130 | "locator('body').locator(locator('section')).filter({ hasText: /Playground/i }).locator(getByRole('button', { name: 'Reset Color' }))", 131 | ); 132 | 133 | await reset1.click(); 134 | await playgroundRed.click(); 135 | await reset2.click(); 136 | }); 137 | }); 138 | -------------------------------------------------------------------------------- /intTest/tests/with-options/locatorSchema/getNestedLocator.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@fixtures/withOptions"; 2 | 3 | test("given the same locatorSchemaPath, getNestedLocator should return the equvalent of manually chaining with getLocator", async ({ 4 | testPage, 5 | }) => { 6 | const topMenu = await testPage.getLocator("topMenu"); 7 | 8 | const topMenuNotifications = topMenu.locator(await testPage.getLocator("topMenu.notifications")); 9 | 10 | const topMenuNotificationsDropdown = topMenuNotifications.locator( 11 | await testPage.getLocator("topMenu.notifications.dropdown"), 12 | ); 13 | 14 | const topMenuNotificationsDropdownItem = topMenuNotificationsDropdown.locator( 15 | await testPage.getLocator("topMenu.notifications.dropdown.item"), 16 | ); 17 | 18 | expect(topMenuNotificationsDropdownItem).not.toBeNull(); 19 | expect(topMenuNotificationsDropdownItem).not.toBeUndefined(); 20 | expect(`${topMenuNotificationsDropdownItem}`).toEqual( 21 | "locator('.w3-top').locator(locator('.w3-dropdown-hover')).locator(locator('.w3-dropdown-content')).locator(locator('.w3-bar-item'))", 22 | ); 23 | 24 | const automaticallyChainedLocator = await testPage.getNestedLocator("topMenu.notifications.dropdown.item"); 25 | expect(automaticallyChainedLocator).not.toBeNull(); 26 | expect(automaticallyChainedLocator).not.toBeUndefined(); 27 | expect(`${automaticallyChainedLocator}`).toEqual(`${topMenuNotificationsDropdownItem}`); 28 | }); 29 | -------------------------------------------------------------------------------- /intTest/tests/with-options/locatorSchema/update.spec.ts: -------------------------------------------------------------------------------- 1 | // TODO 2 | -------------------------------------------------------------------------------- /intTest/tests/with-options/locatorSchema/updates.spec.ts: -------------------------------------------------------------------------------- 1 | // TODO 2 | -------------------------------------------------------------------------------- /intTest/tests/with-options/optionalDynamicUrlTypes.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@fixtures/withOptions"; 2 | 3 | test("Should be able to validate navigation to url with urlPath and fullUrl as RegExp instead of string", async ({ 4 | testPath, 5 | color, 6 | }) => { 7 | await testPath.page.goto(testPath.fullUrl); 8 | 9 | await testPath.expectThisPage(); 10 | 11 | const linkToColorPage = await testPath.getNestedLocator("body.link@color"); 12 | await linkToColorPage.waitFor({ state: "visible" }); 13 | 14 | await linkToColorPage.click(); 15 | 16 | await color.expectThisPage(); 17 | 18 | await color.validateColorPage(); 19 | }); 20 | -------------------------------------------------------------------------------- /intTest/tests/with-options/sessionStorage.spec.ts: -------------------------------------------------------------------------------- 1 | // TODO 2 | -------------------------------------------------------------------------------- /intTest/tests/with-options/testFilters.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@fixtures/withOptions"; 2 | 3 | test("locatorSchema filter property should function", async ({ testFilters }) => { 4 | await testFilters.page.goto(testFilters.fullUrl); 5 | 6 | const playgroundSection = await testFilters.getNestedLocator("body.section@playground"); 7 | // await expect(playgroundSection).not.toHaveCSS("background-color", "red"); 8 | 9 | const playgroundBtnRed = await testFilters.getNestedLocator("body.section@playground.button@red"); 10 | await playgroundBtnRed.click(); 11 | await expect(playgroundSection).toHaveCSS("background-color", "rgb(255, 0, 0)"); 12 | }); 13 | -------------------------------------------------------------------------------- /intTest/tests/with-options/testPage.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@fixtures/withOptions"; 2 | 3 | test("topMenu should have a notification badge with count", async ({ testPage }) => { 4 | await testPage.page.goto(testPage.fullUrl); 5 | 6 | const notificationBadge = await testPage.getNestedLocator("topMenu.notifications.button.countBadge"); 7 | await expect(notificationBadge).toHaveText("3"); 8 | }); 9 | -------------------------------------------------------------------------------- /intTest/tests/without-options/locatorSchema/getLocator.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@fixtures/withoutOptions"; 2 | 3 | test.afterEach(async ({ testPage }) => { 4 | await testPage.page.goto(testPage.fullUrl); 5 | }); 6 | 7 | test("getLocator should return the single locator the complete LocatorSchemaPath resolves to", async ({ testPage }) => { 8 | const locator = await testPage.getLocator("topMenu.notifications.dropdown.item"); 9 | expect(locator).not.toBeNull(); 10 | expect(locator).not.toBeUndefined(); 11 | expect(`${locator}`).toEqual("locator('.w3-bar-item')"); 12 | }); 13 | 14 | test("should be able to manually chain locators returned by getLocator", async ({ testPage }) => { 15 | const topMenu = await testPage.getLocator("topMenu"); 16 | 17 | const topMenuNotifications = topMenu.locator(await testPage.getLocator("topMenu.notifications")); 18 | 19 | const topMenuNotificationsDropdown = topMenuNotifications.locator( 20 | await testPage.getLocator("topMenu.notifications.dropdown"), 21 | ); 22 | 23 | const topMenuNotificationsDropdownItem = topMenuNotificationsDropdown.locator( 24 | await testPage.getLocator("topMenu.notifications.dropdown.item"), 25 | ); 26 | 27 | expect(topMenuNotificationsDropdownItem).not.toBeNull(); 28 | expect(topMenuNotificationsDropdownItem).not.toBeUndefined(); 29 | expect(`${topMenuNotificationsDropdownItem}`).toEqual( 30 | "locator('.w3-top').locator(locator('.w3-dropdown-hover')).locator(locator('.w3-dropdown-content')).locator(locator('.w3-bar-item'))", 31 | ); 32 | }); 33 | -------------------------------------------------------------------------------- /intTest/tests/without-options/locatorSchema/getNestedLocator.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@fixtures/withoutOptions"; 2 | 3 | test("given the same locatorSchemaPath, getNestedLocator should return the equvalent of manually chaining with getLocator", async ({ 4 | testPage, 5 | }) => { 6 | const topMenu = await testPage.getLocator("topMenu"); 7 | 8 | const topMenuNotifications = topMenu.locator(await testPage.getLocator("topMenu.notifications")); 9 | 10 | const topMenuNotificationsDropdown = topMenuNotifications.locator( 11 | await testPage.getLocator("topMenu.notifications.dropdown"), 12 | ); 13 | 14 | const topMenuNotificationsDropdownItem = topMenuNotificationsDropdown.locator( 15 | await testPage.getLocator("topMenu.notifications.dropdown.item"), 16 | ); 17 | 18 | expect(topMenuNotificationsDropdownItem).not.toBeNull(); 19 | expect(topMenuNotificationsDropdownItem).not.toBeUndefined(); 20 | expect(`${topMenuNotificationsDropdownItem}`).toEqual( 21 | "locator('.w3-top').locator(locator('.w3-dropdown-hover')).locator(locator('.w3-dropdown-content')).locator(locator('.w3-bar-item'))", 22 | ); 23 | 24 | const automaticallyChainedLocator = await testPage.getNestedLocator("topMenu.notifications.dropdown.item"); 25 | expect(automaticallyChainedLocator).not.toBeNull(); 26 | expect(automaticallyChainedLocator).not.toBeUndefined(); 27 | expect(`${automaticallyChainedLocator}`).toEqual(`${topMenuNotificationsDropdownItem}`); 28 | }); 29 | -------------------------------------------------------------------------------- /intTest/tests/without-options/locatorSchema/update.spec.ts: -------------------------------------------------------------------------------- 1 | // TODO 2 | -------------------------------------------------------------------------------- /intTest/tests/without-options/locatorSchema/updates.spec.ts: -------------------------------------------------------------------------------- 1 | // TODO 2 | -------------------------------------------------------------------------------- /intTest/tests/without-options/sessionStorage.spec.ts: -------------------------------------------------------------------------------- 1 | // TODO 2 | -------------------------------------------------------------------------------- /intTest/tests/without-options/testPage.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@fixtures/withoutOptions"; 2 | 3 | test("topMenu should have a notification badge with count", async ({ testPage }) => { 4 | await testPage.page.goto(testPage.fullUrl); 5 | 6 | const notificationBadge = await testPage.getNestedLocator("topMenu.notifications.button.countBadge"); 7 | await expect(notificationBadge).toHaveText("3"); 8 | }); 9 | -------------------------------------------------------------------------------- /intTest/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "@fixtures/*": ["fixtures/*"], 7 | "@page-object-models/*": ["page-object-models/*"], 8 | "@test-data/*": ["test-data/*"] 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /pack-test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | TEST_DIR="intTest" 4 | 5 | # Function to revert to the latest published version, ensuring it's executed in the ./$TEST_DIR directory 6 | cleanup() { 7 | # Check if the current directory is ./test, if not, try to change to it 8 | if [[ $(basename "$PWD") != "$TEST_DIR" ]]; then 9 | echo "Not in ./$TEST_DIR directory. Trying to change to ./$TEST_DIR directory..." 10 | if [[ -d "$TEST_DIR" ]]; then 11 | cd $TEST_DIR || { echo "Failed to change to ./$TEST_DIR directory"; return 1; } 12 | else 13 | echo "The ./$TEST_DIR directory does not exist. Exiting cleanup." 14 | return 1 15 | fi 16 | fi 17 | 18 | echo "Reverting to latest published version of POMWright in the ./$TEST_DIR directory..." 19 | pnpm i -D pomwright@latest || { echo "Failed to revert to latest POMWright version"; exit 1; } 20 | } 21 | 22 | # Trap statement that calls cleanup function on exit 23 | trap cleanup EXIT 24 | 25 | # Stop the script if any command fails 26 | set -e 27 | 28 | # Extract version from package.json 29 | VERSION=$(node -pe "require('./package.json').version") 30 | 31 | # Install, Build & Pack 32 | pnpm i --frozen-lockfile || { echo "Installation failed"; exit 1; } 33 | pnpm build || { echo "Build failed"; exit 1; } 34 | pnpm pack || { echo "Packaging failed"; exit 1; } 35 | 36 | # Move to the test directory 37 | cd $TEST_DIR || { echo "Changing directory failed"; exit 1; } 38 | 39 | # Install the local package 40 | pnpm i -D ../pomwright-$VERSION.tgz || { echo "Local package installation failed"; exit 1; } 41 | 42 | # Install dependencies and run playwright tests 43 | pnpm i --frozen-lockfile || { echo "Installation failed"; exit 1; } 44 | pnpm playwright install --with-deps || { echo "Playwright dependencies installation failed"; exit 1; } 45 | pnpm playwright test || { echo "Tests failed"; exit 1; } 46 | 47 | echo "Testing completed successfully." 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pomwright", 3 | "version": "1.3.0", 4 | "description": "POMWright is a complementary test framework for Playwright written in TypeScript.", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/DyHex/POMWright" 8 | }, 9 | "homepage": "https://github.com/DyHex/POMWright#readme", 10 | "bugs": { 11 | "url": "https://github.com/DyHex/POMWright/issues" 12 | }, 13 | "author": { 14 | "name": "Magnus Blütecher Dysthe" 15 | }, 16 | "license": "Apache 2.0", 17 | "main": "dist/index.js", 18 | "module": "dist/index.mjs", 19 | "types": "dist/index.d.ts", 20 | "scripts": { 21 | "build": "tsup index.ts --format cjs,esm --dts", 22 | "release": "pnpm run build && changeset publish", 23 | "lint": "biome check ./src", 24 | "format": "biome format ./src --write", 25 | "pack-test": "bash pack-test.sh", 26 | "test": "vitest run && bash pack-test.sh" 27 | }, 28 | "keywords": [ 29 | "Playwright", 30 | "POM", 31 | "Page Object Model", 32 | "Locator", 33 | "Chaining", 34 | "Nesting", 35 | "LocatorSchema", 36 | "SessionStorage", 37 | "Test", 38 | "Test Framework", 39 | "E2E", 40 | "End-to-End Testing", 41 | "Automation", 42 | "Browser Automation", 43 | "Web Testing", 44 | "Logging", 45 | "Log Levels", 46 | "TypeScript" 47 | ], 48 | "devDependencies": { 49 | "@biomejs/biome": "^2.2.4", 50 | "@changesets/changelog-github": "^0.5.1", 51 | "@changesets/cli": "^2.29.7", 52 | "@types/node": "^24.5.2", 53 | "tsup": "^8.5.0", 54 | "typescript": "^5.9.2", 55 | "vitest": "^3.2.4" 56 | }, 57 | "peerDependencies": { 58 | "@playwright/test": ">=1.41.0 <1.42.0 || >=1.43.0 <2.0.0" 59 | }, 60 | "packageManager": "pnpm@9.12.0" 61 | } 62 | -------------------------------------------------------------------------------- /src/api/baseApi.ts: -------------------------------------------------------------------------------- 1 | import type { APIRequestContext } from "@playwright/test"; 2 | import type { PlaywrightReportLogger } from "../helpers/playwrightReportLogger"; 3 | 4 | export class BaseApi { 5 | protected baseUrl: string; 6 | public apiName: string; 7 | protected log: PlaywrightReportLogger; 8 | protected request: APIRequestContext; 9 | 10 | constructor(baseUrl: string, apiName: string, context: APIRequestContext, pwrl: PlaywrightReportLogger) { 11 | this.baseUrl = baseUrl; 12 | this.apiName = apiName; 13 | this.log = pwrl.getNewChildLogger(apiName); 14 | this.request = context; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/basePage.ts: -------------------------------------------------------------------------------- 1 | import { type Locator, type Page, type Selectors, selectors, type TestInfo } from "@playwright/test"; 2 | import { GetLocatorBase, type SubPaths } from "./helpers/getLocatorBase"; 3 | import type { PlaywrightReportLogger } from "./helpers/playwrightReportLogger"; 4 | import { SessionStorage } from "./helpers/sessionStorage.actions"; 5 | import { createCypressIdEngine } from "./utils/selectorEngines"; 6 | 7 | /** 8 | * BasePageOptions can define optional patterns for baseUrl and urlPath. 9 | * Defaults assume they are strings if not specified. 10 | */ 11 | export type BasePageOptions = { 12 | urlOptions?: { 13 | baseUrlType?: string | RegExp; // Optional, defaults to string 14 | urlPathType?: string | RegExp; // Optional, defaults to string 15 | }; 16 | }; 17 | 18 | export type ExtractBaseUrlType = T["urlOptions"] extends { baseUrlType: RegExp } 19 | ? RegExp 20 | : string; 21 | export type ExtractUrlPathType = T["urlOptions"] extends { urlPathType: RegExp } 22 | ? RegExp 23 | : string; 24 | export type ExtractFullUrlType = T["urlOptions"] extends 25 | | { baseUrlType: RegExp } 26 | | { urlPathType: RegExp } 27 | ? RegExp 28 | : string; 29 | 30 | let selectorRegistered = false; 31 | 32 | /** 33 | * BasePage: 34 | * The foundational class for all Page Object Classes. 35 | * 36 | * Generics: 37 | * - LocatorSchemaPathType: A union of valid locator paths. 38 | * - Options: Configuration type for URLs. 39 | * - LocatorSubstring: The chosen substring locator. 40 | * 41 | * We instantiate GetLocatorBase with these generics. When calling getLocatorSchema, 42 | * the chosen path P sets LocatorSubstring = P, ensuring methods like addFilter only suggests valid sub-paths. 43 | * 44 | * BasePage provides: 45 | * - Common properties: page, testInfo, selectors, URLs, logging, sessionStorage. 46 | * - getNestedLocator & getLocator methods that delegate to getLocatorSchema. 47 | * - Abstract initLocatorSchemas method to be implemented by concrete POCs. 48 | */ 49 | export abstract class BasePage< 50 | LocatorSchemaPathType extends string, 51 | Options extends BasePageOptions = { urlOptions: { baseUrlType: string; urlPathType: string } }, 52 | LocatorSubstring extends LocatorSchemaPathType | undefined = undefined, 53 | > { 54 | /** Provides Playwright page methods */ 55 | page: Page; 56 | 57 | /** Playwright TestInfo contains information about currently running test, available to any test function */ 58 | testInfo: TestInfo; 59 | 60 | /** Selectors can be used to install custom selector engines.*/ 61 | selector: Selectors; 62 | 63 | /** The base URL of the Page Object Class */ 64 | baseUrl: ExtractBaseUrlType; 65 | 66 | /** The URL path of the Page Object Class */ 67 | urlPath: ExtractUrlPathType; 68 | 69 | /** The full URL of the Page Object Class */ 70 | fullUrl: ExtractFullUrlType; 71 | 72 | /** The name of the Page Object Class */ 73 | pocName: string; 74 | 75 | /** The Page Object Class' PlaywrightReportLogger instance, prefixed with its name. Log levels: debug, info, warn, and error. */ 76 | protected log: PlaywrightReportLogger; 77 | 78 | /** The SessionStorage class provides methods for setting and getting session storage data in Playwright.*/ 79 | sessionStorage: SessionStorage; 80 | 81 | /** 82 | * locators: 83 | * An instance of GetLocatorBase that handles schema management and provides getLocatorSchema calls. 84 | * Initially, LocatorSubstring is undefined. Once getLocatorSchema(path) is called, 85 | * we get a chainable object typed with LocatorSubstring = P. 86 | */ 87 | protected locators: GetLocatorBase; 88 | 89 | constructor( 90 | page: Page, 91 | testInfo: TestInfo, 92 | baseUrl: ExtractBaseUrlType, 93 | urlPath: ExtractUrlPathType, 94 | pocName: string, 95 | pwrl: PlaywrightReportLogger, 96 | locatorSubstring?: LocatorSubstring, 97 | ) { 98 | this.page = page; 99 | this.testInfo = testInfo; 100 | this.selector = selectors; 101 | 102 | this.baseUrl = baseUrl; 103 | this.urlPath = urlPath; 104 | this.fullUrl = this.constructFullUrl(baseUrl, urlPath); // `${this.baseUrl}${this.urlPath}` 105 | this.pocName = pocName; 106 | 107 | this.log = pwrl.getNewChildLogger(pocName); 108 | 109 | // Instantiate GetLocatorBase following the minimal POC pattern. 110 | this.locators = new GetLocatorBase( 111 | this, 112 | this.log.getNewChildLogger("GetLocator"), 113 | locatorSubstring, 114 | ); 115 | this.initLocatorSchemas(); 116 | 117 | this.sessionStorage = new SessionStorage(this.page, this.pocName); 118 | 119 | // Register a custom selector engine once globally. 120 | if (!selectorRegistered) { 121 | selectors.register("data-cy", createCypressIdEngine); 122 | selectorRegistered = true; 123 | } 124 | } 125 | 126 | /** 127 | * constructFullUrl: 128 | * Combines baseUrl and urlPath, handling both strings and RegExps. 129 | * Ensures a flexible approach to URL matching (string or regex-based). 130 | */ 131 | private constructFullUrl( 132 | baseUrl: ExtractBaseUrlType, 133 | urlPath: ExtractUrlPathType, 134 | ): ExtractFullUrlType { 135 | /** 136 | * Escapes special regex characters in a string by adding a backslash (\) before them. 137 | * This ensures the string can be safely used in a regular expression. 138 | * Characters escaped: - / \ ^ $ * + ? . ( ) | [ ] { } 139 | * 140 | * @param str - The input string containing potential regex characters. 141 | * @returns The escaped string, safe for regex use. 142 | */ 143 | const escapeStringForRegExp = (str: string) => str.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&"); 144 | 145 | if (typeof baseUrl === "string" && typeof urlPath === "string") { 146 | return `${baseUrl}${urlPath}` as ExtractFullUrlType; 147 | } 148 | if (typeof baseUrl === "string" && urlPath instanceof RegExp) { 149 | return new RegExp(`^${escapeStringForRegExp(baseUrl)}${urlPath.source}`) as ExtractFullUrlType; 150 | } 151 | if (baseUrl instanceof RegExp && typeof urlPath === "string") { 152 | return new RegExp(`${baseUrl.source}${escapeStringForRegExp(urlPath)}$`) as ExtractFullUrlType; 153 | } 154 | if (baseUrl instanceof RegExp && urlPath instanceof RegExp) { 155 | return new RegExp(`${baseUrl.source}${urlPath.source}`) as ExtractFullUrlType; 156 | } 157 | throw new Error("Invalid baseUrl or urlPath types. Expected string or RegExp."); 158 | } 159 | 160 | /** 161 | * Short-hand wrapper method for calling .getLocatorSchema(LocatorSchemaPath).getNestedLocator(subPathIndices?) 162 | * 163 | * Asynchronously builds a nested locator from all LocatorSchema that make up the full LocatorSchemaPath. Optionally, 164 | * you can provide a list of subPaths and indices to have one or more LocatorSchema that make up the full 165 | * LocatorSchemaPath each resolved to a specific .nth(n) occurrence of the element(s). 166 | * 167 | * Note: This short-hand wrapper method is useful for quickly building nested locators without having to call 168 | * getLocatorSchema("...") first. On the other hand, it can't be used to update or add filters to the LocatorSchema. 169 | * 170 | * Test retry: POMWright will set the log level to debug during retries of tests. This will trigger getNestedLocator 171 | * to resolve the locator in DOM per nesting step and attach the log results to the HTML report for debugging purposes. 172 | * This enables us to easily see which locator in the chain failed to resolve, making it easier to identify an issue 173 | * or which LocatorSchema needs to be updated. 174 | * 175 | * Debug: Using POMWright's "log" fixture, you can set the log level to "debug" to see the nested locator evaluation 176 | * results when a test isn't running retry. 177 | * 178 | * @example 179 | * // Usage: 180 | * const submitButton = await poc.getNestedLocator("main.form.button@submit"); 181 | * await submitButton.click(); 182 | * 183 | * // With indexing: 184 | * const something = await poc.getNestedLocator("main.form.item.something", { 185 | * "main.form": 0, // locator.first() / locator.nth(0) 186 | * "main.form.item": 1, // locator.nth(1) 187 | * }); 188 | * await something.click(); 189 | */ 190 | public async getNestedLocator

( 191 | locatorSchemaPath: P, 192 | subPathIndices?: { [K in SubPaths]?: number | null }, 193 | ): Promise; 194 | 195 | /** 196 | * @deprecated Use { SubPaths: index } instead of {4:2}, i.e. subPath-based keys instead of indices, see example. 197 | * 198 | * Deprecated short-hand wrapper method for calling .getLocatorSchema(LocatorSchemaPath).getNestedLocator(subPathIndices?) 199 | * 200 | * @example 201 | * // New Usage: 202 | * const something = await poc.getNestedLocator("main.form.item.something", { 203 | * "main.form": 0, // locator.first() / locator.nth(0) 204 | * "main.form.item": 1, // locator.nth(1) 205 | * }); 206 | * await something.click(); 207 | */ 208 | public async getNestedLocator( 209 | locatorSchemaPath: LocatorSchemaPathType, 210 | indices?: { [key: number]: number | null } | null, 211 | ): Promise; 212 | 213 | /** 214 | * Implementation of getNestedLocator. 215 | */ 216 | public async getNestedLocator( 217 | locatorSchemaPath: LocatorSchemaPathType, 218 | subPathIndices?: { [K in SubPaths]?: number | null }, 219 | ): Promise { 220 | const withValidation = new WithSubPathValidation( 221 | this as BasePage, 222 | this.log.getNewChildLogger("SubPathValidation"), 223 | locatorSchemaPath, 224 | ); 225 | return await withValidation.getNestedLocator(subPathIndices); 226 | } 227 | 228 | /** 229 | * Short-hand wrapper method for calling .getLocatorSchema(LocatorSchemaPath).getLocator() 230 | * 231 | * This method does not perform nesting,and will return the locator for which the full LocatorSchemaPath resolves to, 232 | * provided by getLocatorSchema("...") 233 | * 234 | * Note: This short-hand wrapper method is useful for quickly getting a locator without having to call 235 | * getLocatorSchema("...") first. On the other hand, it can't be used to update or add filters to the LocatorSchema. 236 | * 237 | * @example 238 | * // Usage: 239 | * const submitButton = await poc.getLocator("main.form.button@submit"); 240 | * await expect(submitButton, "should only exist one submit button").toHaveCount(1); 241 | */ 242 | public getLocator = async (locatorSchemaPath: LocatorSchemaPathType): Promise => { 243 | return await this.getLocatorSchema(locatorSchemaPath).getLocator(); 244 | }; 245 | 246 | /** 247 | * getLocatorSchema: 248 | * Delegates to this.locators.getLocatorSchema. 249 | * Returns a chainable schema object for the given path. 250 | * Once called with a specific path P, the update and addFilter methods are restricted to sub-paths of P. 251 | * 252 | * The "getLocatorSchema" method is used to retrieve an updatable deep copy of a LocatorSchema defined in the 253 | * GetLocatorBase class. It enriches the returned schema with additional methods to handle updates and retrieval of 254 | * deep copy locators. 255 | * 256 | * getLocatorSchema adds the following chainable methods to the returned LocatorSchemaWithMethods object: 257 | * 258 | * update 259 | * - Allows updating any schema in the chain by specifying the subPath directly. 260 | * - Gives compile-time suggestions for valid sub-paths of the LocatorSchemaPath provided to .getLocatorSchema(). 261 | * - If you want to update multiple schemas, chain multiple .update() calls. 262 | * 263 | * addFilter(subPath: SubPaths, filterData: FilterEntry) 264 | * - The equivalent of the Playwright locator.filter() method 265 | * - This method is used for filtering the specified locator based on the provided filterData. 266 | * - Can be chained multiple times to add multiple filters to the same or different LocatorSchema. 267 | * 268 | * getNestedLocator 269 | * - Asynchronously builds a nested locator based on the LocatorSchemaPath provided by getLocatorSchema("...") 270 | * - Can be chained once after the update and addFilter methods or directly on the .getLocatorSchema method. 271 | * - getNestedLocator will end the method chain and return a nested Playwright Locator. 272 | * - Optionally parameter takes a list of key(subPath)-value(index) pairs, the locator constructed from the LocatorSchema 273 | * with the specified subPath will resolve to the .nth(n) occurrence of the element, within the chain. 274 | * 275 | * getLocator() 276 | * - Asynchronously retrieves a locator based on the current LocatorSchemaPath. 277 | * - This method does not perform nesting and will return the locator for which the full LocatorSchemaPath resolves to, provided by getLocatorSchema("...") 278 | * - Can be chained once after the update and addFilter methods or directly on the .getLocatorSchema method. 279 | * - getLocator will end the method chain and return a Playwright Locator. 280 | * 281 | * Note: Calling getLocator() and getNestedLocator() on the same LocatorSchemaPath will return a Locator for the same 282 | * element, but the Locator returned by getNestedLocator() will be a locator resolving to said same element through 283 | * a chain of locators. While the Locator returned by getLocator() will be a single locator which resolves directly 284 | * to said element. Thus getLocator() is rarely used, while getNestedLocator() is used extensively. 285 | * 286 | * That said, for certain use cases, getLocator() can be useful, and you could use it to manually chain locators 287 | * yourself if some edge case required it. Though, it would be likely be more prudent to expand your LocatorSchemaPath 288 | * type and initLocatorSchemas() method to include the additional locators you need for the given POC, and then use 289 | * getNestedLocator() instead, or by implementing a helper method on your Page Object Class. 290 | */ 291 | public getLocatorSchema

(path: P) { 292 | return this.locators.getLocatorSchema(path); 293 | } 294 | 295 | /** 296 | * initLocatorSchemas: 297 | * Abstract method to be implemented by each POC. 298 | * POCs define their own type LocatorSchemaPath and add their schemas using locators.addSchema(...). 299 | * 300 | * Each Page Object Class (POC) extending BasePage should define its own 301 | * LocatorSchemaPath type, which is a string type using dot (".") notation. 302 | * The format should start and end with a word, and words should be separated by dots. 303 | * For example: "section.subsection.element". 304 | * 305 | * Implement this method in derived classes to populate the locator map. 306 | * You can define locator schemas directly within this method or import them 307 | * from a separate file (recommended for larger sets of schemas). 308 | * 309 | * @example 310 | * // Example of defining LocatorSchemaPathType in a POC: 311 | * 312 | * export type LocatorSchemaPath = 313 | * | "main.heading" 314 | * | "main.button.addItem"; 315 | * 316 | * // Example implementation using direct definitions: 317 | * 318 | * initLocatorSchemas() { 319 | * this.addSchema("main.heading", { 320 | * role: "heading", 321 | * roleOptions: { 322 | * name: "Main Heading" 323 | * }, 324 | * locatorMethod: GetBy.role 325 | * }); 326 | * 327 | * this.addSchema("main.button.addItem", { 328 | * role: "button", 329 | * roleOptions: { 330 | * name: "Add item" 331 | * }, 332 | * testId: "add-item-button", 333 | * locatorMethod: GetBy.role 334 | * }); 335 | * 336 | * // Add more schemas as needed 337 | * } 338 | * 339 | * // Example implementation using a separate file: 340 | * // Create a file named 'pocName.locatorSchema.ts' and define a function 341 | * // that populates the locator schemas, for example: 342 | * 343 | * // In pocName.locatorSchema.ts 344 | * export type LocatorSchemaPath = 345 | * | "main.heading" 346 | * | "main.button.addItem"; 347 | * 348 | * export function initLocatorSchemas(locators: GetLocatorBase) { 349 | * locators.addSchema("main.heading", { 350 | * role: "heading", 351 | * roleOptions: { 352 | * name: "Main Heading" 353 | * }, 354 | * locatorMethod: GetBy.role 355 | * }); 356 | * 357 | * locators.addSchema("main.button.addItem", { 358 | * role: "button", 359 | * roleOptions: { 360 | * name: "Add item" 361 | * }, 362 | * testId: "add-item-button", 363 | * locatorMethod: GetBy.role 364 | * }); 365 | * 366 | * // Add more schemas as needed 367 | * } 368 | * 369 | * // In the derived POC class 370 | * import { initLocatorSchemas, LocatorSchemaPath } from "./pocName.locatorSchema"; 371 | * 372 | * initLocatorSchemas() { 373 | * initPocNameLocatorSchemas(this.locators); 374 | * } 375 | */ 376 | protected abstract initLocatorSchemas(): void; 377 | } 378 | 379 | class WithSubPathValidation< 380 | LocatorSchemaPathType extends string, 381 | ValidatedPath extends LocatorSchemaPathType, 382 | > extends GetLocatorBase { 383 | constructor( 384 | pageObjectClass: BasePage, 385 | log: PlaywrightReportLogger, 386 | private locatorSchemaPath: ValidatedPath, 387 | ) { 388 | super(pageObjectClass, log, locatorSchemaPath); 389 | } 390 | 391 | /** 392 | * getNestedLocator: 393 | * Ensures `subPathIndices` keys are valid sub-paths of the provided `locatorSchemaPath`. 394 | */ 395 | public async getNestedLocator( 396 | subPathIndices?: { [K in SubPaths]?: number | null }, 397 | ): Promise; 398 | 399 | /** 400 | * Legacy overload (deprecated). 401 | */ 402 | public async getNestedLocator(indices?: { [key: number]: number | null }): Promise; 403 | 404 | public async getNestedLocator( 405 | arg?: { [K in SubPaths]?: number | null } | { [key: number]: number | null }, 406 | ): Promise { 407 | return await this.pageObjectClass.getLocatorSchema(this.locatorSchemaPath).getNestedLocator(arg); 408 | } 409 | } 410 | -------------------------------------------------------------------------------- /src/fixture/base.fixtures.ts: -------------------------------------------------------------------------------- 1 | import { test as base } from "@playwright/test"; 2 | import { type LogEntry, type LogLevel, PlaywrightReportLogger } from "../helpers/playwrightReportLogger"; 3 | 4 | type baseFixtures = { 5 | log: PlaywrightReportLogger; 6 | }; 7 | 8 | export const test = base.extend({ 9 | // biome-ignore lint/correctness/noEmptyPattern: 10 | log: async ({}, use, testInfo) => { 11 | const contextName = "TestCase"; 12 | const sharedLogEntry: LogEntry[] = []; 13 | 14 | const sharedLogLevel: { current: LogLevel; initial: LogLevel } = 15 | testInfo.retry === 0 ? { current: "warn", initial: "warn" } : { current: "debug", initial: "debug" }; 16 | 17 | // Set up fixture, logLevel defaults to "warn" unless a test is retried: 18 | const log = new PlaywrightReportLogger(sharedLogLevel, sharedLogEntry, contextName); 19 | 20 | // Use the fixture value in the test: 21 | await use(log); 22 | 23 | // After the test is done, attach the logs to the test case in the HTML report 24 | log.attachLogsToTest(testInfo); 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /src/helpers/getBy.locator.ts: -------------------------------------------------------------------------------- 1 | import type { Locator, Page } from "@playwright/test"; 2 | import { GetByMethod, type LocatorSchema } from "./locatorSchema.interface"; 3 | import type { PlaywrightReportLogger } from "./playwrightReportLogger"; 4 | 5 | // Type definition for a subset of GetByMethod enum values, excluding specific values for manually implemented methods. 6 | type GetByMethodSubset = Exclude< 7 | GetByMethod, 8 | GetByMethod.frameLocator | GetByMethod.testId | GetByMethod.dataCy | GetByMethod.id 9 | >; 10 | 11 | // Type definition for a function that retrieves a Locator based on a selector and optional options. 12 | type GetByMethodFunction = { 13 | (selector: string | RegExp | Locator | undefined): Locator; 14 | // biome-ignore lint/suspicious/noExplicitAny: 15 | (selector: string | RegExp | Locator | undefined, options: any): Locator; 16 | }; 17 | 18 | /** 19 | * The GetBy class encapsulates methods for generating and obtaining Playwright Locators using LocatorSchema. 20 | * It maps locator methods to corresponding Playwright page functions and provides a convenient interface to interact 21 | * with these locators. It holds a reference to a Playwright Page object and a PlaywrightReportLogger for logging. 22 | * The constructor initializes the logger and sets up method mappings for locator creation. 23 | */ 24 | export class GetBy { 25 | private log: PlaywrightReportLogger; 26 | private methodMap: Record Locator>; 27 | // biome-ignore lint/suspicious/noExplicitAny: 28 | private subMethodMap: Record Locator>; 29 | 30 | constructor( 31 | private page: Page, 32 | pwrl: PlaywrightReportLogger, 33 | ) { 34 | this.log = pwrl.getNewChildLogger(this.constructor.name); 35 | 36 | // Map enum values to corresponding methods. 37 | this.methodMap = { 38 | [GetByMethod.role]: this.role, 39 | [GetByMethod.text]: this.text, 40 | [GetByMethod.label]: this.label, 41 | [GetByMethod.placeholder]: this.placeholder, 42 | [GetByMethod.altText]: this.altText, 43 | [GetByMethod.title]: this.title, 44 | [GetByMethod.locator]: this.locator, 45 | [GetByMethod.frameLocator]: this.frameLocator, 46 | [GetByMethod.testId]: this.testId, 47 | [GetByMethod.dataCy]: this.dataCy, 48 | [GetByMethod.id]: this.id, 49 | }; 50 | 51 | // Map enum values to generated corresponding methods. 52 | this.subMethodMap = { 53 | [GetByMethod.role]: this.page.getByRole, 54 | [GetByMethod.text]: this.page.getByText, 55 | [GetByMethod.label]: this.page.getByLabel, 56 | [GetByMethod.placeholder]: this.page.getByPlaceholder, 57 | [GetByMethod.altText]: this.page.getByAltText, 58 | [GetByMethod.title]: this.page.getByTitle, 59 | [GetByMethod.locator]: this.page.locator, 60 | }; 61 | } 62 | 63 | /** 64 | * Retrieves a Locator based on the details provided in a LocatorSchema. 65 | * The method identifies the appropriate locator creation function from methodMap and invokes it. 66 | * Throws an error if the locator method is unsupported. 67 | */ 68 | public getLocator = (locatorSchema: LocatorSchema): Locator => { 69 | const methodName = locatorSchema.locatorMethod; 70 | 71 | const method = this.methodMap[methodName]; 72 | 73 | if (method) { 74 | return method(locatorSchema); 75 | } 76 | 77 | throw new Error(`Unsupported locator method: ${methodName}`); 78 | }; 79 | 80 | /** 81 | * Internal method to retrieve a Locator using a specified GetByMethodSubset and LocatorSchema. 82 | * It identifies the appropriate locator creation function from subMethodMap and invokes it. 83 | * Throws an error if the caller is unknown or if the initial locator is not found. 84 | */ 85 | private getBy = (caller: GetByMethodSubset, locator: LocatorSchema): Locator => { 86 | const method: GetByMethodFunction = this.subMethodMap[caller]; 87 | 88 | if (!method) { 89 | const errorText = "Error: unknown caller of method getBy(caller, locator) in getBy.locators.ts"; 90 | this.log.error(errorText); 91 | throw new Error(errorText); 92 | } 93 | 94 | const initialPWLocator = locator[caller] 95 | ? method.call(this.page, locator[caller], locator?.[`${caller}Options`]) 96 | : null; 97 | 98 | if (!initialPWLocator) { 99 | const errorText = `Locator "${locator.locatorSchemaPath}" .${caller} is undefined.`; 100 | this.log.warn(errorText); 101 | throw new Error(errorText); 102 | } 103 | 104 | return initialPWLocator; 105 | }; 106 | 107 | /** 108 | * Creates a method for generating a Locator using a specific GetByMethodSubset. 109 | * Returns a function that takes a LocatorSchema and returns a Locator. 110 | * The returned function is a locator creation function corresponding to the specified methodName. 111 | */ 112 | private createByMethod = (methodName: GetByMethodSubset) => { 113 | return (locator: LocatorSchema): Locator => { 114 | return this.getBy(methodName, locator); 115 | }; 116 | }; 117 | 118 | // Methods for creating locators using different locator methods. 119 | // These methods are generated using createByMethod and provide a unified way to create locators based on LocatorSchema. 120 | // Each method is responsible for creating a Locator based on a specific attribute (role, text, label, etc.) provided in LocatorSchema. 121 | // These methods return a Locator and throw an error if the necessary attribute is not defined in the LocatorSchema. 122 | private role = this.createByMethod(GetByMethod.role); 123 | private text = this.createByMethod(GetByMethod.text); 124 | private label = this.createByMethod(GetByMethod.label); 125 | private placeholder = this.createByMethod(GetByMethod.placeholder); 126 | private altText = this.createByMethod(GetByMethod.altText); 127 | private title = this.createByMethod(GetByMethod.title); 128 | private locator = this.createByMethod(GetByMethod.locator); 129 | 130 | /** 131 | * Returns a FrameLocator using the 'frameLocator' selector from a LocatorSchema. 132 | * Throws an error if the frameLocator is not defined. 133 | */ 134 | private frameLocator = (locatorSchema: LocatorSchema): Locator => { 135 | const initialFrameLocator = locatorSchema.frameLocator 136 | ? (this.page.frameLocator(locatorSchema.frameLocator) as unknown as Locator) // intentional, might need a rework... 137 | : null; 138 | 139 | if (!initialFrameLocator) { 140 | const errorText = `Locator "${locatorSchema.locatorSchemaPath}" .frameLocator is not defined.`; 141 | this.log.warn(errorText); 142 | throw new Error(errorText); 143 | } 144 | 145 | return initialFrameLocator; 146 | }; 147 | 148 | /** 149 | * Returns a Locator using the 'testId' selector from a LocatorSchema. 150 | * Throws an error if the testId is not defined. 151 | */ 152 | private testId = (locator: LocatorSchema): Locator => { 153 | const initialPWLocator = locator.testId ? this.page.getByTestId(locator.testId) : null; 154 | 155 | if (!initialPWLocator) { 156 | const errorText = `Locator "${locator.locatorSchemaPath}" .testId is not defined.`; 157 | this.log.warn(`Locator "${locator.locatorSchemaPath}" .testId is not defined.`); 158 | throw new Error(errorText); 159 | } 160 | 161 | return initialPWLocator; 162 | }; 163 | 164 | /** 165 | * Returns a Locator using the 'dataCy' selector from a LocatorSchema. 166 | * Throws an error if the dataCy is undefined. 167 | */ 168 | private dataCy = (locator: LocatorSchema): Locator => { 169 | let initialPWLocator: Locator | null = null; 170 | 171 | if (locator.dataCy) { 172 | initialPWLocator = locator.dataCy.startsWith("data-cy=") 173 | ? this.page.locator(locator.dataCy) 174 | : this.page.locator(`data-cy=${locator.dataCy}`); 175 | } else { 176 | const errorText = `Locator "${locator.locatorSchemaPath}" .dataCy is undefined.`; 177 | this.log.warn(errorText); 178 | throw new Error(errorText); 179 | } 180 | 181 | return initialPWLocator; 182 | }; 183 | 184 | /** 185 | * Returns a Locator using the 'id' selector from a LocatorSchema. 186 | * Throws an error if the id is not defined or the id type is unsupported. 187 | */ 188 | private id = (locator: LocatorSchema): Locator => { 189 | let initialPWLocator: Locator | null = null; 190 | let selector: string; 191 | let regexPattern: string; 192 | 193 | if (!locator.id) { 194 | const errorText = `Locator "${locator.locatorSchemaPath}" .id is not defined.`; 195 | this.log.warn(errorText); 196 | throw new Error(errorText); 197 | } 198 | 199 | if (typeof locator.id === "string") { 200 | if (locator.id.startsWith("#")) { 201 | selector = locator.id; 202 | } else if (locator.id.startsWith("id=")) { 203 | selector = `#${locator.id.slice("id=".length)}`; 204 | } else { 205 | selector = `#${locator.id}`; 206 | } 207 | } else if (locator.id instanceof RegExp) { 208 | regexPattern = locator.id.source; 209 | selector = `*[id^="${regexPattern}"]`; 210 | } else { 211 | const errorText = `Unsupported id type: ${typeof locator.id}`; 212 | this.log.error(errorText); 213 | throw new Error(errorText); 214 | } 215 | 216 | initialPWLocator = this.page.locator(selector); 217 | 218 | return initialPWLocator; 219 | }; 220 | } 221 | -------------------------------------------------------------------------------- /src/helpers/locatorSchema.interface.ts: -------------------------------------------------------------------------------- 1 | import type { Locator, Page } from "@playwright/test"; 2 | export type AriaRoleType = Parameters[0]; 3 | 4 | /** 5 | * ENUM representing methods from the "GetBy" helper class, used by the "GetLocatorBase" class when building nested locators 6 | */ 7 | export enum GetByMethod { 8 | role = "role", 9 | text = "text", 10 | label = "label", 11 | placeholder = "placeholder", 12 | altText = "altText", 13 | title = "title", 14 | locator = "locator", 15 | frameLocator = "frameLocator", 16 | testId = "testId", 17 | dataCy = "dataCy", 18 | id = "id", 19 | } 20 | 21 | /** 22 | * An interface representing a locator object, which can be used with Playwright or other automation tools to create a reusable and maintainable "library" of Locator objects. 23 | * 24 | * To make tests resilient, prioritize user-facing attributes and explicit contracts such as role locators (ARIA). 25 | * 26 | * @interface 27 | */ 28 | export interface LocatorSchema { 29 | /** The ARIA role of the element, this is the prefered way to locate and interact with elements, as it is the closest way to how users and assistive technology perceive the page. {@link AriaRole} */ 30 | role?: AriaRoleType; 31 | /** The options for the role property.*/ 32 | roleOptions?: { 33 | /** Whether the element is checked, an attribute usually set by aria-checked or native 'input type=checkbox' controls. */ 34 | checked?: boolean; 35 | /** Whether the element is disabled, an attribute usually set by aria-disabled or disabled. */ 36 | disabled?: boolean; 37 | /** Whether name is matched exactly. Playwright: case-sensitive and whole-string, still trims whitespace. Ignored when locating by a regular expression.*/ 38 | exact?: boolean; 39 | /** Whether the element is expanded, an attribute usually set by aria-expanded. */ 40 | expanded?: boolean; 41 | /** Whether to include/match hidden elements. */ 42 | includeHidden?: boolean; 43 | /** The level of the element in the accessibility hierarchy, a number attribute that is usually present for roles heading, listitem, row, treeitem, with default values for h1-h6 elements. */ 44 | level?: number; 45 | /** Option to match the accessible name. Playwright: By default, matching is case-insensitive and searches for a substring, use exact to control this behavior. */ 46 | name?: string | RegExp; 47 | /** Whether the element is pressed, an attribute usually set by aria-pressed. */ 48 | pressed?: boolean; 49 | /** Whether the element is selected, an attribute usually set by aria-selected. */ 50 | selected?: boolean; 51 | }; 52 | /** The text content of the element, allows locating elements that contain given text. */ 53 | text?: string | RegExp; 54 | /** The options for the text property. */ 55 | textOptions?: { 56 | /** Whether to match the text content exactly. Playwright: case-sensitive and whole-string, still trims whitespace. Ignored when locating by a regular expression.*/ 57 | exact?: boolean; 58 | }; 59 | /** Text to locate the element 'for', allows locating input elements by the text of the associated label. */ 60 | label?: string | RegExp; 61 | /** The options for the label property. */ 62 | labelOptions?: { 63 | /** Whether to match the text content of the associated label exactly. Playwright: case-sensitive and whole-string, still trims whitespace. Ignored when locating by a regular expression.*/ 64 | exact?: boolean; 65 | }; 66 | /** The text content of a placeholder element, allows locating input elements by the placeholder text. */ 67 | placeholder?: string | RegExp; 68 | /** The options for the placeholder property. */ 69 | placeholderOptions?: { 70 | /** Whether to match the placeholder text content exactly. Playwright: case-sensitive and whole-string, still trims whitespace. Ignored when locating by a regular expression.*/ 71 | exact?: boolean; 72 | }; 73 | /** The 'alt' text of the element, allows locating elements by their alt text. */ 74 | altText?: string | RegExp; 75 | /** The options for the altText property. */ 76 | altTextOptions?: { 77 | /** Whether to match the 'alt' text content exactly. Playwright: case-sensitive and whole-string, still trims whitespace. Ignored when locating by a regular expression.*/ 78 | exact?: boolean; 79 | }; 80 | /** The title of the element, allows locating elements by their title attribute. */ 81 | title?: string | RegExp; 82 | /** The options for the altText property. */ 83 | titleOptions?: { 84 | /** Whether to match the 'title' attribute exactly. Playwright: case-sensitive and whole-string, still trims whitespace. Ignored when locating by a regular expression.*/ 85 | exact?: boolean; 86 | }; 87 | /** A Playwright Locator, typically used through Playwright's "page.locator()" method */ 88 | locator?: string | Locator; 89 | /** The options for the locator property */ 90 | locatorOptions?: { 91 | has?: Locator; 92 | hasNot?: Locator; 93 | hasNotText?: string | RegExp; 94 | hasText?: string | RegExp; 95 | }; 96 | /** A Playwright FrameLocator, represents a view to an iframe on the page, e.g.: "page.frameLocator('#my-frame')" */ 97 | frameLocator?: string; 98 | /** The test ID of the element. Playwright default: "data-testid", can be changed by configuring playwright.config.ts. 'testId' string format: "id-value" */ 99 | testId?: string | RegExp; 100 | /** FOR BACKWARDS COMPATIBILITY ONLY! A custom Selector Engine is implemented in 'base.page.ts' to support the ICE Web-Teams Cypress test ID. 'dataCy' string format: "data-cy=id-value"" */ 101 | dataCy?: string; 102 | /** The ID of the element. 'id' string format: "value", or a regex expression of the value */ 103 | id?: string | RegExp; 104 | /** The equivalent of the Playwright locator.filter() method */ 105 | filter?: { 106 | has?: Locator; 107 | hasNot?: Locator; 108 | hasNotText?: string | RegExp; 109 | hasText?: string | RegExp; 110 | }; 111 | /** Defines the preferred Playwright locator method to be used on this LocatorSchema Object */ 112 | locatorMethod: GetByMethod; 113 | /** The human-readable name of the defined locator object, used for debug logging and test report enrichment. */ 114 | readonly locatorSchemaPath: string; 115 | } 116 | 117 | /** 118 | * `locatorSchemaDummy` is a module-scoped, dummy LocatorSchema object. 119 | * It serves as a template or example of what a typical LocatorSchema object 120 | * might look like. This object is not exported and thus remains encapsulated 121 | * within this module, preventing accidental external modifications. 122 | * 123 | * The purpose of this dummy object is to provide a reference for valid LocatorSchema 124 | * properties, particularly useful in functions like deepMerge in GetLocatorBase. 125 | * It helps in validating properties during dynamic updates and ensures only valid 126 | * LocatorSchema properties are used. 127 | * 128 | * Properties in this dummy object are not intended to be used directly in the application logic. 129 | * Instead, they serve as a structural reference. 130 | */ 131 | const locatorSchemaDummy: Partial = { 132 | role: undefined as unknown as AriaRoleType, 133 | roleOptions: { 134 | checked: undefined as unknown as boolean, 135 | disabled: undefined as unknown as boolean, 136 | exact: undefined as unknown as boolean, 137 | expanded: undefined as unknown as boolean, 138 | includeHidden: undefined as unknown as boolean, 139 | level: undefined as unknown as number, 140 | name: undefined as unknown as string | RegExp, 141 | pressed: undefined as unknown as boolean, 142 | selected: undefined as unknown as boolean, 143 | }, 144 | text: undefined as unknown as string | RegExp, 145 | textOptions: { 146 | exact: undefined as unknown as boolean, 147 | }, 148 | label: undefined as unknown as string | RegExp, 149 | labelOptions: { 150 | exact: undefined as unknown as boolean, 151 | }, 152 | placeholder: undefined as unknown as string | RegExp, 153 | placeholderOptions: { 154 | exact: undefined as unknown as boolean, 155 | }, 156 | altText: undefined as unknown as string | RegExp, 157 | altTextOptions: { 158 | exact: undefined as unknown as boolean, 159 | }, 160 | title: undefined as unknown as string | RegExp, 161 | titleOptions: { 162 | exact: undefined as unknown as boolean, 163 | }, 164 | locator: undefined as unknown as string | Locator, 165 | locatorOptions: { 166 | has: undefined as unknown as Locator, 167 | hasNot: undefined as unknown as Locator, 168 | hasNotText: undefined as unknown as string | RegExp, 169 | hasText: undefined as unknown as string | RegExp, 170 | }, 171 | frameLocator: undefined as unknown as string, 172 | testId: undefined as unknown as string | RegExp, 173 | dataCy: undefined as unknown as string, 174 | id: undefined as unknown as string | RegExp, 175 | filter: { 176 | has: undefined as unknown as Locator, 177 | hasNot: undefined as unknown as Locator, 178 | hasNotText: undefined as unknown as string | RegExp, 179 | hasText: undefined as unknown as string | RegExp, 180 | }, 181 | locatorMethod: undefined as unknown as GetByMethod, 182 | locatorSchemaPath: undefined as unknown as string, 183 | }; 184 | 185 | /** 186 | * `getLocatorSchemaDummy` is a publicly accessible getter function that returns 187 | * the `locatorSchemaDummy` object. This function allows other modules to access 188 | * the structure of a typical LocatorSchema object without exposing the object 189 | * for direct modification. It provides a safe way to reference the dummy object's 190 | * structure, especially useful for property validation in dynamic contexts. 191 | * 192 | * Usage Example: 193 | * - In `GetLocatorBase.ts`, use `getLocatorSchemaDummy` within the `deepMerge` 194 | * function to validate the keys being merged. 195 | * - Useful in scenarios where you need to ensure that the properties being 196 | * added or modified in a LocatorSchema are valid and recognized. 197 | */ 198 | export function getLocatorSchemaDummy(): Partial { 199 | return locatorSchemaDummy; 200 | } 201 | -------------------------------------------------------------------------------- /src/helpers/playwrightReportLogger.ts: -------------------------------------------------------------------------------- 1 | import type { TestInfo } from "@playwright/test"; 2 | 3 | // Defines valid log levels as a union of string literal types. 4 | export type LogLevel = "debug" | "info" | "warn" | "error"; 5 | 6 | // Defines the structure of a log entry. 7 | export type LogEntry = { 8 | timestamp: Date; 9 | logLevel: LogLevel; 10 | prefix: string; 11 | message: string; 12 | }; 13 | 14 | /** 15 | * PlaywrightReportLogger is a logger implementation designed for Playwright tests. 16 | * It records log messages and attaches them to the Playwright HTML report. 17 | * 18 | * The logger enables all fixtures implementing it to share the same log level 19 | * within the scope of a single test, independant of other tests run in parallell. 20 | * In the same way each fixture will share a single logEntry for recording all log 21 | * statements produced throughout the tests execution, when the test is done, all log 22 | * entries are chronologically sorted and attached to the playwright HTML report. 23 | * 24 | * Log messages can be recorded with various log levels (debug, info, warn, error). 25 | * 26 | * The getNewChildLogger method allows you to create a new 'child' logger instance 27 | * with a new contextual name (e.g. the class it's used), while sharing the logLevel 28 | * and LogEntry with the 'parent'. 29 | * 30 | * @example 31 | * 20:49:50 05.05.2023 - DEBUG : [TestCase] 32 | * 20:49:50 05.05.2023 - DEBUG : [TestCase -> MobilEier] 33 | * 20:49:51 05.05.2023 - ERROR : [TestCase -> MobilEier -> Axe] 34 | * 20:49:52 05.05.2023 - INFO : [TestCase -> MobilEier] 35 | * 20:49:52 05.05.2023 - DEBUG : [TestCase -> MobilEier -> GetBy] 36 | */ 37 | export class PlaywrightReportLogger { 38 | private readonly contextName: string; 39 | private readonly logLevels: LogLevel[] = ["debug", "info", "warn", "error"]; 40 | 41 | // Initializes the logger with shared log level, log entries, and a context name. 42 | constructor( 43 | private sharedLogLevel: { current: LogLevel; initial: LogLevel }, 44 | private sharedLogEntry: LogEntry[], 45 | contextName: string, 46 | ) { 47 | this.contextName = contextName; 48 | } 49 | 50 | /** 51 | * Creates a child logger with a new contextual name, sharing the same log level and log entries with the parent logger. 52 | * 53 | * The root loggers log "level" is referenced by all child loggers and their child loggers and so on... 54 | * Changing the log "level" of one, will change it for all. 55 | */ 56 | getNewChildLogger(prefix: string): PlaywrightReportLogger { 57 | return new PlaywrightReportLogger(this.sharedLogLevel, this.sharedLogEntry, `${this.contextName} -> ${prefix}`); 58 | } 59 | 60 | /** 61 | * Logs a message with the specified log level, prefix, and additional arguments if the current log level permits. 62 | */ 63 | // biome-ignore lint/suspicious/noExplicitAny: 64 | private log(level: LogLevel, message: string, ...args: any[]) { 65 | const logLevelIndex = this.logLevels.indexOf(level); 66 | 67 | if (logLevelIndex < this.getCurrentLogLevelIndex()) { 68 | return; 69 | } 70 | 71 | this.sharedLogEntry.push({ 72 | timestamp: new Date(), 73 | logLevel: level, 74 | prefix: this.contextName, 75 | message: `${message}\n\n${args.join("\n\n")}`, 76 | }); 77 | } 78 | 79 | /** 80 | * Logs a debug-level message with the specified message and arguments. 81 | */ 82 | // biome-ignore lint/suspicious/noExplicitAny: 83 | debug(message: string, ...args: any[]) { 84 | this.log("debug", message, ...args); 85 | } 86 | 87 | /** 88 | * Logs a info-level message with the specified message and arguments. 89 | */ 90 | // biome-ignore lint/suspicious/noExplicitAny: 91 | info(message: string, ...args: any[]) { 92 | this.log("info", message, ...args); 93 | } 94 | 95 | /** 96 | * Logs a warn-level message with the specified message and arguments. 97 | */ 98 | // biome-ignore lint/suspicious/noExplicitAny: 99 | warn(message: string, ...args: any[]) { 100 | this.log("warn", message, ...args); 101 | } 102 | 103 | /** 104 | * Logs a error-level message with the specified message and arguments. 105 | */ 106 | // biome-ignore lint/suspicious/noExplicitAny: 107 | error(message: string, ...args: any[]) { 108 | this.log("error", message, ...args); 109 | } 110 | 111 | /** 112 | * Sets the current log level to the specified level during runTime. 113 | */ 114 | setLogLevel(level: LogLevel) { 115 | this.sharedLogLevel.current = level; 116 | } 117 | 118 | /** 119 | * Retrieves the current log level during runtime. 120 | */ 121 | getCurrentLogLevel(): LogLevel { 122 | return this.sharedLogLevel.current; 123 | } 124 | 125 | /** 126 | * Retrieves the index of the current log level in the logLevels array during runtime. 127 | */ 128 | getCurrentLogLevelIndex(): number { 129 | return this.logLevels.indexOf(this.sharedLogLevel.current); 130 | } 131 | 132 | /** 133 | * Resets the current log level to the initial level during runtime. 134 | */ 135 | resetLogLevel() { 136 | this.sharedLogLevel.current = this.sharedLogLevel.initial; 137 | } 138 | 139 | /** 140 | * Checks if the input log level is equal to the current log level of the PlaywrightReportLogger instance. 141 | */ 142 | isCurrentLogLevel(level: LogLevel): boolean { 143 | return this.sharedLogLevel.current === level; 144 | } 145 | 146 | /** 147 | * Returns 'true' if the "level" parameter provided has an equal or greater index than the current logLevel. 148 | */ 149 | public isLogLevelEnabled(level: LogLevel): boolean { 150 | const logLevelIndex = this.logLevels.indexOf(level); 151 | 152 | if (logLevelIndex < this.getCurrentLogLevelIndex()) { 153 | return false; 154 | } 155 | 156 | return true; 157 | } 158 | 159 | /** 160 | * Attaches the recorded log entries to the Playwright HTML report in a sorted and formatted manner. 161 | */ 162 | attachLogsToTest(testInfo: TestInfo) { 163 | this.sharedLogEntry.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); 164 | 165 | for (const log of this.sharedLogEntry) { 166 | const printTime = log.timestamp.toLocaleTimeString("nb-NO", { 167 | hour: "2-digit", 168 | minute: "2-digit", 169 | second: "2-digit", 170 | }); 171 | const printDate = log.timestamp.toLocaleDateString("nb-NO", { 172 | day: "2-digit", 173 | month: "2-digit", 174 | year: "numeric", 175 | }); 176 | const printLogLevel = `${log.logLevel.toUpperCase()}`; 177 | const printPrefix = log.prefix ? `: [${log.prefix}]` : ""; 178 | 179 | let messageBody = ""; 180 | let messageContentType = ""; 181 | try { 182 | const parsedMessage = JSON.parse(log.message); 183 | messageContentType = "application/json"; 184 | messageBody = JSON.stringify(parsedMessage, null, 2); 185 | } catch (_error) { 186 | messageContentType = "text/plain"; 187 | messageBody = log.message; 188 | } 189 | 190 | testInfo.attach(`${printTime} ${printDate} - ${printLogLevel} ${printPrefix}`, { 191 | contentType: messageContentType, 192 | body: Buffer.from(messageBody), 193 | }); 194 | } 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /src/helpers/sessionStorage.actions.ts: -------------------------------------------------------------------------------- 1 | import { type Page, test } from "@playwright/test"; 2 | 3 | /** 4 | * Defines the SessionStorage class to manage session storage in Playwright. 5 | * It provides methods to set, get, and clear session storage data, and to handle data before page navigation. 6 | */ 7 | export class SessionStorage { 8 | // Defines an object to hold states to be set in session storage, allowing any value type. 9 | // biome-ignore lint/suspicious/noExplicitAny: 10 | private queuedStates: { [key: string]: any } = {}; 11 | // Indicates if the session storage manipulation has been initiated. 12 | private isInitiated = false; 13 | 14 | // Initializes the class with a Playwright Page object and a name for the Page Object Class. 15 | constructor( 16 | private page: Page, 17 | private pocName: string, 18 | ) {} 19 | 20 | /** Writes states to session storage. Accepts an object with key-value pairs representing the states. */ 21 | // biome-ignore lint/suspicious/noExplicitAny: 22 | private async writeToSessionStorage(states: { [key: string]: any }) { 23 | await this.page.evaluate((storage) => { 24 | for (const [key, value] of Object.entries(storage)) { 25 | window.sessionStorage.setItem(key, JSON.stringify(value)); 26 | } 27 | }, states); 28 | } 29 | 30 | /** Reads all states from session storage and returns them as an object. */ 31 | // biome-ignore lint/suspicious/noExplicitAny: 32 | private async readFromSessionStorage(): Promise<{ [key: string]: any }> { 33 | return await this.page.evaluate(() => { 34 | // biome-ignore lint/suspicious/noExplicitAny: 35 | const storage: { [key: string]: any } = {}; 36 | for (let i = 0; i < sessionStorage.length; i++) { 37 | const key = sessionStorage.key(i); 38 | if (key !== null) { 39 | const item = sessionStorage.getItem(key); 40 | try { 41 | storage[key] = item ? JSON.parse(item) : null; 42 | } catch (_e) { 43 | storage[key] = item; 44 | } 45 | } 46 | } 47 | return storage; 48 | }); 49 | } 50 | 51 | /** 52 | * Sets the specified states in session storage. 53 | * Optionally reloads the page after setting the data to ensure the new session storage state is active. 54 | * 55 | * Parameters: 56 | * states: Object representing the states to set in session storage. 57 | * reload: Boolean indicating whether to reload the page after setting the session storage data. 58 | */ 59 | // biome-ignore lint/suspicious/noExplicitAny: 60 | public async set(states: { [key: string]: any }, reload: boolean) { 61 | await test.step(`${this.pocName}: setSessionStorage`, async () => { 62 | await this.writeToSessionStorage(states); 63 | if (reload) { 64 | await this.page.reload(); 65 | } 66 | }); 67 | } 68 | 69 | /** 70 | * Queues states to be set in the sessionStorage before the next navigation occurs. 71 | * Handles different scenarios based on whether the context exists or multiple calls are made. 72 | * 73 | * 1. No Context, Single Call: Queues and sets states upon the next navigation. 74 | * 2. No Context, Multiple Calls: Merges states from multiple calls and sets them upon the next navigation. 75 | * 3. With Context: Directly sets states in sessionStorage if the context already exists. 76 | * 77 | * Parameters: 78 | * states: Object representing the states to queue for setting in session storage. 79 | */ 80 | // biome-ignore lint/suspicious/noExplicitAny: 81 | public async setOnNextNavigation(states: { [key: string]: any }) { 82 | this.queuedStates = { ...this.queuedStates, ...states }; 83 | 84 | const populateStorage = async () => { 85 | await test.step(`${this.pocName}: setSessionStorageBeforeNavigation`, async () => { 86 | await this.writeToSessionStorage(this.queuedStates); 87 | }); 88 | this.queuedStates = {}; // Clear queued states 89 | }; 90 | 91 | let contextExists = false; 92 | 93 | try { 94 | contextExists = await this.page.evaluate(() => { 95 | return typeof window !== "undefined" && window.sessionStorage !== undefined; 96 | }); 97 | } catch (_e) { 98 | // Execution context was destroyed; navigate event likely occurred 99 | contextExists = false; 100 | } 101 | 102 | if (contextExists) { 103 | await populateStorage(); 104 | return; 105 | } 106 | 107 | if (!this.isInitiated) { 108 | this.isInitiated = true; 109 | this.page.once("framenavigated", async () => { 110 | await populateStorage(); 111 | }); 112 | } 113 | } 114 | 115 | /** 116 | * Fetches states from session storage. 117 | * If specific keys are provided, fetches only those states; otherwise, fetches all states. 118 | * 119 | * Parameters: 120 | * keys: Optional array of keys to specify which states to fetch from session storage. 121 | * 122 | * Returns: 123 | * Object containing the fetched states. 124 | */ 125 | // biome-ignore lint/suspicious/noExplicitAny: 126 | public async get(keys?: string[]): Promise<{ [key: string]: any }> { 127 | // biome-ignore lint/suspicious/noExplicitAny: 128 | let result: { [key: string]: any } = {}; 129 | await test.step(`${this.pocName}: getSessionStorage`, async () => { 130 | const allData = await this.readFromSessionStorage(); 131 | if (keys && keys.length > 0) { 132 | for (const key of keys) { 133 | if (Object.hasOwn(allData, key)) { 134 | result[key] = allData[key]; 135 | } 136 | } 137 | } else { 138 | result = allData; 139 | } 140 | }); 141 | return result; 142 | } 143 | 144 | /** 145 | * Clears all states in sessionStorage. 146 | */ 147 | public async clear() { 148 | await test.step(`${this.pocName}: clear SessionStorage`, async () => { 149 | await this.page.evaluate(() => sessionStorage.clear()); 150 | }); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/utils/selectorEngines.ts: -------------------------------------------------------------------------------- 1 | // Type definitions for DOM query selector methods. 2 | type QuerySelectorType = typeof document.querySelector; 3 | type QuerySelectorAllType = typeof document.querySelectorAll; 4 | 5 | /** 6 | * Creates a custom selector engine for Cypress that focuses on selecting elements by the 'data-cy' attribute. 7 | * This engine provides methods to query single or multiple elements based on the provided 'data-cy' value. 8 | */ 9 | export function createCypressIdEngine() { 10 | return { 11 | /** 12 | * Uses the document's querySelector method to find the first element with a specific 'data-cy' attribute. 13 | * Constructs a selector string for the 'data-cy' attribute and searches the DOM for the first match. 14 | * 15 | * Parameters: 16 | * - document: An object that mimics the global document, having a querySelector method. 17 | * - selector: A string representing the value of the 'data-cy' attribute to search for. 18 | * 19 | * Returns the first HTML element matching the 'data-cy' attribute, or null if no match is found. 20 | */ 21 | query(document: { querySelector: QuerySelectorType }, selector: string) { 22 | const attr = `[data-cy="${selector}"]`; 23 | const el = document.querySelector(attr); 24 | return el; 25 | }, 26 | 27 | /** 28 | * Uses the document's querySelectorAll method to find all elements with a specific 'data-cy' attribute. 29 | * Constructs a selector string for the 'data-cy' attribute and retrieves all matching elements in the DOM. 30 | * Converts the NodeList from querySelectorAll into an array for easier handling and manipulation. 31 | * 32 | * Parameters: 33 | * - document: An object that mimics the global document, having a querySelectorAll method. 34 | * - selector: A string representing the value of the 'data-cy' attribute to search for. 35 | * 36 | * Returns an array of HTML elements matching the 'data-cy' attribute. Returns an empty array if no matches are found. 37 | */ 38 | queryAll( 39 | document: { 40 | querySelectorAll: QuerySelectorAllType; 41 | }, 42 | selector: string, 43 | ) { 44 | const attr = `[data-cy="${selector}"]`; 45 | const els = Array.from(document.querySelectorAll(attr)); 46 | return els; 47 | }, 48 | }; 49 | } 50 | -------------------------------------------------------------------------------- /test/api/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DyHex/POMWright/ea50553daa07bd62462636047578f359f3eb76c6/test/api/.gitkeep -------------------------------------------------------------------------------- /test/basePage.test.poc.ts: -------------------------------------------------------------------------------- 1 | import type { Page, TestInfo } from "@playwright/test"; 2 | import { BasePage, GetByMethod, type GetLocatorBase, type PlaywrightReportLogger } from "../index"; 3 | 4 | export type LocatorSchemaPath = 5 | | "getByRole" 6 | | "getByRoleWithOptions" 7 | | "getByText" 8 | | "getByTextWithOptions" 9 | | "getByLabel" 10 | | "getByLabelWithOptions" 11 | | "getByPlaceholder" 12 | | "getByPlaceholderWithOptions" 13 | | "getByAltText" 14 | | "getByAltTextWithOptions" 15 | | "getByTitle" 16 | | "getByTitleWithOptions" 17 | | "getByLocator" 18 | | "getByLocatorWithOptions" 19 | | "getByFrameLocator" 20 | | "getByTestId" 21 | | "getByDataCy" 22 | | "getById" 23 | | "minimumLocatorSchema" 24 | | "maximumLocatorSchema" 25 | | "null.locator" 26 | | "null.null.locator" 27 | | "locator" 28 | | "locator.null.locator" 29 | | "locator.null.null.locator" 30 | | "id" 31 | | "id.dataCy" 32 | | "id.dataCy.testId" 33 | | "id.dataCy.testId.locator" 34 | | "id.dataCy.testId.locator.title" 35 | | "id.dataCy.testId.locator.title.altText" 36 | | "id.dataCy.testId.locator.title.altText.placeholder" 37 | | "id.dataCy.testId.locator.title.altText.placeholder.label" 38 | | "id.dataCy.testId.locator.title.altText.placeholder.label.text" 39 | | "id.dataCy.testId.locator.title.altText.placeholder.label.text.role"; 40 | 41 | function initLocatorSchemas(locators: GetLocatorBase) { 42 | locators.addSchema("getByRole", { 43 | role: "button", 44 | locatorMethod: GetByMethod.role, 45 | }); 46 | 47 | locators.addSchema("getByRoleWithOptions", { 48 | role: "button", 49 | roleOptions: { name: "button", exact: true }, 50 | locatorMethod: GetByMethod.role, 51 | }); 52 | 53 | locators.addSchema("getByText", { 54 | text: "text", 55 | locatorMethod: GetByMethod.text, 56 | }); 57 | 58 | locators.addSchema("getByTextWithOptions", { 59 | text: "text", 60 | textOptions: { exact: true }, 61 | locatorMethod: GetByMethod.text, 62 | }); 63 | 64 | locators.addSchema("getByLabel", { 65 | label: "label", 66 | locatorMethod: GetByMethod.label, 67 | }); 68 | 69 | locators.addSchema("getByLabelWithOptions", { 70 | label: "label", 71 | labelOptions: { exact: true }, 72 | locatorMethod: GetByMethod.label, 73 | }); 74 | 75 | locators.addSchema("getByPlaceholder", { 76 | placeholder: "placeholder", 77 | locatorMethod: GetByMethod.placeholder, 78 | }); 79 | 80 | locators.addSchema("getByPlaceholderWithOptions", { 81 | placeholder: "placeholder", 82 | placeholderOptions: { exact: true }, 83 | locatorMethod: GetByMethod.placeholder, 84 | }); 85 | 86 | locators.addSchema("getByAltText", { 87 | altText: "altText", 88 | locatorMethod: GetByMethod.altText, 89 | }); 90 | 91 | locators.addSchema("getByAltTextWithOptions", { 92 | altText: "altText", 93 | altTextOptions: { exact: true }, 94 | locatorMethod: GetByMethod.altText, 95 | }); 96 | 97 | locators.addSchema("getByTitle", { 98 | title: "title", 99 | locatorMethod: GetByMethod.title, 100 | }); 101 | 102 | locators.addSchema("getByTitleWithOptions", { 103 | title: "title", 104 | titleOptions: { exact: true }, 105 | locatorMethod: GetByMethod.title, 106 | }); 107 | 108 | locators.addSchema("getByLocator", { 109 | locator: ".class", 110 | locatorMethod: GetByMethod.locator, 111 | }); 112 | 113 | locators.addSchema("getByLocatorWithOptions", { 114 | locator: ".class", 115 | locatorOptions: { hasText: "text" }, 116 | locatorMethod: GetByMethod.locator, 117 | }); 118 | 119 | locators.addSchema("getByFrameLocator", { 120 | frameLocator: 'iframe[title="frame"]', 121 | locatorMethod: GetByMethod.frameLocator, 122 | }); 123 | 124 | locators.addSchema("getByTestId", { 125 | testId: "testId", 126 | locatorMethod: GetByMethod.testId, 127 | }); 128 | 129 | locators.addSchema("getByDataCy", { 130 | dataCy: "dataCy", 131 | locatorMethod: GetByMethod.dataCy, 132 | }); 133 | 134 | locators.addSchema("getById", { 135 | id: "id", 136 | locatorMethod: GetByMethod.id, 137 | }); 138 | 139 | locators.addSchema("minimumLocatorSchema", { 140 | // will throw an error during runtime since locator is undefined 141 | locatorMethod: GetByMethod.locator, 142 | }); 143 | 144 | locators.addSchema("maximumLocatorSchema", { 145 | role: "button", 146 | roleOptions: { name: "button", exact: true }, 147 | text: "text", 148 | textOptions: { exact: true }, 149 | label: "label", 150 | labelOptions: { exact: true }, 151 | placeholder: "placeholder", 152 | placeholderOptions: { exact: true }, 153 | altText: "altText", 154 | altTextOptions: { exact: true }, 155 | title: "title", 156 | titleOptions: { exact: true }, 157 | locator: ".class", 158 | locatorOptions: { hasText: "text" }, 159 | frameLocator: 'iframe[title="frame"]', 160 | testId: "testId", 161 | dataCy: "dataCy", 162 | id: "id", 163 | locatorMethod: GetByMethod.locator, 164 | }); 165 | 166 | locators.addSchema("null.locator", { 167 | locator: "button .class", 168 | locatorMethod: GetByMethod.locator, 169 | }); 170 | 171 | locators.addSchema("null.null.locator", { 172 | locator: "button .class", 173 | locatorMethod: GetByMethod.locator, 174 | }); 175 | 176 | locators.addSchema("locator", { 177 | locator: "button .class", 178 | locatorMethod: GetByMethod.locator, 179 | }); 180 | 181 | locators.addSchema("locator.null.locator", { 182 | locator: "button .class", 183 | locatorMethod: GetByMethod.locator, 184 | }); 185 | 186 | locators.addSchema("locator.null.null.locator", { 187 | locator: "button .class", 188 | locatorMethod: GetByMethod.locator, 189 | }); 190 | 191 | locators.addSchema("id", { 192 | id: "id", 193 | locatorMethod: GetByMethod.id, 194 | }); 195 | 196 | locators.addSchema("id.dataCy", { 197 | dataCy: "dataCy", 198 | locatorMethod: GetByMethod.dataCy, 199 | }); 200 | 201 | locators.addSchema("id.dataCy.testId", { 202 | testId: "testId", 203 | locatorMethod: GetByMethod.testId, 204 | }); 205 | 206 | locators.addSchema("id.dataCy.testId.locator", { 207 | locator: ".class", 208 | locatorMethod: GetByMethod.locator, 209 | }); 210 | 211 | locators.addSchema("id.dataCy.testId.locator.title", { 212 | title: "title", 213 | locatorMethod: GetByMethod.title, 214 | }); 215 | 216 | locators.addSchema("id.dataCy.testId.locator.title.altText", { 217 | altText: "altText", 218 | locatorMethod: GetByMethod.altText, 219 | }); 220 | 221 | locators.addSchema("id.dataCy.testId.locator.title.altText.placeholder", { 222 | placeholder: "placeholder", 223 | locatorMethod: GetByMethod.placeholder, 224 | }); 225 | 226 | locators.addSchema("id.dataCy.testId.locator.title.altText.placeholder.label", { 227 | label: "label", 228 | locatorMethod: GetByMethod.label, 229 | }); 230 | 231 | locators.addSchema("id.dataCy.testId.locator.title.altText.placeholder.label.text", { 232 | text: "text", 233 | locatorMethod: GetByMethod.text, 234 | }); 235 | 236 | locators.addSchema("id.dataCy.testId.locator.title.altText.placeholder.label.text.role", { 237 | role: "button", 238 | locatorMethod: GetByMethod.role, 239 | }); 240 | } 241 | 242 | export class POC extends BasePage { 243 | constructor(page: Page, testInfo: TestInfo, pwrl: PlaywrightReportLogger) { 244 | super(page, testInfo, "http://localhost:8080", "/", POC.name, pwrl); 245 | } 246 | 247 | protected initLocatorSchemas() { 248 | initLocatorSchemas(this.locators); 249 | } 250 | } 251 | 252 | export class POCWithRegExpBaseUrl extends BasePage { 253 | constructor(page: Page, testInfo: TestInfo, pwrl: PlaywrightReportLogger) { 254 | super(page, testInfo, /http:\/\/localhost:8080/, "/", POC.name, pwrl); 255 | } 256 | 257 | protected initLocatorSchemas() { 258 | initLocatorSchemas(this.locators); 259 | } 260 | } 261 | 262 | export class POCWithRegExpUrlPath extends BasePage { 263 | constructor(page: Page, testInfo: TestInfo, pwrl: PlaywrightReportLogger) { 264 | super(page, testInfo, "http://localhost:8080", /\//, POC.name, pwrl); 265 | } 266 | 267 | protected initLocatorSchemas() { 268 | initLocatorSchemas(this.locators); 269 | } 270 | } 271 | 272 | export class POCWithRegExpBaseUrlAndUrlPath extends BasePage< 273 | LocatorSchemaPath, 274 | { urlOptions: { baseUrlType: RegExp; urlPathType: RegExp } } 275 | > { 276 | constructor(page: Page, testInfo: TestInfo, pwrl: PlaywrightReportLogger) { 277 | super(page, testInfo, /http:\/\/localhost:8080/, /\//, POC.name, pwrl); 278 | } 279 | 280 | protected initLocatorSchemas() { 281 | initLocatorSchemas(this.locators); 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /test/basePage.test.ts: -------------------------------------------------------------------------------- 1 | import type { Page, TestInfo } from "@playwright/test"; 2 | import { beforeEach, describe, expect, test } from "vitest"; 3 | import { PlaywrightReportLogger } from "../index"; 4 | import { getLocatorSchemaDummy } from "../src/helpers/locatorSchema.interface"; 5 | import type { LogEntry } from "../src/helpers/playwrightReportLogger"; 6 | import { 7 | type LocatorSchemaPath, 8 | POC, 9 | POCWithRegExpBaseUrl, 10 | POCWithRegExpBaseUrlAndUrlPath, 11 | POCWithRegExpUrlPath, 12 | } from "./basePage.test.poc"; 13 | 14 | /** 15 | * BasePage: PageObjectModel 16 | */ 17 | 18 | describe("BasePage: PageObjectModel", () => { 19 | let pageObjectClass: POC; 20 | 21 | beforeEach(() => { 22 | const page = {} as Page; 23 | const testInfo = {} as TestInfo; 24 | const sharedLogEntry: LogEntry[] = []; 25 | const pwrl = new PlaywrightReportLogger({ current: "warn", initial: "warn" }, sharedLogEntry, "test"); 26 | pageObjectClass = new POC(page, testInfo, pwrl); 27 | }); 28 | 29 | test("has property 'page'", () => { 30 | expect(pageObjectClass.page).toBeTypeOf("object"); 31 | expect(pageObjectClass.page).toBeInstanceOf(Object); 32 | }); 33 | 34 | test("has property 'testInfo'", () => { 35 | expect(pageObjectClass.testInfo).toBeTypeOf("object"); 36 | }); 37 | 38 | test("has property 'selector'", () => { 39 | expect(pageObjectClass.selector).toBeTypeOf("object"); 40 | }); 41 | 42 | test("has property 'baseUrl'", () => { 43 | expect(pageObjectClass.baseUrl).toBeTypeOf("string"); 44 | expect(pageObjectClass.baseUrl).toBe("http://localhost:8080"); 45 | }); 46 | 47 | test("has property 'urlPath'", () => { 48 | expect(pageObjectClass.urlPath).toBeTypeOf("string"); 49 | expect(pageObjectClass.urlPath).toBe("/"); 50 | }); 51 | 52 | test("has property 'fullUrl'", () => { 53 | expect(pageObjectClass.fullUrl).toBeTypeOf("string"); 54 | expect(pageObjectClass.fullUrl).toBe(`${pageObjectClass.baseUrl}${pageObjectClass.urlPath}`); 55 | }); 56 | 57 | test("has property 'pocName'", () => { 58 | expect(pageObjectClass.pocName).toBeTypeOf("string"); 59 | expect(pageObjectClass.pocName).toBe("POC"); 60 | }); 61 | 62 | test("has property 'log'", () => { 63 | expect(pageObjectClass).toHaveProperty("log"); 64 | }); 65 | 66 | test("has property sessionStorage", async () => { 67 | expect(pageObjectClass.sessionStorage).toBeTypeOf("object"); 68 | expect(pageObjectClass.sessionStorage).toHaveProperty("queuedStates"); 69 | expect(pageObjectClass.sessionStorage).toHaveProperty("isInitiated"); 70 | expect(pageObjectClass.sessionStorage).toHaveProperty("writeToSessionStorage"); 71 | expect(pageObjectClass.sessionStorage).toHaveProperty("readFromSessionStorage"); 72 | expect(pageObjectClass.sessionStorage).toHaveProperty("clear"); 73 | expect(pageObjectClass.sessionStorage.clear).toBeTypeOf("function"); 74 | expect(pageObjectClass.sessionStorage).toHaveProperty("get"); 75 | expect(pageObjectClass.sessionStorage.get).toBeTypeOf("function"); 76 | expect(pageObjectClass.sessionStorage).toHaveProperty("set"); 77 | expect(pageObjectClass.sessionStorage.set).toBeTypeOf("function"); 78 | expect(pageObjectClass.sessionStorage).toHaveProperty("setOnNextNavigation"); 79 | expect(pageObjectClass.sessionStorage.setOnNextNavigation).toBeTypeOf("function"); 80 | }); 81 | 82 | test("has property 'locators'", () => { 83 | expect(pageObjectClass).toHaveProperty("locators"); 84 | }); 85 | 86 | test("has property 'initLocatorSchemas'", () => { 87 | expect(pageObjectClass).toHaveProperty("initLocatorSchemas"); 88 | }); 89 | 90 | test("has property 'getNestedLocator'", () => { 91 | expect(pageObjectClass).toHaveProperty("getNestedLocator"); 92 | }); 93 | 94 | test("has property 'getLocator'", () => { 95 | expect(pageObjectClass).toHaveProperty("getLocator"); 96 | }); 97 | 98 | test("has property 'getLocatorSchema'", () => { 99 | expect(pageObjectClass).toHaveProperty("getLocatorSchema"); 100 | }); 101 | }); 102 | 103 | /** 104 | * BasePage: PageObjectModel with RegExp baseUrl 105 | */ 106 | 107 | describe("BasePage: PageObjectModel with RegExp baseUrl", () => { 108 | let pageObjectClass: POCWithRegExpBaseUrl; 109 | 110 | beforeEach(() => { 111 | const page = {} as Page; 112 | const testInfo = {} as TestInfo; 113 | const sharedLogEntry: LogEntry[] = []; 114 | const pwrl = new PlaywrightReportLogger({ current: "warn", initial: "warn" }, sharedLogEntry, "test"); 115 | pageObjectClass = new POCWithRegExpBaseUrl(page, testInfo, pwrl); 116 | }); 117 | 118 | const urlToMatch = "http://localhost:8080/"; 119 | 120 | test("has property 'baseUrl'", () => { 121 | expect(pageObjectClass.baseUrl).toBeTypeOf("object"); 122 | expect(pageObjectClass.baseUrl).toBeInstanceOf(RegExp); 123 | expect(urlToMatch).toMatch(pageObjectClass.baseUrl); 124 | }); 125 | 126 | test("has property 'urlPath'", () => { 127 | expect(pageObjectClass.urlPath).toBeTypeOf("string"); 128 | expect(pageObjectClass.urlPath).toBe("/"); 129 | }); 130 | 131 | test("has property 'fullUrl'", () => { 132 | expect(pageObjectClass.fullUrl).toBeTypeOf("object"); 133 | expect(pageObjectClass.fullUrl).toBeInstanceOf(RegExp); 134 | expect(urlToMatch).toMatch(pageObjectClass.fullUrl); 135 | }); 136 | }); 137 | 138 | /** 139 | * BasePage: PageObjectModel with RegExp urlPath 140 | */ 141 | 142 | describe("BasePage: PageObjectModel with RegExp urlPath", () => { 143 | let pageObjectClass: POCWithRegExpUrlPath; 144 | 145 | beforeEach(() => { 146 | const page = {} as Page; 147 | const testInfo = {} as TestInfo; 148 | const sharedLogEntry: LogEntry[] = []; 149 | const pwrl = new PlaywrightReportLogger({ current: "warn", initial: "warn" }, sharedLogEntry, "test"); 150 | pageObjectClass = new POCWithRegExpUrlPath(page, testInfo, pwrl); 151 | }); 152 | 153 | const urlToMatch = "http://localhost:8080/"; 154 | 155 | test("has property 'baseUrl'", () => { 156 | expect(pageObjectClass.baseUrl).toBeTypeOf("string"); 157 | expect(pageObjectClass.baseUrl).toBe("http://localhost:8080"); 158 | }); 159 | 160 | test("has property 'urlPath'", () => { 161 | expect(pageObjectClass.urlPath).toBeTypeOf("object"); 162 | expect(pageObjectClass.urlPath).toBeInstanceOf(RegExp); 163 | expect("/").toMatch(pageObjectClass.urlPath); 164 | }); 165 | 166 | test("has property 'fullUrl'", () => { 167 | expect(pageObjectClass.fullUrl).toBeTypeOf("object"); 168 | expect(pageObjectClass.fullUrl).toBeInstanceOf(RegExp); 169 | expect(urlToMatch).toMatch(pageObjectClass.fullUrl); 170 | }); 171 | }); 172 | 173 | /** 174 | * BasePage: PageObjectModel with RegExp baseUrl & urlPath 175 | */ 176 | 177 | describe("BasePage: PageObjectModel with RegExp baseUrl & urlPath", () => { 178 | let pageObjectClass: POCWithRegExpBaseUrlAndUrlPath; 179 | 180 | beforeEach(() => { 181 | const page = {} as Page; 182 | const testInfo = {} as TestInfo; 183 | const sharedLogEntry: LogEntry[] = []; 184 | const pwrl = new PlaywrightReportLogger({ current: "warn", initial: "warn" }, sharedLogEntry, "test"); 185 | pageObjectClass = new POCWithRegExpBaseUrlAndUrlPath(page, testInfo, pwrl); 186 | }); 187 | 188 | const urlToMatch = "http://localhost:8080/"; 189 | 190 | test("has property 'baseUrl'", () => { 191 | expect(pageObjectClass.baseUrl).toBeTypeOf("object"); 192 | expect(pageObjectClass.baseUrl).toBeInstanceOf(RegExp); 193 | expect(urlToMatch).toMatch(pageObjectClass.baseUrl); 194 | }); 195 | 196 | test("has property 'urlPath'", () => { 197 | expect(pageObjectClass.urlPath).toBeTypeOf("object"); 198 | expect(pageObjectClass.urlPath).toBeInstanceOf(RegExp); 199 | expect("/").toMatch(pageObjectClass.urlPath); 200 | }); 201 | 202 | test("has property 'fullUrl'", () => { 203 | expect(pageObjectClass.fullUrl).toBeTypeOf("object"); 204 | expect(pageObjectClass.fullUrl).toBeInstanceOf(RegExp); 205 | expect(urlToMatch).toMatch(pageObjectClass.fullUrl); 206 | }); 207 | }); 208 | 209 | /** 210 | * BasePage: PageObjectModel.getLocatorSchema 211 | */ 212 | 213 | const baseExpectedProperties = [ 214 | "locatorMethod", 215 | "locatorSchemaPath", 216 | "schemasMap", 217 | "update", 218 | "updates", 219 | "addFilter", 220 | "filterMap", 221 | "getNestedLocator", 222 | "getLocator", 223 | ]; 224 | const allposProp = Object.keys(getLocatorSchemaDummy()); 225 | const allPossibleProperties = [...baseExpectedProperties, ...allposProp]; 226 | 227 | const locatorSchemaCases = [ 228 | { 229 | path: "getByRole" as LocatorSchemaPath, 230 | additionalExpectedProperties: ["role"], 231 | }, 232 | { 233 | path: "getByRoleWithOptions" as LocatorSchemaPath, 234 | additionalExpectedProperties: ["role", "roleOptions"], 235 | }, 236 | { 237 | path: "getByText" as LocatorSchemaPath, 238 | additionalExpectedProperties: ["text"], 239 | }, 240 | { 241 | path: "getByTextWithOptions" as LocatorSchemaPath, 242 | additionalExpectedProperties: ["text", "textOptions"], 243 | }, 244 | { 245 | path: "getByLabel" as LocatorSchemaPath, 246 | additionalExpectedProperties: ["label"], 247 | }, 248 | { 249 | path: "getByLabelWithOptions" as LocatorSchemaPath, 250 | additionalExpectedProperties: ["label", "labelOptions"], 251 | }, 252 | { 253 | path: "getByPlaceholder" as LocatorSchemaPath, 254 | additionalExpectedProperties: ["placeholder"], 255 | }, 256 | { 257 | path: "getByPlaceholderWithOptions" as LocatorSchemaPath, 258 | additionalExpectedProperties: ["placeholder", "placeholderOptions"], 259 | }, 260 | { 261 | path: "getByAltText" as LocatorSchemaPath, 262 | additionalExpectedProperties: ["altText"], 263 | }, 264 | { 265 | path: "getByAltTextWithOptions" as LocatorSchemaPath, 266 | additionalExpectedProperties: ["altText", "altTextOptions"], 267 | }, 268 | { 269 | path: "getByTitle" as LocatorSchemaPath, 270 | additionalExpectedProperties: ["title"], 271 | }, 272 | { 273 | path: "getByTitleWithOptions" as LocatorSchemaPath, 274 | additionalExpectedProperties: ["title", "titleOptions"], 275 | }, 276 | { 277 | path: "getByLocator" as LocatorSchemaPath, 278 | additionalExpectedProperties: ["locator"], 279 | }, 280 | { 281 | path: "getByLocatorWithOptions" as LocatorSchemaPath, 282 | additionalExpectedProperties: ["locator", "locatorOptions"], 283 | }, 284 | { 285 | path: "getByFrameLocator" as LocatorSchemaPath, 286 | additionalExpectedProperties: ["frameLocator"], 287 | }, 288 | { 289 | path: "getByTestId" as LocatorSchemaPath, 290 | additionalExpectedProperties: ["testId"], 291 | }, 292 | { 293 | path: "getByDataCy" as LocatorSchemaPath, 294 | additionalExpectedProperties: ["dataCy"], 295 | }, 296 | { 297 | path: "getById" as LocatorSchemaPath, 298 | additionalExpectedProperties: ["id"], 299 | }, 300 | { 301 | path: "minimumLocatorSchema" as LocatorSchemaPath, 302 | additionalExpectedProperties: [], 303 | }, 304 | { 305 | path: "maximumLocatorSchema" as LocatorSchemaPath, 306 | additionalExpectedProperties: [ 307 | "role", 308 | "roleOptions", 309 | "text", 310 | "textOptions", 311 | "label", 312 | "labelOptions", 313 | "placeholder", 314 | "placeholderOptions", 315 | "altText", 316 | "altTextOptions", 317 | "title", 318 | "titleOptions", 319 | "locator", 320 | "locatorOptions", 321 | "frameLocator", 322 | "testId", 323 | "dataCy", 324 | "id", 325 | ], 326 | }, 327 | ]; 328 | 329 | describe("BasePage: PageObjectModel.getLocatorSchema", () => { 330 | let pageObjectClass: POC; 331 | 332 | beforeEach(() => { 333 | const page = {} as Page; 334 | const testInfo = {} as TestInfo; 335 | const sharedLogEntry: LogEntry[] = []; 336 | const pwrl = new PlaywrightReportLogger({ current: "warn", initial: "warn" }, sharedLogEntry, "test"); 337 | pageObjectClass = new POC(page, testInfo, pwrl); 338 | }); 339 | 340 | test.each(locatorSchemaCases)( 341 | "getLocatorSchema($path) should return a LocatorSchemaWithMethods", 342 | ({ path, additionalExpectedProperties }) => { 343 | const locatorSchemaWithMethods = pageObjectClass.getLocatorSchema(path); 344 | expect(locatorSchemaWithMethods).toBeTruthy(); 345 | expect(locatorSchemaWithMethods).toBeTypeOf("object"); 346 | 347 | const expectedProperties = [...baseExpectedProperties, ...additionalExpectedProperties]; 348 | const notExpectedProperties = allPossibleProperties.filter((prop) => !expectedProperties.includes(prop)); 349 | 350 | for (const property of expectedProperties) { 351 | expect(locatorSchemaWithMethods).toHaveProperty(property); 352 | } 353 | 354 | for (const property of notExpectedProperties) { 355 | expect(locatorSchemaWithMethods).not.toHaveProperty(property); 356 | } 357 | 358 | const hasMethods = 359 | typeof locatorSchemaWithMethods.update === "function" && 360 | typeof locatorSchemaWithMethods.updates === "function" && 361 | typeof locatorSchemaWithMethods.addFilter === "function" && 362 | typeof locatorSchemaWithMethods.getNestedLocator === "function" && 363 | typeof locatorSchemaWithMethods.getLocator === "function"; 364 | expect(hasMethods).toBe(true); 365 | 366 | expect(locatorSchemaWithMethods.locatorMethod).toBeTypeOf("string"); 367 | 368 | expect(locatorSchemaWithMethods.locatorSchemaPath).toBeTruthy(); 369 | expect(locatorSchemaWithMethods.locatorSchemaPath).toBeTypeOf("string"); 370 | expect(locatorSchemaWithMethods.locatorSchemaPath).toContain(path); 371 | }, 372 | ); 373 | }); 374 | -------------------------------------------------------------------------------- /test/fixture/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DyHex/POMWright/ea50553daa07bd62462636047578f359f3eb76c6/test/fixture/.gitkeep -------------------------------------------------------------------------------- /test/utils/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DyHex/POMWright/ea50553daa07bd62462636047578f359f3eb76c6/test/utils/.gitkeep -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Base Options: */ 4 | "esModuleInterop": true, 5 | "skipLibCheck": true, 6 | "target": "es2022", 7 | "verbatimModuleSyntax": true, 8 | "allowJs": true, 9 | "checkJs": true, 10 | "resolveJsonModule": true, 11 | "moduleDetection": "force", 12 | /* Strictness */ 13 | "strict": true, 14 | "noUncheckedIndexedAccess": true, 15 | "forceConsistentCasingInFileNames": true, 16 | /* If NOT transpiling with TypeScript: */ 17 | "moduleResolution": "Bundler", 18 | "module": "ESNext", 19 | "noEmit": true, 20 | /* If your code runs in the DOM: */ 21 | "lib": ["es2022", "dom", "dom.iterable"], 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true, 6 | environment: "node", 7 | dir: "test", 8 | }, 9 | }); 10 | --------------------------------------------------------------------------------