├── .editorconfig ├── .env.example ├── .github └── workflows │ ├── auto-merge.yml │ └── main.yml ├── .gitignore ├── .gitlab-ci.yml ├── .npmignore ├── AGENTS.md ├── CHANGELOG.md ├── LICENSE ├── README.md ├── build.js ├── bypass-captcha.config.example.ts ├── install.js ├── package-lock.json ├── package.json ├── playwright.config.example.ts ├── tests.config.example.ts ├── tests ├── account.spec.ts ├── auth.setup.ts ├── cart.spec.ts ├── category.spec.ts ├── checkout.spec.ts ├── compare.spec.ts ├── config │ ├── element-identifiers.json │ ├── input-values.json │ ├── outcome-markers.json │ ├── slugs.json │ └── test-toggles.json ├── contact.spec.ts ├── healthcheck.spec.ts ├── home.spec.ts ├── login.spec.ts ├── mainmenu.spec.ts ├── minicart.spec.ts ├── orderhistory.spec.ts ├── poms │ ├── adminhtml │ │ └── magentoAdmin.page.ts │ └── frontend │ │ ├── account.page.ts │ │ ├── cart.page.ts │ │ ├── category.page.ts │ │ ├── checkout.page.ts │ │ ├── compare.page.ts │ │ ├── contact.page.ts │ │ ├── home.page.ts │ │ ├── login.page.ts │ │ ├── mainmenu.page.ts │ │ ├── minicart.page.ts │ │ ├── newsletter.page.ts │ │ ├── orderhistory.page.ts │ │ ├── product.page.ts │ │ ├── register.page.ts │ │ └── search.page.ts ├── product.spec.ts ├── register.spec.ts ├── search.spec.ts ├── setup.spec.ts ├── types │ └── magewire.d.ts └── utils │ ├── env.utils.ts │ ├── magewire.utils.ts │ └── notification.validator.ts ├── translate-json.js └── tsconfig.example.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 4 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.{ts,json}] 12 | indent_size = 2 -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | PLAYWRIGHT_BASE_URL=https://hyva-demo.elgentos.io/ 2 | PLAYWRIGHT_PRODUCTION_URL=https://hyva-demo.elgentos.io/ 3 | PLAYWRIGHT_REVIEW_URL=https://hyva-demo.elgentos.io/ 4 | 5 | MAGENTO_ADMIN_SLUG= 6 | MAGENTO_ADMIN_USERNAME= 7 | MAGENTO_ADMIN_PASSWORD= 8 | MAGENTO_THEME_LOCALE= 9 | 10 | MAGENTO_NEW_ACCOUNT_PASSWORD= 11 | MAGENTO_EXISTING_ACCOUNT_EMAIL_CHROMIUM= 12 | MAGENTO_EXISTING_ACCOUNT_EMAIL_FIREFOX= 13 | MAGENTO_EXISTING_ACCOUNT_EMAIL_WEBKIT= 14 | MAGENTO_EXISTING_ACCOUNT_PASSWORD= 15 | MAGENTO_EXISTING_ACCOUNT_CHANGED_PASSWORD= 16 | 17 | MAGENTO_COUPON_CODE_CHROMIUM= 18 | MAGENTO_COUPON_CODE_FIREFOX= 19 | MAGENTO_COUPON_CODE_WEBKIT= 20 | 21 | CAPTCHA_BYPASS= 22 | -------------------------------------------------------------------------------- /.github/workflows/auto-merge.yml: -------------------------------------------------------------------------------- 1 | name: Auto Merge gitlab-main to main 2 | 3 | on: 4 | push: 5 | branches: 6 | - gitlab-main 7 | 8 | jobs: 9 | pr-and-merge: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout repo 14 | uses: actions/checkout@v3 15 | 16 | - name: Authenticate GitHub CLI 17 | run: gh auth setup-git 18 | env: 19 | GH_TOKEN: ${{ secrets.AUTOMERGE_TOKEN }} 20 | 21 | - name: Check or create PR 22 | id: pr 23 | env: 24 | GH_TOKEN: ${{ secrets.AUTOMERGE_TOKEN }} 25 | run: | 26 | set -e 27 | pr_number=$(gh pr list --head gitlab-main --base main --state open --json number -q '.[0].number') 28 | 29 | if [ -z "$pr_number" ]; then 30 | echo "No PR found. Creating new PR..." 31 | gh pr create \ 32 | --head gitlab-main \ 33 | --base main \ 34 | --title "Sync gitlab-main to main" \ 35 | --body "Automated PR from GitLab CI via gh CLI" \ 36 | --label auto-merge 37 | 38 | # PR is just created; get number again 39 | pr_number=$(gh pr list --head gitlab-main --base main --state open --json number -q '.[0].number') 40 | else 41 | echo "PR already exists with number: $pr_number" 42 | fi 43 | 44 | echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT" 45 | 46 | - name: Approve PR 47 | id: pr_approve 48 | env: 49 | GH_TOKEN: ${{ secrets.AUTOAPPROVE_TOKEN }} 50 | run: | 51 | PR_NUMBER="${{ steps.pr.outputs.pr_number }}" 52 | gh pr review "$PR_NUMBER" --approve 53 | echo "merge_approved=true" >> "$GITHUB_OUTPUT" 54 | 55 | - name: Merge PR 56 | id: pr_merge 57 | if: steps.pr_approve.outputs.merge_approved == 'true' 58 | env: 59 | GH_TOKEN: ${{ secrets.AUTOMERGE_TOKEN }} 60 | run: | 61 | PR_NUMBER="${{ steps.pr.outputs.pr_number }}" 62 | gh pr merge "$PR_NUMBER" --merge --admin 63 | echo "merge_succeeded=true" >> "$GITHUB_OUTPUT" 64 | 65 | - name: Delete branch 66 | if: steps.pr_merge.outputs.merge_succeeded == 'true' 67 | env: 68 | GH_TOKEN: ${{ secrets.AUTOMERGE_TOKEN }} 69 | run: | 70 | echo "Deleting remote branch 'gitlab-main'" 71 | gh auth setup-git 72 | git push origin --delete gitlab-main 73 | echo "branch_deleted=true" >> "$GITHUB_OUTPUT" -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Magento 2 Playwright Self Test 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build-and-selftest: 10 | if: github.head_ref != 'gitlab-main' 11 | runs-on: windows-latest 12 | 13 | env: 14 | PLAYWRIGHT_BASE_URL: https://hyva-demo.elgentos.io/ 15 | PLAYWRIGHT_PRODUCTION_URL: https://hyva-demo.elgentos.io/ 16 | PLAYWRIGHT_STAGING_URL: https://hyva-demo.elgentos.io/ 17 | 18 | MAGENTO_ADMIN_SLUG: ${{ secrets.MAGENTO_ADMIN_SLUG }} 19 | MAGENTO_ADMIN_USERNAME: ${{ secrets.MAGENTO_ADMIN_USERNAME }} 20 | MAGENTO_ADMIN_PASSWORD: ${{ secrets.MAGENTO_ADMIN_PASSWORD }} 21 | 22 | MAGENTO_NEW_ACCOUNT_PASSWORD: ${{ secrets.MAGENTO_NEW_ACCOUNT_PASSWORD }} 23 | MAGENTO_EXISTING_ACCOUNT_EMAIL_CHROMIUM: ${{ secrets.MAGENTO_EXISTING_ACCOUNT_EMAIL_CHROMIUM }} 24 | MAGENTO_EXISTING_ACCOUNT_EMAIL_FIREFOX: ${{ secrets.MAGENTO_EXISTING_ACCOUNT_EMAIL_FIREFOX }} 25 | MAGENTO_EXISTING_ACCOUNT_EMAIL_WEBKIT: ${{ secrets.MAGENTO_EXISTING_ACCOUNT_EMAIL_WEBKIT }} 26 | MAGENTO_EXISTING_ACCOUNT_PASSWORD: ${{ secrets.MAGENTO_EXISTING_ACCOUNT_PASSWORD }} 27 | MAGENTO_EXISTING_ACCOUNT_CHANGED_PASSWORD: ${{ secrets.MAGENTO_EXISTING_ACCOUNT_CHANGED_PASSWORD }} 28 | 29 | MAGENTO_COUPON_CODE_CHROMIUM: ${{ secrets.MAGENTO_COUPON_CODE_CHROMIUM }} 30 | MAGENTO_COUPON_CODE_FIREFOX: ${{ secrets.MAGENTO_COUPON_CODE_FIREFOX }} 31 | MAGENTO_COUPON_CODE_WEBKIT: ${{ secrets.MAGENTO_COUPON_CODE_WEBKIT }} 32 | 33 | CAPTCHA_BYPASS: true 34 | 35 | steps: 36 | - name: Checkout repository 37 | uses: actions/checkout@v3 38 | 39 | - name: Set up Node.js 40 | uses: actions/setup-node@v3 41 | with: 42 | node-version: 18 43 | 44 | - name: Install dependencies 45 | run: npm install 46 | 47 | - name: Install Playwright browsers 48 | run: npx playwright install --with-deps 49 | 50 | - name: Copy config files 51 | run: | 52 | cp playwright.config.example.ts playwright.config.ts 53 | cp bypass-captcha.config.example.ts bypass-captcha.config.ts 54 | cp tests.config.example.ts tests.config.ts 55 | cp tsconfig.example.json tsconfig.json 56 | 57 | - name: Run Playwright setup test 58 | run: npx playwright test --reporter=line --workers=4 tests/setup.spec.ts 59 | env: 60 | CI: true 61 | 62 | - name: Run Playwright tests 63 | run: npx playwright test --workers=4 --grep-invert "@setup" --max-failures=1 64 | env: 65 | CI: true 66 | - uses: actions/upload-artifact@v4 67 | if: ${{ !cancelled() }} 68 | with: 69 | name: playwright-report 70 | path: playwright-report/ 71 | retention-days: 30 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #folders 2 | .idea 3 | .vscode 4 | node_modules/ 5 | /test-results/ 6 | /auth-storage/ 7 | /playwright-report/ 8 | 9 | #files 10 | /*.config.ts 11 | /.env 12 | /tsconfig.json 13 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: node:18-bullseye 2 | 3 | stages: 4 | - mirror 5 | 6 | mirror_to_github: 7 | stage: mirror 8 | only: 9 | - main 10 | script: 11 | - mkdir -p ~/.ssh 12 | - printf "%s\n" "$GIT_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519 13 | - chmod 600 ~/.ssh/id_ed25519 14 | - ssh-keyscan github.com >> ~/.ssh/known_hosts 15 | - git config --global user.name "developer-elgentos" 16 | - git config --global user.email "developer@elgentos.nl" 17 | - git remote remove github || true 18 | - git remote add github git@github.com:elgentos/magento2-playwright.git 19 | - git checkout main 20 | - git push github main:gitlab-main 21 | 22 | test_mirror_pipeline: 23 | stage: mirror 24 | rules: 25 | - if: '$CI_COMMIT_BRANCH != "main"' 26 | script: 27 | - mkdir -p ~/.ssh 28 | - printf "%s\n" "$GIT_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519 29 | - chmod 600 ~/.ssh/id_ed25519 30 | - ssh-keyscan github.com >> ~/.ssh/known_hosts 31 | - git config --global user.name "developer-elgentos" 32 | - git config --global user.email "developer@elgentos.nl" 33 | - git ls-remote git@github.com:elgentos/magento2-playwright.git 34 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | #folders 2 | .idea 3 | .vscode 4 | .github 5 | node_modules/ 6 | /test-results/ 7 | /auth-storage/ 8 | /playwright-report/ 9 | 10 | #files 11 | /*.config.ts 12 | /.env 13 | /tsconfig.json -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # Repository Guidelines 2 | 3 | ## Style 4 | - Use two spaces for indentation in TypeScript and JSON files. 5 | - Ensure files end with a newline and no trailing whitespace. 6 | - Never use hardcoded strings in your code. Always add or use variables from the config folder. 7 | 8 | ## Commit Messages 9 | - Write concise commit messages describing the change. 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 5 | 6 | ## [2.0.0-alpha] - 2025-07-02 7 | The initial NPM Package release! 8 | 9 | Due to a difference in releases between the GitHub version and the npm package, we've now marked both as version 2.0.0-alpha to signify their merger and to avoid confusion in the future. 10 | 11 | ### Added 12 | 13 | - Build scripts to help set up the testing suite through the npm package! 14 | - Configuration helper files that make customization easier by automatically picking the correct import of JSON files. 15 | - Custom files in 'tests' folder will now automatically be used if corresponding spec file is also placed in test folder. 16 | - Notification Validator: A utility for validating Magento frontend messages during testing (e.g. "Add to Cart" success message). 17 | - Environment Variable Utility: Centralized handling of environment variables for consistent configuration across test environments. 18 | - Magewire Utility: Helper functions to interact with Magewire components in tests. 19 | - New tests: 20 | - `search.spec.ts` 21 | - `product.spec.ts` 22 | - `orderhistory.spec.ts` 23 | - `healthcheck.spec.ts` 24 | - `compare.spec.ts` 25 | - `category.spec.ts` 26 | 27 | ### Changed 28 | 29 | - The elgentos testing suite is now a npm package for maintainability: [link to testing suite npm package](https://www.npmjs.com/package/@elgentos/magento2-playwright). 30 | - Removed test toggles to avoid issues where the suite would fail without giving a helpful message. 31 | - Added 'smoke' tags to healthcheck tests to better adhere to industry standards. 32 | - 'Fixtures' folder is now 'poms'. 33 | - Split the magento admin page files and frontend page files in the renamed 'poms' folder. 34 | - .env attributes renamed, and defaults will be suggested during installation. 35 | - Divided the steps in `setup.spec.ts` to make them easier to read. 36 | - Moved and renamed config files 37 | - Readme files 38 | 39 | ### Fixes 40 | 41 | - Various fixes for stability, better adherence to the DRY-principle and separation of concerns. 42 | 43 | ## [1.0.0-alpha] - 2025-01-29 44 | The initial Alpha Release! 45 | 46 | ### Added 47 | 48 | - Setup script to make it easier for users to prepare Magento 2 admin section. 49 | - Test cases for key features such as creating an account, ordering a product, adding a coupon code, and more. 50 | - Element identifiers, input values, and outcome markers added in JSON files to make customization easier. 51 | - Example GitHub Actions workflow to show how easily our tool can be integrated into the CI/CD pipeline. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Elgentos 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # elgentos Magento 2 Playwright BDD E2E Testing Suite 2 | 3 | This package contains an end-to-end (E2E) testing suite for Magento 2, powered by [Playwright](https://playwright.dev/). It enables you to quickly set up, run, and extend automated browser tests for your Magento 2 store. Installation is simple via npm, allowing you to seamlessly integrate robust testing into your development workflow. 4 | 5 | 6 | ⚠️ Please note: if you’re not sure what each test does, **then you should only run this in a testing environment**! Some tests involve the database, and for the suite to run `setup.spec.ts` will disable the CATPCHA of your webshop. 7 | 8 | 9 | 🏃**Just want to install and get going?** 10 | 11 | If you’re simply looking to install, check the [prerequisites](#prerequisites) and then go to [🧪 Installing the suite](#-installing-the-suite). 12 | 13 | 14 | 15 | --- 16 | 17 | ## Table of contents 18 | 19 | - [Prerequisites](#Prerequisites) 20 | - [Installing the suite](#-installing-the-suite) 21 | - [Before your run](#-before-you-run) 22 | - [Running the setup](#-run-setup-then-you-can-run-the-suite) 23 | - [How to use the testing suite](#-how-to-use-the-testing-suite) 24 | - [Running tests](#running-tests) 25 | - [Skipping specific tests](#skipping-specific-tests) 26 | - [Tags and Annotations](#tags-and-annotations) 27 | - [Customizing the testing suite](#-customizing-the-testing-suite) 28 | - [Examples](#examples) 29 | - [How to help](#how-to-help) 30 | 31 | --- 32 | 33 | ## Prerequisites 34 | 35 | * This testing suite has been designed to work within a Hÿva theme in Magento 2, but can work with other themes. 36 | * **Magento 2 instance:** A running instance of Magento 2 for testing purposes. Elgentos sponsors a [Hyvä demo website](https://hyva-demo.elgentos.io/) for this project. 37 | 38 | 39 | 40 | --- 41 | 42 | ## 🧪 Installing the suite 43 | 44 | 1. **Create a playwright/ directory inside your theme’s** `/web` **folder** 45 | 46 | Navigate to the `web` folder of your theme. This is usually located in `app/design/frontend/{vendor}/{theme}/web`. Within this folder, create a `playwright` folder, then navigate to it: 47 | 48 | 49 | ```bash 50 | cd app/design/frontend/demo-store/demo-theme/web 51 | mkdir playwright 52 | cd playwright 53 | ``` 54 | 55 | 56 | 2. **Initialize an npm project:** 57 | 58 | 59 | ```bash 60 | npm init -y 61 | ``` 62 | 63 | 64 | 3. **Install the test suite package** 65 | 66 | Lastly, simply run the command to install the elgentos Magento2 Playwright package, and the installation script will set things up for you! You will be prompted to input values for the `.env` variables, but these also come with default values. 67 | 68 | 69 | ```bash 70 | npm install @elgentos/magento2-playwright 71 | ``` 72 | 73 | 74 | 75 | --- 76 | 77 | ## ⏸️ Before you run 78 | 79 | After the installation, a variety of folders will have been created. Most notable in these are `base-tests`, which contain the tests without alteration, and `tests`. **You will never have to make changes to the** `base-tests` **folder. Doing so might break things, so please be cautious.** 80 | 81 | 82 | 83 | > If you want to make changes to your iteration of the testing suite such as making changes to how the test works, updating element identifiers etc., see the section ‘Customizing the testing suite’ below. 84 | 85 | 86 | 87 | --- 88 | 89 | ## 🤖 Run setup… then you can run the suite! 90 | 91 | Finally, before running the testing suite, you must run `setup.spec.ts`. This must be done as often as your server resets. You can run this using the following command: 92 | 93 | 94 | ```bash 95 | npx playwright test --grep "@setup" --trace on 96 | ``` 97 | 98 | 99 | After that - you’re all set! 🥳 You can run the testing suite - feel free to skip the setup at this point: 100 | 101 | ```bash 102 | npx playrwight test --grep-invert "@setup" --trace on 103 | ``` 104 | 105 | 106 | 107 | --- 108 | 109 | ## 🚀 How to use the testing suite 110 | 111 | The Testing Suite offers a variety of tests for your Magento 2 application in Chromium, Firefox, and Webkit. 112 | 113 | 114 | ### Running tests 115 | 116 | To run all tests, run the following command: 117 | 118 | ```bash 119 | npx playwright test --grep-invert "@setup" 120 | ``` 121 | 122 | 123 | This command will run all tests located in the `base-tests` directory. If you have custom tests in the `test` folder, these will be used instead of their `base-tests` counterpart. 124 | 125 | 126 | You can also run a specific test file: 127 | 128 | ```bash 129 | npx playwright test tests/example.test.ts 130 | ``` 131 | 132 | 133 | The above commands will run your tests, then offer a report. You can also use [the UI mode](https://playwright.dev/docs/running-tests#debug-tests-in-ui-mode) to see what the tests are doing, which is helpful for debugging. To open up UI mode, run this command: 134 | 135 | 136 | ```bash 137 | npx playwright test --ui 138 | ``` 139 | 140 | 141 | Playwright also offers a trace view. While using the UI mode is seen as the default for developing and debugging tests, you may want to run the tests and collect a trace instead. This can be done with the following command: 142 | 143 | 144 | ```bash 145 | npx playwright test --trace on 146 | ``` 147 | 148 | 149 | ### Skipping specific tests 150 | 151 | Certain `spec` files and specific tests are used as a setup. For example, all setup tests (such as creating an account and setting a coupon code in your Magento 2 environment) have the tag ‘@setup’. Since these only have to be used once (or in the case of our demo website every 24 hours), most of the time you can skip these. These means most of the time, using the following command is best. This command skips both the `user can register an account` test, as well as the whole of `base/setup.spec.ts`. 152 | 153 | 154 | ```bash 155 | npx playwright test –-grep-invert @setup 156 | ``` 157 | 158 | ### Tags and Annotations 159 | 160 | Most tests have been provided with a tag. This allows the user to run specific groups of tests, or skip specific tests. For example, tests that check the functionality of coupon codes are provided with the tag ‘@coupon-code’. To run only these tests, use: 161 | 162 | 163 | ```bash 164 | npx playwright test –-grep @coupon-code 165 | ``` 166 | 167 | 168 | You can also run multiple tags with logic operators: 169 | 170 | 171 | ```bash 172 | npx playwright test –-grep ”@coupon-code|@cart” 173 | ``` 174 | 175 | 176 | Use `--grep-invert` to run all tests **except** the tests with the specified test. Playwright docs offer more information: [Playwright: Tag Annotations](https://playwright.dev/docs/test-annotations#tag-tests). The following command, for example, skips all tests with the tag ‘@coupon-code’. 177 | 178 | 179 | ```bash 180 | npx playwright test –-grep-invert @coupon-code 181 | ``` 182 | 183 | 184 | 185 | --- 186 | 187 | ## ✏️ Customizing the testing suite 188 | 189 | The newly created `tests` folder will become your base of operations. In here, you should use the same folder structure that you see in `base-tests`. For example, if your login page works slightly differently from the demo website version, create a copy of `login.page.ts` and place it `tests/config/poms/frontend/` and make your edits in this file. **You will also have to copy the corresponding** `.spec.ts` **file**. The next time you run the testing suite, it will automatically use these custom files. 190 | 191 | 192 | ### Examples 193 | 194 | Below are some example tests to illustrate how to write and structure your tests. 195 | 196 | 197 | **User registration test:** 198 | 199 | 200 | ```javascript 201 | /** 202 | * @feature User Registration 203 | * @scenario User successfully registers on the website 204 | * @given I am on the registration page 205 | * @when I fill in the registration form with valid data 206 | * @and I submit the form 207 | * @then I should see a confirmation message 208 | */ 209 | test('User can register an account', async ({ page }) => { 210 | // Implementation details 211 | }); 212 | ``` 213 | 214 | 215 | **Checkout process test:** 216 | 217 | 218 | ```javascript 219 | /** 220 | * @feature Product Checkout 221 | * @scenario User completes a purchase 222 | * @given I have a product in my cart 223 | * @when I proceed to checkout 224 | * @and I complete the checkout process 225 | * @then I should receive an order confirmation 226 | */ 227 | test('User can complete the checkout process', async ({ page }) => { 228 | // Implementation details 229 | }); 230 | ``` 231 | 232 | 233 | 234 | --- 235 | 236 | ## How to help 237 | 238 | This package, and therefore the testing suite, is part of our open-source initiative to create an extensive library of end-to-end tests for Magento 2 stores. Do you want to help? Check out the [elgentos Magento 2 Playwright repo on Github](https://github.com/elgentos/magento2-playwright). -------------------------------------------------------------------------------- /build.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | class Build { 5 | 6 | pathToBaseDir = '../../../'; // default: when installed via npm 7 | tempDirTests = 'base-tests'; 8 | exampleFileName = '.example'; 9 | 10 | constructor() { 11 | const isLocalDev = fs.existsSync(path.resolve(__dirname, '.git')); 12 | 13 | if (isLocalDev) { 14 | this.pathToBaseDir = './'; // we're in the root of the dev repo 15 | } 16 | 17 | this.copyExampleFiles(); 18 | this.copyTestsToTempFolder(); 19 | this.createNewTestsFolderForCustomTests(); 20 | } 21 | 22 | /** 23 | * @feature Copy config example files 24 | * @scenario Copy all `.example` files from the current directory to the root directory. 25 | * @given I have `.example` files in this directory 26 | * @when I run the Build script 27 | * @then The `.example` files should be copied to the root directory without the `.example` extension 28 | * @and Existing destination files should NOT be overwritten, but skipped 29 | */ 30 | copyExampleFiles() { 31 | // const exampleFiles = new Set(); 32 | const exampleFiles = new Set(fs.readdirSync(__dirname).filter(file => file.includes(this.exampleFileName))); 33 | 34 | for (const file of exampleFiles) { 35 | // destination will be created or overwritten by default. 36 | const sourceFile = './' + file; 37 | const destFile = this.pathToBaseDir + file.replace(this.exampleFileName, ''); 38 | 39 | try { 40 | fs.copyFileSync(sourceFile, destFile, fs.constants.COPYFILE_EXCL); 41 | console.log(`${path.basename(destFile)} was copied to destination`); 42 | } catch (err) { 43 | if (err.code === 'EEXIST') { 44 | console.log(`${path.basename(destFile)} already exists, skipping copy.`); 45 | } else { 46 | throw err; 47 | } 48 | } 49 | } 50 | } 51 | 52 | /** 53 | * @feature Copy base test files 54 | * @scenario Prepare test suite by copying `tests/` to the root-level `base-tests/` folder. 55 | * @given There is a `tests/` folder in the package directory 56 | * @when I run the Build script 57 | * @and A `base-tests/` folder already exists in the root 58 | * @then The existing `base-tests/` folder should be removed 59 | * @and A fresh copy of `tests/` should be placed in `../../../base-tests` 60 | */ 61 | copyTestsToTempFolder() { 62 | 63 | const sourceDir = path.resolve(__dirname, 'tests'); 64 | const targetDir = path.resolve(__dirname, this.pathToBaseDir, this.tempDirTests); 65 | 66 | try { 67 | if (fs.existsSync(targetDir)) { 68 | fs.rmSync(targetDir, {recursive: true, force: true}); 69 | } 70 | 71 | fs.cpSync(sourceDir, targetDir, {recursive: true}); 72 | if (process.env.CI === 'true') { 73 | fs.rmSync(sourceDir, {recursive: true, force: true}); 74 | } 75 | console.log(`Copied tests from ${sourceDir} to ${targetDir}`); 76 | } catch (err) { 77 | console.error('Error copying test directory:', err); 78 | } 79 | } 80 | 81 | /** 82 | * @feature Create tests directory 83 | * @scenario Ensure the `tests/` directory exists at the project root level. 84 | * @given There is no `tests/` directory at the project root 85 | * @when I run the `createNewTestsFolderForCustomTests` function 86 | * @then A new `tests/` directory should be created at `../../../tests` 87 | * @and A log message "Created tests directory: " should be output 88 | * @given The `tests/` directory already exists at the project root 89 | * @when I run the `createNewTestsFolderForCustomTests` function 90 | * @then No new directory should be created 91 | * @and A log message "Tests directory already exists: " should be output 92 | */ 93 | createNewTestsFolderForCustomTests() { 94 | const testsDir = path.resolve(__dirname, this.pathToBaseDir, 'tests'); 95 | if (!fs.existsSync(testsDir)) { 96 | fs.mkdirSync(testsDir); 97 | console.log(`Created tests directory: ${testsDir}`); 98 | } else { 99 | console.log(`Tests directory already exists: ${testsDir}`); 100 | } 101 | } 102 | } 103 | 104 | new Build(); 105 | -------------------------------------------------------------------------------- /bypass-captcha.config.example.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * This file is used to set up the CAPTCHA bypass for your tests. 5 | * It will set the global cookie to bypass CAPTCHA for Magento 2. 6 | * See: https://github.com/elgentos/magento2-bypass-captcha-cookie 7 | * 8 | */ 9 | 10 | import { FullConfig } from '@playwright/test'; 11 | import * as playwright from 'playwright'; 12 | import dotenv from 'dotenv'; 13 | 14 | dotenv.config(); 15 | 16 | async function globalSetup(config: FullConfig) { 17 | const bypassCaptcha = process.env.CAPTCHA_BYPASS === 'true'; 18 | 19 | for (const project of config.projects) { 20 | const { storageState, browserName = 'chromium' } = project.use || {}; 21 | if (storageState) { 22 | const browserType = playwright[browserName]; 23 | const browser = await browserType.launch(); 24 | const context = await browser.newContext(); 25 | 26 | if (bypassCaptcha) { 27 | // Set the global cookie to bypass CAPTCHA 28 | await context.addCookies([{ 29 | name: 'disable_captcha', // this cookie will be read by 'magento2-bypass-captcha-cookie' module. 30 | value: '', // Fill with generated token. 31 | domain: 'hyva-demo.elgentos.io', // Replace with your domain 32 | path: '/', 33 | httpOnly: true, 34 | secure: true, 35 | sameSite: 'Lax', 36 | }]); 37 | console.log(`CAPTCHA bypass enabled for browser: ${project.name}`); 38 | } else { 39 | // Do nothing. 40 | } 41 | 42 | await context.storageState({ path: `./auth-storage/${project.name}-storage-state.json` }); 43 | await browser.close(); 44 | } 45 | } 46 | } 47 | 48 | export default globalSetup; 49 | -------------------------------------------------------------------------------- /install.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const readline = require('readline'); 6 | const { execSync } = require('child_process'); 7 | 8 | const rl = readline.createInterface({ 9 | input: process.stdin, 10 | output: process.stdout 11 | }); 12 | 13 | const question = (query) => new Promise((resolve) => rl.question(query, resolve)); 14 | 15 | async function install() { 16 | 17 | // Get current user 18 | const currentUser = execSync('whoami').toString().trim(); 19 | 20 | // Environment variables with defaults 21 | const envVars = { 22 | 'PLAYWRIGHT_BASE_URL': { default: 'https://hyva-demo.elgentos.io/' }, 23 | 'PLAYWRIGHT_PRODUCTION_URL': { default: 'https://hyva-demo.elgentos.io/' }, 24 | 'PLAYWRIGHT_REVIEW_URL': { default: 'https://hyva-demo.elgentos.io/' }, 25 | 26 | 'MAGENTO_ADMIN_SLUG': { default: 'admin' }, 27 | 'MAGENTO_ADMIN_USERNAME': { default: currentUser }, 28 | 'MAGENTO_ADMIN_PASSWORD': { default: 'Test1234!' }, 29 | 'MAGENTO_THEME_LOCALE': { default: 'nl_NL' }, 30 | 31 | 'MAGENTO_NEW_ACCOUNT_PASSWORD': { default: 'NewTest1234!' }, 32 | 'MAGENTO_EXISTING_ACCOUNT_EMAIL_CHROMIUM': { default: 'user-CHROMIUM@elgentos.nl' }, 33 | 'MAGENTO_EXISTING_ACCOUNT_EMAIL_FIREFOX': { default: 'user-FIREFOX@elgentos.nl' }, 34 | 'MAGENTO_EXISTING_ACCOUNT_EMAIL_WEBKIT': { default: 'user-WEBKIT@elgentos.nl' }, 35 | 'MAGENTO_EXISTING_ACCOUNT_PASSWORD': { default: 'Test1234!' }, 36 | 'MAGENTO_EXISTING_ACCOUNT_CHANGED_PASSWORD': { default: 'AanpassenKan@0212' }, 37 | 38 | 'MAGENTO_COUPON_CODE_CHROMIUM': { default: 'CHROMIUM321' }, 39 | 'MAGENTO_COUPON_CODE_FIREFOX': { default: 'FIREFOX321' }, 40 | 'MAGENTO_COUPON_CODE_WEBKIT': { default: 'WEBKIT321' } 41 | }; 42 | 43 | // Read and update .env file 44 | const envPath = path.join('.env'); 45 | let envContent = ''; 46 | 47 | for (const [key, value] of Object.entries(envVars)) { 48 | const userInput = await question(`Enter ${ key } (default: ${ value.default }): `); 49 | envContent += `${ key }=${ userInput || value.default }\n`; 50 | } 51 | 52 | fs.writeFileSync(envPath, envContent); 53 | 54 | console.log('\nInstallation completed successfully!'); 55 | console.log('\nFor more information, please visit:'); 56 | console.log('https://wiki.elgentos.nl/doc/stappenplan-testing-suite-implementeren-voor-klanten-hCGe4hVQvN'); 57 | 58 | rl.close(); 59 | } 60 | 61 | install(); -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@elgentos/magento2-playwright", 3 | "version": "2.0.0-alpha", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "@elgentos/magento2-playwright", 9 | "version": "2.0.0-alpha", 10 | "hasInstallScript": true, 11 | "license": "ISC", 12 | "dependencies": { 13 | "@faker-js/faker": "^9.8.0", 14 | "@playwright/test": "^1.53.1", 15 | "@types/node": "^22.7.4", 16 | "csv-parse": "^5.5.3", 17 | "dotenv": "^16.4.5" 18 | }, 19 | "devDependencies": {} 20 | }, 21 | "node_modules/@faker-js/faker": { 22 | "version": "9.8.0", 23 | "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.8.0.tgz", 24 | "integrity": "sha512-U9wpuSrJC93jZBxx/Qq2wPjCuYISBueyVUGK7qqdmj7r/nxaxwW8AQDCLeRO7wZnjj94sh3p246cAYjUKuqgfg==", 25 | "funding": [ 26 | { 27 | "type": "opencollective", 28 | "url": "https://opencollective.com/fakerjs" 29 | } 30 | ], 31 | "license": "MIT", 32 | "engines": { 33 | "node": ">=18.0.0", 34 | "npm": ">=9.0.0" 35 | } 36 | }, 37 | "node_modules/@playwright/test": { 38 | "version": "1.53.1", 39 | "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.1.tgz", 40 | "integrity": "sha512-Z4c23LHV0muZ8hfv4jw6HngPJkbbtZxTkxPNIg7cJcTc9C28N/p2q7g3JZS2SiKBBHJ3uM1dgDye66bB7LEk5w==", 41 | "license": "Apache-2.0", 42 | "dependencies": { 43 | "playwright": "1.53.1" 44 | }, 45 | "bin": { 46 | "playwright": "cli.js" 47 | }, 48 | "engines": { 49 | "node": ">=18" 50 | } 51 | }, 52 | "node_modules/@types/node": { 53 | "version": "22.15.32", 54 | "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.32.tgz", 55 | "integrity": "sha512-3jigKqgSjsH6gYZv2nEsqdXfZqIFGAV36XYYjf9KGZ3PSG+IhLecqPnI310RvjutyMwifE2hhhNEklOUrvx/wA==", 56 | "license": "MIT", 57 | "dependencies": { 58 | "undici-types": "~6.21.0" 59 | } 60 | }, 61 | "node_modules/csv-parse": { 62 | "version": "5.6.0", 63 | "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.6.0.tgz", 64 | "integrity": "sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==", 65 | "license": "MIT" 66 | }, 67 | "node_modules/dotenv": { 68 | "version": "16.5.0", 69 | "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", 70 | "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", 71 | "license": "BSD-2-Clause", 72 | "engines": { 73 | "node": ">=12" 74 | }, 75 | "funding": { 76 | "url": "https://dotenvx.com" 77 | } 78 | }, 79 | "node_modules/fsevents": { 80 | "version": "2.3.2", 81 | "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", 82 | "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", 83 | "hasInstallScript": true, 84 | "license": "MIT", 85 | "optional": true, 86 | "os": [ 87 | "darwin" 88 | ], 89 | "engines": { 90 | "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 91 | } 92 | }, 93 | "node_modules/playwright": { 94 | "version": "1.53.1", 95 | "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.1.tgz", 96 | "integrity": "sha512-LJ13YLr/ocweuwxyGf1XNFWIU4M2zUSo149Qbp+A4cpwDjsxRPj7k6H25LBrEHiEwxvRbD8HdwvQmRMSvquhYw==", 97 | "license": "Apache-2.0", 98 | "dependencies": { 99 | "playwright-core": "1.53.1" 100 | }, 101 | "bin": { 102 | "playwright": "cli.js" 103 | }, 104 | "engines": { 105 | "node": ">=18" 106 | }, 107 | "optionalDependencies": { 108 | "fsevents": "2.3.2" 109 | } 110 | }, 111 | "node_modules/playwright-core": { 112 | "version": "1.53.1", 113 | "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.1.tgz", 114 | "integrity": "sha512-Z46Oq7tLAyT0lGoFx4DOuB1IA9D1TPj0QkYxpPVUnGDqHHvDpCftu1J2hM2PiWsNMoZh8+LQaarAWcDfPBc6zg==", 115 | "license": "Apache-2.0", 116 | "bin": { 117 | "playwright-core": "cli.js" 118 | }, 119 | "engines": { 120 | "node": ">=18" 121 | } 122 | }, 123 | "node_modules/undici-types": { 124 | "version": "6.21.0", 125 | "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", 126 | "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", 127 | "license": "MIT" 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@elgentos/magento2-playwright", 3 | "version": "2.0.0-alpha", 4 | "author": "elgentos", 5 | "license": "ISC", 6 | "description": "A Playwright End-To-End (E2E) testing suite for Magento 2 with Hyva that helps you find (potential) issues on your webshop.", 7 | "scripts": { 8 | "postinstall": "node build.js && npx playwright install", 9 | "translate": "node translate-json.js nl_NL", 10 | "preinstall": "node install.js" 11 | }, 12 | "dependencies": { 13 | "@faker-js/faker": "^9.8.0", 14 | "@playwright/test": "^1.53.1", 15 | "@types/node": "^22.7.4", 16 | "csv-parse": "^5.5.3", 17 | "dotenv": "^16.4.5" 18 | }, 19 | "devDependencies": {} 20 | } -------------------------------------------------------------------------------- /playwright.config.example.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { defineConfig, devices } from '@playwright/test'; 4 | import dotenv from 'dotenv'; 5 | import path from 'path'; 6 | import fs from "node:fs"; 7 | 8 | dotenv.config({ path: path.resolve(__dirname, '.env') }); 9 | 10 | function getTestFiles(baseDir: string, customDir?: string): string[] { 11 | const baseFiles = new Set(fs.readdirSync(baseDir).filter(file => file.endsWith('.spec.ts'))); 12 | 13 | if (!customDir || !fs.existsSync(customDir)) { 14 | return Array.from(baseFiles); 15 | } 16 | 17 | const customFiles = fs.readdirSync(customDir).filter(file => file.endsWith('.spec.ts')); 18 | 19 | if(customFiles.length === 0) { 20 | return Array.from(baseFiles); 21 | } 22 | 23 | const testFiles = new Set(); 24 | 25 | // Get base files that have an override in custom 26 | for (const file of baseFiles) { 27 | const baseFilePath = path.join(baseDir, file); 28 | const customFilePath = path.join(customDir, file); 29 | 30 | testFiles.add(fs.existsSync(customFilePath) ? customFilePath : baseFilePath); 31 | } 32 | 33 | // Add custom tests that aren't in base 34 | for (const file of customFiles) { 35 | if (!baseFiles.has(file)) { 36 | testFiles.add(path.join(customDir, file)); 37 | } 38 | } 39 | 40 | return Array.from(testFiles); 41 | } 42 | 43 | const testFiles = getTestFiles( 44 | path.join(__dirname, 'base-tests'), 45 | path.join(__dirname, 'tests'), 46 | ); 47 | 48 | /** 49 | * See https://playwright.dev/docs/test-configuration. 50 | */ 51 | export default defineConfig({ 52 | /* Run tests in files in parallel */ 53 | fullyParallel: true, 54 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 55 | forbidOnly: !!process.env.CI, 56 | /* Retry on CI only */ 57 | retries: process.env.CI ? 2 : 0, 58 | /* Opt out of parallel tests on CI. */ 59 | workers: process.env.CI ? 1 : undefined, 60 | /* Increase default timeout */ 61 | timeout: 120_000, 62 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 63 | reporter: 'html', 64 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 65 | use: { 66 | baseURL: process.env.PLAYWRIGHT_BASE_URL || 'https://hyva-demo.elgentos.io/', 67 | 68 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 69 | trace: 'on-first-retry', 70 | 71 | /* Ignore https errors if they apply (should only happen on local) */ 72 | ignoreHTTPSErrors: true, 73 | }, 74 | 75 | /* 76 | * Setup for global cookie to bypass CAPTCHA, remove '.example' when used. 77 | * If this is disabled remove storageState from all project objects. 78 | */ 79 | globalSetup: require.resolve('./bypass-captcha.config.ts'), 80 | 81 | /* Configure projects for major browsers */ 82 | projects: [ 83 | // Import our auth.setup.ts file 84 | //{ name: 'setup', testMatch: /.*\.setup\.ts/ }, 85 | 86 | { 87 | name: 'chromium', 88 | testMatch: testFiles, 89 | use: { 90 | ...devices['Desktop Chrome'], 91 | storageState: './auth-storage/chromium-storage-state.json', 92 | }, 93 | }, 94 | 95 | { 96 | name: 'firefox', 97 | testMatch: testFiles, 98 | use: { 99 | ...devices['Desktop Firefox'], 100 | storageState: './auth-storage/firefox-storage-state.json', 101 | }, 102 | }, 103 | 104 | { 105 | name: 'webkit', 106 | testMatch: testFiles, 107 | use: { 108 | ...devices['Desktop Safari'], 109 | storageState: './auth-storage/webkit-storage-state.json', 110 | }, 111 | }, 112 | 113 | /* Test against mobile viewports. */ 114 | // { 115 | // name: 'Mobile Chrome', 116 | // use: { ...devices['Pixel 5'] }, 117 | // }, 118 | // { 119 | // name: 'Mobile Safari', 120 | // use: { ...devices['iPhone 12'] }, 121 | // }, 122 | 123 | /* Test against branded browsers. */ 124 | // { 125 | // name: 'Microsoft Edge', 126 | // use: { ...devices['Desktop Edge'], channel: 'msedge' }, 127 | // }, 128 | // { 129 | // name: 'Google Chrome', 130 | // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, 131 | // }, 132 | ], 133 | 134 | /* Run your local dev server before starting the tests */ 135 | // webServer: { 136 | // command: 'npm run start', 137 | // url: 'http://127.0.0.1:3000', 138 | // reuseExistingServer: !process.env.CI, 139 | // }, 140 | }); -------------------------------------------------------------------------------- /tests.config.example.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | 6 | const basePath = path.resolve('./base-tests/config'); 7 | const overridePath = path.resolve('./tests/config'); 8 | 9 | function loadConfig(filename: string) { 10 | const overrideFile = path.join(overridePath, filename); 11 | const baseFile = path.join(basePath, filename); 12 | 13 | if (fs.existsSync(overrideFile)) { 14 | return require(overrideFile); 15 | } else { 16 | return require(baseFile); 17 | } 18 | } 19 | 20 | const UIReference = loadConfig('element-identifiers.json'); 21 | const outcomeMarker = loadConfig('outcome-markers.json'); 22 | const slugs = loadConfig('slugs.json'); 23 | const inputValues = loadConfig('input-values.json'); 24 | const toggles = loadConfig('test-toggles.json'); 25 | 26 | export { UIReference, outcomeMarker, slugs, inputValues, toggles }; -------------------------------------------------------------------------------- /tests/account.spec.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { test, expect } from '@playwright/test'; 4 | import { faker } from '@faker-js/faker'; 5 | import { UIReference, outcomeMarker, slugs} from 'config'; 6 | import { requireEnv } from './utils/env.utils'; 7 | 8 | import AccountPage from './poms/frontend/account.page'; 9 | import LoginPage from './poms/frontend/login.page'; 10 | import MainMenuPage from './poms/frontend/mainmenu.page'; 11 | import NewsletterSubscriptionPage from './poms/frontend/newsletter.page'; 12 | import RegisterPage from './poms/frontend/register.page'; 13 | 14 | // Before each test, log in 15 | test.beforeEach(async ({ page, browserName }) => { 16 | const browserEngine = browserName?.toUpperCase() || "UNKNOWN"; 17 | const emailInputValue = requireEnv(`MAGENTO_EXISTING_ACCOUNT_EMAIL_${browserEngine}`); 18 | const passwordInputValue = requireEnv('MAGENTO_EXISTING_ACCOUNT_PASSWORD'); 19 | 20 | const loginPage = new LoginPage(page); 21 | await loginPage.login(emailInputValue, passwordInputValue); 22 | }); 23 | 24 | test.describe('Account information actions', {annotation: {type: 'Account Dashboard', description: 'Test for Account Information'},}, () => { 25 | 26 | test.beforeEach(async ({page}) => { 27 | await page.goto(slugs.account.accountOverviewSlug); 28 | await page.waitForLoadState(); 29 | }); 30 | 31 | /** 32 | * @feature Magento 2 Change Password 33 | * @scenario User changes their password 34 | * @given I am logged in 35 | * @and I am on the Account Dashboard page 36 | * @when I navigate to the Account Information page 37 | * @and I check the 'change password' option 38 | * @when I fill in the new credentials 39 | * @and I click Save 40 | * @then I should see a notification that my password has been updated 41 | * @and I should be able to login with my new credentials. 42 | */ 43 | test('Change_password',{ tag: ['@account-credentials', '@hot'] }, async ({page, browserName}, testInfo) => { 44 | 45 | // Create instances and set variables 46 | const mainMenu = new MainMenuPage(page); 47 | const registerPage = new RegisterPage(page); 48 | const accountPage = new AccountPage(page); 49 | const loginPage = new LoginPage(page); 50 | 51 | const browserEngine = browserName?.toUpperCase() || "UNKNOWN"; 52 | let randomNumberforEmail = Math.floor(Math.random() * 101); 53 | let emailPasswordUpdatevalue = `passwordupdate-${randomNumberforEmail}-${browserEngine}@example.com`; 54 | let passwordInputValue = requireEnv('MAGENTO_EXISTING_ACCOUNT_PASSWORD'); 55 | let changedPasswordValue = requireEnv('MAGENTO_EXISTING_ACCOUNT_CHANGED_PASSWORD'); 56 | 57 | // Log out of current account 58 | if(await page.getByRole('link', { name: UIReference.mainMenu.myAccountLogoutItem }).isVisible()){ 59 | await mainMenu.logout(); 60 | } 61 | 62 | // Create account 63 | 64 | await registerPage.createNewAccount(faker.person.firstName(), faker.person.lastName(), emailPasswordUpdatevalue, passwordInputValue); 65 | 66 | // Update password 67 | await page.goto(slugs.account.changePasswordSlug); 68 | await page.waitForLoadState(); 69 | await accountPage.updatePassword(passwordInputValue, changedPasswordValue); 70 | 71 | // If login with changePasswordValue is possible, then password change was succesful. 72 | await loginPage.login(emailPasswordUpdatevalue, changedPasswordValue); 73 | 74 | // Logout again, login with original account 75 | await mainMenu.logout(); 76 | const emailInputValue = requireEnv(`MAGENTO_EXISTING_ACCOUNT_EMAIL_${browserEngine}`); 77 | await loginPage.login(emailInputValue, passwordInputValue); 78 | }); 79 | 80 | /** 81 | * @feature Magento 2 Update E-mail Address 82 | * @scenario User updates their e-mail address 83 | * @given I am logged in 84 | * @and I am on the Account Dashboard page 85 | * @when I navigate to the Account Information page 86 | * @and I fill in a new e-mail address and my current password 87 | * @and I click Save 88 | * @then I should see a notification that my account has been updated 89 | * @and I should be able to login with my new e-mail address. 90 | */ 91 | test('Update_my_e-mail_address',{ tag: ['@account-credentials', '@hot'] }, async ({page, browserName}) => { 92 | const mainMenu = new MainMenuPage(page); 93 | const registerPage = new RegisterPage(page); 94 | const accountPage = new AccountPage(page); 95 | const loginPage = new LoginPage(page); 96 | 97 | const browserEngine = browserName?.toUpperCase() || 'UNKNOWN'; 98 | let randomNumberforEmail = Math.floor(Math.random() * 101); 99 | let originalEmail = `emailupdate-${randomNumberforEmail}-${browserEngine}@example.com`; 100 | let updatedEmail = `updated-${randomNumberforEmail}-${browserEngine}@example.com`; 101 | let passwordInputValue = process.env.MAGENTO_EXISTING_ACCOUNT_PASSWORD; 102 | 103 | if(await page.getByRole('link', { name: UIReference.mainMenu.myAccountLogoutItem }).isVisible()) { 104 | await mainMenu.logout(); 105 | } 106 | 107 | if(!passwordInputValue) { 108 | throw new Error('MAGENTO_EXISTING_ACCOUNT_PASSWORD in your .env file is not defined or could not be read.'); 109 | } 110 | 111 | await registerPage.createNewAccount(faker.person.firstName(), faker.person.lastName(), originalEmail, passwordInputValue); 112 | 113 | await page.goto(slugs.account.accountEditSlug); 114 | await page.waitForLoadState(); 115 | await accountPage.updateEmail(passwordInputValue, updatedEmail); 116 | 117 | await mainMenu.logout(); 118 | await loginPage.login(updatedEmail, passwordInputValue); 119 | 120 | await mainMenu.logout(); 121 | let emailInputValue = process.env[`MAGENTO_EXISTING_ACCOUNT_EMAIL_${browserEngine}`]; 122 | if(!emailInputValue) { 123 | throw new Error(`MAGENTO_EXISTING_ACCOUNT_EMAIL_${browserEngine} and/or MAGENTO_EXISTING_ACCOUNT_PASSWORD have not defined in the .env file, or the account hasn't been created yet.`); 124 | } 125 | await loginPage.login(emailInputValue, passwordInputValue); 126 | }); 127 | }); 128 | 129 | test.describe.serial('Account address book actions', { annotation: {type: 'Account Dashboard', description: 'Tests for the Address Book'},}, () => { 130 | 131 | /** 132 | * @feature Magento 2 Add First Address to Account 133 | * @scenario User adds a first address to their account 134 | * @given I am logged in 135 | * @and I am on the account dashboard page 136 | * @when I go to the page where I can add my address 137 | * @and I haven't added an address yet 138 | * @when I fill in the required information 139 | * @and I click the save button 140 | * @then I should see a notification my address has been updated. 141 | * @and The new address should be selected as default and shipping address 142 | */ 143 | 144 | test('Add_first_address',{ tag: ['@account-credentials', '@hot'] }, async ({page}, testInfo) => { 145 | const accountPage = new AccountPage(page); 146 | let addNewAddressTitle = page.getByRole('heading', {level: 1, name: UIReference.newAddress.addNewAddressTitle}); 147 | 148 | if(await addNewAddressTitle.isHidden()) { 149 | await accountPage.deleteAllAddresses(); 150 | testInfo.annotations.push({ type: 'Notification: deleted addresses', description: `All addresses are deleted to recreate the first address flow.` }); 151 | await page.goto(slugs.account.addressNewSlug); 152 | } 153 | 154 | await accountPage.addNewAddress(); 155 | }); 156 | 157 | /** 158 | * @given I am logged in 159 | * @and I am on the account dashboard page 160 | * @when I go to the page where I can add another address 161 | * @when I fill in the required information 162 | * @and I click the save button 163 | * @then I should see a notification my address has been updated. 164 | * @and The new address should be listed 165 | */ 166 | test('Add_another_address',{ tag: ['@account-credentials', '@hot'] }, async ({page}) => { 167 | await page.goto(slugs.account.addressNewSlug); 168 | const accountPage = new AccountPage(page); 169 | 170 | await accountPage.addNewAddress(); 171 | }); 172 | 173 | /** 174 | * @feature Magento 2 Update Address in Account 175 | * @scenario User updates an existing address to their account 176 | * @given I am logged in 177 | * @and I am on the account dashboard page 178 | * @when I go to the page where I can see my address(es) 179 | * @when I click on the button to edit the address 180 | * @and I fill in the required information correctly 181 | * @then I click the save button 182 | * @then I should see a notification my address has been updated. 183 | * @and The updated address should be visible in the addres book page. 184 | */ 185 | test('Edit_existing_address',{ tag: ['@account-credentials', '@hot'] }, async ({page}) => { 186 | const accountPage = new AccountPage(page); 187 | await page.goto(slugs.account.addressNewSlug); 188 | let editAddressButton = page.getByRole('link', {name: UIReference.accountDashboard.editAddressIconButton}).first(); 189 | 190 | if(await editAddressButton.isHidden()){ 191 | // The edit address button was not found, add another address first. 192 | await accountPage.addNewAddress(); 193 | } 194 | 195 | await page.goto(slugs.account.addressBookSlug); 196 | await accountPage.editExistingAddress(); 197 | }); 198 | 199 | /** 200 | * @feature Magento 2 Delete Address from account 201 | * @scenario User removes an address from their account 202 | * @given I am logged in 203 | * @and I am on the account dashboard page 204 | * @when I go to the page where I can see my address(es) 205 | * @when I click the trash button for the address I want to delete 206 | * @and I click the confirmation button 207 | * @then I should see a notification my address has been deleted. 208 | * @and The address should be removed from the overview. 209 | */ 210 | test('Delete_an_address',{ tag: ['@account-credentials', '@hot'] }, async ({page}, testInfo) => { 211 | const accountPage = new AccountPage(page); 212 | 213 | let deleteAddressButton = page.getByRole('link', {name: UIReference.accountDashboard.addressDeleteIconButton}).first(); 214 | 215 | if(await deleteAddressButton.isHidden()) { 216 | await page.goto(slugs.account.addressNewSlug); 217 | await accountPage.addNewAddress(); 218 | } 219 | await accountPage.deleteFirstAddressFromAddressBook(); 220 | }); 221 | }); 222 | 223 | test.describe('Newsletter actions', { annotation: {type: 'Account Dashboard', description: 'Newsletter tests'},}, () => { 224 | 225 | /** 226 | * @feature Magento 2 newsletter subscriptions 227 | * @scenario User (un)subscribes from a newsletter 228 | * @given I am logged in 229 | * @and I am on the account dashboard page 230 | * @when I click on the newsletter link in the sidebar 231 | * @then I should navigate to the newsletter subscription page 232 | * @when I (un)check the subscription button 233 | * @then I should see a message confirming my action 234 | * @and My subscription option should be updated. 235 | */ 236 | test('Update_newsletter_subscription',{ tag: ['@newsletter-actions', '@cold'] }, async ({page, browserName}) => { 237 | test.skip(browserName === 'webkit', '.click() does not work, still searching for a workaround'); 238 | const newsletterPage = new NewsletterSubscriptionPage(page); 239 | let newsletterLink = page.getByRole('link', { name: UIReference.accountDashboard.links.newsletterLink }); 240 | const newsletterCheckElement = page.getByLabel(UIReference.newsletterSubscriptions.generalSubscriptionCheckLabel); 241 | 242 | await newsletterLink.click(); 243 | await expect(page.getByText(outcomeMarker.account.newsletterSubscriptionTitle, { exact: true })).toBeVisible(); 244 | 245 | let updateSubscription = await newsletterPage.updateNewsletterSubscription(); 246 | 247 | await newsletterLink.click(); 248 | 249 | if(updateSubscription){ 250 | await expect(newsletterCheckElement).toBeChecked(); 251 | } else { 252 | await expect(newsletterCheckElement).not.toBeChecked(); 253 | } 254 | }); 255 | }); 256 | -------------------------------------------------------------------------------- /tests/auth.setup.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { test as setup, expect } from '@playwright/test'; 4 | import path from 'path'; 5 | import { UIReference, slugs } from 'config'; 6 | import { requireEnv } from './utils/env.utils'; 7 | 8 | const authFile = path.join(__dirname, '../playwright/.auth/user.json'); 9 | 10 | setup('authenticate', async ({ page, browserName }) => { 11 | const browserEngine = browserName?.toUpperCase() || "UNKNOWN"; 12 | const emailInputValue = requireEnv(`MAGENTO_EXISTING_ACCOUNT_EMAIL_${browserEngine}`); 13 | const passwordInputValue = requireEnv('MAGENTO_EXISTING_ACCOUNT_PASSWORD'); 14 | 15 | // Perform authentication steps. Replace these actions with your own. 16 | await page.goto(slugs.account.loginSlug); 17 | await page.getByLabel(UIReference.credentials.emailFieldLabel, {exact: true}).fill(emailInputValue); 18 | await page.getByLabel(UIReference.credentials.passwordFieldLabel, {exact: true}).fill(passwordInputValue); 19 | await page.getByRole('button', { name: UIReference.credentials.loginButtonLabel }).click(); 20 | // Wait until the page receives the cookies. 21 | // 22 | // Sometimes login flow sets cookies in the process of several redirects. 23 | // Wait for the final URL to ensure that the cookies are actually set. 24 | // await page.waitForURL(''); 25 | // Alternatively, you can wait until the page reaches a state where all cookies are set. 26 | await expect(page.getByRole('link', { name: UIReference.mainMenu.myAccountLogoutItem })).toBeVisible(); 27 | 28 | // End of authentication steps. 29 | 30 | await page.context().storageState({ path: authFile }); 31 | }); 32 | -------------------------------------------------------------------------------- /tests/cart.spec.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { test, expect } from '@playwright/test'; 4 | import { UIReference, slugs, outcomeMarker } from 'config'; 5 | 6 | import CartPage from './poms/frontend/cart.page'; 7 | import LoginPage from './poms/frontend/login.page'; 8 | import ProductPage from './poms/frontend/product.page'; 9 | import { requireEnv } from './utils/env.utils'; 10 | import NotificationValidator from './utils/notification.validator'; 11 | 12 | test.describe('Cart functionalities (guest)', () => { 13 | /** 14 | * @feature BeforeEach runs before each test in this group. 15 | * @scenario Add a product to the cart and confirm it's there. 16 | * @given I am on any page 17 | * @when I navigate to a (simple) product page 18 | * @and I add it to my cart 19 | * @then I should see a notification 20 | * @when I click the cart in the main menu 21 | * @then the minicart should become visible 22 | * @and I should see the product in the minicart 23 | */ 24 | test.beforeEach(async ({ page }, testInfo) => { 25 | const productPage = new ProductPage(page); 26 | await productPage.addSimpleProductToCart(UIReference.productPage.simpleProductTitle, slugs.productpage.simpleProductSlug); 27 | 28 | const productAddedNotification = `${outcomeMarker.productPage.simpleProductAddedNotification} ${UIReference.productPage.simpleProductTitle}`; 29 | const notificationValidator = new NotificationValidator(page, testInfo); 30 | await notificationValidator.validate(productAddedNotification); 31 | 32 | // await mainMenu.openMiniCart(); 33 | // await expect(page.getByText(outcomeMarker.miniCart.simpleProductInCartTitle)).toBeVisible(); 34 | await page.goto(slugs.cart.cartSlug); 35 | }); 36 | 37 | /** 38 | * @feature Product can be added to cart 39 | * @scenario User adds a product to their cart 40 | * @given I have added a product to my cart 41 | * @and I am on the cart page 42 | * @then I should see the name of the product in my cart 43 | */ 44 | test('Add_product_to_cart',{ tag: ['@cart', '@cold'],}, async ({page}) => { 45 | await expect(page.getByRole('strong').getByRole('link', {name: UIReference.productPage.simpleProductTitle}), `Product is visible in cart`).toBeVisible(); 46 | }); 47 | 48 | /** 49 | * @feature Product permanence after login 50 | * @scenario A product added to the cart should still be there after user has logged in 51 | * @given I have a product in my cart 52 | * @when I log in 53 | * @then I should still have that product in my cart 54 | */ 55 | test('Product_remains_in_cart_after_login',{ tag: ['@cart', '@account', '@hot']}, async ({page, browserName}) => { 56 | await test.step('Add another product to cart', async () =>{ 57 | const productpage = new ProductPage(page); 58 | await page.goto(slugs.productpage.secondSimpleProductSlug); 59 | await productpage.addSimpleProductToCart(UIReference.productPage.secondSimpleProducTitle, slugs.productpage.secondSimpleProductSlug); 60 | }); 61 | 62 | await test.step('Log in with account', async () =>{ 63 | const browserEngine = browserName?.toUpperCase() || "UNKNOWN"; 64 | const loginPage = new LoginPage(page); 65 | const emailInputValue = requireEnv(`MAGENTO_EXISTING_ACCOUNT_EMAIL_${browserEngine}`); 66 | const passwordInputValue = requireEnv('MAGENTO_EXISTING_ACCOUNT_PASSWORD'); 67 | 68 | await loginPage.login(emailInputValue, passwordInputValue); 69 | }); 70 | 71 | await page.goto(slugs.cart.cartSlug); 72 | await expect(page.getByRole('strong').getByRole('link', { name: UIReference.productPage.simpleProductTitle }),`${UIReference.productPage.simpleProductTitle} should still be in cart`).toBeVisible(); 73 | await expect(page.getByRole('strong').getByRole('link', { name: UIReference.productPage.secondSimpleProducTitle }),`${UIReference.productPage.secondSimpleProducTitle} should still be in cart`).toBeVisible(); 74 | }); 75 | 76 | /** 77 | * @feature Remove product from cart 78 | * @scenario User has added a product and wants to remove it from the cart page 79 | * @given I have added a product to my cart 80 | * @and I am on the cart page 81 | * @when I click the delete button 82 | * @then I should see a notification that the product has been removed from my cart 83 | * @and I should no longer see the product in my cart 84 | */ 85 | test('Remove_product_from_cart',{ tag: ['@cart','@cold'],}, async ({page}) => { 86 | const cart = new CartPage(page); 87 | await cart.removeProduct(UIReference.productPage.simpleProductTitle); 88 | }); 89 | 90 | /** 91 | * @feature Change quantity of products in cart 92 | * @scenario User has added a product and changes the quantity 93 | * @given I have a product in my cart 94 | * @and I am on the cart page 95 | * @when I change the quantity of the product 96 | * @and I click the update button 97 | * @then the quantity field should have the new amount 98 | * @and the subtotal/grand total should update 99 | */ 100 | test('Change_product_quantity_in_cart',{ tag: ['@cart', '@cold'],}, async ({page}) => { 101 | const cart = new CartPage(page); 102 | await cart.changeProductQuantity('2'); 103 | }); 104 | 105 | /** 106 | * @feature Discount Code 107 | * @scenario User adds a discount code to their cart 108 | * @given I have a product in my cart 109 | * @and I am on my cart page 110 | * @when I click on the 'add discount code' button 111 | * @then I fill in a code 112 | * @and I click on 'apply code' 113 | * @then I should see a confirmation that my code has been added 114 | * @and the code should be visible in the cart 115 | * @and a discount should be applied to the product 116 | */ 117 | test('Add_coupon_code_in_cart',{ tag: ['@cart', '@coupon-code', '@cold']}, async ({page, browserName}) => { 118 | const browserEngine = browserName?.toUpperCase() || "UNKNOWN"; 119 | const cart = new CartPage(page); 120 | const discountCode = requireEnv(`MAGENTO_COUPON_CODE_${browserEngine}`); 121 | 122 | await cart.applyDiscountCode(discountCode); 123 | }); 124 | 125 | /** 126 | * @feature Remove discount code from cart 127 | * @scenario User has added a discount code, then removes it 128 | * @given I have a product in my cart 129 | * @and I am on my cart page 130 | * @when I add a discount code 131 | * @then I should see a notification 132 | * @and the code should be visible in the cart 133 | * @and a discount should be applied to a product 134 | * @when I click the 'cancel coupon' button 135 | * @then I should see a notification the discount has been removed 136 | * @and the discount should no longer be visible. 137 | */ 138 | test('Remove_coupon_code_from_cart',{ tag: ['@cart', '@coupon-code', '@cold'] }, async ({page, browserName}) => { 139 | const browserEngine = browserName?.toUpperCase() || "UNKNOWN"; 140 | const cart = new CartPage(page); 141 | const discountCode = requireEnv(`MAGENTO_COUPON_CODE_${browserEngine}`); 142 | 143 | await cart.applyDiscountCode(discountCode); 144 | await cart.removeDiscountCode(); 145 | }); 146 | 147 | /** 148 | * @feature Incorrect discount code check 149 | * @scenario The user provides an incorrect discount code, the system should reflect that 150 | * @given I have a product in my cart 151 | * @and I am on the cart page 152 | * @when I enter a wrong discount code 153 | * @then I should get a notification that the code did not work. 154 | */ 155 | 156 | test('Invalid_coupon_code_is_rejected',{ tag: ['@cart', '@coupon-code', '@cold'] }, async ({page}) => { 157 | const cart = new CartPage(page); 158 | await cart.enterWrongCouponCode("Incorrect Coupon Code"); 159 | }); 160 | }) 161 | 162 | test.describe('Price checking tests', () => { 163 | 164 | // Test: Configurable Product Input check from PDP to checkout 165 | // test.step: add configurable product to cart, return priceOnPDP and productAmount as variables 166 | // test.step: call function retrieveCheckoutPrices() to go to checkout, retrieve values 167 | // test.step: call function compareRetrievedPrices() to compare price on PDP to price in checkout 168 | 169 | /** 170 | * @feature Simple Product price/amount check from PDP to Checkout 171 | * @given none 172 | * @when I go to a (simple) product page 173 | * @and I add one or more to my cart 174 | * @when I go to the checkout 175 | * @then the amount of the product should be the same 176 | * @and the price in the checkout should equal the price of the product * the amount of the product 177 | */ 178 | test('Simple_product_cart_data_consistent_from_PDP_to_checkout',{ tag: ['@cart-price-check', '@cold']}, async ({page}) => { 179 | var productPagePrice: string; 180 | var productPageAmount: string; 181 | var checkoutProductDetails: string[]; 182 | 183 | const cart = new CartPage(page); 184 | 185 | await test.step('Step: Add simple product to cart', async () =>{ 186 | const productPage = new ProductPage(page); 187 | await page.goto(slugs.productpage.simpleProductSlug); 188 | // set quantity to 2 so we can see that the math works 189 | await page.getByLabel(UIReference.productPage.quantityFieldLabel).fill('2'); 190 | 191 | productPagePrice = await page.locator(UIReference.productPage.simpleProductPrice).innerText(); 192 | productPageAmount = await page.getByLabel(UIReference.productPage.quantityFieldLabel).inputValue(); 193 | await productPage.addSimpleProductToCart(UIReference.productPage.simpleProductTitle, slugs.productpage.simpleProductSlug, '2'); 194 | 195 | }); 196 | 197 | await test.step('Step: go to checkout, get values', async () =>{ 198 | await page.goto(slugs.checkout.checkoutSlug); 199 | await page.waitForLoadState(); 200 | 201 | // returns productPriceInCheckout and productQuantityInCheckout 202 | checkoutProductDetails = await cart.getCheckoutValues(UIReference.productPage.simpleProductTitle, productPagePrice, productPageAmount); 203 | }); 204 | 205 | await test.step('Step: Calculate and check expectations', async () =>{ 206 | await cart.calculateProductPricesAndCompare(productPagePrice, productPageAmount, checkoutProductDetails[0], checkoutProductDetails[1]); 207 | }); 208 | 209 | }); 210 | 211 | /** 212 | * @feature Configurable Product price/amount check from PDP to Checkout 213 | * @given none 214 | * @when I go to a (configurable) product page 215 | * @and I add one or more to my cart 216 | * @when I go to the checkout 217 | * @then the amount of the product should be the same 218 | * @and the price in the checkout should equal the price of the product * the amount of the product 219 | */ 220 | test('Configurable_product_cart_data_consistent_from_PDP_to_checkout',{ tag: ['@cart-price-check', '@cold']}, async ({page}) => { 221 | var productPagePrice: string; 222 | var productPageAmount: string; 223 | var checkoutProductDetails: string[]; 224 | 225 | const cart = new CartPage(page); 226 | 227 | await test.step('Step: Add configurable product to cart', async () =>{ 228 | const productPage = new ProductPage(page); 229 | // Navigate to the configurable product page so we can retrieve price and amount before adding it to cart 230 | await page.goto(slugs.productpage.configurableProductSlug); 231 | // set quantity to 2 so we can see that the math works 232 | await page.getByLabel('Quantity').fill('2'); 233 | 234 | productPagePrice = await page.locator(UIReference.productPage.simpleProductPrice).innerText(); 235 | productPageAmount = await page.getByLabel(UIReference.productPage.quantityFieldLabel).inputValue(); 236 | await productPage.addConfigurableProductToCart(UIReference.productPage.configurableProductTitle, slugs.productpage.configurableProductSlug, '2'); 237 | 238 | }); 239 | 240 | await test.step('Step: go to checkout, get values', async () =>{ 241 | await page.goto(slugs.checkout.checkoutSlug); 242 | await page.waitForLoadState(); 243 | 244 | // returns productPriceInCheckout and productQuantityInCheckout 245 | checkoutProductDetails = await cart.getCheckoutValues(UIReference.productPage.configurableProductTitle, productPagePrice, productPageAmount); 246 | }); 247 | 248 | await test.step('Step: Calculate and check expectations', async () =>{ 249 | await cart.calculateProductPricesAndCompare(productPagePrice, productPageAmount, checkoutProductDetails[0], checkoutProductDetails[1]); 250 | }); 251 | 252 | }); 253 | }); 254 | -------------------------------------------------------------------------------- /tests/category.spec.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { test } from '@playwright/test'; 4 | 5 | import CategoryPage from './poms/frontend/category.page'; 6 | 7 | test('Filter_category_on_size',{ tag: ['@category', '@cold']}, async ({page, browserName}) => { 8 | const categoryPage = new CategoryPage(page); 9 | await categoryPage.goToCategoryPage(); 10 | 11 | await categoryPage.filterOnSize(browserName); 12 | }); 13 | 14 | test('Sort_category_by_price',{ tag: ['@category', '@cold']}, async ({page}) => { 15 | const categoryPage = new CategoryPage(page); 16 | await categoryPage.goToCategoryPage(); 17 | 18 | await categoryPage.sortProducts('price'); 19 | }); 20 | 21 | test('Change_amount_of_products_shown',{ tag: ['@category', '@cold'],}, async ({page}) => { 22 | const categoryPage = new CategoryPage(page); 23 | await categoryPage.goToCategoryPage(); 24 | 25 | await categoryPage.showMoreProducts(); 26 | // insert your code here 27 | }); 28 | 29 | test('Switch_from_grid_to_list_view',{ tag: ['@category', '@cold'],}, async ({page}) => { 30 | const categoryPage = new CategoryPage(page); 31 | await categoryPage.goToCategoryPage(); 32 | await categoryPage.switchView(); 33 | }); -------------------------------------------------------------------------------- /tests/checkout.spec.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { test, expect } from '@playwright/test'; 4 | import { UIReference, slugs } from 'config'; 5 | 6 | import LoginPage from './poms/frontend/login.page'; 7 | import ProductPage from './poms/frontend/product.page'; 8 | import AccountPage from './poms/frontend/account.page'; 9 | import { requireEnv } from './utils/env.utils'; 10 | import CheckoutPage from './poms/frontend/checkout.page'; 11 | 12 | 13 | /** 14 | * @feature BeforeEach runs before each test in this group. 15 | * @scenario Add product to the cart, confirm it's there, then move to checkout. 16 | * @given I am on any page 17 | * @when I navigate to a (simple) product page 18 | * @and I add it to my cart 19 | * @then I should see a notification 20 | * @when I navigate to the checkout 21 | * @then the checkout page should be shown 22 | * @and I should see the product in the minicart 23 | */ 24 | test.beforeEach(async ({ page }) => { 25 | const productPage = new ProductPage(page); 26 | 27 | await page.goto(slugs.productpage.simpleProductSlug); 28 | await productPage.addSimpleProductToCart(UIReference.productPage.simpleProductTitle, slugs.productpage.simpleProductSlug); 29 | await page.goto(slugs.checkout.checkoutSlug); 30 | }); 31 | 32 | 33 | test.describe('Checkout (login required)', () => { 34 | // Before each test, log in 35 | test.beforeEach(async ({ page, browserName }) => { 36 | const browserEngine = browserName?.toUpperCase() || "UNKNOWN"; 37 | const emailInputValue = requireEnv(`MAGENTO_EXISTING_ACCOUNT_EMAIL_${browserEngine}`); 38 | const passwordInputValue = requireEnv('MAGENTO_EXISTING_ACCOUNT_PASSWORD'); 39 | 40 | const loginPage = new LoginPage(page); 41 | await loginPage.login(emailInputValue, passwordInputValue); 42 | await page.goto(slugs.checkout.checkoutSlug); 43 | }); 44 | 45 | /** 46 | * @feature Automatically fill in certain data in checkout (if user is logged in) 47 | * @scenario When the user navigates to the checkout (with a product), their name and address should be filled in. 48 | * @given I am logged in 49 | * @and I have a product in my cart 50 | * @and I have navigated to the checkout page 51 | * @then My name and address should already be filled in 52 | */ 53 | test('Address_is_pre_filled_in_checkout',{ tag: ['@checkout', '@hot']}, async ({page}) => { 54 | let signInLink = page.getByRole('link', { name: UIReference.credentials.loginButtonLabel }); 55 | let addressField = page.getByLabel(UIReference.newAddress.streetAddressLabel); 56 | let addressAlreadyAdded = false; 57 | 58 | if(await signInLink.isVisible()) { 59 | throw new Error(`Sign in link found, user is not logged in. Please check the test setup.`); 60 | } 61 | 62 | // name field should NOT be on the page 63 | await expect(page.getByLabel(UIReference.personalInformation.firstNameLabel)).toBeHidden(); 64 | 65 | if(await addressField.isVisible()) { 66 | if(!addressAlreadyAdded){ 67 | // Address field is visible and addressalreadyAdded is not true, so we need to add an address to the account. 68 | const accountPage = new AccountPage(page); 69 | await accountPage.addNewAddress(); 70 | } else { 71 | throw new Error(`Address field is visible even though an address has been added to the account.`); 72 | } 73 | } 74 | 75 | // expect to see radio button to select existing address 76 | let shippingRadioButton = page.locator(UIReference.checkout.shippingAddressRadioLocator).first(); 77 | await expect(shippingRadioButton, 'Radio button to select address should be visible').toBeVisible(); 78 | 79 | }); 80 | 81 | 82 | /** 83 | * @feature Place order for simple product 84 | * @scenario User places an order for a simple product 85 | * @given I have a product in my cart 86 | * @and I am on any page 87 | * @when I navigate to the checkout 88 | * @and I fill in the required fields 89 | * @and I click the button to place my order 90 | * @then I should see a confirmation that my order has been placed 91 | * @and a order number should be created and show to me 92 | */ 93 | test('Place_order_for_simple_product',{ tag: ['@simple-product-order', '@hot'],}, async ({page}, testInfo) => { 94 | const checkoutPage = new CheckoutPage(page); 95 | let orderNumber = await checkoutPage.placeOrder(); 96 | testInfo.annotations.push({ type: 'Order number', description: `${orderNumber}` }); 97 | }); 98 | }); 99 | 100 | test.describe('Checkout (guest)', () => { 101 | /** 102 | * @feature Discount Code 103 | * @scenario User adds a discount code to their cart 104 | * @given I have a product in my cart 105 | * @and I am on my cart page 106 | * @when I click on the 'add discount code' button 107 | * @then I fill in a code 108 | * @and I click on 'apply code' 109 | * @then I should see a confirmation that my code has been added 110 | * @and the code should be visible in the cart 111 | * @and a discount should be applied to the product 112 | */ 113 | test('Add_coupon_code_in_checkout',{ tag: ['@checkout', '@coupon-code', '@cold']}, async ({page, browserName}) => { 114 | const checkout = new CheckoutPage(page); 115 | const browserEngine = browserName?.toUpperCase() || "UNKNOWN"; 116 | const discountCode = requireEnv(`MAGENTO_COUPON_CODE_${browserEngine}`); 117 | 118 | await checkout.applyDiscountCodeCheckout(discountCode); 119 | }); 120 | 121 | test('Verify_price_calculations_in_checkout', { tag: ['@checkout', '@price-calculation'] }, async ({ page }) => { 122 | const productPage = new ProductPage(page); 123 | const checkoutPage = new CheckoutPage(page); 124 | 125 | // Add product to cart and go to checkout 126 | await productPage.addSimpleProductToCart(UIReference.productPage.simpleProductTitle, slugs.productpage.simpleProductSlug); 127 | await page.goto(slugs.checkout.checkoutSlug); 128 | 129 | // Select shipping method to trigger price calculations 130 | await checkoutPage.shippingMethodOptionFixed.check(); 131 | 132 | // Wait for totals to update 133 | await page.waitForFunction(() => { 134 | const element = document.querySelector('.magewire\\.messenger'); 135 | return element && getComputedStyle(element).height === '0px'; 136 | }); 137 | 138 | // Get all price components using the verifyPriceCalculations method from the CheckoutPage fixture 139 | await checkoutPage.verifyPriceCalculations(); 140 | }); 141 | 142 | /** 143 | * @feature Remove discount code from checkout 144 | * @scenario User has added a discount code, then removes it 145 | * @given I have a product in my cart 146 | * @and I am on the checkout page 147 | * @when I add a discount code 148 | * @then I should see a notification 149 | * @and the code should be visible in the cart 150 | * @and a discount should be applied to a product 151 | * @when I click the 'cancel coupon' button 152 | * @then I should see a notification the discount has been removed 153 | * @and the discount should no longer be visible. 154 | */ 155 | 156 | test('Remove_coupon_code_from_checkout',{ tag: ['@checkout', '@coupon-code', '@cold']}, async ({page, browserName}) => { 157 | const checkout = new CheckoutPage(page); 158 | const browserEngine = browserName?.toUpperCase() || "UNKNOWN"; 159 | const discountCode = requireEnv(`MAGENTO_COUPON_CODE_${browserEngine}`); 160 | 161 | await checkout.applyDiscountCodeCheckout(discountCode); 162 | await checkout.removeDiscountCode(); 163 | }); 164 | 165 | /** 166 | * @feature Incorrect discount code check 167 | * @scenario The user provides an incorrect discount code, the system should reflect that 168 | * @given I have a product in my cart 169 | * @and I am on the cart page 170 | * @when I enter a wrong discount code 171 | * @then I should get a notification that the code did not work. 172 | */ 173 | 174 | test('Invalid_coupon_code_in_checkout_is_rejected',{ tag: ['@checkout', '@coupon-code', '@cold'] }, async ({page}) => { 175 | const checkout = new CheckoutPage(page); 176 | await checkout.enterWrongCouponCode("incorrect discount code"); 177 | }); 178 | 179 | /** 180 | * @feature Payment Method Selection 181 | * @scenario Guest user selects different payment methods during checkout 182 | * @given I have a product in my cart 183 | * @and I am on the checkout page as a guest 184 | * @when I select a payment method 185 | * @and I complete the checkout process 186 | * @then I should see a confirmation that my order has been placed 187 | * @and a order number should be created and shown to me 188 | */ 189 | test('Guest_can_select_payment_methods', { tag: ['@checkout', '@payment-methods', '@cold'] }, async ({ page }) => { 190 | const checkoutPage = new CheckoutPage(page); 191 | 192 | // Test with check/money order payment 193 | await test.step('Place order with check/money order payment', async () => { 194 | await page.goto(slugs.checkout.checkoutSlug); 195 | await checkoutPage.fillShippingAddress(); 196 | await checkoutPage.shippingMethodOptionFixed.check(); 197 | await checkoutPage.selectPaymentMethod('check'); 198 | let orderNumber = await checkoutPage.placeOrder(); 199 | expect(orderNumber, 'Order number should be generated and returned').toBeTruthy(); 200 | }); 201 | }); 202 | }); 203 | -------------------------------------------------------------------------------- /tests/compare.spec.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { test, expect } from '@playwright/test'; 4 | import { UIReference, outcomeMarker, slugs } from 'config'; 5 | 6 | import ComparePage from './poms/frontend/compare.page'; 7 | import LoginPage from './poms/frontend/login.page'; 8 | import ProductPage from './poms/frontend/product.page'; 9 | import { requireEnv } from './utils/env.utils'; 10 | 11 | // TODO: Create a fixture for this 12 | test.beforeEach('Add 2 products to compare, then navigate to comparison page', async ({ page }) => { 13 | await test.step('Add products to compare', async () =>{ 14 | const productPage = new ProductPage(page); 15 | await productPage.addProductToCompare(UIReference.productPage.simpleProductTitle, slugs.productpage.simpleProductSlug); 16 | await productPage.addProductToCompare(UIReference.productPage.secondSimpleProducTitle, slugs.productpage.secondSimpleProductSlug); 17 | }); 18 | 19 | await test.step('Navigate to product comparison page', async () =>{ 20 | await page.goto(slugs.productpage.productComparisonSlug); 21 | await expect(page.getByRole('heading', { name: UIReference.comparePage.comparisonPageTitleText }).locator('span')).toBeVisible(); 22 | }); 23 | }); 24 | 25 | /** 26 | * @feature Add product to cart from the comparison page 27 | * @scenario User adds a product to their cart from the comparison page 28 | * @given I am on the comparison page and have a product in my comparison list 29 | * @when I click the 'add to cart' button 30 | * @then I should see a notification that the product has been added 31 | */ 32 | test('Add_product_to_cart_from_comparison_page',{ tag: ['@comparison-page', '@cold']}, async ({page}) => { 33 | const comparePage = new ComparePage(page); 34 | await comparePage.addToCart(UIReference.productPage.simpleProductTitle); 35 | }); 36 | 37 | /** 38 | * @feature A product cannot be added to the wishlist without being logged in 39 | * @scenario User attempt to add a product to their wishlist from the comparison page 40 | * @given I am on the comparison page and have a product in my comparison list 41 | * @when I click the 'add to wishlist' button 42 | * @then I should see an error message 43 | */ 44 | test('Guests_can_not_add_a_product_to_their_wishlist',{ tag: ['@comparison-page', '@cold']}, async ({page}) => { 45 | const errorMessage = page.locator(UIReference.general.errorMessageLocator); 46 | let productNotWishlistedNotificationText = outcomeMarker.comparePage.productNotWishlistedNotificationText; 47 | let addToWishlistButton = page.getByLabel(`${UIReference.comparePage.addToWishListLabel} ${UIReference.productPage.simpleProductTitle}`); 48 | await addToWishlistButton.click(); 49 | await errorMessage.waitFor(); 50 | await expect(page.getByText(productNotWishlistedNotificationText)).toBeVisible(); 51 | 52 | await expect(page.url()).toContain(slugs.account.loginSlug); 53 | }); 54 | 55 | /** 56 | * @feature Add product to wishlist from the comparison page 57 | * @scenario User adds a product to their wishlist from the comparison page 58 | * @given I am on the comparison page and have a product in my comparison list 59 | * @and I am logged in 60 | * @when I click the 'add to wishlist' button 61 | * @then I should see a notification that the product has been added to my wishlist 62 | */ 63 | test('Add_product_to_wishlist_from_comparison_page',{ tag: ['@comparison-page', '@hot']}, async ({page, browserName}) => { 64 | await test.step('Log in with account', async () =>{ 65 | const loginPage = new LoginPage(page); 66 | const browserEngine = browserName?.toUpperCase() || "UNKNOWN"; 67 | const emailInputValue = requireEnv(`MAGENTO_EXISTING_ACCOUNT_EMAIL_${browserEngine}`); 68 | const passwordInputValue = requireEnv('MAGENTO_EXISTING_ACCOUNT_PASSWORD'); 69 | 70 | await loginPage.login(emailInputValue, passwordInputValue); 71 | }); 72 | 73 | await test.step('Add product to compare', async () =>{ 74 | const productPage = new ProductPage(page); 75 | await page.goto(slugs.productpage.productComparisonSlug); 76 | await productPage.addProductToCompare(UIReference.productPage.simpleProductTitle, slugs.productpage.simpleProductSlug); 77 | }); 78 | 79 | await test.step('Add product to wishlist', async () =>{ 80 | const comparePage = new ComparePage(page); 81 | await comparePage.addToWishList(UIReference.productPage.simpleProductTitle); 82 | 83 | //TODO: Also remove the product for clear testing environment) 84 | }); 85 | }); 86 | 87 | 88 | 89 | test.afterEach('Remove products from compare', async ({ page }) => { 90 | // ensure we are on the right page 91 | await page.goto(slugs.productpage.productComparisonSlug); 92 | 93 | page.on('dialog', dialog => dialog.accept()); 94 | const comparePage = new ComparePage(page); 95 | await comparePage.removeProductFromCompare(UIReference.productPage.simpleProductTitle); 96 | await comparePage.removeProductFromCompare(UIReference.productPage.secondSimpleProducTitle); 97 | }); -------------------------------------------------------------------------------- /tests/config/element-identifiers.json: -------------------------------------------------------------------------------- 1 | { 2 | "accountCreation": { 3 | "createAccountButtonLabel": "Create an Account" 4 | }, 5 | "accountDashboard": { 6 | "accountDashboardTitleLabel": "Account Information", 7 | "accountSideBarLabel": "Sidebar Main", 8 | "addAddressButtonLabel": "ADD NEW ADDRESS", 9 | "addressBookArea": ".block-addresses-list", 10 | "addressDeleteIconButton": "trash", 11 | "editAddressIconButton": "pencil-alt", 12 | "accountInformationFieldLocator": ".column > div > div > .flex", 13 | "links": { 14 | "newsletterLink": "Newsletter Subscriptions" 15 | } 16 | }, 17 | "newAddress": { 18 | "addNewAddressTitle": "Add New Address", 19 | "companyNameLabel": "Company", 20 | "phoneNumberLabel": "Phone Number", 21 | "streetAddressLabel": "Street Address", 22 | "zipCodeLabel": "Zip/Postal Code", 23 | "cityNameLabel": "City", 24 | "countryLabel": "Country", 25 | "provinceSelectLabel": "State/Province", 26 | "provinceSelectFilterLabel": "Please select a region, state or province.", 27 | "saveAdressButton": "Save Address" 28 | }, 29 | "newsletterSubscriptions": { 30 | "generalSubscriptionCheckLabel": "General Subscription", 31 | "saveSubscriptionsButton": "Save" 32 | }, 33 | "productPage": { 34 | "addToCartButtonLocator": "shopping-cart Add to Cart", 35 | "addToCompareButtonLabel": "Add to Compare", 36 | "addToWishlistButtonLabel": "Add to Wish List", 37 | "simpleProductTitle": "Push It Messenger Bag", 38 | "secondSimpleProducTitle": "Aim Analog Watch", 39 | "simpleProductPrice": ".final-price .price-wrapper .price", 40 | "configurableProductTitle": "Inez Full Zip Jacket", 41 | "configurableProductSizeLabel": "Size", 42 | "configurableProductColorLabel": "Color", 43 | "configurableProductOptionForm": "#product_addtocart_form", 44 | "configurableProductOptionValue": ".product-option-value-label", 45 | "quantityFieldLabel": "Quantity", 46 | "fullScreenOpenLabel": "Click to view image in", 47 | "fullScreenCloseLabel": "Close fullscreen", 48 | "thumbnailImageLabel": "View larger image", 49 | "reviewCountLabel": "Show items per page" 50 | }, 51 | "categoryPage":{ 52 | "activeViewLocator": ".active", 53 | "categoryPageTitleText": "Women", 54 | "firstFilterOptionLocator": "#filter-option-0-content", 55 | "itemsOnPageAmountLocator": ".toolbar-number", 56 | "itemsPerPageButtonLabel": "Show items per page", 57 | "productGridLocator": ".products-grid", 58 | "removeActiveFilterButtonLabel": "Remove active", 59 | "sizeFilterButtonLabel": "Size filter", 60 | "sizeLButtonLocator": "a.swatch-option-link-layered[aria-label*=\"Filter Size L\"]", 61 | "sortByButtonLabel": "Sort by", 62 | "sortByButtonLocator": ".form-select.sorter-options", 63 | "viewSwitchLabel": "Products view mode", 64 | "viewGridLabel": "Products view mode - Grid", 65 | "viewListLabel": "Products view mode - List" 66 | }, 67 | "cart": { 68 | "applyDiscountButtonLabel": "Apply Discount", 69 | "cancelCouponButtonLabel": "Cancel Coupon", 70 | "cartTitleText": "Shopping Cart", 71 | "cartQuantityLabel": "Qty", 72 | "discountInputFieldLabel": "Enter discount code", 73 | "showDiscountFormButtonLabel": "Apply Discount Code", 74 | "updateItemButtonLabel": "shopping-cart Update item", 75 | "updateShoppingCartButtonLabel": "Update Shopping Cart" 76 | }, 77 | "cartPriceRulesPage": { 78 | "actionsSubtitleLabel": "Actions", 79 | "addCartPriceRuleButtonLabel": "Add New Rule", 80 | "couponCodeFieldLabel": "Coupon Code", 81 | "couponTypeSelectField": "select[name='coupon_type']", 82 | "customerGroupsSelectLabel": "Customer Groups", 83 | "discountAmountFieldLabel": "Discount Amount", 84 | "ruleNameFieldLabel": "Rule Name", 85 | "saveRuleButtonLabel": "Save", 86 | "websitesSelectLabel": "Websites" 87 | }, 88 | "checkout": { 89 | "applyDiscountButtonLabel": "Apply Coupon", 90 | "applyDiscountCodeLabel": "Apply Discount Code", 91 | "cancelDiscountButtonLabel": "Cancel Coupon", 92 | "cartDetailsLocator": "#checkout-cart-details div", 93 | "continueShoppingLabel": "Continue Shopping", 94 | "discountInputFieldLabel": "Enter discount code", 95 | "openCartButtonLabel": "Cart", 96 | "openCartButtonLabelCont": "item", 97 | "openCartButtonLabelContMultiple": "items", 98 | "openDiscountFormLabel": "Apply Discount Code", 99 | "paymentOptionCheckLabel": "Check / Money order Free", 100 | "paymentOptionCreditCardLabel": "Credit Card", 101 | "paymentOptionPaypalLabel": "PayPal", 102 | "creditCardNumberLabel": "Credit Card Number", 103 | "creditCardExpiryLabel": "Expiration Date", 104 | "creditCardCVVLabel": "Card Verification Number", 105 | "creditCardNameLabel": "Name on Card", 106 | "placeOrderButtonLabel": "Place Order", 107 | "remove": "Remove", 108 | "shippingAddressRadioLocator": "#shipping-details input[type='radio']", 109 | "shippingMethodFixedLabel": "Fixed" 110 | }, 111 | "comparePage": { 112 | "removeCompareLabel": "Remove Product", 113 | "addToWishListLabel": "Add to Wish List", 114 | "comparisonPageEmptyText": "You have no items to compare.", 115 | "comparisonPageTitleText": "Compare Products" 116 | }, 117 | "configurationPage": { 118 | "advancedAdministrationTabLabel": "Admin", 119 | "advancedTabLabel": "Advanced", 120 | "allowMultipleLoginsSelectField": "#admin_security_admin_account_sharing", 121 | "allowMultipleLoginsSystemCheckbox": "#admin_security_admin_account_sharing_inherit", 122 | "captchaSettingSelectField": "#customer_captcha_enable", 123 | "captchaSettingSystemCheckbox": "#customer_captcha_enable_inherit", 124 | "captchaSectionLabel": "CAPTCHA", 125 | "customerConfigurationTabLabel": "Customer Configuration", 126 | "customersTabLabel": "Customers", 127 | "saveConfigButtonLabel": "Save Config", 128 | "securitySectionLabel": "Security" 129 | }, 130 | "contactPage": { 131 | "messageFieldSelector": "#comment" 132 | }, 133 | "credentials": { 134 | "currentPasswordFieldLabel": "Current Password", 135 | "emailFieldLabel": "Email", 136 | "emailCheckoutFieldLabel": "Email address", 137 | "loginButtonLabel": "Sign In", 138 | "nameFieldLabel": "Name", 139 | "newPasswordConfirmFieldLabel": "Confirm New Password", 140 | "newPasswordFieldLabel": "New Password", 141 | "passwordConfirmFieldLabel": "Confirm Password", 142 | "passwordFieldLabel": "Password" 143 | }, 144 | "general": { 145 | "addToCartLabel": "Add to Cart", 146 | "closeMessageLabel": "Close message", 147 | "errorMessageLocator": "div.message.error", 148 | "genericPriceLabel": "Price", 149 | "genericPriceSymbol": "$", 150 | "genericSaveButtonLabel": "Save", 151 | "genericSubmitButtonLabel": "Submit", 152 | "messageLocator": "div.message", 153 | "removeLabel": "Remove", 154 | "successMessageLocator": "div.message.success" 155 | }, 156 | "homePage": { 157 | "homePageTitleText": "Hyvä Themes" 158 | }, 159 | "magentoAdminPage": { 160 | "loginButtonLabel": "Sign In", 161 | "navigation": { 162 | "marketingButtonLabel": "Marketing", 163 | "storesButtonLabel": "Stores" 164 | }, 165 | "passwordFieldLabel": "Password", 166 | "subNavigation": { 167 | "cartPriceRulesButtonLabel": "Cart Price Rules", 168 | "configurationButtonLabel": "Configuration" 169 | }, 170 | "usernameFieldLabel": "Username" 171 | }, 172 | "mainMenu": { 173 | "miniCartLabel": "Toggle minicart", 174 | "myAccountButtonLabel": "My Account", 175 | "myAccountLogoutItem": "Sign Out" 176 | }, 177 | "miniCart": { 178 | "checkOutButtonLabel": "Checkout", 179 | "editProductIconLabel": "Edit product", 180 | "minicartButtonLocator": "#menu-cart-icon", 181 | "minicartAmountBubbleLocator": "#menu-cart-icon > span", 182 | "minicartPriceFieldClass": ".price-excluding-tax .minicart-price .price", 183 | "miniCartToggleLabelEmpty": "Cart is empty", 184 | "miniCartToggleLabelMultiItem": "items", 185 | "miniCartToggleLabelOneItem": "1 item", 186 | "miniCartToggleLabelPrefix": "Toggle minicart,", 187 | "productQuantityFieldLabel": "Quantity", 188 | "removeProductIconLabel": "Remove product", 189 | "toCartLinkLabel": "View and Edit Cart" 190 | }, 191 | "search": { 192 | "searchToggleLocator": "#menu-search-icon", 193 | "searchInputLocator": "#search", 194 | "suggestionBoxLocator": "#search_autocomplete" 195 | }, 196 | "personalInformation": { 197 | "changePasswordCheckLabel": "Change Password", 198 | "changeEmailCheckLabel": "Change Email", 199 | "firstNameLabel": "First Name", 200 | "lastNameLabel": "Last Name" 201 | }, 202 | "wishListPage": { 203 | "wishListItemGridLabel": "#wishlist-view-form", 204 | "updateCompareListButtonLabel": "Update Wish List" 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /tests/config/input-values.json: -------------------------------------------------------------------------------- 1 | { 2 | "accountCreation": { 3 | "emailHandleValue":"test-user", 4 | "emailHostValue": "gmail.com", 5 | "firstNameValue": "John", 6 | "lastNameValue": "Doe" 7 | }, 8 | "adminLogins": { 9 | "allowMultipleLogins": "Yes" 10 | }, 11 | "captcha": { 12 | "captchaDisabled": "No" 13 | }, 14 | "contact": { 15 | "contactFormEmailValue": "robertbaratheon@gameofthrones.com", 16 | "contactFormMessage": "Hello! I am filling out this form as a test only. Feel free to ignore this message." 17 | }, 18 | "coupon": { 19 | "couponCodeRuleName": "Test coupon", 20 | "couponType": "Specific Coupon" 21 | }, 22 | "addressCountries": [ 23 | "Netherlands", 24 | "United Kingdom" 25 | ], 26 | "editedAddress": { 27 | "editCityValue": "Pallet Town", 28 | "editCompanyNameValue": "Pokémon", 29 | "editfirstNameValue": "Ash", 30 | "editLastNameValue": "Ketchum", 31 | "editStateValue": "Kansas", 32 | "editStreetAddressValue": "House on the left", 33 | "editZipCodeValue": "00151" 34 | }, 35 | "firstAddress": { 36 | "firstCityValue": "Testing Valley", 37 | "firstCompanyNameValue": "ACME Company", 38 | "firstNonDefaultCountry": "Netherlands", 39 | "firstPhoneNumberValue": "0622000000", 40 | "firstProvinceValue": "Idaho", 41 | "firstStreetAddressValue": "Testingstreet 1", 42 | "firstZipCodeValue": "12345" 43 | }, 44 | "payment": { 45 | "creditCard": { 46 | "number": "4111111111111111", 47 | "expiry": "12/25", 48 | "cvv": "123", 49 | "name": "Test User" 50 | } 51 | }, 52 | "secondAddress": { 53 | "secondCityValue": "Little Whinging", 54 | "secondCompanyNameValue": "Hogwarts", 55 | "secondNonDefaultCountry": "United Kingdom", 56 | "secondPhoneNumberValue": "0620081998", 57 | "secondProvinceValue": "South Dakota", 58 | "secondStreetAddressValue": "Under the Stairs, 4 Privet Drive", 59 | "secondZipCodeValue": "67890" 60 | }, 61 | "search": { 62 | "queryMultipleResults": "bag", 63 | "querySpecificProduct": "Push It Messenger Bag", 64 | "queryNoResults": "sdfasdfasddd" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /tests/config/outcome-markers.json: -------------------------------------------------------------------------------- 1 | { 2 | "account": { 3 | "accountCreatedNotificationText": "Thank you for registering with Main Website Store.", 4 | "accountPageTitle": "Account Information", 5 | "addressBookTitle": "Customer Address", 6 | "changedPasswordNotificationText": "You saved the account", 7 | "newsletterRemovedNotification": "We have removed your newsletter subscription.", 8 | "newsletterSavedNotification": "We have saved your subscription.", 9 | "newsletterSubscriptionTitle": "Newsletter Subscription", 10 | "newsletterUpdatedNotification": "We have updated your subscription." 11 | }, 12 | "address": { 13 | "addressDeletedNotification": "You deleted the address.", 14 | "newAddressAddedNotifcation": "You saved the address." 15 | }, 16 | "cart": { 17 | "discountAppliedNotification": "You used coupon code", 18 | "discountRemovedNotification": "You canceled the coupon code.", 19 | "incorrectCouponCodeNotificationOne": "The coupon code", 20 | "incorrectCouponCodeNotificationTwo": "is not valid.", 21 | "priceReducedSymbols": "- $" 22 | }, 23 | "checkout": { 24 | "checkoutPriceReducedSymbol": "-$", 25 | "couponAppliedNotification": "Your coupon was successfully applied", 26 | "couponRemovedNotification": "Your coupon was successfully removed", 27 | "incorrectDiscountNotification": "The coupon code isn't valid. Verify the code and try again.", 28 | "orderPlacedNotification": "Thank you for your purchase!", 29 | "orderPlacedNumberText": "Your order number is" 30 | }, 31 | "comparePage": { 32 | "productRemovedNotificationTextOne": "You removed product", 33 | "productRemovedNotificationTextTwo": "from the comparison list.", 34 | "productNotWishlistedNotificationText": "You must login or register to add items to your wishlist." 35 | }, 36 | "contactPage": { 37 | "messageSentConfirmationText": "Thanks for contacting us with" 38 | }, 39 | "homePage": { 40 | "firstProductName": "Aim Analog Watch" 41 | }, 42 | "logout": { 43 | "logoutConfirmationText": "You have signed out" 44 | }, 45 | "miniCart": { 46 | "configurableProductMinicartTitle": "x Inez Full Zip Jacket", 47 | "miniCartTitle": "My Cart", 48 | "productQuantityChangedConfirmation": "was updated in your shopping cart", 49 | "productRemovedConfirmation": "You removed the item.", 50 | "simpleProductInCartTitle": "x Push It Messenger Bag" 51 | }, 52 | "productPage": { 53 | "borderClassRegex": ".* border-primary$", 54 | "simpleProductAddedNotification": "You added" 55 | }, 56 | "login": { 57 | "invalidCredentialsMessage": "The account sign-in was incorrect or your account is disabled temporarily. Please wait and try again later." 58 | }, 59 | "search": { 60 | "noResultsMessage": "Your search returned no results." 61 | }, 62 | "wishListPage": { 63 | "wishListAddedNotification": "has been added to your Wish List." 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /tests/config/slugs.json: -------------------------------------------------------------------------------- 1 | { 2 | "account": { 3 | "accountOverviewSlug": "/customer/account/", 4 | "addressBookSlug": "/customer/address", 5 | "addressIndexSlug": "/customer/address/index", 6 | "addressNewSlug": "customer/address/new", 7 | "accountEditSlug": "/customer/account/edit/", 8 | "changePasswordSlug": "/customer/account/edit/changepass/1/", 9 | "createAccountSlug": "/customer/account/create", 10 | "loginSlug": "/customer/account/login", 11 | "orderHistorySlug": "/sales/order/history/" 12 | }, 13 | "cart": { 14 | "cartProductChangeSlug": "/cart/configure/", 15 | "cartSlug": "/checkout/cart/" 16 | }, 17 | "categoryPage": { 18 | "categorySlug": "/women.html" 19 | }, 20 | "checkout": { 21 | "checkoutSlug": "/checkout/" 22 | }, 23 | "contact": { 24 | "contactSlug": "/contact" 25 | }, 26 | "productpage": { 27 | "configurableProductSlug": "/inez-full-zip-jacket.html", 28 | "productComparisonSlug": "/catalog/product_compare/index/", 29 | "secondSimpleProductSlug": "/aim-analog-watch.html", 30 | "simpleProductSlug": "/push-it-messenger-bag.html" 31 | }, 32 | "search": { 33 | "resultsSlug": "/catalogsearch/result/" 34 | }, 35 | "wishlist": { 36 | "wishListRegex": ".*wishlist.*" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tests/config/test-toggles.json: -------------------------------------------------------------------------------- 1 | { 2 | "general": { 3 | "setup": false 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tests/contact.spec.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { test } from '@playwright/test'; 4 | import ContactPage from './poms/frontend/contact.page'; 5 | 6 | /** 7 | * @feature Magento 2 Contact Form 8 | * @scenario User fills in the contact form and sends a message 9 | * @given I om any Magento 2 page 10 | * @when I navigate to the contact page 11 | * @and I fill in the required fields 12 | * @when I click the button to send the form 13 | * @then I should see a notification my message has been sent 14 | * @and the fields should be empty again. 15 | */ 16 | test('Send_message_through_contact_form',{ tag: ['@contact-form', '@cold']}, async ({page}) => { 17 | const contactPage = new ContactPage(page); 18 | await contactPage.fillOutForm(); 19 | }); 20 | -------------------------------------------------------------------------------- /tests/healthcheck.spec.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { test, expect } from '@playwright/test'; 4 | import { UIReference, slugs } from 'config'; 5 | 6 | test.describe('Page health checks', () => { 7 | test('Homepage_returns_200', { tag: ['@smoke', '@cold'] }, async ({page}) => { 8 | const homepageURL = process.env.PLAYWRIGHT_BASE_URL || process.env.BASE_URL; 9 | if (!homepageURL) { 10 | throw new Error("PLAYWRIGHT_BASE_URL has not been defined in the .env file."); 11 | } 12 | 13 | const homepageResponsePromise = page.waitForResponse(homepageURL); 14 | await page.goto(homepageURL); 15 | const homepageResponse = await homepageResponsePromise; 16 | expect(homepageResponse.status(), 'Homepage should return 200').toBe(200); 17 | 18 | await expect( 19 | page.getByRole('heading', {name: UIReference.homePage.homePageTitleText}), 20 | 'Homepage has a visible title' 21 | ).toBeVisible(); 22 | }); 23 | 24 | test('Plp_returns_200', { tag: ['@smoke', '@cold'] }, async ({page}) => { 25 | const plpResponsePromise = page.waitForResponse(slugs.categoryPage.categorySlug); 26 | await page.goto(slugs.categoryPage.categorySlug); 27 | const plpResponse = await plpResponsePromise; 28 | expect(plpResponse.status(), 'PLP should return 200').toBe(200); 29 | 30 | await expect( 31 | page.getByRole('heading', {name: UIReference.categoryPage.categoryPageTitleText}), 32 | 'PLP has a visible title' 33 | ).toBeVisible(); 34 | }); 35 | 36 | test('Pdp_returns_200', { tag: ['@smoke', '@cold'] }, async ({page}) => { 37 | const pdpResponsePromise = page.waitForResponse(slugs.productpage.simpleProductSlug); 38 | await page.goto(slugs.productpage.simpleProductSlug); 39 | const pdpResponse = await pdpResponsePromise; 40 | expect(pdpResponse.status(), 'PDP should return 200').toBe(200); 41 | 42 | await expect( 43 | page.getByRole('heading', {level: 1, name: UIReference.productPage.simpleProductTitle}), 44 | 'PDP has a visible title' 45 | ).toBeVisible(); 46 | }); 47 | 48 | test('Checkout_returns_200', { tag: ['@smoke', '@cold'] }, async ({page}) => { 49 | const responsePromise = page.waitForResponse(slugs.checkout.checkoutSlug); 50 | 51 | await page.goto(slugs.checkout.checkoutSlug); 52 | const response = await responsePromise; 53 | 54 | expect(response.status(), 'Cart empty, checkout should return 302').toBe(302); 55 | expect(page.url(), 'Cart empty, checkout should redirect to cart').toContain(slugs.cart.cartSlug); 56 | 57 | await expect( 58 | page.getByRole('heading', {name: UIReference.cart.cartTitleText}), 59 | 'Cart has a visible title' 60 | ).toBeVisible(); 61 | 62 | expect((await page.request.head(page.url())).status(), `Current page (${page.url()}) should return 200`).toBe(200); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /tests/home.spec.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { test, expect } from '@playwright/test'; 4 | import { outcomeMarker } from 'config'; 5 | 6 | import MainMenuPage from './poms/frontend/mainmenu.page'; 7 | import HomePage from './poms/frontend/home.page'; 8 | 9 | test('Add_product_on_homepage_to_cart',{ tag: ['@homepage', '@cold']}, async ({page}) => { 10 | const homepage = new HomePage(page); 11 | const mainmenu = new MainMenuPage(page); 12 | 13 | await page.goto(''); 14 | await homepage.addHomepageProductToCart(); 15 | await mainmenu.openMiniCart(); 16 | await expect(page.getByText('x ' + outcomeMarker.homePage.firstProductName), 'product should be visible in cart').toBeVisible(); 17 | }); 18 | -------------------------------------------------------------------------------- /tests/login.spec.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { test as base, expect } from '@playwright/test'; 4 | import { outcomeMarker, inputValues } from 'config'; 5 | import { requireEnv } from './utils/env.utils'; 6 | 7 | import LoginPage from './poms/frontend/login.page'; 8 | 9 | base('User_logs_in_with_valid_credentials', {tag: '@hot'}, async ({page, browserName}) => { 10 | const browserEngine = browserName?.toUpperCase() || "UNKNOWN"; 11 | // We can't move this browser specific check inside LoginPage because the 12 | // variable name differs per browser engine. 13 | const emailInputValue = requireEnv(`MAGENTO_EXISTING_ACCOUNT_EMAIL_${browserEngine}`); 14 | const passwordInputValue = requireEnv('MAGENTO_EXISTING_ACCOUNT_PASSWORD'); 15 | 16 | const loginPage = new LoginPage(page) 17 | await loginPage.login(emailInputValue, passwordInputValue); 18 | await page.waitForLoadState('networkidle'); 19 | 20 | // Check customer section data in localStorage and verify name 21 | const customerData = await page.evaluate(() => { 22 | const data = localStorage.getItem('mage-cache-storage'); 23 | return data ? data : null; 24 | }); 25 | 26 | expect(customerData, 'Customer data should exist in localStorage').toBeTruthy(); 27 | expect(customerData, 'Customer data should contain customer information').toContain('customer'); 28 | 29 | // Parse the JSON and verify firstname and lastname 30 | const parsedData = await page.evaluate(() => { 31 | const data = localStorage.getItem('mage-cache-storage'); 32 | return data ? JSON.parse(data) : null; 33 | }); 34 | 35 | expect(parsedData.customer.firstname, 'Customer firstname should match').toBe(inputValues.accountCreation.firstNameValue); 36 | expect(parsedData.customer.fullname, 'Customer lastname should match').toContain(inputValues.accountCreation.lastNameValue); 37 | }); 38 | 39 | base('Invalid_credentials_are_rejected', async ({page}) => { 40 | const loginPage = new LoginPage(page); 41 | await loginPage.loginExpectError('invalid@example.com', 'wrongpassword', outcomeMarker.login.invalidCredentialsMessage); 42 | }); 43 | 44 | base('Login_fails_with_missing_password', async ({page}) => { 45 | const loginPage = new LoginPage(page); 46 | await loginPage.loginExpectError('invalid@example.com', '', ''); 47 | }); 48 | -------------------------------------------------------------------------------- /tests/mainmenu.spec.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { test } from '@playwright/test'; 4 | import { UIReference, slugs } from 'config'; 5 | 6 | import LoginPage from './poms/frontend/login.page'; 7 | import MainMenuPage from './poms/frontend/mainmenu.page'; 8 | import ProductPage from './poms/frontend/product.page'; 9 | import { requireEnv } from './utils/env.utils'; 10 | 11 | // no resetting storageState, mainmenu has more functionalities when logged in. 12 | 13 | // Before each test, log in 14 | test.beforeEach(async ({ page, browserName }) => { 15 | const browserEngine = browserName?.toUpperCase() || "UNKNOWN"; 16 | const emailInputValue = requireEnv(`MAGENTO_EXISTING_ACCOUNT_EMAIL_${browserEngine}`); 17 | const passwordInputValue = requireEnv('MAGENTO_EXISTING_ACCOUNT_PASSWORD'); 18 | 19 | const loginPage = new LoginPage(page); 20 | await loginPage.login(emailInputValue, passwordInputValue); 21 | }); 22 | 23 | /** 24 | * @feature Logout 25 | * @scenario The user can log out 26 | * @given I am logged in 27 | * @and I am on any Magento 2 page 28 | * @when I open the account menu 29 | * @and I click the Logout option 30 | * @then I should see a message confirming I am logged out 31 | */ 32 | test('User_logs_out', { tag: ['@mainmenu', '@hot'] }, async ({page}) => { 33 | const mainMenu = new MainMenuPage(page); 34 | await mainMenu.logout(); 35 | }); 36 | 37 | 38 | test('Navigate_to_account_page', { tag: ['@mainmenu', '@hot'] }, async ({page}) => { 39 | const mainMenu = new MainMenuPage(page); 40 | await mainMenu.gotoMyAccount(); 41 | }); 42 | 43 | test('Open_the_minicart', { tag: ['@mainmenu', '@cold'] }, async ({page}, testInfo) => { 44 | testInfo.annotations.push({ type: 'WARNING (FIREFOX)', description: `The minicart icon does not lose its aria-disabled=true flag when the first product is added. This prevents Playwright from clicking it. A fix will be added in the future.`}); 45 | 46 | const mainMenu = new MainMenuPage(page); 47 | const productPage = new ProductPage(page); 48 | 49 | await productPage.addSimpleProductToCart(UIReference.productPage.simpleProductTitle, slugs.productpage.simpleProductSlug); 50 | await mainMenu.openMiniCart(); 51 | }); 52 | -------------------------------------------------------------------------------- /tests/minicart.spec.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import {test, expect} from '@playwright/test'; 4 | import {UIReference, outcomeMarker, slugs} from 'config'; 5 | 6 | import MainMenuPage from './poms/frontend/mainmenu.page'; 7 | import ProductPage from './poms/frontend/product.page'; 8 | import MiniCartPage from './poms/frontend/minicart.page'; 9 | 10 | test.describe('Minicart Actions', {annotation: {type: 'Minicart', description: 'Minicart simple product tests'},}, () => { 11 | 12 | /** 13 | * @feature BeforeEach runs before each test in this group. 14 | * @scenario Add a product to the cart and confirm it's there. 15 | * @given I am on any page 16 | * @when I navigate to a (simple) product page 17 | * @and I add it to my cart 18 | * @then I should see a notification 19 | * @when I click the cart in the main menu 20 | * @then the minicart should become visible 21 | * @and I should see the product in the minicart 22 | */ 23 | test.beforeEach(async ({ page }) => { 24 | const mainMenu = new MainMenuPage(page); 25 | const productPage = new ProductPage(page); 26 | 27 | await page.goto(slugs.productpage.simpleProductSlug); 28 | await productPage.addSimpleProductToCart(UIReference.productPage.simpleProductTitle, slugs.productpage.simpleProductSlug); 29 | await mainMenu.openMiniCart(); 30 | await expect(page.getByText(outcomeMarker.miniCart.simpleProductInCartTitle)).toBeVisible(); 31 | }); 32 | 33 | /** 34 | * @feature Magento 2 Minicart to Checkout 35 | * @scenario User adds a product to cart, then uses minicart to navigate to checkout 36 | * @given I have added a (simple) product to the cart and opened the minicart 37 | * @when I click on the 'to checkout' button 38 | * @then I should navigate to the checkout page 39 | */ 40 | 41 | test('Add_product_to_minicart_and_go_to_checkout',{ tag: ['@minicart-simple-product', '@cold']}, async ({page}) => { 42 | const miniCart = new MiniCartPage(page); 43 | await miniCart.goToCheckout(); 44 | }); 45 | 46 | /** 47 | * @feature Magento 2 Minicart to Cart 48 | * @scenario User adds a product to cart, then uses minicart to navigate to their cart 49 | * @given I have added a (simple) product to the cart and opened the minicart 50 | * @when I click on the 'to cart' link 51 | * @then I should be navigated to the cart page 52 | */ 53 | 54 | test('Add_product_to_minicart_and_go_to_cart',{ tag: ['@minicart-simple-product', '@cold']}, async ({page}) => { 55 | const miniCart = new MiniCartPage(page); 56 | await miniCart.goToCart(); 57 | }); 58 | 59 | /** 60 | * @feature Magento 2 Minicart quantity change 61 | * @scenario User adds a product to the minicart, then changes the quantity using the minicart 62 | * @given I have added a (simple) product to the cart and opened the minicart 63 | * @when I click on the pencil for the product I want to update 64 | * @then I should navigate to a product page that is in my cart 65 | * @when I change the amount 66 | * @and I click the 'update item' button 67 | * @then I should see a confirmation 68 | * @and the new amount should be shown in the minicart 69 | */ 70 | test('Change_product_quantity_in_minicart',{ tag: ['@minicart-simple-product', '@cold']}, async ({page}) => { 71 | const miniCart = new MiniCartPage(page); 72 | await miniCart.updateProduct('3'); 73 | }); 74 | 75 | /** 76 | * @feature Magento 2 minicart product deletion 77 | * @scenario User adds product to cart, then removes from minicart 78 | * @given I have added a (simple) product to the cart and opened the minicart 79 | * @when I click on the delete button 80 | * @then The product should not be in my cart anymore 81 | * @and I should see a notification that the product was removed 82 | */ 83 | test('Delete_product_from_minicart',{ tag: ['@minicart-simple-product', '@cold']}, async ({page}, testInfo) => { 84 | testInfo.annotations.push({ type: 'WARNING (FIREFOX)', description: `The minicart icon does not lose its aria-disabled=true flag when the first product is added. This prevents Playwright from clicking it. A fix will be added in the future.`}); 85 | const miniCart = new MiniCartPage(page); 86 | await miniCart.removeProductFromMinicart(UIReference.productPage.simpleProductTitle); 87 | }); 88 | 89 | /** 90 | * @feature Price Check: Simple Product on Product Detail Page (PDP) and Minicart 91 | * @scenario The price on a PDP should be the same as the price in the minicart 92 | * @given I have added a (simple) product to the cart and opened the minicart 93 | * @then the price listed in the minicart (per product) should be the same as the price on the PDP 94 | */ 95 | test('Pdp_price_matches_minicart_price',{ tag: ['@minicart-simple-product', '@cold']}, async ({page}) => { 96 | const miniCart = new MiniCartPage(page); 97 | await miniCart.checkPriceWithProductPage(); 98 | }); 99 | }); 100 | 101 | test.describe('Minicart Actions', {annotation: {type: 'Minicart', description: 'Minicart configurable product tests'},}, () => { 102 | /** 103 | * @feature BeforeEach runs before each test in this group. 104 | * @scenario Add a configurable product to the cart and confirm it's there. 105 | * @given I am on any page 106 | * @when I navigate to a (simple) product page 107 | * @and I add it to my cart 108 | * @then I should see a notification 109 | * @when I click the cart in the main menu 110 | * @then the minicart should become visible 111 | * @and I should see the product in the minicart 112 | */ 113 | test.beforeEach(async ({ page }) => { 114 | const mainMenu = new MainMenuPage(page); 115 | const productPage = new ProductPage(page); 116 | 117 | await page.goto(slugs.productpage.configurableProductSlug); 118 | await productPage.addConfigurableProductToCart(UIReference.productPage.configurableProductTitle, slugs.productpage.configurableProductSlug, '2'); 119 | await mainMenu.openMiniCart(); 120 | await expect(page.getByText(outcomeMarker.miniCart.configurableProductMinicartTitle)).toBeVisible(); 121 | }); 122 | 123 | /** 124 | * @feature Price Check: Configurable Product on Product Detail Page (PDP) and Minicart 125 | * @scenario The price on a PDP should be the same as the price in the minicart 126 | * @given I have added a (configurable) product to the cart and opened the minicart 127 | * @then the price listed in the minicart (per product) should be the same as the price on the PDP 128 | */ 129 | test('Configurable_pdp_price_matches_minicart_price',{ tag: ['@minicart-simple-product', '@cold']}, async ({page}) => { 130 | const miniCart = new MiniCartPage(page); 131 | await miniCart.checkPriceWithProductPage(); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /tests/orderhistory.spec.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { test } from '@playwright/test'; 4 | import { UIReference, slugs } from 'config'; 5 | import { requireEnv } from './utils/env.utils'; 6 | 7 | import LoginPage from './poms/frontend/login.page'; 8 | import ProductPage from './poms/frontend/product.page'; 9 | import CheckoutPage from './poms/frontend/checkout.page'; 10 | import OrderHistoryPage from './poms/frontend/orderhistory.page'; 11 | 12 | test('Recent_order_is_visible_in_history', async ({ page, browserName }) => { 13 | const browserEngine = browserName?.toUpperCase() || 'UNKNOWN'; 14 | const emailInputValue = requireEnv(`MAGENTO_EXISTING_ACCOUNT_EMAIL_${browserEngine}`); 15 | const passwordInputValue = requireEnv('MAGENTO_EXISTING_ACCOUNT_PASSWORD'); 16 | 17 | const loginPage = new LoginPage(page); 18 | const productPage = new ProductPage(page); 19 | const checkoutPage = new CheckoutPage(page); 20 | const orderHistoryPage = new OrderHistoryPage(page); 21 | 22 | await loginPage.login(emailInputValue, passwordInputValue); 23 | 24 | await page.goto(slugs.productpage.simpleProductSlug); 25 | await productPage.addSimpleProductToCart(UIReference.productPage.simpleProductTitle, slugs.productpage.simpleProductSlug); 26 | await page.goto(slugs.checkout.checkoutSlug); 27 | const orderNumberLocator = await checkoutPage.placeOrder(); 28 | const orderNumberText = await orderNumberLocator.innerText(); 29 | const orderNumber = orderNumberText.replace(/\D/g, ''); 30 | 31 | await orderHistoryPage.open(); 32 | await orderHistoryPage.verifyOrderPresent(orderNumber); 33 | }); 34 | -------------------------------------------------------------------------------- /tests/poms/adminhtml/magentoAdmin.page.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { expect, type Locator, type Page } from '@playwright/test'; 4 | import { UIReference, inputValues } from 'config'; 5 | 6 | class MagentoAdminPage { 7 | readonly page: Page; 8 | readonly adminLoginEmailField: Locator; 9 | readonly adminLoginPasswordField: Locator; 10 | readonly adminLoginButton: Locator; 11 | 12 | constructor(page: Page) { 13 | this.page = page; 14 | this.adminLoginEmailField = page.getByLabel(UIReference.magentoAdminPage.usernameFieldLabel); 15 | this.adminLoginPasswordField = page.getByLabel(UIReference.magentoAdminPage.passwordFieldLabel); 16 | this.adminLoginButton = page.getByRole('button', {name: UIReference.magentoAdminPage.loginButtonLabel}); 17 | } 18 | 19 | async login(username: string, password: string){ 20 | if(!process.env.MAGENTO_ADMIN_SLUG) { 21 | throw new Error("MAGENTO_ADMIN_SLUG is not defined in your .env file."); 22 | } 23 | 24 | await this.page.goto(process.env.MAGENTO_ADMIN_SLUG); 25 | await this.page.waitForLoadState('networkidle'); 26 | await this.adminLoginEmailField.fill(username); 27 | await this.adminLoginPasswordField.fill(password); 28 | await this.adminLoginButton.click(); 29 | await this.page.waitForLoadState('networkidle'); 30 | } 31 | 32 | async addCartPriceRule(magentoCouponCode: string){ 33 | if(!process.env.MAGENTO_COUPON_CODE_CHROMIUM || !process.env.MAGENTO_COUPON_CODE_FIREFOX || !process.env.MAGENTO_COUPON_CODE_WEBKIT) { 34 | throw new Error("MAGENTO_COUPON_CODE_CHROMIUM, MAGENTO_COUPON_CODE_FIREFOX or MAGENTO_COUPON_CODE_WEBKIT is not defined in your .env file."); 35 | } 36 | 37 | await this.page.getByRole('link', {name: UIReference.magentoAdminPage.navigation.marketingButtonLabel}).click(); 38 | await this.page.waitForLoadState('networkidle'); 39 | //await this.page.getByRole('link', {name: UIReference.magentoAdminPage.subNavigation.cartPriceRulesButtonLabel}).waitFor(); 40 | await expect(this.page.getByRole('link', {name: UIReference.magentoAdminPage.subNavigation.cartPriceRulesButtonLabel})).toBeVisible(); 41 | await this.page.getByRole('link', {name: UIReference.magentoAdminPage.subNavigation.cartPriceRulesButtonLabel}).click(); 42 | await this.page.waitForLoadState('networkidle'); 43 | await this.page.getByRole('button', {name: UIReference.cartPriceRulesPage.addCartPriceRuleButtonLabel}).click(); 44 | await this.page.getByLabel(UIReference.cartPriceRulesPage.ruleNameFieldLabel).fill(inputValues.coupon.couponCodeRuleName); 45 | 46 | const websiteSelector = this.page.getByLabel(UIReference.cartPriceRulesPage.websitesSelectLabel); 47 | await websiteSelector.evaluate(select => { 48 | for (const option of select.options) { 49 | option.selected = true; 50 | } 51 | select.dispatchEvent(new Event('change')); 52 | }); 53 | 54 | const customerGroupsSelector = this.page.getByLabel(UIReference.cartPriceRulesPage.customerGroupsSelectLabel, { exact: true }); 55 | await customerGroupsSelector.evaluate(select => { 56 | for (const option of select.options) { 57 | option.selected = true; 58 | } 59 | select.dispatchEvent(new Event('change')); 60 | }); 61 | 62 | await this.page.locator(UIReference.cartPriceRulesPage.couponTypeSelectField).selectOption({ label: inputValues.coupon.couponType }); 63 | await this.page.getByLabel(UIReference.cartPriceRulesPage.couponCodeFieldLabel).fill(magentoCouponCode); 64 | 65 | await this.page.getByText(UIReference.cartPriceRulesPage.actionsSubtitleLabel, { exact: true }).click(); 66 | await this.page.getByLabel(UIReference.cartPriceRulesPage.discountAmountFieldLabel).fill('10'); 67 | 68 | await this.page.getByRole('button', { name: 'Save', exact: true }).click(); 69 | } 70 | 71 | async enableMultipleAdminLogins() { 72 | await this.page.waitForLoadState('networkidle'); 73 | await this.page.getByRole('link', { name: UIReference.magentoAdminPage.navigation.storesButtonLabel }).click(); 74 | await this.page.getByRole('link', { name: UIReference.magentoAdminPage.subNavigation.configurationButtonLabel }).first().click(); 75 | await this.page.getByRole('tab', { name: UIReference.configurationPage.advancedTabLabel }).click(); 76 | await this.page.getByRole('link', { name: UIReference.configurationPage.advancedAdministrationTabLabel, exact: true }).click(); 77 | 78 | if (!await this.page.locator(UIReference.configurationPage.allowMultipleLoginsSystemCheckbox).isVisible()) { 79 | await this.page.getByRole('link', { name: UIReference.configurationPage.securitySectionLabel }).click(); 80 | } 81 | 82 | await this.page.locator(UIReference.configurationPage.allowMultipleLoginsSystemCheckbox).uncheck(); 83 | await this.page.locator(UIReference.configurationPage.allowMultipleLoginsSelectField).selectOption({ label: inputValues.adminLogins.allowMultipleLogins }); 84 | await this.page.getByRole('button', { name: UIReference.configurationPage.saveConfigButtonLabel }).click(); 85 | } 86 | 87 | async disableLoginCaptcha() { 88 | await this.page.waitForLoadState('networkidle'); 89 | await this.page.getByRole('link', { name: UIReference.magentoAdminPage.navigation.storesButtonLabel }).click(); 90 | await this.page.getByRole('link', { name: UIReference.magentoAdminPage.subNavigation.configurationButtonLabel }).first().click(); 91 | await this.page.waitForLoadState('networkidle'); 92 | await this.page.getByRole('tab', { name: UIReference.configurationPage.customersTabLabel }).click(); 93 | await this.page.getByRole('link', { name: UIReference.configurationPage.customerConfigurationTabLabel }).click(); 94 | await this.page.waitForLoadState('networkidle'); 95 | 96 | if (!await this.page.locator(UIReference.configurationPage.captchaSettingSystemCheckbox).isVisible()) { 97 | // await this.page.getByRole('link', { name: new RegExp(UIReference.configurationPage.captchaSectionLabel) }).click(); 98 | await this.page.getByRole('link', { name: UIReference.configurationPage.captchaSectionLabel }).click(); 99 | } 100 | 101 | await this.page.locator(UIReference.configurationPage.captchaSettingSystemCheckbox).uncheck(); 102 | await this.page.locator(UIReference.configurationPage.captchaSettingSelectField).selectOption({ label: inputValues.captcha.captchaDisabled }); 103 | await this.page.getByRole('button', { name: UIReference.configurationPage.saveConfigButtonLabel }).click(); 104 | await this.page.waitForLoadState('networkidle'); 105 | } 106 | } 107 | 108 | export default MagentoAdminPage; 109 | -------------------------------------------------------------------------------- /tests/poms/frontend/account.page.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { expect, type Locator, type Page } from '@playwright/test'; 4 | import { faker } from '@faker-js/faker'; 5 | import { UIReference, outcomeMarker, inputValues } from 'config'; 6 | import LoginPage from './login.page'; 7 | 8 | class AccountPage { 9 | readonly page: Page; 10 | readonly accountDashboardTitle: Locator; 11 | readonly firstNameField: Locator; 12 | readonly lastNameField: Locator; 13 | readonly phoneNumberField: Locator; 14 | readonly loginPage: LoginPage; 15 | readonly streetAddressField: Locator; 16 | readonly zipCodeField: Locator; 17 | readonly cityField: Locator; 18 | readonly countrySelectorField: Locator; 19 | readonly stateSelectorField: Locator; 20 | readonly stateInputField: Locator; 21 | readonly saveAddressButton: Locator; 22 | readonly addNewAddressButton: Locator; 23 | readonly deleteAddressButton: Locator; 24 | readonly editAddressButton: Locator; 25 | readonly changePasswordCheck: Locator; 26 | readonly changeEmailCheck: Locator; 27 | readonly currentPasswordField: Locator; 28 | readonly newPasswordField: Locator; 29 | readonly confirmNewPasswordField: Locator; 30 | readonly genericSaveButton: Locator; 31 | readonly accountCreationFirstNameField: Locator; 32 | readonly accountCreationLastNameField: Locator; 33 | readonly accountCreationEmailField: Locator; 34 | readonly accountCreationPasswordField: Locator; 35 | readonly accountCreationPasswordRepeatField: Locator; 36 | readonly accountCreationConfirmButton: Locator; 37 | readonly accountInformationField: Locator; 38 | 39 | 40 | constructor(page: Page){ 41 | this.page = page; 42 | this.loginPage = new LoginPage(page); 43 | this.accountDashboardTitle = page.getByRole('heading', { name: UIReference.accountDashboard.accountDashboardTitleLabel }); 44 | this.firstNameField = page.getByLabel(UIReference.personalInformation.firstNameLabel); 45 | this.lastNameField = page.getByLabel(UIReference.personalInformation.lastNameLabel); 46 | this.phoneNumberField = page.getByLabel(UIReference.newAddress.phoneNumberLabel); 47 | this.streetAddressField = page.getByLabel(UIReference.newAddress.streetAddressLabel, {exact:true}); 48 | this.zipCodeField = page.getByLabel(UIReference.newAddress.zipCodeLabel); 49 | this.cityField = page.getByLabel(UIReference.newAddress.cityNameLabel); 50 | this.countrySelectorField = page.getByLabel(UIReference.newAddress.countryLabel); 51 | this.stateInputField = page.getByLabel(UIReference.newAddress.provinceSelectLabel); 52 | this.stateSelectorField = this.stateInputField.filter({hasText: UIReference.newAddress.provinceSelectFilterLabel}); 53 | this.saveAddressButton = page.getByRole('button',{name: UIReference.newAddress.saveAdressButton}); 54 | 55 | // Account Information elements 56 | this.changePasswordCheck = page.getByRole('checkbox', {name: UIReference.personalInformation.changePasswordCheckLabel}); 57 | this.changeEmailCheck = page.getByRole('checkbox', {name: UIReference.personalInformation.changeEmailCheckLabel}); 58 | this.currentPasswordField = page.getByLabel(UIReference.credentials.currentPasswordFieldLabel); 59 | this.newPasswordField = page.getByLabel(UIReference.credentials.newPasswordFieldLabel, {exact:true}); 60 | this.confirmNewPasswordField = page.getByLabel(UIReference.credentials.newPasswordConfirmFieldLabel); 61 | this.genericSaveButton = page.getByRole('button', { name: UIReference.general.genericSaveButtonLabel }); 62 | 63 | // Account Creation elements 64 | this.accountCreationFirstNameField = page.getByLabel(UIReference.personalInformation.firstNameLabel); 65 | this.accountCreationLastNameField = page.getByLabel(UIReference.personalInformation.lastNameLabel); 66 | this.accountCreationEmailField = page.getByLabel(UIReference.credentials.emailFieldLabel, { exact: true}); 67 | this.accountCreationPasswordField = page.getByLabel(UIReference.credentials.passwordFieldLabel, { exact: true }); 68 | this.accountCreationPasswordRepeatField = page.getByLabel(UIReference.credentials.passwordConfirmFieldLabel); 69 | this.accountCreationConfirmButton = page.getByRole('button', {name: UIReference.accountCreation.createAccountButtonLabel}); 70 | 71 | this.accountInformationField = page.locator(UIReference.accountDashboard.accountInformationFieldLocator).first(); 72 | 73 | // Address Book elements 74 | this.addNewAddressButton = page.getByRole('button',{name: UIReference.accountDashboard.addAddressButtonLabel}); 75 | this.deleteAddressButton = page.getByRole('link', {name: UIReference.accountDashboard.addressDeleteIconButton}).first(); 76 | this.editAddressButton = page.getByRole('link', {name: UIReference.accountDashboard.editAddressIconButton}).first(); 77 | } 78 | 79 | async addNewAddress(){ 80 | let addressAddedNotification = outcomeMarker.address.newAddressAddedNotifcation; 81 | let streetName = faker.location.streetAddress(); 82 | 83 | // Name should be filled in automatically. 84 | await expect(this.firstNameField).not.toBeEmpty(); 85 | await expect(this.lastNameField).not.toBeEmpty(); 86 | 87 | await this.phoneNumberField.fill(faker.phone.number()); 88 | await this.streetAddressField.fill(streetName); 89 | await this.zipCodeField.fill(faker.location.zipCode()); 90 | await this.cityField.fill(faker.location.city()); 91 | 92 | const country = faker.helpers.arrayElement(inputValues.addressCountries); 93 | await this.countrySelectorField.selectOption({ label: country }); 94 | 95 | const stateValue = faker.location.state(); 96 | if (await this.stateSelectorField.count()) { 97 | await this.stateSelectorField.selectOption(stateValue); 98 | } else { 99 | await this.stateInputField.fill(stateValue); 100 | } 101 | 102 | await this.saveAddressButton.click(); 103 | await this.page.waitForLoadState(); 104 | 105 | await expect.soft(this.page.getByText(addressAddedNotification)).toBeVisible(); 106 | await expect(this.page.getByText(streetName).last()).toBeVisible(); 107 | } 108 | 109 | 110 | async editExistingAddress(){ 111 | // the notification for a modified address is the same as the notification for a new address. 112 | let addressModifiedNotification = outcomeMarker.address.newAddressAddedNotifcation; 113 | let streetName = faker.location.streetAddress(); 114 | 115 | await this.editAddressButton.click(); 116 | 117 | // Name should be filled in automatically, but editable. 118 | await expect(this.firstNameField).not.toBeEmpty(); 119 | await expect(this.lastNameField).not.toBeEmpty(); 120 | 121 | await this.firstNameField.fill(faker.person.firstName()); 122 | await this.lastNameField.fill(faker.person.lastName()); 123 | await this.streetAddressField.fill(streetName); 124 | await this.zipCodeField.fill(faker.location.zipCode()); 125 | await this.cityField.fill(faker.location.city()); 126 | const stateEdit = faker.location.state(); 127 | if (await this.stateSelectorField.count()) { 128 | await this.stateSelectorField.selectOption(stateEdit); 129 | } else { 130 | await this.stateInputField.fill(stateEdit); 131 | } 132 | 133 | await this.saveAddressButton.click(); 134 | await this.page.waitForLoadState(); 135 | 136 | await expect.soft(this.page.getByText(addressModifiedNotification)).toBeVisible(); 137 | await expect(this.page.getByText(streetName).last()).toBeVisible(); 138 | } 139 | 140 | 141 | async deleteFirstAddressFromAddressBook(){ 142 | let addressDeletedNotification = outcomeMarker.address.addressDeletedNotification; 143 | let addressBookSection = this.page.locator(UIReference.accountDashboard.addressBookArea); 144 | 145 | // Dialog function to click confirm 146 | this.page.on('dialog', async (dialog) => { 147 | if (dialog.type() === 'confirm') { 148 | await dialog.accept(); 149 | } 150 | }); 151 | 152 | // Grab addresses from the address book, split the string and grab the address to be deleted. 153 | let addressBookArray = await addressBookSection.allInnerTexts(); 154 | let arraySplit = addressBookArray[0].split('\n'); 155 | let addressToBeDeleted = arraySplit[7]; 156 | 157 | await this.deleteAddressButton.click(); 158 | await this.page.waitForLoadState(); 159 | 160 | await expect(this.page.getByText(addressDeletedNotification)).toBeVisible(); 161 | await expect(addressBookSection, `${addressToBeDeleted} should not be visible`).not.toContainText(addressToBeDeleted); 162 | } 163 | 164 | async updatePassword(currentPassword:string, newPassword: string){ 165 | let passwordUpdatedNotification = outcomeMarker.account.changedPasswordNotificationText; 166 | await this.changePasswordCheck.check(); 167 | 168 | await this.currentPasswordField.fill(currentPassword); 169 | await this.newPasswordField.fill(newPassword); 170 | await this.confirmNewPasswordField.fill(newPassword); 171 | 172 | await this.genericSaveButton.click(); 173 | await this.page.waitForLoadState(); 174 | 175 | await expect(this.page.getByText(passwordUpdatedNotification)).toBeVisible(); 176 | } 177 | 178 | async updateEmail(currentPassword: string, newEmail: string) { 179 | let accountUpdatedNotification = outcomeMarker.account.changedPasswordNotificationText; 180 | 181 | await this.changeEmailCheck.check(); 182 | await this.accountCreationEmailField.fill(newEmail); 183 | await this.currentPasswordField.fill(currentPassword); 184 | await this.genericSaveButton.click(); 185 | await this.page.waitForLoadState(); 186 | await this.loginPage.login(newEmail, currentPassword); 187 | 188 | await expect(this.accountInformationField, `Account information should contain email: ${newEmail}`).toContainText(newEmail); 189 | } 190 | 191 | async deleteAllAddresses() { 192 | let addressDeletedNotification = outcomeMarker.address.addressDeletedNotification; 193 | 194 | this.page.on('dialog', async (dialog) => { 195 | if (dialog.type() === 'confirm') { 196 | await dialog.accept(); 197 | } 198 | }); 199 | 200 | while (await this.deleteAddressButton.isVisible()) { 201 | await this.deleteAddressButton.click(); 202 | await this.page.waitForLoadState(); 203 | 204 | await expect.soft(this.page.getByText(addressDeletedNotification)).toBeVisible(); 205 | } 206 | } 207 | } 208 | 209 | export default AccountPage; 210 | -------------------------------------------------------------------------------- /tests/poms/frontend/cart.page.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { expect, type Locator, type Page } from '@playwright/test'; 4 | import { UIReference, outcomeMarker } from 'config'; 5 | 6 | class CartPage { 7 | readonly page: Page; 8 | readonly showDiscountButton: Locator; 9 | productQuantityInCheckout: string | undefined; 10 | productPriceInCheckout: string | undefined; 11 | 12 | constructor(page: Page) { 13 | this.page = page; 14 | this.showDiscountButton = this.page.getByRole('button', { name: UIReference.cart.showDiscountFormButtonLabel }); 15 | } 16 | 17 | async changeProductQuantity(amount: string){ 18 | const productRow = this.page.getByRole('row', {name: UIReference.productPage.simpleProductTitle}); 19 | let currentQuantity = await productRow.getByLabel(UIReference.cart.cartQuantityLabel).inputValue(); 20 | 21 | if(currentQuantity == amount){ 22 | // quantity is the same as amount, therefore we change amount to ensure test can continue. 23 | amount = '3'; 24 | } 25 | 26 | await productRow.getByLabel(UIReference.cart.cartQuantityLabel).fill(amount); 27 | let subTotalBeforeUpdate = await productRow.getByText(UIReference.general.genericPriceSymbol).last().innerText(); 28 | 29 | await this.page.getByRole('button', { name: UIReference.cart.updateShoppingCartButtonLabel }).click(); 30 | await this.page.reload(); 31 | 32 | currentQuantity = await productRow.getByLabel(UIReference.cart.cartQuantityLabel).inputValue(); 33 | 34 | // Last $ to get the Subtotal 35 | let subTotalAfterUpdate = await productRow.getByText(UIReference.general.genericPriceSymbol).last().innerText(); 36 | 37 | // Assertions: subtotals are different, and quantity field is still the new amount. 38 | expect(subTotalAfterUpdate, `Subtotals should not be the same`).not.toEqual(subTotalBeforeUpdate); 39 | expect(currentQuantity, `quantity should be the new value`).toEqual(amount); 40 | } 41 | 42 | // ============================================== 43 | // Product-related methods 44 | // ============================================== 45 | 46 | async removeProduct(productTitle: string){ 47 | let removeButton = this.page.getByLabel(`${UIReference.general.removeLabel} ${productTitle}`); 48 | await removeButton.click(); 49 | await this.page.waitForLoadState(); 50 | await expect(removeButton,`Button to remove specified product is not visible in the cart`).toBeHidden(); 51 | 52 | // Expect product to no longer be visible in the cart 53 | await expect (this.page.getByRole('cell', { name: productTitle }), `Product is not visible in cart`).toBeHidden(); 54 | } 55 | 56 | // ============================================== 57 | // Discount-related methods 58 | // ============================================== 59 | async applyDiscountCode(code: string){ 60 | if(await this.page.getByPlaceholder(UIReference.cart.discountInputFieldLabel).isHidden()){ 61 | // discount field is not open. 62 | await this.showDiscountButton.click(); 63 | } 64 | 65 | let applyDiscoundButton = this.page.getByRole('button', {name: UIReference.cart.applyDiscountButtonLabel, exact:true}); 66 | let discountField = this.page.getByPlaceholder(UIReference.cart.discountInputFieldLabel); 67 | await discountField.fill(code); 68 | await applyDiscoundButton.click(); 69 | await this.page.waitForLoadState(); 70 | 71 | await expect.soft(this.page.getByText(`${outcomeMarker.cart.discountAppliedNotification} "${code}"`),`Notification that discount code ${code} has been applied`).toBeVisible(); 72 | await expect(this.page.getByText(outcomeMarker.cart.priceReducedSymbols),`'- $' should be visible on the page`).toBeVisible(); 73 | //Close message to prevent difficulties with other tests. 74 | await this.page.getByLabel(UIReference.general.closeMessageLabel).click(); 75 | } 76 | 77 | async removeDiscountCode(){ 78 | if(await this.page.getByPlaceholder(UIReference.cart.discountInputFieldLabel).isHidden()){ 79 | // discount field is not open. 80 | await this.showDiscountButton.click(); 81 | } 82 | 83 | let cancelCouponButton = this.page.getByRole('button', {name: UIReference.cart.cancelCouponButtonLabel}); 84 | await cancelCouponButton.click(); 85 | await this.page.waitForLoadState(); 86 | 87 | await expect.soft(this.page.getByText(outcomeMarker.cart.discountRemovedNotification),`Notification should be visible`).toBeVisible(); 88 | await expect(this.page.getByText(outcomeMarker.cart.priceReducedSymbols),`'- $' should not be on the page`).toBeHidden(); 89 | } 90 | 91 | async enterWrongCouponCode(code: string){ 92 | if(await this.page.getByPlaceholder(UIReference.cart.discountInputFieldLabel).isHidden()){ 93 | // discount field is not open. 94 | await this.showDiscountButton.click(); 95 | } 96 | 97 | let applyDiscoundButton = this.page.getByRole('button', {name: UIReference.cart.applyDiscountButtonLabel, exact:true}); 98 | let discountField = this.page.getByPlaceholder(UIReference.cart.discountInputFieldLabel); 99 | await discountField.fill(code); 100 | await applyDiscoundButton.click(); 101 | await this.page.waitForLoadState(); 102 | 103 | let incorrectNotification = `${outcomeMarker.cart.incorrectCouponCodeNotificationOne} "${code}" ${outcomeMarker.cart.incorrectCouponCodeNotificationTwo}`; 104 | 105 | //Assertions: notification that code was incorrect & discount code field is still editable 106 | await expect.soft(this.page.getByText(incorrectNotification), `Code should not work`).toBeVisible(); 107 | await expect(discountField).toBeEditable(); 108 | } 109 | 110 | 111 | // ============================================== 112 | // Additional methods 113 | // ============================================== 114 | 115 | async getCheckoutValues(productName:string, pricePDP:string, amountPDP:string){ 116 | // Open minicart based on amount of products in cart 117 | let cartItemAmount = await this.page.locator(UIReference.miniCart.minicartAmountBubbleLocator).count(); 118 | if(cartItemAmount == 1) { 119 | await this.page.getByLabel(`${UIReference.checkout.openCartButtonLabel} ${cartItemAmount} ${UIReference.checkout.openCartButtonLabelCont}`).click(); 120 | } else { 121 | await this.page.getByLabel(`${UIReference.checkout.openCartButtonLabel} ${cartItemAmount} ${UIReference.checkout.openCartButtonLabelContMultiple}`).click(); 122 | } 123 | 124 | // Get values from checkout page 125 | let productInCheckout = this.page.locator(UIReference.checkout.cartDetailsLocator).filter({ hasText: productName }).nth(1); 126 | this.productPriceInCheckout = await productInCheckout.getByText(UIReference.general.genericPriceSymbol).innerText(); 127 | this.productPriceInCheckout = this.productPriceInCheckout.trim(); 128 | let productImage = this.page.locator(UIReference.checkout.cartDetailsLocator) 129 | .filter({ has: this.page.getByRole('img', { name: productName })}); 130 | this.productQuantityInCheckout = await productImage.locator('> span').innerText(); 131 | 132 | return [this.productPriceInCheckout, this.productQuantityInCheckout]; 133 | } 134 | 135 | async calculateProductPricesAndCompare(pricePDP: string, amountPDP:string, priceCheckout:string, amountCheckout:string){ 136 | // perform magic to calculate price * amount and mold it into the correct form again 137 | pricePDP = pricePDP.replace(UIReference.general.genericPriceSymbol,''); 138 | let pricePDPInt = Number(pricePDP); 139 | let quantityPDPInt = +amountPDP; 140 | let calculatedPricePDP = `${UIReference.general.genericPriceSymbol}` + (pricePDPInt * quantityPDPInt).toFixed(2); 141 | 142 | expect(amountPDP,`Amount on PDP (${amountPDP}) equals amount in checkout (${amountCheckout})`).toEqual(amountCheckout); 143 | expect(calculatedPricePDP, `Price * qty on PDP (${calculatedPricePDP}) equals price * qty in checkout (${priceCheckout})`).toEqual(priceCheckout); 144 | } 145 | } 146 | 147 | export default CartPage; 148 | -------------------------------------------------------------------------------- /tests/poms/frontend/category.page.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { expect, type Locator, type Page } from '@playwright/test'; 4 | import { UIReference, slugs } from 'config'; 5 | 6 | class CategoryPage { 7 | readonly page:Page; 8 | categoryPageTitle: Locator; 9 | 10 | constructor(page: Page) { 11 | this.page = page; 12 | this.categoryPageTitle = this.page.getByRole('heading', { name: UIReference.categoryPage.categoryPageTitleText }); 13 | } 14 | 15 | async goToCategoryPage(){ 16 | await this.page.goto(slugs.categoryPage.categorySlug); 17 | // Wait for the first filter option to be visible 18 | const firstFilterOption = this.page.locator(UIReference.categoryPage.firstFilterOptionLocator); 19 | await firstFilterOption.waitFor(); 20 | 21 | this.page.waitForLoadState(); 22 | await expect(this.categoryPageTitle).toBeVisible(); 23 | } 24 | 25 | async filterOnSize(browser:string){ 26 | const sizeFilterButton = this.page.getByRole('button', {name: UIReference.categoryPage.sizeFilterButtonLabel}); 27 | const sizeLButton = this.page.locator(UIReference.categoryPage.sizeLButtonLocator); 28 | const removeActiveFilterLink = this.page.getByRole('link', {name: UIReference.categoryPage.removeActiveFilterButtonLabel}).first(); 29 | const amountOfItemsBeforeFilter = parseInt(await this.page.locator(UIReference.categoryPage.itemsOnPageAmountLocator).last().innerText()); 30 | 31 | 32 | /** 33 | * BROWSER_WORKAROUND 34 | * The size filter seems to auto-close in Firefox and Webkit. 35 | * Therefore, we open it manually. 36 | */ 37 | if(browser !== 'chromium'){ 38 | await sizeFilterButton.click(); 39 | } 40 | 41 | await sizeLButton.click(); 42 | 43 | const sizeFilterRegex = new RegExp(`\\?size=L$`); 44 | await this.page.waitForURL(sizeFilterRegex); 45 | 46 | const amountOfItemsAfterFilter = parseInt(await this.page.locator(UIReference.categoryPage.itemsOnPageAmountLocator).last().innerText()); 47 | await expect(removeActiveFilterLink, 'Trash button to remove filter is visible').toBeVisible(); 48 | expect(amountOfItemsAfterFilter, `Amount of items shown with filter (${amountOfItemsAfterFilter}) is less than without (${amountOfItemsBeforeFilter})`).toBeLessThan(amountOfItemsBeforeFilter); 49 | 50 | } 51 | 52 | async sortProducts(attribute:string){ 53 | const sortButton = this.page.getByLabel(UIReference.categoryPage.sortByButtonLabel); 54 | await sortButton.selectOption(attribute); 55 | const sortRegex = new RegExp(`\\?product_list_order=${attribute}$`); 56 | await this.page.waitForURL(sortRegex); 57 | 58 | const selectedValue = await this.page.$eval(UIReference.categoryPage.sortByButtonLocator, sel => sel.value); 59 | 60 | // sortButton should now display attribute 61 | expect(selectedValue, `Sort button should now display ${attribute}`).toEqual(attribute); 62 | // URL now has ?product_list_order=${attribute} 63 | expect(this.page.url(), `URL should contain ?product_list_order=${attribute}`).toContain(`product_list_order=${attribute}`); 64 | } 65 | 66 | 67 | async showMoreProducts(){ 68 | const itemsPerPageButton = this.page.getByLabel(UIReference.categoryPage.itemsPerPageButtonLabel); 69 | const productGrid = this.page.locator(UIReference.categoryPage.productGridLocator); 70 | 71 | await itemsPerPageButton.selectOption('36'); 72 | const itemsRegex = /\?product_list_limit=36$/; 73 | await this.page.waitForURL(itemsRegex); 74 | 75 | const amountOfItems = await productGrid.locator('li').count(); 76 | 77 | expect(this.page.url(), `URL should contain ?product_list_limit=36`).toContain(`?product_list_limit=36`); 78 | expect(amountOfItems, `Amount of items on the page should be 36`).toBe(36); 79 | } 80 | 81 | async switchView(){ 82 | const viewSwitcher = this.page.getByLabel(UIReference.categoryPage.viewSwitchLabel, {exact: true}).locator(UIReference.categoryPage.activeViewLocator); 83 | const activeView = await viewSwitcher.getAttribute('title'); 84 | 85 | if(activeView == 'Grid'){ 86 | await this.page.getByLabel(UIReference.categoryPage.viewListLabel).click(); 87 | } else { 88 | await this.page.getByLabel(UIReference.categoryPage.viewGridLabel).click(); 89 | } 90 | 91 | const viewRegex = /\?product_list_mode=list$/; 92 | await this.page.waitForURL(viewRegex); 93 | 94 | const newActiveView = await viewSwitcher.getAttribute('title'); 95 | expect(newActiveView, `View (now ${newActiveView}) should be switched (old: ${activeView})`).not.toEqual(activeView); 96 | expect(this.page.url(),`URL should contain ?product_list_mode=${newActiveView?.toLowerCase()}`).toContain(`?product_list_mode=${newActiveView?.toLowerCase()}`); 97 | } 98 | } 99 | 100 | export default CategoryPage; 101 | -------------------------------------------------------------------------------- /tests/poms/frontend/checkout.page.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { expect, type Locator, type Page } from '@playwright/test'; 4 | import { faker } from '@faker-js/faker'; 5 | import { UIReference, outcomeMarker, slugs, inputValues } from 'config'; 6 | 7 | import MagewireUtils from '../../utils/magewire.utils'; 8 | 9 | class CheckoutPage extends MagewireUtils { 10 | 11 | readonly shippingMethodOptionFixed: Locator; 12 | readonly paymentMethodOptionCheck: Locator; 13 | readonly showDiscountFormButton: Locator; 14 | readonly placeOrderButton: Locator; 15 | readonly continueShoppingButton: Locator; 16 | readonly subtotalElement: Locator; 17 | readonly shippingElement: Locator; 18 | readonly taxElement: Locator; 19 | readonly grandTotalElement: Locator; 20 | readonly paymentMethodOptionCreditCard: Locator; 21 | readonly paymentMethodOptionPaypal: Locator; 22 | readonly creditCardNumberField: Locator; 23 | readonly creditCardExpiryField: Locator; 24 | readonly creditCardCVVField: Locator; 25 | readonly creditCardNameField: Locator; 26 | 27 | constructor(page: Page){ 28 | super(page); 29 | this.shippingMethodOptionFixed = this.page.getByLabel(UIReference.checkout.shippingMethodFixedLabel); 30 | this.paymentMethodOptionCheck = this.page.getByLabel(UIReference.checkout.paymentOptionCheckLabel); 31 | this.showDiscountFormButton = this.page.getByRole('button', {name: UIReference.checkout.openDiscountFormLabel}); 32 | this.placeOrderButton = this.page.getByRole('button', { name: UIReference.checkout.placeOrderButtonLabel }); 33 | this.continueShoppingButton = this.page.getByRole('link', { name: UIReference.checkout.continueShoppingLabel }); 34 | this.subtotalElement = page.getByText('Subtotal $'); 35 | this.shippingElement = page.getByText('Shipping & Handling (Flat Rate - Fixed) $'); 36 | this.taxElement = page.getByText('Tax $'); 37 | this.grandTotalElement = page.getByText('Grand Total $'); 38 | this.paymentMethodOptionCreditCard = this.page.getByLabel(UIReference.checkout.paymentOptionCreditCardLabel); 39 | this.paymentMethodOptionPaypal = this.page.getByLabel(UIReference.checkout.paymentOptionPaypalLabel); 40 | this.creditCardNumberField = this.page.getByLabel(UIReference.checkout.creditCardNumberLabel); 41 | this.creditCardExpiryField = this.page.getByLabel(UIReference.checkout.creditCardExpiryLabel); 42 | this.creditCardCVVField = this.page.getByLabel(UIReference.checkout.creditCardCVVLabel); 43 | this.creditCardNameField = this.page.getByLabel(UIReference.checkout.creditCardNameLabel); 44 | } 45 | 46 | // ============================================== 47 | // Order-related methods 48 | // ============================================== 49 | 50 | async placeOrder(){ 51 | let orderPlacedNotification = outcomeMarker.checkout.orderPlacedNotification; 52 | 53 | // If we're not already on the checkout page, go there 54 | if (!this.page.url().includes(slugs.checkout.checkoutSlug)) { 55 | await this.page.goto(slugs.checkout.checkoutSlug); 56 | } 57 | 58 | // If shipping method is not selected, select it 59 | if (!(await this.shippingMethodOptionFixed.isChecked())) { 60 | await this.shippingMethodOptionFixed.check(); 61 | await this.waitForMagewireRequests(); 62 | } 63 | 64 | await this.paymentMethodOptionCheck.check(); 65 | await this.waitForMagewireRequests(); 66 | 67 | await this.placeOrderButton.click(); 68 | await this.waitForMagewireRequests(); 69 | 70 | await expect.soft(this.page.getByText(orderPlacedNotification)).toBeVisible(); 71 | let orderNumber = await this.page.locator('p').filter({ hasText: outcomeMarker.checkout.orderPlacedNumberText }); 72 | 73 | await expect(this.continueShoppingButton, `${outcomeMarker.checkout.orderPlacedNumberText} ${orderNumber}`).toBeVisible(); 74 | return orderNumber; 75 | } 76 | 77 | 78 | // ============================================== 79 | // Discount-related methods 80 | // ============================================== 81 | 82 | async applyDiscountCodeCheckout(code: string){ 83 | if(await this.page.getByPlaceholder(UIReference.cart.discountInputFieldLabel).isHidden()){ 84 | // discount field is not open. 85 | await this.showDiscountFormButton.click(); 86 | await this.waitForMagewireRequests(); 87 | } 88 | 89 | if(await this.page.getByText(outcomeMarker.cart.priceReducedSymbols).isVisible()){ 90 | // discount is already active. 91 | let cancelCouponButton = this.page.getByRole('button', { name: UIReference.checkout.cancelDiscountButtonLabel }); 92 | await cancelCouponButton.click(); 93 | await this.waitForMagewireRequests(); 94 | } 95 | 96 | let applyCouponCheckoutButton = this.page.getByRole('button', { name: UIReference.checkout.applyDiscountButtonLabel }); 97 | let checkoutDiscountField = this.page.getByPlaceholder(UIReference.checkout.discountInputFieldLabel); 98 | 99 | await checkoutDiscountField.fill(code); 100 | await applyCouponCheckoutButton.click(); 101 | await this.waitForMagewireRequests(); 102 | 103 | await expect.soft(this.page.getByText(`${outcomeMarker.checkout.couponAppliedNotification}`),`Notification that discount code ${code} has been applied`).toBeVisible({timeout: 30000}); 104 | await expect(this.page.getByText(outcomeMarker.checkout.checkoutPriceReducedSymbol),`'-$' should be visible on the page`).toBeVisible(); 105 | } 106 | 107 | async enterWrongCouponCode(code: string){ 108 | if(await this.page.getByPlaceholder(UIReference.cart.discountInputFieldLabel).isHidden()){ 109 | // discount field is not open. 110 | await this.showDiscountFormButton.click(); 111 | await this.waitForMagewireRequests(); 112 | } 113 | 114 | let applyCouponCheckoutButton = this.page.getByRole('button', { name: UIReference.checkout.applyDiscountButtonLabel }); 115 | let checkoutDiscountField = this.page.getByPlaceholder(UIReference.checkout.discountInputFieldLabel); 116 | await checkoutDiscountField.fill(code); 117 | await applyCouponCheckoutButton.click(); 118 | await this.waitForMagewireRequests(); 119 | 120 | await expect.soft(this.page.getByText(outcomeMarker.checkout.incorrectDiscountNotification), `Code should not work`).toBeVisible(); 121 | await expect(checkoutDiscountField).toBeEditable(); 122 | } 123 | 124 | async removeDiscountCode(){ 125 | if(await this.page.getByPlaceholder(UIReference.cart.discountInputFieldLabel).isHidden()){ 126 | // discount field is not open. 127 | await this.showDiscountFormButton.click(); 128 | await this.waitForMagewireRequests(); 129 | } 130 | 131 | let cancelCouponButton = this.page.getByRole('button', {name: UIReference.cart.cancelCouponButtonLabel}); 132 | await cancelCouponButton.click(); 133 | await this.waitForMagewireRequests(); 134 | 135 | await expect.soft(this.page.getByText(outcomeMarker.checkout.couponRemovedNotification),`Notification should be visible`).toBeVisible(); 136 | await expect(this.page.getByText(outcomeMarker.checkout.checkoutPriceReducedSymbol),`'-$' should not be on the page`).toBeHidden(); 137 | 138 | let checkoutDiscountField = this.page.getByPlaceholder(UIReference.checkout.discountInputFieldLabel); 139 | await expect(checkoutDiscountField).toBeEditable(); 140 | } 141 | 142 | // ============================================== 143 | // Price summary methods 144 | // ============================================== 145 | 146 | async getPriceValue(element: Locator): Promise { 147 | const priceText = await element.innerText(); 148 | // Extract just the price part after the $ symbol 149 | const match = priceText.match(/\$\s*([\d.]+)/); 150 | return match ? parseFloat(match[1]) : 0; 151 | } 152 | 153 | async verifyPriceCalculations() { 154 | const subtotal = await this.getPriceValue(this.subtotalElement); 155 | const shipping = await this.getPriceValue(this.shippingElement); 156 | const tax = await this.getPriceValue(this.taxElement); 157 | const grandTotal = await this.getPriceValue(this.grandTotalElement); 158 | 159 | const calculatedTotal = +(subtotal + shipping + tax).toFixed(2); 160 | 161 | expect(subtotal, `Subtotal (${subtotal}) should be greater than 0`).toBeGreaterThan(0); 162 | expect(shipping, `Shipping cost (${shipping}) should be greater than 0`).toBeGreaterThan(0); 163 | // Enable when tax settings are set. 164 | //expect(tax, `Tax (${tax}) should be greater than 0`).toBeGreaterThan(0); 165 | expect(grandTotal, `Grand total (${grandTotal}) should equal calculated total (${calculatedTotal})`).toBe(calculatedTotal); 166 | } 167 | 168 | async selectPaymentMethod(method: 'check' | 'creditcard' | 'paypal'): Promise { 169 | switch(method) { 170 | case 'check': 171 | await this.paymentMethodOptionCheck.check(); 172 | break; 173 | case 'creditcard': 174 | await this.paymentMethodOptionCreditCard.check(); 175 | // Fill credit card details 176 | await this.creditCardNumberField.fill(inputValues.payment?.creditCard?.number || '4111111111111111'); 177 | await this.creditCardExpiryField.fill(inputValues.payment?.creditCard?.expiry || '12/25'); 178 | await this.creditCardCVVField.fill(inputValues.payment?.creditCard?.cvv || '123'); 179 | await this.creditCardNameField.fill(inputValues.payment?.creditCard?.name || 'Test User'); 180 | break; 181 | case 'paypal': 182 | await this.paymentMethodOptionPaypal.check(); 183 | break; 184 | } 185 | 186 | await this.waitForMagewireRequests(); 187 | } 188 | 189 | async fillShippingAddress() { 190 | // Fill required shipping address fields 191 | await this.page.getByLabel(UIReference.credentials.emailCheckoutFieldLabel, { exact: true }).fill(faker.internet.email()); 192 | await this.page.getByLabel(UIReference.personalInformation.firstNameLabel).fill(faker.person.firstName()); 193 | await this.page.getByLabel(UIReference.personalInformation.lastNameLabel).fill(faker.person.lastName()); 194 | await this.page.getByLabel(UIReference.newAddress.streetAddressLabel).first().fill(faker.location.streetAddress()); 195 | await this.page.getByLabel(UIReference.newAddress.zipCodeLabel).fill(faker.location.zipCode()); 196 | await this.page.getByLabel(UIReference.newAddress.cityNameLabel).fill(faker.location.city()); 197 | await this.page.getByLabel(UIReference.newAddress.phoneNumberLabel).fill(faker.phone.number()); 198 | 199 | // Select country (if needed) 200 | // await this.page.getByLabel('Country').selectOption('US'); 201 | 202 | // Select state 203 | await this.page.getByLabel('State/Province').selectOption(faker.location.state()); 204 | 205 | // Wait for any Magewire updates 206 | await this.waitForMagewireRequests(); 207 | } 208 | } 209 | 210 | export default CheckoutPage; 211 | -------------------------------------------------------------------------------- /tests/poms/frontend/compare.page.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { expect, type Page } from '@playwright/test'; 4 | import { UIReference, outcomeMarker } from 'config'; 5 | 6 | class ComparePage { 7 | page: Page; 8 | 9 | constructor(page: Page) { 10 | this.page = page; 11 | } 12 | 13 | async removeProductFromCompare(product:string){ 14 | let comparisonPageEmptyText = this.page.getByText(UIReference.comparePage.comparisonPageEmptyText); 15 | // if the comparison page is empty, we can't remove anything 16 | if (await comparisonPageEmptyText.isVisible()) { 17 | return; 18 | } 19 | 20 | let removeFromCompareButton = this.page.getByLabel(`${UIReference.comparePage.removeCompareLabel} ${product}`); 21 | let productRemovedNotification = this.page.getByText(`${outcomeMarker.comparePage.productRemovedNotificationTextOne} ${product} ${outcomeMarker.comparePage.productRemovedNotificationTextTwo}`); 22 | await removeFromCompareButton.click(); 23 | await expect(productRemovedNotification).toBeVisible(); 24 | } 25 | 26 | async addToCart(product:string){ 27 | const successMessage = this.page.locator(UIReference.general.successMessageLocator); 28 | let productAddedNotification = this.page.getByText(`${outcomeMarker.productPage.simpleProductAddedNotification} ${product}`); 29 | let addToCartbutton = this.page.getByLabel(`${UIReference.general.addToCartLabel} ${product}`); 30 | 31 | await addToCartbutton.click(); 32 | await successMessage.waitFor(); 33 | await expect(productAddedNotification).toBeVisible(); 34 | } 35 | 36 | async addToWishList(product:string){ 37 | const successMessage = this.page.locator(UIReference.general.successMessageLocator); 38 | let addToWishlistButton = this.page.getByLabel(`${UIReference.comparePage.addToWishListLabel} ${product}`); 39 | let productAddedNotification = this.page.getByText(`${product} ${outcomeMarker.wishListPage.wishListAddedNotification}`); 40 | 41 | await addToWishlistButton.click(); 42 | await successMessage.waitFor(); 43 | } 44 | } 45 | export default ComparePage; 46 | -------------------------------------------------------------------------------- /tests/poms/frontend/contact.page.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { expect, type Locator, type Page } from '@playwright/test'; 4 | import { faker } from '@faker-js/faker'; 5 | import { UIReference, outcomeMarker, slugs } from 'config'; 6 | 7 | class ContactPage { 8 | readonly page: Page; 9 | readonly nameField: Locator; 10 | readonly emailField: Locator; 11 | readonly messageField: Locator; 12 | readonly sendFormButton: Locator; 13 | 14 | constructor(page: Page){ 15 | this.page = page; 16 | this.nameField = this.page.getByLabel(UIReference.credentials.nameFieldLabel); 17 | this.emailField = this.page.getByPlaceholder('Email', { exact: true }); 18 | this.messageField = this.page.locator(UIReference.contactPage.messageFieldSelector); 19 | this.sendFormButton = this.page.getByRole('button', { name: UIReference.general.genericSubmitButtonLabel }); 20 | } 21 | 22 | async fillOutForm(){ 23 | await this.page.goto(slugs.contact.contactSlug); 24 | let messageSentConfirmationText = outcomeMarker.contactPage.messageSentConfirmationText; 25 | 26 | // Add a wait for the form to be visible 27 | await this.nameField.waitFor({state: 'visible', timeout: 10000}); 28 | 29 | await this.nameField.fill(faker.person.firstName()); 30 | await this.emailField.fill(faker.internet.email()); 31 | await this.messageField.fill(faker.lorem.paragraph()); 32 | 33 | await this.sendFormButton.click(); 34 | 35 | await expect(this.page.getByText(messageSentConfirmationText)).toBeVisible(); 36 | await expect(this.nameField, 'name should be empty now').toBeEmpty(); 37 | await expect(this.emailField, 'email should be empty now').toBeEmpty(); 38 | await expect(this.messageField, 'message should be empty now').toBeEmpty(); 39 | } 40 | } 41 | 42 | export default ContactPage; -------------------------------------------------------------------------------- /tests/poms/frontend/home.page.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { type Page } from '@playwright/test'; 4 | import { UIReference } from 'config'; 5 | 6 | class HomePage { 7 | 8 | readonly page: Page; 9 | 10 | constructor(page: Page) { 11 | this.page = page; 12 | } 13 | 14 | async addHomepageProductToCart(){ 15 | let buyProductButton = this.page.getByRole('button').filter({hasText: UIReference.general.addToCartLabel}).first(); 16 | 17 | if(await buyProductButton.isVisible()) { 18 | await buyProductButton.click(); 19 | } else { 20 | throw new Error(`No 'Add to Cart' button found on homepage`); 21 | } 22 | } 23 | } 24 | 25 | export default HomePage; 26 | -------------------------------------------------------------------------------- /tests/poms/frontend/login.page.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { expect, type Locator, type Page } from '@playwright/test'; 4 | import { UIReference, slugs } from 'config'; 5 | 6 | class LoginPage { 7 | readonly page: Page; 8 | readonly loginEmailField: Locator; 9 | readonly loginPasswordField: Locator; 10 | readonly loginButton: Locator; 11 | 12 | constructor(page: Page) { 13 | this.page = page; 14 | this.loginEmailField = page.getByLabel(UIReference.credentials.emailFieldLabel, {exact: true}); 15 | this.loginPasswordField = page.getByLabel(UIReference.credentials.passwordFieldLabel, {exact: true}); 16 | this.loginButton = page.getByRole('button', { name: UIReference.credentials.loginButtonLabel }); 17 | } 18 | 19 | async login(email: string, password: string){ 20 | await this.page.goto(slugs.account.loginSlug); 21 | await this.loginEmailField.fill(email); 22 | await this.loginPasswordField.fill(password); 23 | // usage of .press("Enter") to prevent webkit issues with button.click(); 24 | await this.loginButton.press("Enter"); 25 | } 26 | 27 | async loginExpectError(email: string, password: string, errorMessage: string) { 28 | await this.page.goto(slugs.account.loginSlug); 29 | await this.loginEmailField.fill(email); 30 | await this.loginPasswordField.fill(password); 31 | await this.loginButton.press('Enter'); 32 | await this.page.waitForLoadState('networkidle'); 33 | 34 | await expect(this.page, 'Should stay on login page').toHaveURL(new RegExp(slugs.account.loginSlug)); 35 | } 36 | } 37 | 38 | export default LoginPage; 39 | -------------------------------------------------------------------------------- /tests/poms/frontend/mainmenu.page.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { expect, type Locator, type Page } from '@playwright/test'; 4 | import { UIReference, outcomeMarker, slugs } from 'config'; 5 | 6 | class MainMenuPage { 7 | readonly page: Page; 8 | readonly mainMenuAccountButton: Locator; 9 | readonly mainMenuMiniCartButton: Locator; 10 | readonly mainMenuMyAccountItem: Locator; 11 | readonly mainMenuLogoutItem: Locator; 12 | 13 | constructor(page: Page) { 14 | this.page = page; 15 | this.mainMenuAccountButton = page.getByLabel(UIReference.mainMenu.myAccountButtonLabel); 16 | this.mainMenuMiniCartButton = page.getByLabel(UIReference.mainMenu.miniCartLabel); 17 | this.mainMenuLogoutItem = page.getByTitle(UIReference.mainMenu.myAccountLogoutItem); 18 | this.mainMenuMyAccountItem = page.getByTitle(UIReference.mainMenu.myAccountButtonLabel); 19 | } 20 | 21 | async gotoMyAccount(){ 22 | await this.page.goto(slugs.productpage.simpleProductSlug); 23 | await this.mainMenuAccountButton.click(); 24 | await this.mainMenuMyAccountItem.click(); 25 | 26 | await expect(this.page.getByRole('heading', { name: UIReference.accountDashboard.accountDashboardTitleLabel })).toBeVisible(); 27 | } 28 | 29 | async gotoAddressBook() { 30 | // create function to navigate to Address Book through the header menu links 31 | } 32 | 33 | async openMiniCart() { 34 | // await this.page.reload(); 35 | // FIREFOX_WORKAROUND: wait for 3 seconds to allow minicart to be updated. 36 | await this.page.waitForTimeout(3000); 37 | const cartAmountBubble = this.mainMenuMiniCartButton.locator('span'); 38 | cartAmountBubble.waitFor(); 39 | const amountInCart = await cartAmountBubble.innerText(); 40 | 41 | // waitFor is added to ensure the minicart button is visible before clicking, mostly as a fix for Firefox. 42 | // await this.mainMenuMiniCartButton.waitFor(); 43 | 44 | await this.mainMenuMiniCartButton.click(); 45 | 46 | let miniCartDrawer = this.page.locator("#cart-drawer-title"); 47 | await expect(miniCartDrawer.getByText(outcomeMarker.miniCart.miniCartTitle)).toBeVisible(); 48 | } 49 | 50 | async logout(){ 51 | await this.page.goto(slugs.account.accountOverviewSlug); 52 | await this.mainMenuAccountButton.click(); 53 | await this.mainMenuLogoutItem.click(); 54 | 55 | //assertions: notification that user is logged out & logout button no longer visible 56 | await expect(this.page.getByText(outcomeMarker.logout.logoutConfirmationText, { exact: true })).toBeVisible(); 57 | await expect(this.mainMenuLogoutItem).toBeHidden(); 58 | } 59 | } 60 | 61 | export default MainMenuPage; 62 | -------------------------------------------------------------------------------- /tests/poms/frontend/minicart.page.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { expect, type Locator, type Page } from '@playwright/test'; 4 | import { UIReference, outcomeMarker, slugs } from 'config'; 5 | 6 | class MiniCartPage { 7 | readonly page: Page; 8 | readonly toCheckoutButton: Locator; 9 | readonly toCartButton: Locator; 10 | readonly editProductButton: Locator; 11 | readonly productQuantityField: Locator; 12 | readonly updateItemButton: Locator; 13 | readonly priceOnPDP: Locator; 14 | readonly priceInMinicart: Locator; 15 | 16 | constructor(page: Page) { 17 | this.page = page; 18 | this.toCheckoutButton = page.getByRole('link', { name: UIReference.miniCart.checkOutButtonLabel }); 19 | this.toCartButton = page.getByRole('link', { name: UIReference.miniCart.toCartLinkLabel }); 20 | this.editProductButton = page.getByLabel(UIReference.miniCart.editProductIconLabel); 21 | this.productQuantityField = page.getByLabel(UIReference.miniCart.productQuantityFieldLabel); 22 | this.updateItemButton = page.getByRole('button', { name: UIReference.cart.updateItemButtonLabel }); 23 | this.priceOnPDP = page.getByLabel(UIReference.general.genericPriceLabel).getByText(UIReference.general.genericPriceSymbol); 24 | this.priceInMinicart = page.getByText(UIReference.general.genericPriceSymbol).first(); 25 | } 26 | 27 | async goToCheckout(){ 28 | await this.toCheckoutButton.click(); 29 | await expect(this.page).toHaveURL(new RegExp(`${slugs.checkout.checkoutSlug}.*`)); 30 | } 31 | 32 | async goToCart(){ 33 | await this.toCartButton.click(); 34 | await expect(this.page).toHaveURL(new RegExp(`${slugs.cart.cartSlug}.*`)); 35 | } 36 | 37 | async removeProductFromMinicart(product: string) { 38 | let productRemovedNotification = outcomeMarker.miniCart.productRemovedConfirmation; 39 | let removeProductMiniCartButton = this.page.getByLabel(`${UIReference.miniCart.removeProductIconLabel} "${UIReference.productPage.simpleProductTitle}"`); 40 | await removeProductMiniCartButton.click(); 41 | await expect.soft(this.page.getByText(productRemovedNotification)).toBeVisible(); 42 | await expect(removeProductMiniCartButton).toBeHidden(); 43 | } 44 | 45 | async updateProduct(amount: string){ 46 | let productQuantityChangedNotification = outcomeMarker.miniCart.productQuantityChangedConfirmation; 47 | await this.editProductButton.click(); 48 | await expect(this.page).toHaveURL(new RegExp(`${slugs.cart.cartProductChangeSlug}.*`)); 49 | 50 | await this.productQuantityField.fill(amount); 51 | await this.updateItemButton.click(); 52 | await expect.soft(this.page.getByText(productQuantityChangedNotification)).toBeVisible(); 53 | 54 | let productQuantityInCart = await this.page.getByLabel(UIReference.cart.cartQuantityLabel).first().inputValue(); 55 | expect(productQuantityInCart).toBe(amount); 56 | } 57 | 58 | async checkPriceWithProductPage() { 59 | const priceOnPage = await this.page.locator(UIReference.productPage.simpleProductPrice).first().innerText(); 60 | const productTitle = await this.page.getByRole('heading', { level : 1}).innerText(); 61 | const productListing = this.page.locator('div').filter({hasText: productTitle}); 62 | const priceInMinicart = await productListing.locator(UIReference.miniCart.minicartPriceFieldClass).first().textContent(); 63 | //expect(priceOnPage).toBe(priceInMinicart); 64 | expect(priceOnPage, `Expect these prices to be the same: priceOnpage: ${priceOnPage} and priceInMinicart: ${priceInMinicart}`).toBe(priceInMinicart); 65 | } 66 | } 67 | 68 | export default MiniCartPage; 69 | -------------------------------------------------------------------------------- /tests/poms/frontend/newsletter.page.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { expect, type Locator, type Page } from '@playwright/test'; 4 | import { UIReference, outcomeMarker } from 'config'; 5 | 6 | class NewsletterSubscriptionPage { 7 | readonly page: Page; 8 | readonly newsletterCheckElement: Locator; 9 | readonly saveSubscriptionsButton: Locator; 10 | 11 | constructor(page: Page) { 12 | this.page = page; 13 | this.newsletterCheckElement = page.getByLabel(UIReference.newsletterSubscriptions.generalSubscriptionCheckLabel); 14 | this.saveSubscriptionsButton = page.getByRole('button', {name:UIReference.newsletterSubscriptions.saveSubscriptionsButton}); 15 | } 16 | 17 | async updateNewsletterSubscription(){ 18 | 19 | if(await this.newsletterCheckElement.isChecked()) { 20 | // user is already subscribed, test runs unsubscribe 21 | var subscriptionUpdatedNotification = outcomeMarker.account.newsletterRemovedNotification; 22 | 23 | await this.newsletterCheckElement.uncheck(); 24 | await this.saveSubscriptionsButton.click(); 25 | 26 | var subscribed = false; 27 | 28 | } else { 29 | // user is not yet subscribed, test runs subscribe 30 | subscriptionUpdatedNotification = outcomeMarker.account.newsletterSavedNotification; 31 | 32 | await this.newsletterCheckElement.check(); 33 | await this.saveSubscriptionsButton.click(); 34 | 35 | subscribed = true; 36 | } 37 | 38 | await expect(this.page.getByText(subscriptionUpdatedNotification)).toBeVisible(); 39 | return subscribed; 40 | } 41 | } 42 | 43 | export default NewsletterSubscriptionPage; 44 | -------------------------------------------------------------------------------- /tests/poms/frontend/orderhistory.page.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { expect, type Page } from '@playwright/test'; 4 | import { slugs } from 'config'; 5 | 6 | class OrderHistoryPage { 7 | readonly page: Page; 8 | 9 | constructor(page: Page) { 10 | this.page = page; 11 | } 12 | 13 | async open() { 14 | await this.page.goto(slugs.account.orderHistorySlug); 15 | await this.page.waitForLoadState(); 16 | } 17 | 18 | async verifyOrderPresent(orderNumber: string) { 19 | await expect(this.page.getByText(orderNumber)).toBeVisible(); 20 | } 21 | } 22 | 23 | export default OrderHistoryPage; 24 | -------------------------------------------------------------------------------- /tests/poms/frontend/product.page.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { expect, type Locator, type Page } from '@playwright/test'; 4 | import { UIReference, outcomeMarker, slugs } from 'config'; 5 | 6 | class ProductPage { 7 | readonly page: Page; 8 | simpleProductTitle: Locator | undefined; 9 | configurableProductTitle: Locator | undefined; 10 | addToCartButton: Locator; 11 | addToCompareButton: Locator; 12 | addToWishlistButton: Locator; 13 | 14 | constructor(page: Page) { 15 | this.page = page; 16 | this.addToCartButton = page.getByRole('button', { name: UIReference.productPage.addToCartButtonLocator }); 17 | this.addToCompareButton = page.getByLabel(UIReference.productPage.addToCompareButtonLabel, { exact: true }); 18 | this.addToWishlistButton = page.getByLabel(UIReference.productPage.addToWishlistButtonLabel, { exact: true }); 19 | } 20 | 21 | // ============================================== 22 | // Productpage-related methods 23 | // ============================================== 24 | 25 | async addProductToCompare(product:string, url: string){ 26 | let productAddedNotification = `${outcomeMarker.productPage.simpleProductAddedNotification} product`; 27 | const successMessage = this.page.locator(UIReference.general.successMessageLocator); 28 | 29 | await this.page.goto(url); 30 | await this.addToCompareButton.click(); 31 | await successMessage.waitFor(); 32 | await expect(this.page.getByText(productAddedNotification)).toBeVisible(); 33 | 34 | await this.page.goto(slugs.productpage.productComparisonSlug); 35 | 36 | // Assertion: a cell with the product name inside a cell with the product name should be visible 37 | await expect(this.page.getByRole('cell', {name: product}).getByText(product, {exact: true})).toBeVisible(); 38 | } 39 | 40 | async addProductToWishlist(product:string, url: string){ 41 | let addedToWishlistNotification = `${product} ${outcomeMarker.wishListPage.wishListAddedNotification}`; 42 | await this.page.goto(url); 43 | await this.addToWishlistButton.click(); 44 | 45 | await this.page.waitForLoadState(); 46 | 47 | let productNameInWishlist = this.page.locator(UIReference.wishListPage.wishListItemGridLabel).getByText(UIReference.productPage.simpleProductTitle, {exact: true}); 48 | 49 | await expect(this.page).toHaveURL(new RegExp(slugs.wishlist.wishListRegex)); 50 | await expect(this.page.getByText(addedToWishlistNotification)).toBeVisible(); 51 | await expect(productNameInWishlist).toContainText(product); 52 | } 53 | 54 | async leaveProductReview(product:string, url: string){ 55 | await this.page.goto(url); 56 | 57 | //TODO: Uncomment this and fix test once website is fixed 58 | /* 59 | await page.locator('#Rating_5_label path').click(); 60 | await page.getByPlaceholder('Nickname*').click(); 61 | await page.getByPlaceholder('Nickname*').fill('John'); 62 | await page.getByPlaceholder('Nickname*').press('Tab'); 63 | await page.getByPlaceholder('Summary*').click(); 64 | await page.getByPlaceholder('Summary*').fill('A short paragraph'); 65 | await page.getByPlaceholder('Review*').click(); 66 | await page.getByPlaceholder('Review*').fill('Review message!'); 67 | await page.getByRole('button', { name: 'Submit Review' }).click(); 68 | await page.getByRole('img', { name: 'loader' }).click(); 69 | */ 70 | } 71 | 72 | async openLightboxAndScrollThrough(url: string){ 73 | await this.page.goto(url); 74 | let fullScreenOpener = this.page.getByLabel(UIReference.productPage.fullScreenOpenLabel); 75 | let fullScreenCloser = this.page.getByLabel(UIReference.productPage.fullScreenCloseLabel); 76 | let thumbnails = this.page.getByRole('button', {name: UIReference.productPage.thumbnailImageLabel}); 77 | 78 | await fullScreenOpener.click(); 79 | await expect(fullScreenCloser).toBeVisible(); 80 | 81 | for (const img of await thumbnails.all()) { 82 | await img.click(); 83 | // wait for transition animation 84 | await this.page.waitForTimeout(500); 85 | await expect(img, `CSS class 'border-primary' appended to button`).toHaveClass(new RegExp(outcomeMarker.productPage.borderClassRegex)); 86 | } 87 | 88 | await fullScreenCloser.click(); 89 | await expect(fullScreenCloser).toBeHidden(); 90 | 91 | } 92 | 93 | async changeReviewCountAndVerify(url: string) { 94 | await this.page.goto(url); 95 | 96 | // Get the default review count from URL or UI 97 | const initialUrl = this.page.url(); 98 | 99 | // Find and click the review count selector 100 | const reviewCountSelector = this.page.getByLabel(UIReference.productPage.reviewCountLabel); 101 | await expect(reviewCountSelector).toBeVisible(); 102 | 103 | // Select 20 reviews per page 104 | await reviewCountSelector.selectOption('20'); 105 | await this.page.waitForURL(/.*limit=20.*/); 106 | 107 | // Verify URL contains the new limit 108 | const urlAfterFirstChange = this.page.url(); 109 | expect(urlAfterFirstChange, 'URL should contain limit=20 parameter').toContain('limit=20'); 110 | expect(urlAfterFirstChange, 'URL should have changed after selecting 20 items per page').not.toEqual(initialUrl); 111 | 112 | // Select 50 reviews per page 113 | await reviewCountSelector.selectOption('50'); 114 | await this.page.waitForURL(/.*limit=50.*/); 115 | 116 | // Verify URL contains the new limit 117 | const urlAfterSecondChange = this.page.url(); 118 | expect(urlAfterSecondChange, 'URL should contain limit=50 parameter').toContain('limit=50'); 119 | expect(urlAfterSecondChange, 'URL should have changed after selecting 50 items per page').not.toEqual(urlAfterFirstChange); 120 | } 121 | 122 | // ============================================== 123 | // Cart-related methods 124 | // ============================================== 125 | 126 | async addSimpleProductToCart(product: string, url: string, quantity?: string) { 127 | 128 | await this.page.goto(url); 129 | this.simpleProductTitle = this.page.getByRole('heading', {name: product, exact:true}); 130 | expect(await this.simpleProductTitle.innerText()).toEqual(product); 131 | await expect(this.simpleProductTitle.locator('span')).toBeVisible(); 132 | 133 | if(quantity){ 134 | // set quantity 135 | await this.page.getByLabel(UIReference.productPage.quantityFieldLabel).fill('2'); 136 | } 137 | 138 | await this.addToCartButton.click(); 139 | 140 | await expect(this.page.locator(UIReference.general.messageLocator)).toBeVisible(); 141 | return ; 142 | } 143 | 144 | async addConfigurableProductToCart(product: string, url:string, quantity?:string){ 145 | await this.page.goto(url); 146 | this.configurableProductTitle = this.page.getByRole('heading', {name: product, exact:true}); 147 | let productAddedNotification = `${outcomeMarker.productPage.simpleProductAddedNotification} ${product}`; 148 | const productOptions = this.page.locator(UIReference.productPage.configurableProductOptionForm); 149 | 150 | // loop through each radiogroup (product option) within the form 151 | for (const option of await productOptions.getByRole('radiogroup').all()) { 152 | await option.locator(UIReference.productPage.configurableProductOptionValue).first().check(); 153 | } 154 | 155 | if(quantity){ 156 | // set quantity 157 | await this.page.getByLabel(UIReference.productPage.quantityFieldLabel).fill('2'); 158 | } 159 | 160 | await this.addToCartButton.click(); 161 | let successMessage = this.page.locator(UIReference.general.successMessageLocator); 162 | await successMessage.waitFor(); 163 | await expect(this.page.getByText(productAddedNotification)).toBeVisible(); 164 | } 165 | } 166 | 167 | export default ProductPage; 168 | -------------------------------------------------------------------------------- /tests/poms/frontend/register.page.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { expect, type Locator, type Page } from '@playwright/test'; 4 | import { UIReference, outcomeMarker, slugs} from 'config'; 5 | 6 | class RegisterPage { 7 | readonly page: Page; 8 | readonly accountCreationFirstNameField: Locator; 9 | readonly accountCreationLastNameField: Locator; 10 | readonly accountCreationEmailField: Locator; 11 | readonly accountCreationPasswordField: Locator; 12 | readonly accountCreationPasswordRepeatField: Locator; 13 | readonly accountCreationConfirmButton: Locator; 14 | 15 | constructor(page: Page){ 16 | this.page = page; 17 | this.accountCreationFirstNameField = page.getByLabel(UIReference.personalInformation.firstNameLabel); 18 | this.accountCreationLastNameField = page.getByLabel(UIReference.personalInformation.lastNameLabel); 19 | this.accountCreationEmailField = page.getByLabel(UIReference.credentials.emailFieldLabel, { exact: true}); 20 | this.accountCreationPasswordField = page.getByLabel(UIReference.credentials.passwordFieldLabel, { exact: true }); 21 | this.accountCreationPasswordRepeatField = page.getByLabel(UIReference.credentials.passwordConfirmFieldLabel); 22 | this.accountCreationConfirmButton = page.getByRole('button', {name: UIReference.accountCreation.createAccountButtonLabel}); 23 | } 24 | 25 | 26 | async createNewAccount(firstName: string, lastName: string, email: string, password: string, muted: boolean = false){ 27 | let accountInformationField = this.page.locator(UIReference.accountDashboard.accountInformationFieldLocator).first(); 28 | await this.page.goto(slugs.account.createAccountSlug); 29 | 30 | await this.accountCreationFirstNameField.fill(firstName); 31 | await this.accountCreationLastNameField.fill(lastName); 32 | await this.accountCreationEmailField.fill(email); 33 | await this.accountCreationPasswordField.fill(password); 34 | await this.accountCreationPasswordRepeatField.fill(password); 35 | await this.accountCreationConfirmButton.click(); 36 | 37 | if(!muted) { 38 | // Assertions: Account created notification, navigated to account page, email visible on page 39 | await expect(this.page.getByText(outcomeMarker.account.accountCreatedNotificationText), 'Account creation notification should be visible').toBeVisible(); 40 | await expect(this.page, 'Should be redirected to account overview page').toHaveURL(new RegExp('.+' + slugs.account.accountOverviewSlug)); 41 | await expect(accountInformationField, `Account information should contain email: ${email}`).toContainText(email); 42 | } 43 | } 44 | } 45 | 46 | export default RegisterPage; 47 | -------------------------------------------------------------------------------- /tests/poms/frontend/search.page.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { expect, type Locator, type Page } from '@playwright/test'; 4 | import { UIReference } from 'config'; 5 | 6 | class SearchPage { 7 | readonly page: Page; 8 | readonly searchToggle: Locator; 9 | readonly searchInput: Locator; 10 | readonly suggestionBox: Locator; 11 | 12 | constructor(page: Page) { 13 | this.page = page; 14 | this.searchToggle = page.locator(UIReference.search.searchToggleLocator); 15 | this.searchInput = page.locator(UIReference.search.searchInputLocator); 16 | this.suggestionBox = page.locator(UIReference.search.suggestionBoxLocator); 17 | } 18 | 19 | async openSearch() { 20 | await this.searchToggle.waitFor({ state: 'visible' }); 21 | await this.searchToggle.click(); 22 | await expect(this.searchInput).toBeVisible(); 23 | } 24 | 25 | async search(query: string) { 26 | await this.openSearch(); 27 | await this.searchInput.fill(query); 28 | await this.searchInput.press('Enter'); 29 | await this.page.waitForLoadState('networkidle'); 30 | } 31 | } 32 | 33 | export default SearchPage; 34 | -------------------------------------------------------------------------------- /tests/product.spec.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { test } from '@playwright/test'; 4 | import { UIReference ,slugs } from 'config'; 5 | 6 | import ProductPage from './poms/frontend/product.page'; 7 | import LoginPage from './poms/frontend/login.page'; 8 | import { requireEnv } from './utils/env.utils'; 9 | 10 | test.describe('Product page tests',{ tag: '@product',}, () => { 11 | test('Add_product_to_compare',{ tag: '@cold'}, async ({page}) => { 12 | const productPage = new ProductPage(page); 13 | await productPage.addProductToCompare(UIReference.productPage.simpleProductTitle, slugs.productpage.simpleProductSlug); 14 | }); 15 | 16 | test('Add_product_to_wishlist',{ tag: '@cold'}, async ({page, browserName}) => { 17 | await test.step('Log in with account', async () =>{ 18 | const browserEngine = browserName?.toUpperCase() || "UNKNOWN"; 19 | const emailInputValue = requireEnv(`MAGENTO_EXISTING_ACCOUNT_EMAIL_${browserEngine}`); 20 | const passwordInputValue = requireEnv('MAGENTO_EXISTING_ACCOUNT_PASSWORD'); 21 | 22 | const loginPage = new LoginPage(page); 23 | await loginPage.login(emailInputValue, passwordInputValue); 24 | }); 25 | 26 | await test.step('Add product to wishlist', async () =>{ 27 | const productPage = new ProductPage(page); 28 | await productPage.addProductToWishlist(UIReference.productPage.simpleProductTitle, slugs.productpage.simpleProductSlug); 29 | }); 30 | }); 31 | 32 | 33 | test.fixme('Leave a product review (Test currently fails due to error on website)',{ tag: '@cold'}, async ({page}) => { 34 | // const productPage = new ProductPage(page); 35 | // await productPage.leaveProductReview(UIReference.productPage.simpleProductTitle, slugs.productpage.simpleProductSlug); 36 | }); 37 | 38 | test('Open_pictures_in_lightbox_and_scroll', async ({page}) => { 39 | const productPage = new ProductPage(page); 40 | await productPage.openLightboxAndScrollThrough(slugs.productpage.configurableProductSlug); 41 | }); 42 | 43 | test('Change_number_of_reviews_shown_on_product_page', async ({page}) => { 44 | const productPage = new ProductPage(page); 45 | await productPage.changeReviewCountAndVerify(slugs.productpage.simpleProductSlug); 46 | }); 47 | }); 48 | 49 | test.describe('Simple product tests',{ tag: '@simple-product',}, () => { 50 | test.fixme('Simple tests will be added later', async ({ page }) => {}); 51 | }); 52 | 53 | test.describe('Configurable product tests',{ tag: '@conf-product',}, () => { 54 | test.fixme('Configurable tests will be added later', async ({ page }) => {}); 55 | }); 56 | -------------------------------------------------------------------------------- /tests/register.spec.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import {test} from '@playwright/test'; 4 | import {faker} from '@faker-js/faker'; 5 | import { inputValues } from 'config'; 6 | 7 | import RegisterPage from './poms/frontend/register.page'; 8 | import { requireEnv } from './utils/env.utils'; 9 | 10 | // Reset storageState to ensure we're not logged in before running these tests. 11 | test.use({ storageState: { cookies: [], origins: [] } }); 12 | 13 | /** 14 | * @feature Magento 2 Account Creation 15 | * @scenario The user creates an account on the website 16 | * @given I am on any Magento 2 page 17 | * @when I go to the account creation page 18 | * @and I fill in the required information correctly 19 | * @then I click the 'Create account' button 20 | * @then I should see a messsage confirming my account was created 21 | */ 22 | test('User_registers_an_account', { tag: ['@setup', '@hot'] }, async ({page, browserName}, testInfo) => { 23 | const registerPage = new RegisterPage(page); 24 | 25 | // Retrieve desired password from .env file 26 | const existingAccountPassword = requireEnv('MAGENTO_EXISTING_ACCOUNT_PASSWORD'); 27 | var firstName = faker.person.firstName(); 28 | var lastName = faker.person.lastName(); 29 | 30 | const browserEngine = browserName?.toUpperCase() || "UNKNOWN"; 31 | let randomNumber = Math.floor(Math.random() * 100); 32 | let emailHandle = inputValues.accountCreation.emailHandleValue; 33 | let emailHost = inputValues.accountCreation.emailHostValue; 34 | const accountEmail = `${emailHandle}${randomNumber}-${browserEngine}@${emailHost}`; 35 | //const accountEmail = process.env[`MAGENTO_EXISTING_ACCOUNT_EMAIL_${browserEngine}`]; 36 | 37 | if (!accountEmail) { 38 | throw new Error(`Generated account email is invalid.`); 39 | } 40 | // end of browserNameEmailSection 41 | 42 | await registerPage.createNewAccount(firstName, lastName, accountEmail, existingAccountPassword); 43 | testInfo.annotations.push({ type: 'Notification: account created!', description: `Credentials used: ${accountEmail}, password: ${existingAccountPassword}` }); 44 | }); 45 | -------------------------------------------------------------------------------- /tests/search.spec.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { test, expect } from '@playwright/test'; 4 | import { UIReference, outcomeMarker, inputValues, slugs } from 'config'; 5 | 6 | import SearchPage from './poms/frontend/search.page'; 7 | 8 | test.describe('Search functionality', () => { 9 | test('Search_query_returns_multiple_results', async ({ page }) => { 10 | await page.goto(''); 11 | const searchPage = new SearchPage(page); 12 | await searchPage.search(inputValues.search.queryMultipleResults); 13 | await expect(page).toHaveURL(new RegExp(slugs.search.resultsSlug)); 14 | const results = page.locator(`${UIReference.categoryPage.productGridLocator} li`); 15 | const resultCount = await results.count(); 16 | expect(resultCount).toBeGreaterThan(1); 17 | }); 18 | 19 | test('User_can_find_a_specific_product_and_navigate_to_its_page', async ({ page }) => { 20 | await page.goto(''); 21 | const searchPage = new SearchPage(page); 22 | await searchPage.search(inputValues.search.querySpecificProduct); 23 | await expect(page).toHaveURL(slugs.productpage.simpleProductSlug); 24 | }); 25 | 26 | test('No_results_message_is_shown_for_unknown_query', async ({ page }) => { 27 | await page.goto(''); 28 | const searchPage = new SearchPage(page); 29 | await searchPage.search(inputValues.search.queryNoResults); 30 | await expect(page.getByText(outcomeMarker.search.noResultsMessage)).toBeVisible(); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /tests/setup.spec.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { test as base } from '@playwright/test'; 4 | import fs from 'fs'; 5 | import path from 'path'; 6 | import { toggles, inputValues } from 'config'; 7 | 8 | import MagentoAdminPage from './poms/adminhtml/magentoAdmin.page'; 9 | import RegisterPage from './poms/frontend/register.page'; 10 | import { requireEnv } from './utils/env.utils'; 11 | 12 | /** 13 | * NOTE: 14 | * The first if-statement checks if we are running in CI. 15 | * If so, we always run the setup. 16 | * Else, we check if the 'setup' test toggle in test-toggles.json has been set to true. 17 | */ 18 | 19 | const runSetupTests = (describeFn: typeof base.describe | typeof base.describe.only) => { 20 | describeFn('Setting up the testing environment', () => { 21 | base('Enable_multiple_admin_logins', { tag: '@setup' }, async ({ page, browserName }, testInfo) => { 22 | const magentoAdminUsername = requireEnv('MAGENTO_ADMIN_USERNAME'); 23 | const magentoAdminPassword = requireEnv('MAGENTO_ADMIN_PASSWORD'); 24 | 25 | const browserEngine = browserName?.toUpperCase() || "UNKNOWN"; 26 | 27 | if (browserEngine === "CHROMIUM") { 28 | const magentoAdminPage = new MagentoAdminPage(page); 29 | await magentoAdminPage.login(magentoAdminUsername, magentoAdminPassword); 30 | await magentoAdminPage.enableMultipleAdminLogins(); 31 | } else { 32 | testInfo.skip(true, `Skipping because configuration is only needed once.`); 33 | } 34 | }); 35 | 36 | base('Disable_login_captcha', { tag: '@setup' }, async ({ page, browserName }, testInfo) => { 37 | const magentoAdminUsername = requireEnv('MAGENTO_ADMIN_USERNAME'); 38 | const magentoAdminPassword = requireEnv('MAGENTO_ADMIN_PASSWORD'); 39 | 40 | const browserEngine = browserName?.toUpperCase() || "UNKNOWN"; 41 | 42 | if (browserEngine === "CHROMIUM") { 43 | const magentoAdminPage = new MagentoAdminPage(page); 44 | await magentoAdminPage.login(magentoAdminUsername, magentoAdminPassword); 45 | await magentoAdminPage.disableLoginCaptcha(); 46 | } else { 47 | testInfo.skip(true, `Skipping because configuration is only needed once.`); 48 | } 49 | }); 50 | 51 | base('Setup_environment_for_tests', { tag: '@setup' }, async ({ page, browserName }, testInfo) => { 52 | const browserEngine = browserName?.toUpperCase() || "UNKNOWN"; 53 | const setupCompleteVar = `SETUP_COMPLETE_${browserEngine}`; 54 | const isSetupComplete = process.env[setupCompleteVar]; 55 | 56 | if (isSetupComplete === 'DONE') { 57 | testInfo.skip(true, `Skipping because configuration is only needed once.`); 58 | } 59 | 60 | await base.step(`Step 1: Perform actions`, async () => { 61 | const magentoAdminUsername = requireEnv('MAGENTO_ADMIN_USERNAME'); 62 | const magentoAdminPassword = requireEnv('MAGENTO_ADMIN_PASSWORD'); 63 | 64 | const magentoAdminPage = new MagentoAdminPage(page); 65 | await magentoAdminPage.login(magentoAdminUsername, magentoAdminPassword); 66 | 67 | const couponCode = requireEnv(`MAGENTO_COUPON_CODE_${browserEngine}`); 68 | await magentoAdminPage.addCartPriceRule(couponCode); 69 | 70 | const registerPage = new RegisterPage(page); 71 | const accountEmail = requireEnv(`MAGENTO_EXISTING_ACCOUNT_EMAIL_${browserEngine}`); 72 | const accountPassword = requireEnv('MAGENTO_EXISTING_ACCOUNT_PASSWORD'); 73 | 74 | await registerPage.createNewAccount( 75 | inputValues.accountCreation.firstNameValue, 76 | inputValues.accountCreation.lastNameValue, 77 | accountEmail, 78 | accountPassword, 79 | true 80 | ); 81 | }); 82 | 83 | await base.step(`Step 2: (optional) Update env file`, async () => { 84 | if (process.env.CI === 'true') { 85 | console.log("Running in CI environment. Skipping .env update."); 86 | base.skip(); 87 | } 88 | 89 | const envPath = path.resolve(__dirname, '../.env'); 90 | try { 91 | if (fs.existsSync(envPath)) { 92 | const envContent = fs.readFileSync(envPath, 'utf-8'); 93 | if (!envContent.includes(`SETUP_COMPLETE_${browserEngine}='DONE'`)) { 94 | fs.appendFileSync(envPath, `\nSETUP_COMPLETE_${browserEngine}='DONE'`); 95 | console.log(`Environment setup completed. Added SETUP_COMPLETE_${browserEngine}='DONE' to .env`); 96 | } 97 | } else { 98 | throw new Error('.env file not found. Please ensure it exists in the root directory.'); 99 | } 100 | } catch (error) { 101 | const err = error as Error; 102 | throw new Error(`Failed to update .env file: ${err.message}`); 103 | } 104 | }); 105 | }); 106 | }); 107 | }; 108 | 109 | if (process.env.CI) { 110 | runSetupTests(base.describe); 111 | } else if (toggles.general.setup) { 112 | runSetupTests(base.describe.only); 113 | } 114 | -------------------------------------------------------------------------------- /tests/types/magewire.d.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | interface Window { 4 | magewire?: { 5 | processing: boolean; 6 | [key: string]: any; 7 | }; 8 | } 9 | -------------------------------------------------------------------------------- /tests/utils/env.utils.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * Utility to retrieve required environment variables. 5 | * Throws an error when the variable is not set. 6 | */ 7 | export function requireEnv(varName: string): string { 8 | const value = process.env[varName]; 9 | if (!value) { 10 | throw new Error(`${varName} is not defined in the .env file.`); 11 | } 12 | return value; 13 | } 14 | 15 | -------------------------------------------------------------------------------- /tests/utils/magewire.utils.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { Page } from '@playwright/test'; 4 | 5 | class MagewireUtils { 6 | readonly page: Page; 7 | 8 | constructor(page: Page) { 9 | this.page = page; 10 | } 11 | 12 | /** 13 | * Waits for all Magewire requests to complete 14 | * This includes both UI indicators and actual network requests 15 | */ 16 | async waitForMagewireRequests(): Promise { 17 | // Wait for the Magewire messenger element to disappear or have 0 height 18 | await this.page.waitForFunction(() => { 19 | const element = document.querySelector('.magewire\\.messenger'); 20 | return element && getComputedStyle(element).height === '0px'; 21 | }, { timeout: 30000 }); 22 | 23 | // Additionally wait for any pending Magewire network requests to complete 24 | await this.page.waitForFunction(() => { 25 | return !window.magewire || !(window.magewire as any).processing; 26 | }, { timeout: 30000 }); 27 | 28 | // Small additional delay to ensure DOM updates are complete 29 | await this.page.waitForTimeout(500); 30 | } 31 | 32 | /** 33 | * Waits for a specific network request to complete 34 | * @param urlPattern - URL pattern to match (e.g., '*/magewire/*') 35 | */ 36 | async waitForNetworkRequest(urlPattern: string): Promise { 37 | await this.page.waitForResponse( 38 | response => response.url().includes(urlPattern) && response.status() === 200, 39 | { timeout: 30000 } 40 | ); 41 | 42 | // Small additional delay to ensure DOM updates are complete 43 | await this.page.waitForTimeout(500); 44 | } 45 | } 46 | 47 | export default MagewireUtils; 48 | -------------------------------------------------------------------------------- /tests/utils/notification.validator.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { expect, Page, TestInfo } from "@playwright/test"; 4 | import { UIReference } from 'config'; 5 | 6 | class NotificationValidator { 7 | 8 | private page : Page; 9 | private testInfo: TestInfo; 10 | 11 | constructor(page: Page, testInfo: TestInfo) { 12 | this.page = page; 13 | this.testInfo = testInfo; 14 | } 15 | 16 | /** 17 | * @param value 18 | * @return json object 19 | */ 20 | async validate(value: string) { 21 | await this.page.locator(UIReference.general.messageLocator).waitFor({ state: 'visible' }); 22 | const notificationText = await this.page.locator(UIReference.general.messageLocator).textContent(); 23 | let message = { success: true, message: ''}; 24 | 25 | if ( 26 | ! expect.soft(this.page.locator(UIReference.general.messageLocator)).toContainText(value) 27 | ) { 28 | message = { success: false, message: `Notification text not found: ${value}. Found notification text: ${notificationText}` }; 29 | } 30 | 31 | this.testInfo.annotations.push({ type: 'Notification: beforeEach add product to cart', description: message.message }); 32 | } 33 | } 34 | 35 | export default NotificationValidator; -------------------------------------------------------------------------------- /translate-json.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const csv = require('csv-parse/sync'); 6 | 7 | // Check if locale argument is provided 8 | if (process.argv.length < 3) { 9 | console.error('Please provide a locale argument (e.g., nl_NL)'); 10 | process.exit(1); 11 | } 12 | 13 | const locale = process.argv[2]; 14 | 15 | // Function to find CSV files recursively 16 | function findCsvFiles(dir, locale) { 17 | let results = []; 18 | const files = fs.readdirSync(dir); 19 | 20 | for (const file of files) { 21 | const filePath = path.join(dir, file); 22 | const stat = fs.statSync(filePath); 23 | 24 | if (stat.isDirectory()) { 25 | results = results.concat(findCsvFiles(filePath, locale)); 26 | } else if (file === `${locale}.csv`) { 27 | results.push(filePath); 28 | } 29 | } 30 | 31 | return results; 32 | } 33 | 34 | // Function to parse CSV file 35 | function parseCsvFile(filePath) { 36 | const content = fs.readFileSync(filePath, 'utf-8'); 37 | const records = csv.parse(content, { 38 | skip_empty_lines: true, 39 | trim: true 40 | }); 41 | 42 | const translations = {}; 43 | for (const [key, value] of records) { 44 | translations[key] = value; 45 | } 46 | 47 | return translations; 48 | } 49 | 50 | // Function to merge translations with precedence 51 | function mergeTranslations(appTranslations, vendorTranslations) { 52 | return { ...vendorTranslations, ...appTranslations }; 53 | } 54 | 55 | // Function to translate values in an object recursively 56 | function translateObject(obj, translations) { 57 | if (typeof obj === 'string') { 58 | return translations[obj] || obj; 59 | } 60 | 61 | if (Array.isArray(obj)) { 62 | return obj.map(item => translateObject(item, translations)); 63 | } 64 | 65 | if (typeof obj === 'object' && obj !== null) { 66 | const result = {}; 67 | for (const [key, value] of Object.entries(obj)) { 68 | result[key] = translateObject(value, translations); 69 | } 70 | return result; 71 | } 72 | 73 | return obj; 74 | } 75 | 76 | // Main execution 77 | try { 78 | // Find and parse CSV files 79 | const appCsvFiles = findCsvFiles('../../../../../../../app', locale); 80 | const vendorCsvFiles = findCsvFiles('../../../../../../../vendor', locale); 81 | 82 | let appTranslations = {}; 83 | let vendorTranslations = {}; 84 | 85 | // Parse app translations 86 | for (const file of appCsvFiles) { 87 | const translations = parseCsvFile(file); 88 | appTranslations = { ...appTranslations, ...translations }; 89 | } 90 | 91 | // Parse vendor translations 92 | for (const file of vendorCsvFiles) { 93 | const translations = parseCsvFile(file); 94 | vendorTranslations = { ...vendorTranslations, ...translations }; 95 | } 96 | 97 | // Merge translations with app taking precedence 98 | const translations = mergeTranslations(appTranslations, vendorTranslations); 99 | 100 | // Process JSON files 101 | const jsonFiles = [ 102 | 'element-identifiers.json', 103 | 'outcome-markers.json' 104 | ]; 105 | 106 | for (const fileName of jsonFiles) { 107 | const sourcePath = path.resolve('base-tests/config', fileName); 108 | const destPath = path.resolve('tests/config', fileName); 109 | 110 | const content = JSON.parse(fs.readFileSync(sourcePath, 'utf-8')); 111 | const translatedContent = translateObject(content, translations); 112 | 113 | // Ensure target directory exists 114 | fs.mkdirSync(path.dirname(destPath), { recursive: true }); 115 | 116 | fs.writeFileSync(destPath, JSON.stringify(translatedContent, null, 2)); 117 | console.log(`Translated file written: ${destPath}`); 118 | } 119 | 120 | console.log('Translation completed successfully!'); 121 | } catch (error) { 122 | console.error('Error:', error.message); 123 | process.exit(1); 124 | } -------------------------------------------------------------------------------- /tsconfig.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "strict": true, 6 | "esModuleInterop": true, 7 | "allowSyntheticDefaultImports": true, 8 | "resolveJsonModule": true, 9 | "baseUrl": ".", 10 | "paths": { 11 | "config": ["tests.config.ts"], 12 | } 13 | } 14 | } --------------------------------------------------------------------------------